Skip to content

Windows Desktop: bundled node_repl MCP processes leak one-per-thread, never reaped until app-server exit #35485

Open

Description

@mingisrookie

What version of Codex is running?

  • Codex Desktop 26.721.4979.0 (native Windows, no WSL)
  • app-server: codex-cli 0.146.0-alpha.3.1
  • Bundled runtime: cua_node/f8d2abcb7481383b

What operating system are you using?

Windows 11 x64, 24 GB RAM.

What is the issue?

node_repl.exe MCP server child processes accumulate over the life of the codex.exe app-server process and are never reaped. One new node_repl.exe appears roughly per opened conversation/thread, stays alive after the thread is idle or closed, and only goes away when the app-server itself exits.

Snapshot after ~8 hours of normal Desktop usage — 14 live node_repl.exe, all children of the single app-server PID, all idle (0 CPU since spawn):

ProcessId CreationDate            Parent
17248     12:41  (app start)      codex.exe (1772)
16888     13:12                   codex.exe (1772)
19040     13:43                   codex.exe (1772)
 5508     15:18                   codex.exe (1772)
21312     18:40                   codex.exe (1772)
 1668     19:02                   codex.exe (1772)
21144     19:47                   codex.exe (1772)
 9004     19:58                   codex.exe (1772)
20704     19:58                   codex.exe (1772)
21984     20:04                   codex.exe (1772)
20664     20:12                   codex.exe (1772)
14232     20:18                   codex.exe (1772)
20508     20:18                   codex.exe (1772)
20588     20:19                   codex.exe (1772)

Each holds ~7–9 MB working set and ~115–125 handles. Individually small, but they accumulate monotonically (verified creation timestamps map to thread-open events) and add up alongside the other per-thread state. Killing the app-server reaps all of them (verified: after terminating the app-server PID, node_repl.exe count went from 14 to 0).

Relevant config: [mcp_servers.node_repl] pointing at the bundled node_repl.exe, features.computer_use = true, features.multi_agent = true.

What is the expected behavior?

node_repl MCP processes for a thread should be terminated when the thread is closed/idle-expired, or at minimum pooled/reused, instead of leaking one process per thread until app exit.

Additional context

Possibly related to #30408 (per-thread MCP server processes never cleaned up), but this one is specifically the bundled node_repl on native Windows, where each thread spawns a distinct child that outlives the thread.

