Repository navigation
Serialize Gateway lifecycle operations and prevent duplicate startup processes #139
Description
Reporter evidence
On the integrated portable Debug build at dev@6cf5a536, CodexHub auto-started the Gateway while the user also pressed Start Gateway because the UI still appeared stopped. Two Gateway child processes were spawned about 9 ms apart:
- PID 37344 acquired port 9099 and was written to proxy.pid.
- PID 43928 did not own the listener, but the manual start call returned that losing child identity to the UI.
- After terminating only PID 43928, the running state reconciled to one child, one listener, a matching PID file, and a healthy endpoint.
This is deterministic evidence of a lifecycle race, not evidence of two valid listeners.
Problem
Gateway lifecycle operations are not one atomic state machine. The current start path performs roughly:
health probe -> stale PID cleanup -> spawn -> write PID -> wait for health
Automatic launch (start_gateway_on_launch), the manual Start command, and the Providers-page Connect-to-CodexHub fallback can enter this path concurrently. In the confirmed no-manual-Start reproduction, startup waited for the Official catalog refresh while Connect observed proxy_running=false and independently called startProxy(). start, stop, and restart also do not share one lifecycle lock/singleflight boundary. Interleaving can therefore:
- spawn an orphan or non-listening Gateway process;
- return a different PID from the process recorded in proxy.pid;
- make the UI disagree with the actual port owner;
- corrupt the session-ownership truth needed by Stop CodexHub-managed Gateway when the app fully exits #112 for safe cleanup on full app exit.
Outcome
Introduce one Rust-owned Gateway lifecycle coordinator that serializes start, stop, and restart, publishes only reconciled process identity, and coalesces equivalent concurrent starts. restart must hold the lifecycle boundary continuously from stop through the successful or failed replacement start.
The lifecycle result becomes the single source of truth for UI state, proxy.pid, port ownership, and the session-ownership handoff used by #112.
Scope and safety invariants
- Route automatic launch, the Providers-page Connect-to-CodexHub fallback, manual commands, restart, and orderly shutdown hooks through the same lifecycle coordinator.
- A second start while a start is in flight waits for and reuses the first result; it must not spawn another child.
- A start against an already healthy managed Gateway returns the canonical running identity without replacing it.
- Reconcile health, proxy.pid, and the actual 9099 listener before publishing Running or a process identity.
- Keep restart atomic across its stop/start transition so another start cannot enter between them.
- On failure, publish a truthful bounded error state and remove/refuse stale ownership; do not leave an untracked child.
- Preserve the existing ownership-verified stop safeguards. Never kill by broad process name or port alone.
- Do not change FlClash, system proxy, TUN, Provider routing, request transport, or Gateway protocol behavior.
- If frontend state is touched, disable the Start action while lifecycle state is Starting/Stopping/Restarting and keep status feedback non-duplicative.
- Expose the authoritative session-owned identity needed by Stop CodexHub-managed Gateway when the app fully exits #112; terminal-exit behavior itself remains owned by Stop CodexHub-managed Gateway when the app fully exits #112.
Acceptance criteria
- With auto-start still in progress, pressing Start Gateway results in exactly one Gateway child and exactly one 9099 listener.
- With startup Official refresh still delaying auto-start, pressing Connect to CodexHub results in exactly one Gateway child, one 9099 listener, and one canonical PID.
- Two concurrent start calls coalesce and both return the same canonical running identity.
- UI state, command result, proxy.pid, health response, and the actual listener PID all agree after startup.
- Concurrent start/stop/restart interleavings are deterministic and leave no orphan, stale PID file, or false Running state.
- Restart holds the lifecycle boundary from stop through replacement startup and publishes only the replacement identity.
- Failed spawn, failed health readiness, stale PID, and externally replaced/pre-existing Gateway cases fail safely without killing an unrelated process.
- Automatic launch and manual lifecycle actions use the same coordinator; no separate unguarded start path remains.
- The coordinator provides an ownership-safe handoff for Stop CodexHub-managed Gateway when the app fully exits #112, without implementing broad full-exit process cleanup in this issue.
- If the Start control remains visible during startup, it is disabled and cannot issue a second start request.
- A packaged Windows Debug smoke reproduces the original timing (click Start before auto-start completes) and proves one child, one listener, and matching UI/PID/listener identity.
Non-goals
- Changing Gateway routing, retry, SSE, Provider, model, proxy, or TUN behavior.
- Killing arbitrary processes that happen to use port 9099.
- Guaranteeing cleanup after a hard OS crash or Task Manager kill.
- Replacing Stop CodexHub-managed Gateway when the app fully exits #112's terminal-exit acceptance criteria.
Verification
- Add targeted deterministic Rust lifecycle concurrency/state-machine tests, including simultaneous starts and start/stop/restart races.
- Run cd src-tauri; cargo test --locked.
- Run cd src-tauri; cargo clippy --locked --all-targets -- -D warnings.
- If frontend state changes, run npm run test:ui-contract and npm run build.
- Perform the packaged Windows Debug smokes named in the acceptance criteria, covering both explicit Start and Connect-to-CodexHub during delayed auto-start.
- After this issue and Stop CodexHub-managed Gateway when the app fully exits #112 are integrated, perform one combined lifecycle smoke: window close-to-tray retains the single Gateway; tray Exit stops the session-owned Gateway.
Expected hotset
- src-tauri/src/proxy.rs
- src-tauri/src/main.rs
- Rust lifecycle coordinator/state tests adjacent to those modules
- Gateway status/control frontend only if needed for Starting/Stopping/Restarting state
Dependencies
- Blocks Stop CodexHub-managed Gateway when the app fully exits #112. Stop CodexHub-managed Gateway when the app fully exits #112 remains the owner of full-exit and update-restart cleanup behavior.
- The GLM-5.2 shortest-path fixes (Restore bundled provider/model capabilities when installed runtime config predates them #108 and Return permanent apply_patch adapter validation failures as non-retryable client errors #134) are already complete; this lifecycle hardening does not block current single-Gateway use.
Execution contract
Execution-Contract: v2
Verification-Class: strict
Verification-Commands: cd src-tauri; cargo test --locked; cd src-tauri; cargo clippy --locked --all-targets -- -D warnings; frontend checks only if the frontend changes
Manual-Evidence: Packaged Windows Debug: while startup refresh keeps auto-start pending, run two isolated legs—press Start once, then in a fresh leg press Connect to CodexHub—and prove each leg has one Gateway child, one port 9099 listener, and identical UI/command/PID-file/listener identity.
Architecture-Decision: resolved
Review-Owner: orchestrator
Activity
NOirBRight commentedon Jul 15, 2026
2026-07-16 second process-level reproduction
The duplicate-start race reproduced again after one CodexHub process launched its Gateway:
127.0.0.1:9099and matchedproxy.pid;This independently confirms the existing #139 contract: startup is not singleflight, and the losing child is not reliably reaped. No second listener existed.
NOirBRight commentedon Jul 15, 2026
Reporter correction: no manual Start Gateway action
The reporter confirms that this second reproduction did not involve pressing Start Gateway. The observed sequence was:
The process/log timeline and source path align with that report:
serving ... 127.0.0.1:9099entries;refresh_at_startup()before its delayedproxy::start();switch_mode, and when the returned state still saysproxy_running=false, it independently callsstartProxy().Therefore the confirmed race is delayed auto-start versus Connect-to-CodexHub fallback startup, not only auto-start versus the explicit Start Gateway button. Two successive OS listener probes also attributed the sole 9099 listener to different child PIDs while
proxy.pidnamed only one, so the extra process cannot be treated as a harmless already-exited loser.NOirBRight commentedon Jul 16, 2026
Stable 0.1.5 process-level reproduction
The signed Normal 0.1.5 release at
ddcbbee77d3faaac3e5f282d2741e8f268f20e79still reproduced the duplicate lifecycle state after the reporter connected CodexHub:codex_proxy.py --host 127.0.0.1 --port 9099command at the same startup second;proxy.pidnamed only one canonical child;/healthstill reported Running/HTTP 200, so endpoint health masked the conflicting process identity.This is new Stable-release evidence for the existing #139 failure boundary, not a separate Issue. The exact user-action race for this occurrence is not inferred beyond the observed connect/startup sequence.
NOirBRight commentedon Jul 17, 2026
Resolved by #166 (merge commit ff9aa7b).
Gateway lifecycle operations are serialized across desktop GUI, CLI, tray, web bridge, and automatic startup behind one OS-managed cross-process transaction. Running publication now requires reconciled health, PID metadata with process-creation identity, exact process command identity, and actual listener ownership (native GetExtendedTcpTable). Destructive stop/kill requires exact ownership; failed starts return structured recovery ownership; port-change restart stops the recorded old port first.
Verification: full Rust suites (normal/debug), strict clippy, frontend build and UI contract, release flavor contract, Python tests all green on the merged HEAD; independent Spec/Standards reviews passed. The Issue-required packaged Windows Debug timing smoke completed both legs — pressing Start Gateway and pressing Connect to CodexHub while auto-start was pending each produced exactly one Gateway child, one port 9099 listener, and identical UI/command/proxy.pid/health/listener PID, with clean teardown. The smoke driver passed independent review.