TUI: plugin marketplace sync cancels in-flight MCP server startups and orphans the spawned processes
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) andnode_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.exeprocesses from cancelled rounds survive the session and can hold the target application's single-client socket, breaking later sessions. codex execand direct app-serverthread/startsessions initialize the same servers perfectly — TUI-specific.
Reproduction
- Configure any stdio MCP server with a ~2 s startup (e.g.
blender-mcp) and enable plugin marketplaces. - 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/listsucceeds in ~1.5 s — the server itself is healthy. - Launching with
--config features.plugins=falseeliminates the problem completely: single clean server process, socket connection established,/mcplists 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.exewas 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
cancellednotifications 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.
3 Comments
I traced this against current
mainated2f985a26eee9a59cde0fdefd20f69b45bc25f5. The cancellation is real and is separate from the late-notification behavior addressed by #36895.Root cause
The startup race is:
PluginsManagerstarts each process withremote_installed_plugins_cache = None.None -> Some(...)is treated as a change, it invokeseffective_plugins_changed_callback.effective_plugins_changed_callbackunconditionally spawnsThreadManager::invalidate_mcp_runtimes().request_mcp_runtime_refresh()immediately after the thread is published.McpRuntimeand constructs a newMcpConnectionSetfrom the previous one.McpServerConnection::reusable_client()explicitly refuses to reuse a previous connection whilestartup_complete == false.Drop for McpServerConnectioncancels the pending client's token, so slow servers emitMcpStartupStatus::Cancelledand appear inMcpStartupComplete.cancelled.This explains the observed timing pattern: servers that finish before the plugin refresh can be reused, while slower servers such as
codex_appsandopenaiDeveloperDocsare consistently cancelled. It also explains whyfeatures.plugins=falseremoves the symptom and why increasingstartup_timeout_secdoes not help.Relevant paths:
codex-rs/app-server/src/effective_plugin_change.rscodex-rs/core-plugins/src/manager.rscodex-rs/core/src/thread_manager.rscodex-rs/core/src/session/mcp_prewarm.rscodex-rs/codex-mcp/src/runtime.rscodex-rs/codex-mcp/src/connection_manager.rsProposed fix
Use a deferred and coalesced invalidation path specifically for effective plugin changes:
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:
Cancelledupdate is emitted and that no second connection attempt starts.Ready.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.
Updated status after further end-to-end validation. The branch now contains a second commit:
agent/defer-plugin-mcp-refreshin dqtz5vpvj9-create/codex, based on rust-v0.147.0.00acdf09ca: "Defer plugin MCP refresh until startup settles" — plugin-triggered refreshes are queued behind the currently published startup generation instead of cancelling it.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:
plugin_refresh_waits_for_pending_mcp_startup_generation: 1 passed.rust-v0.147.0plus both commits, built on the r743 host and verified locally.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/mcplisting.supports_search_tool=true, MCP tools are registered with deferred exposure and are only reachable throughtool_search; providers/models that never calltool_searchsee no MCP tools even though every server is Ready. Settingsupports_search_tool=falsein the model catalog restores directmcp__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.
Independent reproduction on Windows 11 with
codex-cli 0.147.0and the interactive TUI. No keyboard input was sent during MCP startup.The affected setup has multiple user/project MCP servers (
stdio,npxstdio, and streamable HTTP), withplugins=trueand the defaultremote_plugin=true. There are no installed remote plugins: the sync result was empty.The
logs_2.sqliteordering matches this issue exactly:The TUI banner listed the servers that were pending when the refresh landed, even though their replacement-round clients subsequently initialized.
codex doctorreported the MCP configuration as locally consistent (5 configured, 0 disabled), and increasingstartup_timeout_secdid not address the failure because cancellation happened after roughly 2 seconds rather than at the timeout.A narrower workaround than
features.plugins=falseis effective at removing the identified trigger while retaining local plugins/skills/MCP configuration:Equivalent command:
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 initialNone -> Some(empty set)remote cache fill is sufficient to invalidate the MCP runtime and race the first startup generation.