Activity

  1. added
    bugSomething isn't working
    appIssues related to the Codex desktop app
    windows-osIssues related to Codex on Windows systems
    mcpIssues related to the use of model context protocol (MCP) servers
    app-serverIssues involving app server protocol or interfaces
    on Jul 26, 2026
  2. misosiruda commented on Aug 5, 2026

    @misosiruda

    Current Windows reproduction and lifecycle analysis

    Update: I initially scoped the likely cause too narrowly to subscribed-thread retention. After reviewing prior unmerged work, current main still has at least one additional plausible lifecycle-retention path around detached MCP status/resource/tool RPC work. The analysis and proposed order below now include that prior art.

    I reproduced the same process-retention pattern on a newer Windows Desktop build:

    • Codex Desktop: 26.730.7989.0
    • bundled app-server: codex-cli 0.147.0-alpha.1.2
    • Windows 11 x64
    • root Desktop app-server uptime at the latest capture: about 3h48m

    At an earlier point in the same app-server lifetime I observed 7 direct node_repl.exe children using about 143.5 MiB combined. A fresh read-only capture at 2026-08-05T13:56+09:00 showed 4 direct children using 81.3 MiB and 532 handles. Three of the four used at most 0.02 CPU seconds during a three-second sample; one was active and owned a nested stdio app-server.

    This is an important distinction: these are not Windows PPID-orphans. They remain direct children of the live Desktop app-server (and were inside its Job in the earlier snapshot). Also, my count did decrease from 7 to 4 during the investigation, so my reproduction supports long-lived/logically retained runtimes, but does not support claiming that every instance is literally never reaped under every lifecycle path. Process inspection alone also cannot attribute every retained node_repl.exe to a particular thread, status snapshot, hidden task, or other runtime owner.

    Relevant current-main behavior

    I traced current main at 5d89ab65dc9d4d0c55796c11df112b54157922b4.

    The per-thread path behaves as follows:

    • The app-server unload delay is fixed at 30 minutes, and an unload target exists only when the thread is both inactive and has no subscribers:
      pub(super) const THREAD_UNLOADING_DELAY: Duration = Duration::from_secs(30 * 60);
      #[derive(Clone)]
      pub(super) struct ListenerTaskContext {
      pub(super) thread_manager: Arc<ThreadManager>,
      pub(super) thread_state_manager: ThreadStateManager,
      pub(super) outgoing: Arc<OutgoingMessageSender>,
      pub(super) pending_thread_unloads: Arc<Mutex<HashSet<ThreadId>>>,
      pub(super) thread_watch_manager: ThreadWatchManager,
      pub(super) thread_list_state_permit: Arc<Semaphore>,
      pub(super) fallback_model_provider: String,
      pub(super) codex_home: PathBuf,
      pub(super) skills_watcher: Arc<SkillsWatcher>,
      }
      struct UnloadingState {
      delay: Duration,
      has_subscribers_rx: watch::Receiver<bool>,
      has_subscribers: (bool, Instant),
      thread_status_rx: watch::Receiver<ThreadStatus>,
      is_active: (bool, Instant),
      }
      impl UnloadingState {
      async fn new(
      listener_task_context: &ListenerTaskContext,
      thread_id: ThreadId,
      delay: Duration,
      ) -> Option<Self> {
      let has_subscribers_rx = listener_task_context
      .thread_state_manager
      .subscribe_to_has_connections(thread_id)
      .await?;
      let thread_status_rx = listener_task_context
      .thread_watch_manager
      .subscribe(thread_id)
      .await?;
      let has_subscribers = (*has_subscribers_rx.borrow(), Instant::now());
      let is_active = (
      matches!(*thread_status_rx.borrow(), ThreadStatus::Active { .. }),
      Instant::now(),
      );
      Some(Self {
      delay,
      has_subscribers_rx,
      has_subscribers,
      thread_status_rx,
      is_active,
      })
      }
      fn unloading_target(&self) -> Option<Instant> {
      match (self.has_subscribers, self.is_active) {
      ((false, has_no_subscribers_since), (false, is_inactive_since)) => {
      Some(std::cmp::max(has_no_subscribers_since, is_inactive_since) + self.delay)
      }
      _ => None,
      }
    • thread/unsubscribe removes the connection subscription but does not immediately shut down the thread:
      async fn thread_unsubscribe_response_inner(
      &self,
      params: ThreadUnsubscribeParams,
      connection_id: ConnectionId,
      ) -> Result<ThreadUnsubscribeResponse, JSONRPCErrorError> {
      let thread_id = ThreadId::from_string(&params.thread_id)
      .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
      if self.thread_manager.get_thread(thread_id).await.is_err() {
      self.finalize_thread_teardown(thread_id).await;
      return Ok(ThreadUnsubscribeResponse {
      status: ThreadUnsubscribeStatus::NotLoaded,
      });
      };
      let was_subscribed = self
      .thread_state_manager
      .unsubscribe_connection_from_thread(thread_id, connection_id)
      .await;
      let status = if was_subscribed {
      ThreadUnsubscribeStatus::Unsubscribed
      } else {
      ThreadUnsubscribeStatus::NotSubscribed
      };
      Ok(ThreadUnsubscribeResponse { status })
    • Once thread shutdown is actually entered, it calls mcp_runtime.shutdown():
      async fn shutdown_session_runtime(sess: &Arc<Session>) {
      if let Some(startup_prewarm) = sess.take_session_startup_prewarm().await {
      startup_prewarm.abort().await;
      }
      let _ = sess.conversation.shutdown().await;
      sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
      sess.services
      .unified_exec_manager
      .terminate_all_processes()
      .await;
      if let Err(err) = sess.services.code_mode_service.shutdown().await {
      warn!("failed to shutdown code mode session: {err}");
      }
      sess.stop_mcp_prewarm_worker().await;
      {
      let _refresh = sess.mcp_refresh.acquire().await;
      sess.mcp_refresh.close();
      sess.services.mcp_runtime.shutdown().await;
      }
      sess.guardian_review_session.shutdown().await;
      crate::hook_runtime::run_session_end_hooks(sess).await;
      }

    There is also a separate RPC/temporary-manager path that should not be conflated with thread subscription retention:

    • Current app-server code still detaches mcpServerStatus/list, resource reads, and tool calls with tokio::spawn:
      async fn list_mcp_server_status(
      &self,
      request_id: &ConnectionRequestId,
      params: ListMcpServerStatusParams,
      ) -> Result<(), JSONRPCErrorError> {
      let request = request_id.clone();
      let outgoing = Arc::clone(&self.outgoing);
      let (config, thread) = match params.thread_id.as_deref() {
      Some(thread_id) => {
      let (_, thread) = self.load_thread(thread_id).await?;
      let thread_config = thread.config().await;
      let config = self
      .config_manager
      .load_latest_config_for_thread(thread_config.as_ref())
      .await
      .map_err(|err| internal_error(format!("failed to reload config: {err}")))?;
      (config, Some(thread))
      }
      None => (self.load_latest_config(/*fallback_cwd*/ None).await?, None),
      };
      let mcp_manager = self.thread_manager.mcp_manager();
      let auth = self.auth_manager.auth().await;
      let (mcp_config, runtime_context) = match thread {
      Some(thread) => thread.runtime_mcp_config_and_context(&config).await,
      None => {
      let mcp_config = mcp_manager.runtime_config(&config).await;
      let runtime_context = McpRuntimeContext::new(
      self.thread_manager.environment_manager(),
      config.cwd.to_path_buf(),
      );
      (mcp_config, runtime_context)
      }
      };
      tokio::spawn(async move {
      Self::list_mcp_server_status_task(
      outgoing,
      request,
      params,
      mcp_config,
      auth,
      runtime_context,
      mcp_manager,
      )
      .await;
      });
      Ok(())
      }
      async fn list_mcp_server_status_task(
      outgoing: Arc<OutgoingMessageSender>,
      request_id: ConnectionRequestId,
      params: ListMcpServerStatusParams,
      mcp_config: codex_mcp::McpConfig,
      auth: Option<CodexAuth>,
      runtime_context: McpRuntimeContext,
      mcp_manager: Arc<McpManager>,
      ) {
      let result = Self::list_mcp_server_status_response(
      request_id.request_id.to_string(),
      params,
      mcp_config,
      auth,
      runtime_context,
      mcp_manager,
      )
      .await;
      outgoing.send_result(request_id, result).await;
      }
      async fn list_mcp_server_status_response(

      async fn read_mcp_resource(
      &self,
      request_id: &ConnectionRequestId,
      params: McpResourceReadParams,
      ) -> Result<(), JSONRPCErrorError> {
      let outgoing = Arc::clone(&self.outgoing);
      let McpResourceReadParams {
      thread_id,
      server,
      uri,
      } = params;
      if let Some(thread_id) = thread_id {
      let (_, thread) = self.load_thread(&thread_id).await?;
      let request_id = request_id.clone();
      tokio::spawn(async move {
      let result = thread.read_mcp_resource(&server, &uri).await;
      Self::send_mcp_resource_read_response(outgoing, request_id, result).await;
      });
      return Ok(());
      }
      let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
      let mcp_manager = self.thread_manager.mcp_manager();
      let mcp_config = mcp_manager.runtime_config(&config).await;
      let codex_apps_tools_cache = mcp_manager.codex_apps_tools_cache();
      let tool_catalog_cache = mcp_manager.tool_catalog_cache();
      let auth = self.auth_manager.auth().await;
      let environment_manager = self.thread_manager.environment_manager();
      // This threadless resource-read path has no turn cwd or turn-selected
      // environment. Use config cwd only as the local stdio fallback; named
      // environment stdio MCPs must declare their own absolute cwd.
      let runtime_context =
      McpRuntimeContext::new(Arc::clone(&environment_manager), config.cwd.to_path_buf());
      let request_id = request_id.clone();
      tokio::spawn(async move {
      let result = read_mcp_resource_without_thread(
      &mcp_config,
      auth.as_ref(),
      runtime_context,
      codex_apps_tools_cache,
      tool_catalog_cache,
      &server,
      &uri,
      )
      .await
      .and_then(|result| serde_json::to_value(result).map_err(anyhow::Error::from));
      Self::send_mcp_resource_read_response(outgoing, request_id, result).await;
      });
      Ok(())
      }
      async fn send_mcp_resource_read_response(
      outgoing: Arc<OutgoingMessageSender>,
      request_id: ConnectionRequestId,
      result: anyhow::Result<serde_json::Value>,
      ) {
      let result = result
      .map_err(|error| internal_error(format!("{error:#}")))
      .and_then(|result| {
      serde_json::from_value::<McpResourceReadResponse>(result).map_err(|error| {
      internal_error(format!(
      "failed to deserialize MCP resource read response: {error}"
      ))
      })
      });
      outgoing.send_result(request_id, result).await;
      }
      async fn call_mcp_server_tool(
      &self,
      request_id: &ConnectionRequestId,
      params: McpServerToolCallParams,
      ) -> Result<(), JSONRPCErrorError> {
      let outgoing = Arc::clone(&self.outgoing);
      let thread_id = params.thread_id.clone();
      let (_, thread) = self.load_thread(&thread_id).await?;
      let meta = with_mcp_tool_call_thread_id_meta(params.meta, &thread_id);
      let request_id = request_id.clone();
      tokio::spawn(async move {
      let result = thread
      .call_mcp_tool(&params.server, &params.tool, params.arguments, meta)
      .await
      .map(McpServerToolCallResponse::from)
      .map_err(|error| internal_error(format!("{error:#}")));
      outgoing.send_result(request_id, result).await;
      });
    • The threadless status snapshot creates a fresh McpConnectionSet, then cancels its startup token and returns without explicitly awaiting shutdown():
      pub async fn collect_mcp_server_status_snapshot_with_detail(
      config: &McpConfig,
      auth: Option<&CodexAuth>,
      submit_id: String,
      runtime_context: McpRuntimeContext,
      codex_apps_tools_cache: ConnectorRuntimeManager<ToolInfo>,
      tool_catalog_cache: crate::McpToolCatalogCache,
      detail: McpSnapshotDetail,
      ) -> McpServerStatusSnapshot {
      let mcp_servers = effective_mcp_servers(config, auth);
      if mcp_servers.is_empty() {
      return McpServerStatusSnapshot {
      server_infos: HashMap::new(),
      tools_by_server: HashMap::new(),
      resources: HashMap::new(),
      resource_templates: HashMap::new(),
      auth_statuses: HashMap::new(),
      server_names: Vec::new(),
      };
      }
      let auth_status_entries = compute_auth_statuses(
      mcp_servers.iter(),
      config.mcp_oauth_credentials_store_mode,
      config.auth_keyring_backend_kind,
      auth,
      &runtime_context,
      )
      .await;
      let server_names = mcp_servers.keys().cloned().collect();
      let cancel_token = CancellationToken::new();
      let mut runtime_config = config.clone();
      runtime_config.permission_profile = PermissionProfile::default();
      let mcp_connection_manager = McpConnectionSet::new(
      /*previous*/ None,
      McpPublicationGate::already_published(),
      McpRuntimeInput {
      config: Arc::new(runtime_config),
      plugins_available: false,
      ready_selected_capability_roots: Vec::new(),
      mcp_servers,
      submit_id,
      tx_event: None,
      startup_cancellation_token: cancel_token.clone(),
      runtime_context,
      codex_apps_tools_cache,
      tool_catalog_cache,
      codex_apps_tools_cache_key: connector_runtime_context_key(auth),
      client_mcp_extensions: ClientMcpExtensions::default(),
      auth: auth.cloned(),
      codex_apps_auth_manager: None,
      elicitation_reviewer: None,
      elicitation_lifecycle: None,
      },
      crate::elicitation::ElicitationRequestRouter::default(),
      )
      .await;
    • The Windows local-stdio terminator uses taskkill /PID ... /T /F, but current code discards the command status:
      });
      }
      }
      #[cfg(windows)]
      fn terminate(&self) {
      let _ = std::process::Command::new("taskkill")
      .arg("/PID")
      .arg(self.pid.to_string())
      .arg("/T")
      .arg("/F")
      .stdin(Stdio::null())
      .stdout(Stdio::null())
      .stderr(Stdio::null())

    The evidence therefore points to at least two plausible retention mechanisms:

    1. loaded per-thread MCP runtimes that never become eligible for the no-subscriber idle-unload path because Desktop keeps background/sidebar threads subscribed;
    2. temporary or thread-associated MCP work whose detached RPC task outlives the serialized request lifecycle or is not explicitly drained.

    The first mechanism is consistent with the controlled subscribed-thread analysis reported in #20883:
    #20883 (comment)

    Relevant prior work that was not merged

    Three earlier PRs overlap materially with this issue and should be evaluated before designing a new fix:

    These PRs target different layers, so none should be assumed to be a complete standalone fix for the current architecture. In particular, #20447 addresses detached temporary-manager/RPC lifetime, while #14997 and #15258 address per-thread retention and duplication.

    Proposed fix direction

    My preferred order would now be:

    1. Re-evaluate and port the narrow intent of Fix MCP status/list lifecycle leak #20447 onto current mcp_processor.rs:
      • keep serialized MCP RPC work inside the tracked request future where possible;
      • explicitly drain temporary MCP connection sets;
      • surface failed Windows process-tree termination;
      • add a regression that distinguishes thread-owned runtimes from temporary status/resource runtimes.
    2. Fix the loaded-thread ownership path:
      • have Desktop unsubscribe from background threads when it no longer needs live turn/item events;
      • consider an explicitly negotiated or initially opt-in app-server subscribed-idle safety valve;
      • never evict an active turn/agent or a thread with pending server requests;
      • reset the timer on real thread activity;
      • use the existing shutdown path, emit thread/closed, and preserve resume from the persisted rollout.
    3. Treat project/app-server-scoped MCP pooling as a separate, larger design decision after the two cleanup paths above are bounded.

    Regression coverage should include:

    • serialized status/resource/tool requests do not leave temporary MCP processes behind;
    • subscribed idle eviction;
    • active-turn protection;
    • activity resetting the deadline;
    • resume after eviction;
    • actual stdio MCP child-tree exit on Windows.

    The last item is currently a coverage gap: the existing real-process cleanup integration test is Unix-only:

    #![cfg(unix)]
    use std::collections::HashMap;
    use std::ffi::OsString;
    use std::fs;
    use std::path::Path;
    use std::sync::Arc;
    use std::time::Duration;
    use anyhow::Context;
    use anyhow::Result;
    use codex_rmcp_client::ElicitationAction;
    use codex_rmcp_client::ElicitationResponse;
    use codex_rmcp_client::LocalStdioServerLauncher;
    use codex_rmcp_client::RmcpClient;
    use futures::FutureExt as _;
    use rmcp::model::ClientCapabilities;
    use rmcp::model::Implementation;
    use rmcp::model::InitializeRequestParams;
    use rmcp::model::ProtocolVersion;
    use serde_json::json;
    fn stdio_server_bin() -> Result<std::path::PathBuf> {
    codex_utils_cargo_bin::cargo_bin("test_stdio_server").map_err(Into::into)
    }
    fn init_params() -> InitializeRequestParams {
    InitializeRequestParams::new(
    ClientCapabilities::default(),
    Implementation::new("codex-test", "0.0.0-test").with_title("Codex rmcp shutdown test"),
    )
    .with_protocol_version(ProtocolVersion::V_2025_06_18)
    }
    fn process_exists(pid: u32) -> bool {
    std::process::Command::new("kill")
    .arg("-0")
    .arg(pid.to_string())
    .stderr(std::process::Stdio::null())
    .status()
    .map(|status| status.success())
    .unwrap_or(false)
    }
    async fn wait_for_pid_file(path: &Path) -> Result<u32> {
    for _ in 0..50 {
    match fs::read_to_string(path) {
    Ok(content) => {
    let trimmed = content.trim();
    if trimmed.is_empty() {
    tokio::time::sleep(Duration::from_millis(100)).await;
    continue;
    }
    let pid = trimmed
    .parse::<u32>()
    .with_context(|| format!("failed to parse pid from {}", path.display()))?;
    return Ok(pid);
    }
    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
    tokio::time::sleep(Duration::from_millis(100)).await;
    }
    Err(error) => {
    return Err(error).with_context(|| format!("failed to read {}", path.display()));
    }
    }
    }
    anyhow::bail!("timed out waiting for child pid file at {}", path.display());
    }
    async fn wait_for_process_exit(pid: u32) -> Result<()> {
    for _ in 0..50 {
    if !process_exists(pid) {
    return Ok(());
    }
    tokio::time::sleep(Duration::from_millis(100)).await;
    }
    anyhow::bail!("process {pid} still running after timeout");
    }
    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
    async fn drop_kills_wrapper_process_group() -> Result<()> {
    let temp_dir = tempfile::tempdir()?;
    let child_pid_file = temp_dir.path().join("child.pid");
    let child_pid_file_str = child_pid_file.to_string_lossy().into_owned();
    let client = RmcpClient::new_stdio_client(
    OsString::from("/bin/sh"),
    vec![
    OsString::from("-c"),
    OsString::from(
    "sleep 300 & child_pid=$!; echo \"$child_pid\" > \"$CHILD_PID_FILE\"; cat >/dev/null",
    ),
    ],
    Some(HashMap::from([(
    OsString::from("CHILD_PID_FILE"),
    OsString::from(child_pid_file_str),
    )])),
    &[],
    /*cwd*/ None,
    Arc::new(LocalStdioServerLauncher::new(std::env::current_dir()?)),
    )
    .await?;
    let grandchild_pid = wait_for_pid_file(&child_pid_file).await?;
    assert!(
    process_exists(grandchild_pid),
    "expected grandchild process {grandchild_pid} to be running before dropping client"
    );
    drop(client);
    wait_for_process_exit(grandchild_pid).await
    }
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn shutdown_kills_initialized_stdio_server_with_in_flight_operation() -> Result<()> {

    Would maintainers prefer reviving the narrow #20447 lifecycle fix first, a Desktop subscription fix, or a combined staged approach? If one of those directions aligns with the intended lifecycle, I would be happy to prepare a small, test-first PR if invited.

  3. vbzgn2rc98-ctrl commented on Aug 17, 2026

    @vbzgn2rc98-ctrl

    Additional Windows reproduction on current Desktop 26.810.7004.0, with read-only lifecycle/performance measurements.

    This matches the lifecycle nuance already described above: the helpers are not literally never reaped. On this machine, a true idle period of roughly 30 minutes reclaimed most of a retained batch (28 -> 7; 22/28 baseline helper PIDs exited). However, under sustained multi-thread Desktop use, helper creation outpaced that delayed cleanup and the process tree accumulated rapidly.

    Environment / current build

    • Windows 11 x64
    • Codex Desktop AppX: 26.810.7004.0
    • bundled Chrome target observed separately: 26.810.52044
    • the previous Chrome native-host/junction reconciliation bug did not reproduce on this build (junction identity/target/creation time remained stable)

    Helper lifecycle observations

    Across normal thread/task activity:

    • node_repl.exe: 4 -> 6 -> 10 -> 12 -> 22 -> 23 -> 26 -> 25
    • after a later true-idle ~30 minute window: baseline 28 -> 7
    • 22/28 baseline helper PIDs exited during that idle window
    • before that reclamation window, some helpers had remained alive for >1 hour and two for >24 hours
    • retained helpers were almost completely idle (~3.4 MiB private memory each, negligible CPU/I/O)
    • all observed helpers shared the same sanitized command signature and were direct children of the root codex.exe
    • several helper groups also owned nested codex.exe children
    • existing logs contained rmcp::service / rmcp::transport::child_process plus occurrences involving failed to terminate, failed to kill, orphan, and cleanup

    A particularly narrow trigger was observed: resuming another thread and asking only a design/conversation question (no requested local file, shell, browser, or computer action) increased node_repl.exe from 10 -> 12. Previously observed helpers were not reclaimed at that point. This suggests helper/runtime creation can occur during thread/work-context initialization, not only after an explicit tool call.

    Performance degradation while active workload was running

    During a later sustained-workload bad state, the root codex.exe showed a persistent cache-backed logical-read storm across three consecutive 5-second windows:

    • 265.752 MiB/s
    • 288.004 MiB/s
    • 286.842 MiB/s
    • average: ~280.2 MiB/s
    • peak: ~288.0 MiB/s
    • read operations: ~34k-37k/s

    At the same time:

    • physical disk read: 0-0.079 MiB/s
    • disk busy: max 0.24%
    • disk queue: 0
    • total CPU average: 11.04%, max 19.36%
    • available RAM: ~41.7 GiB
    • committed memory: 33.5%
    • DPC time: 0.13%
    • context switches: ~181,208/s average, ~200,951/s peak
    • node_repl.exe: idle baseline 7, then 22 -> 23 after work resumed
    • root codex.exe private memory grew from ~992 MiB -> 2263 MiB in ~10 minutes, with large short-window allocation/free churn

    User-visible impact was system-wide mouse/input/window-animation stutter despite low total CPU, essentially idle physical disk, ample RAM, and low DPC activity. Fully exiting/restarting Codex cleared the bad state temporarily.

    File-level trace follow-up

    An 8.139-second PID-filtered kernel File I/O trace was later captured after the storm had subsided:

    • ETW events lost: 0
    • ordinary file reads: 799 / 3.297 MiB total (0.405 MiB/s)
    • top files were %USERPROFILE%\.codex\logs_2.sqlite and logs_2.sqlite-wal, read in short ~2-second bursts

    That calm-state trace did not reproduce the prior ~280 MiB/s process-level logical-read storm, so it does not attribute the storm to those SQLite files. The high-read state appears state-dependent/intermittent and would need another trace captured while the stutter is actively present.

    Current interpretation

    This machine supports:

    • delayed (~30 min) helper reclamation exists;
    • under sustained multi-thread use, helper creation can outpace cleanup and produce large net accumulation;
    • the accumulated/active runtime state correlates with severe cache-backed codex.exe logical-read activity and a ~180k-200k/s context-switch storm;
    • the previous Chrome junction reconciliation loop is not the explanation on 26.810.7004.0 here.

    I am intentionally not uploading the raw ETL because it contains local workstation metadata/paths. Sanitized counters or another trace captured during the active bad state can be provided if maintainers need a specific additional measurement.

  4. wakasellahi commented on Aug 17, 2026

    @wakasellahi

    Additional Windows Desktop sample, non-urgent:

    • App package: OpenAI.Codex 26.810.7004.0
    • Same long-lived codex.exe parent PID 19152, started 07:49:07 local
    • node_repl direct children, all parented by that process:
      • 09:07: 25
      • 09:10:58: 23
      • 09:15:10: 22
      • 09:18:38: 20, combined working set 277 MB

    Counts declined throughout the sample, so this is not urgent or evidence of current growth. It still supports the lifecycle issue: child instances persist across task or tool activity rather than being retired per thread. No local process termination was used for this sample; the user will close and relaunch the app normally.

  5. uto96322 commented on Aug 18, 2026

    @uto96322

    Reproduced on a newer Windows Desktop build; this issue is still present.

    Directly measured environment

    • Windows 10 Pro 22H2, version 10.0.19045 (build 19045), x64
    • ChatGPT/Codex Desktop AppX: OpenAI.Codex_26.814.5167.0_x64__2p2nqsd0c76g0
    • App server PID: 10976
    • Bundled runtime: cua_node/2fb562745e6d66f0/bin/node_repl.exe

    Observed behavior

    All observed node_repl.exe instances are direct children of the same long-lived codex.exe app-server.

    • Initial measurement: 15 live node_repl.exe processes
    • Immediate recheck: still 15
    • After the next chat turn / tool activity: 17
    • After several additional normal assistant tool calls: 25
    • Oldest process creation time: 15:52:17 local
    • Newest process creation time: 16:21:18 local
    • None of the earlier instances were reaped during the app-server lifetime

    This confirms the leak/retention also reproduces on Windows 10 and on Desktop build 26.814.5167.0, not only the older Windows 11 build reported in the original issue.

    The same user separately observes a more severe reproduction on a Windows 11 home PC: the count reportedly increases on every chat turn. Disabling node_repl.exe prevents the accumulation, but Browser/Web work and Computer Use then stop functioning, so disabling the runtime is not a viable workaround.

    Expected

    The process count should remain bounded through reuse/pooling, or thread-scoped runtimes should be reaped after the owning turn/thread becomes idle. Long-lived Desktop sessions should not monotonically accumulate node_repl.exe children.

    A full app-server restart remains the only reliable cleanup observed.

  6. grtninja commented on Aug 24, 2026

    @grtninja

    Additional sanitized Windows observation from newer builds:

    • VS Code OpenAI extension build 26.818.41705 was the measured surface. Codex Desktop AppX 26.818.8289.0 was also installed, but I am not attributing the extension process to the AppX package.
    • One extension codex.exe process had 11 direct bundled node_repl.exe children.
    • The children matched the same bundled runtime, had no children of their own, and had no observed listening sockets.
    • Each child held approximately 130–320 handles and 20–22 threads; the parent held approximately 1,904 handles and 276 threads.
    • A later passive whole-machine snapshot had lower totals than the first, so this is not a controlled monotonic-growth measurement and I am not claiming that all 11 children were leaked.
    • I could not safely run a create/close/reap correlation test: subsequent attempts to start bounded one-line PowerShell and cmd probes from the task failed before launch with Win32 error 1816 (ERROR_NOT_ENOUGH_QUOTA).
    • No process was terminated because per-task ownership could not be established.

    The direct codex.exe -> node_repl.exe shape appears related to this report, but this snapshot does not map a child to a specific open or closed thread and therefore does not prove missed cleanup. Is there a supported diagnostic field or procedure for correlating each bundled node_repl child with its owning thread/session and terminal cleanup event?

  7. twk0672005 commented on Aug 26, 2026

    @twk0672005

    Additional native Windows Desktop reproduction on OpenAI.Codex 26.818.8289.0. This measurement is from the Desktop app-server, not the VS Code extension.

    Read-only capture at 2026-08-26T00:21:49Z:

    • one codex.exe app-server, started at 2026-08-25T15:50:19Z;
    • 12 bundled node_repl.exe processes, all direct children of that app-server;
    • combined node_repl.exe working set: approximately 110 MiB;
    • the oldest child started about 41 seconds after the app-server;
    • the newest child appeared roughly 8.4 hours later.

    No processes were terminated.

    This is a point-in-time persistence/accumulation snapshot. It confirms the direct parent-child shape on the current native Desktop build, but it does not map each child to a specific thread or prove that every individual child missed its cleanup deadline.

    For a narrow first fix, I would favor re-evaluating the lifecycle intent of #20447 on current main before attempting MCP pooling:

    1. keep temporary status/resource/tool RPC work inside the tracked request future rather than detached tasks;
    2. explicitly cancel, drain, and shutdown().await temporary McpConnectionSet owners on success, error, and cancellation;
    3. wait for Windows child-tree exit and surface termination failures instead of discarding them;
    4. add a real Windows stdio regression that distinguishes temporary/request-owned runtimes from thread-owned runtimes.

    The subscribed-thread retention path can then be handled separately: Desktop should unsubscribe background threads it no longer needs, and idle unload must protect active turns and pending requests while preserving resume from the rollout. Project/app-server MCP pooling seems better treated as a later architecture change, not a substitute for deterministic teardown.

    Is there a supported diagnostic field or procedure for mapping each bundled node_repl child to its owning thread/request and terminal cleanup event? I can provide the sanitized health JSON or run a controlled create/close/reap correlation test if maintainers specify the intended procedure.

  8. Rober1208 commented on Oct 5, 2026

    @Rober1208

    Same here on Windows: each conversation opened in the desktop app keeps its own node_repl.exe, together with the rest of its MCP set, until the app server exits.

    Until there is a fix, I made a small tool that lists these per-conversation copies, with the conversation title, memory and idle time, and stops the idle ones: mcp-janitor

    npx github:Rober1208/mcp-janitor#v0.1.1
    

    By default it keeps the servers of the conversation you used last (Codex cannot restart a stopped one), and stop --idle 1h --dry-run shows what it would stop without stopping anything. Reports from other Windows setups would help a lot; please open an issue if it misses or mislabels something.

  9. rumours86 commented on Oct 14, 2026

    @rumours86

    Same on Linux with the desktop app (chatgpt 26.1007.21434 .deb, Linux Mint 22.3 / Ubuntu 24.04 base, kernel 7.0).

    One codex app-server (started by the app as
    codex -c features.code_mode_host=true app-server --analytics-default-enabled -c plugins.codex-app-tools@openai-bundled.mcp_servers.codex_app.enabled=true -c plugins.code-review@openai-bundled.mcp_servers.code-review.enabled=true ...)
    78 minutes after the app was started:

    child of app-server count older than 30 min
    cua_node/bin/node ./server.mjs 11 10
    cua_node/.../@oai/cua-repl/bin/cua-repl.mjs (each with its own node_repl child) 6 5
    cua_node/bin/node_repl (direct) 5 4

    Together these hold 682 MB (PSS + swap), 590 MB of it in processes older than 30 minutes that use no CPU.

    The same app-server at 238 minutes: 16 × server.mjs (14 older than 30 min), 8 × cua-repl (7), 8 × node_repl (7) - 994 MB, 871 MB of it in the old idle ones. It only grows. They are direct children of the live app-server, not orphans, so they disappear only when the app quits. On a 16 GB laptop this regularly pushes the system into zram reclaim; the same pattern was described in #46833 (Windows) and #39816 (macOS).

    A new server.mjs / cua-repl pair appears for almost every new thread and is never shut down when the thread finishes or is left idle. An idle timeout for a thread's MCP set (or tearing it down when the thread is unloaded/archived) would fix this on all three platforms.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    appIssues related to the Codex desktop appapp-serverIssues involving app server protocol or interfacesbugSomething isn't workingmcpIssues related to the use of model context protocol (MCP) serversperformancewindows-osIssues related to Codex on Windows systems

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions