TUI: plugin marketplace sync cancels in-flight MCP server startups and orphans the spawned processes

Open 💬 3 comments Opened Aug 5, 2026 by seanzng

Summary

On TUI startup, the plugin subsystem's marketplace sync completes ~1–2 s in and fires invalidate_mcp_runtimes() plus a config reload (effective_plugin_change.rs). This republishes the thread's MCP runtime and cancels the in-flight MCP startup round. Servers still mid-handshake end as Cancelled and are never retried — and their already-spawned child processes are orphaned, not killed.

Environment

  • codex CLI 0.146.0, Windows 11, TUI (codex-tui)
  • Config: 2 plugin marketplaces + 8 plugins enabled
  • MCP servers: blender (stdio, ~1.5–2 s handshake) and node_repl (~20 ms)

Symptoms

  • ⚠ MCP startup interrupted. The following servers were not initialized: … on every TUI launch.
  • The failing set varies between launches (blender; blender, node_repl; blender, codex_apps) — whichever servers are still mid-handshake when the plugin sync lands. Slow servers lose deterministically; fast ones usually win, which makes the set look nondeterministic.
  • Orphaned blender-mcp.exe processes from cancelled rounds survive the session and can hold the target application's single-client socket, breaking later sessions.
  • codex exec and direct app-server thread/start sessions initialize the same servers perfectly — TUI-specific.

Reproduction

  1. Configure any stdio MCP server with a ~2 s startup (e.g. blender-mcp) and enable plugin marketplaces.
  2. Launch the TUI. Observe the interruption warning; check /mcp (server missing/failed) and the process tree (orphaned server process with no owning session).

Evidence the trigger is the plugin sync

  • Direct MCP handshake test against the same server binary: initialize + tools/list succeeds in ~1.5 s — the server itself is healthy.
  • Launching with --config features.plugins=false eliminates the problem completely: single clean server process, socket connection established, /mcp lists all 22 tools, and tools reach the model. 100% reproducible with plugins enabled vs. 100% fixed with plugins disabled across launches.
  • Timeline match: orphaned blender-mcp.exe was spawned at TUI startup and never completed a handshake; the cancellation lands right inside its startup window every launch.

Expected behavior

A runtime refresh should not cancel in-flight startups (wait for the round to finish, or queue re-initialization). If a round must be cancelled, its spawned children should be reaped, and startup should be retried against the new runtime.

Workaround

Launch the TUI with --config features.plugins=false.

Related but distinct

  • #36486 and #36682 report spurious cancelled notifications for servers that actually started; here the cancellation is real — the server never initializes and the child process leaks.
  • #32574 covers MCP tools not reaching model requests after successful startup (separate caching issue).

Happy to attempt a PR if a maintainer confirms the intended lifecycle semantics.

View original on GitHub ↗

3 Comments

dqtz5vpvj9-create · 23 days ago

I traced this against current main at ed2f985a26eee9a59cde0fdefd20f69b45bc25f5. The cancellation is real and is separate from the late-notification behavior addressed by #36895.

Root cause

The startup race is:

  1. PluginsManager starts each process with remote_installed_plugins_cache = None.
  2. The first successful remote installed-plugin refresh writes the fetched set into that cache. Since None -> Some(...) is treated as a change, it invokes effective_plugins_changed_callback.
  3. effective_plugins_changed_callback unconditionally spawns ThreadManager::invalidate_mcp_runtimes().
  4. For a thread still being created, the tracking added in #35204 records that invalidation and calls request_mcp_runtime_refresh() immediately after the thread is published.
  5. The refresh publishes a replacement McpRuntime and constructs a new McpConnectionSet from the previous one.
  6. McpServerConnection::reusable_client() explicitly refuses to reuse a previous connection while startup_complete == false.
  7. The superseded connection set is then dropped. Drop for McpServerConnection cancels the pending client's token, so slow servers emit McpStartupStatus::Cancelled and appear in McpStartupComplete.cancelled.

This explains the observed timing pattern: servers that finish before the plugin refresh can be reused, while slower servers such as codex_apps and openaiDeveloperDocs are consistently cancelled. It also explains why features.plugins=false removes the symptom and why increasing startup_timeout_sec does not help.

Relevant paths:

  • codex-rs/app-server/src/effective_plugin_change.rs
  • codex-rs/core-plugins/src/manager.rs
  • codex-rs/core/src/thread_manager.rs
  • codex-rs/core/src/session/mcp_prewarm.rs
  • codex-rs/codex-mcp/src/runtime.rs
  • codex-rs/codex-mcp/src/connection_manager.rs

Proposed fix

Use a deferred and coalesced invalidation path specifically for effective plugin changes:

  • Mark the MCP projection dirty when the plugin state changes.
  • When the current MCP startup generation still has pending clients, defer publication of the replacement runtime until that generation reaches a terminal state.
  • Coalesce repeated plugin-sync callbacks into one refresh.
  • Preserve immediate refresh behavior for explicit MCP refreshes, authentication changes, OAuth completion, and other paths where reconnecting promptly is required.
  • Preserve the #35204 guarantee for threads that are not published yet by recording the deferred invalidation and applying it after publication and completion of the initial startup generation.

Suppressing the initial plugin-change callback entirely would be incorrect because the cold remote cache fill may add plugin MCP servers, skills, and Apps that must become visible to the thread.

Reusing an identical pending client across runtime generations is another possible design, but it has a wider lifecycle surface: the new generation would need to adopt startup-event ownership and completion accounting for the shared in-flight client. Deferring the plugin-triggered replacement keeps startup ownership within one generation and is more targeted to this race.

Regression coverage

A focused test should:

  1. Start a thread with an optional MCP server whose initialization is blocked.
  2. Complete the initial remote installed-plugin cache refresh while that server is pending.
  3. Assert that no Cancelled update is emitted and that no second connection attempt starts.
  4. Release the server and assert that the original startup reaches Ready.
  5. Assert that exactly one deferred runtime refresh then runs and newly materialized plugin MCP capabilities become visible.
  6. Trigger multiple plugin-change callbacks during startup and assert that they are coalesced.

The repository contribution guide currently requires a maintainer invitation before an external PR. I can submit the implementation and tests once the approach is confirmed and a PR is invited.

dqtz5vpvj9-create · 21 days ago

Updated status after further end-to-end validation. The branch now contains a second commit:

  • Branch: agent/defer-plugin-mcp-refresh in dqtz5vpvj9-create/codex, based on rust-v0.147.0.
  • Commit 00acdf09ca: "Defer plugin MCP refresh until startup settles" — plugin-triggered refreshes are queued behind the currently published startup generation instead of cancelling it.
  • Commit 23338087d0: "Recapture MCP tools after optional startup settles" — when a step captures the binding before optional MCP startup completes, wait up to 30s for the current generation to settle, then mark the runtime dirty, refresh, and recapture instead of permanently dropping those servers' tools from the model-visible toolset.

Validation:

  • Targeted regression test plugin_refresh_waits_for_pending_mcp_startup_generation: 1 passed.
  • Release build from rust-v0.147.0 plus both commits, built on the r743 host and verified locally.
  • End-to-end with a third-party wire_api="responses" provider: an MCP tool from a plugin-provided server was actually invoked and returned a result (OCR text extracted from a generated image). The deferred-refresh path therefore publishes a usable binding, not just a healthy /mcp listing.
  • A separate contributing factor found while reproducing: when model metadata advertises supports_search_tool=true, MCP tools are registered with deferred exposure and are only reachable through tool_search; providers/models that never call tool_search see no MCP tools even though every server is Ready. Setting supports_search_tool=false in the model catalog restores direct mcp__ namespaces, and the same tools become callable end-to-end. This matches reports in #32574 for custom providers.

PR is still not opened because of the maintainer-invitation contribution policy discussed above; happy to open it once invited.

NRG-Nad · 16 days ago

Independent reproduction on Windows 11 with codex-cli 0.147.0 and the interactive TUI. No keyboard input was sent during MCP startup.

The affected setup has multiple user/project MCP servers (stdio, npx stdio, and streamable HTTP), with plugins=true and the default remote_plugin=true. There are no installed remote plugins: the sync result was empty.

The logs_2.sqlite ordering matches this issue exactly:

initial thread-scoped session_init.mcp_manager_init starts MCP clients
remote_installed_plugin_sync: completed ... materialized_remote_plugins=[] ...
~30 ms later: an unscoped mcp.runtime.refresh starts
superseded thread-scoped clients: task cancelled / quit_reason=Cancelled
stdio children from the superseded round: exit code 1
replacement round: affected servers initialize successfully

The TUI banner listed the servers that were pending when the refresh landed, even though their replacement-round clients subsequently initialized. codex doctor reported the MCP configuration as locally consistent (5 configured, 0 disabled), and increasing startup_timeout_sec did not address the failure because cancellation happened after roughly 2 seconds rather than at the timeout.

A narrower workaround than features.plugins=false is effective at removing the identified trigger while retaining local plugins/skills/MCP configuration:

[features]
remote_plugin = false

Equivalent command:

codex features disable remote_plugin

After applying that setting, plugins, plugin_sharing, skills, image generation, and all configured MCP servers remain enabled; only the empty remote-installed-plugin synchronization is disabled. This also confirms that the initial None -> Some(empty set) remote cache fill is sufficient to invalidate the MCP runtime and race the first startup generation.