@tool class_name McpServerLifecycleManager extends RefCounted ## Server spawn / stop / respawn / adopt / recover orchestration plus the ## update-reload handoff. Owns the server-state machine ## (`McpServerState`), version-check seam (`McpServerVersionCheck`), ## adoption metadata, and connection-blocked / dev-mismatch flags. ## ## State previously lived on plugin.gd; PR 6 (#297) moved it here so ## PR 7 (UpdateManager extraction) can absorb the same encapsulation ## pattern. The plugin still owns the physical editor surfaces ## (Connection, Dock, Timer, EditorSettings I/O) and exposes them via ## `_host.()` shims; the test fixtures override those shims to ## drive the manager without touching the editor. ## ## `_host` is untyped to honor the self-update field-storage policy ## plugin.gd calls out near `_connection`. var _host const UvCacheCleanup := preload("res://addons/godot_ai/utils/uv_cache_cleanup.gd") const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd") const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd") const McpServerStateScript := preload("res://addons/godot_ai/utils/mcp_server_state.gd") const McpStartupPathScript := preload("res://addons/godot_ai/utils/mcp_startup_path.gd") const McpAdoptionLabelScript := preload("res://addons/godot_ai/utils/mcp_adoption_label.gd") const McpServerVersionCheckScript := preload("res://addons/godot_ai/utils/server_version_check.gd") # ---- State (owned here, was on plugin.gd through PR 5) --------------- ## Single source of truth for the server-spawn/adopt/version lifecycle. ## See `McpServerState` for the transition table. var _server_state: int = McpServerStateScript.UNINITIALIZED ## OS-level state populated only when WE spawned the process. var _server_pid: int = -1 ## keep_server_on_exit (#800): whether the RUNNING server was launched with ## the keep-alive env opt-outs (no owner pid, NO_IDLE_EXIT staged). Editor ## teardown routes on this, never on the live setting — a server spawned ## without the opt-outs must die with the editor even if the user enabled ## the setting mid-session, or the owner-PID watchdog reaps it seconds ## later and the preserved record goes stale (the #774 scenario). Set at ## spawn, recovered from the managed-server record on adoption. var _server_keep_alive := false var _server_spawn_ms: int = 0 var _server_exit_ms: int = 0 ## Elapsed-since-spawn at the first watch tick that saw the spawn PID dead, or ## 0 when it is alive / has been healed onto the real PID. Only meaningful ## while a Windows trampoline handoff is being waited out (#797): it preserves ## the true exit time so a diagnosis raised after the wait still reports when ## the process actually died. Reset per spawn alongside `_server_spawn_ms`. var _spawn_dead_since_ms: int = 0 ## Version metadata. `expected_version` is what the plugin shipped with; ## `actual_version` is what the live server reported via handshake_ack. var _server_expected_version: String = "" var _server_actual_version: String = "" var _server_actual_name: String = "" ## Diagnostic + recovery flags surfaced to the dock via `get_status()`. var _server_status_message: String = "" ## #647: when a post-crash probe pins the failure on a specific port held ## by a foreign process, this names that port (HTTP or WS) so the dock's ## status line and port-picker gating don't blame the wrong one. Zero when ## no conflict was diagnosed. var _conflict_port: int = 0 var _can_recover_incompatible: bool = false var _connection_blocked: bool = false ## One-shot guard for the stale-uvx-index recovery (#172). Reset at the ## top of `start_server` so each fresh spawn attempt gets its own ## refresh budget. var _refresh_retried: bool = false ## One-shot guard for the spawn-lost-port-race re-adoption (see ## `_diagnose_spawn_fast_exit`). #805: the budget is per RECOVERY, not per ## walk — a walk the re-adopt arm itself triggered must NOT refresh it ## (`_readopt_walk_pending` skips the top-of-walk reset), or a flapping ## godot-ai occupant sustains spawn → fast-exit → re-walk forever. The ## budget refreshes on the paths that prove recovery: a fresh ## user/plugin-initiated walk, a successful `adopt_compatible_server`, ## or a spawn that survives to publish its pid-file. var _readopt_after_spawn_exit_retried: bool = false ## #805: set by the re-adopt arm just before it re-runs `start_server`, ## consumed by the top of `_start_server_impl` to skip that walk's ## `_readopt_after_spawn_exit_retried` reset. Never true outside that ## one triggered walk. var _readopt_walk_pending: bool = false ## Bounded deadline for the foreign-port adoption-confirmation watcher. ## Zero when disarmed. var _adoption_watch_deadline_ms: int = 0 ## Branch-tag from the most recent `start_server` walk. See ## `McpStartupPath`. Drives the startup-trace log. var _startup_path: String = McpStartupPathScript.UNSET ## Version-check seam. Lazily constructed on `arm_version_check` so ## tests that exercise the manager without a connection don't have to ## stub it out. var _version_check ## #678: when true, the blocking primitives on the startup path (port ## scrapes, per-PID brand shells, the HTTP status probe, kill + port-drain ## waits) run on a WorkerThreadPool thread while the main thread keeps ## pumping frames — the editor stays responsive during plugin init/reload ## on a contended port; the dock panel just arrives a beat later. The ## plugin enables this in production. Default false: unit tests (and any ## legacy caller) keep the historical fully-synchronous behavior, where ## the startup coroutines never actually suspend and call-then-assert ## still works. var defer_blocking_work: bool = false ## Cancellation for in-flight async startup work: bumped by `stop_server` ## (and therefore by `_exit_tree` and update-reload prep), checked after ## every await so a suspended `start_server` can't resurrect state — or ## spawn a server — after teardown started. var _async_generation: int = 0 ## Re-entrancy guard: with startup a coroutine, a second `start_server` ## call (respawn watch, dock button) can land mid-flight. var _start_in_flight: bool = false func _init(host) -> void: _host = host ## The worker thread of the walk's current `_run_blocking` call, while it ## runs. `_invalidate_async_startup` JOINS it (bounded by the blocking ## op's own timeout) so no worker can still be executing a plugin method ## when `_exit_tree` frees the plugin — a mid-call free is use-after-free ## on the worker, which wedged the editor on macOS during rapid reload ## churn (main CI, post-#682). Null when no blocking work is in flight. var _active_blocking_thread: Thread = null ## Run `work` off the main thread and suspend until it completes (#678). ## Falls back to inline execution when `defer_blocking_work` is off, or ## when no SceneTree is available to pump frames against. ## ## Uses a dedicated Thread (the dock's #238/#239 worker pattern) rather ## than WorkerThreadPool: `wait_to_finish()` hands the return value back ## without a shared mutable container, and this plugin has already seen ## WorkerThreadPool tasks SIGABRT under concurrency (see the notes in ## script_handler.gd / filesystem_handler.gd). `wait_to_finish` after ## `is_alive()` goes false joins an already-dead thread, so it never ## blocks the main thread. ## ## Returns null (without joining) when `_invalidate_async_startup` took ## ownership of the thread mid-flight — the walk is stale at that point ## and must bail. Callers therefore assign the result to an untyped ## local and bail on `_async_stale(...) or result == null` BEFORE any ## typed use — a typed assignment (or a bool()/int() constructor, both ## of which have no Nil form) trips on the null first. The null check is ## not redundant with the generation check: a caller that loses the slot ## without a generation bump — an invariant violation, but exactly what ## a concurrent fire-and-forget `_run_blocking` user produces — must ## still unwind instead of crashing on the Nil. func _run_blocking(work: Callable) -> Variant: if not defer_blocking_work: return work.call() var tree := Engine.get_main_loop() if not (tree is SceneTree): return work.call() var thread := Thread.new() if thread.start(work) != OK: return work.call() _active_blocking_thread = thread while thread.is_alive(): await (tree as SceneTree).process_frame if _active_blocking_thread != thread: ## Teardown/invalidation already joined this thread; the ## result belongs to a cancelled walk. All resumes and joins ## happen on the main thread, so this check cannot race. return null if _active_blocking_thread != thread: return null _active_blocking_thread = null return thread.wait_to_finish() func _async_stale(generation: int) -> bool: return generation != _async_generation ## Cancel any in-flight async startup walk AND release the re-entrancy ## guard so the very next `start_server()` call walks fresh (#682 review). ## Every one-shot kill-and-restart path must call this before its ## follow-up start: without the generation bump the suspended walk ## resumes against post-kill reality (stale live-status snapshots), and ## without releasing the guard the follow-up start is silently swallowed. ## The cancelled walk unwinds via its post-await staleness checks and ## must NOT clear the guard itself — a newer walk may already own it ## (see the generation check in `start_server`). ## ## Also JOINS the walk's in-flight worker thread (bounded by that op's ## own timeout: lsof/netstat scrape, ≤800ms status probe, or kill + ## port-drain wait). `stop_server` runs this from `_exit_tree`, so once ## it returns no worker thread can still be executing a method of the ## plugin that is about to be freed — the macOS reload-churn wedge. func _invalidate_async_startup() -> void: _async_generation += 1 _start_in_flight = false var thread := _active_blocking_thread _active_blocking_thread = null if thread != null: thread.wait_to_finish() # ---- Public state accessors -------------------------------------------- func get_state() -> int: return _server_state func get_status_dict() -> Dictionary: return { "state": _server_state, "exit_ms": _server_exit_ms, "actual_name": _server_actual_name, "actual_version": _server_actual_version, "expected_version": _server_expected_version, "message": _server_status_message, "can_recover_incompatible": _can_recover_incompatible, "connection_blocked": _connection_blocked, "conflict_port": _conflict_port, "keep_alive": _server_keep_alive, } func get_server_pid() -> int: return _server_pid func get_startup_path() -> String: return _startup_path func get_adoption_watch_deadline_ms() -> int: return _adoption_watch_deadline_ms func is_awaiting_server_version() -> bool: return _version_check != null and _version_check.is_active() func is_connection_blocked() -> bool: return _connection_blocked # ---- State-machine entry points --------------------------------------- ## Validated transition. Returns true on success; false (and logs a ## warning) when the transition is illegal under `McpServerState`'s ## table. Callers that need first-writer-wins among terminal diagnoses ## use `set_terminal_diagnosis` instead — that helper silently no-ops ## without warning when the diagnosis would be a regression. func transition_state(target: int) -> bool: if _server_state == target: return true if not McpServerStateScript.can_transition(_server_state, target): push_warning( "MCP | rejected illegal state transition %s -> %s" % [ McpServerStateScript.name_of(_server_state), McpServerStateScript.name_of(target), ] ) return false _server_state = target return true ## First-writer-wins mutator for terminal diagnoses (CRASHED, ## NO_COMMAND, PORT_EXCLUDED, INCOMPATIBLE, FOREIGN_PORT). Used during ## spawn to make sure a late watch-loop CRASHED doesn't clobber an ## earlier proactive PORT_EXCLUDED. Silent no-op when the current state ## is already a terminal diagnosis — the existing diagnosis is kept. func set_terminal_diagnosis(target: int) -> bool: if not McpServerStateScript.is_terminal_diagnosis(target): push_warning( "MCP | set_terminal_diagnosis called with non-terminal %s" % McpServerStateScript.name_of(target) ) return false if McpServerStateScript.is_terminal_diagnosis(_server_state): return false _server_state = target return true # ---- Adoption confirmation watcher ------------------------------------- ## Arm the FOREIGN_PORT adoption-confirmation watcher. SPAWN_GRACE_MS ## ahead of `now`; `tick_adoption_watch` self-disarms after this expires ## so per-frame cost drops back to zero on a permanent foreign occupant. func arm_adoption_watch() -> void: _adoption_watch_deadline_ms = ( Time.get_ticks_msec() + int(_host.SPAWN_GRACE_MS) ) func tick_adoption_watch(now_msec: int) -> void: if _adoption_watch_deadline_ms > 0 and now_msec >= _adoption_watch_deadline_ms: _adoption_watch_deadline_ms = 0 # ---- Server version-check seam ---------------------------------------- func arm_version_check(connection, expected_version: String) -> void: if _version_check == null: _version_check = McpServerVersionCheckScript.new(self) var expected := _resolve_expected_version(expected_version) _server_expected_version = expected _version_check.arm(connection, expected) func disarm_version_check() -> void: if _version_check != null: _version_check.disarm() func get_version_check(): return _version_check ## Resolves a possibly-empty expected version to the plugin's shipping ## version. Manager methods that are called via test fixtures may ## receive an empty string when the test never seeded ## `_server_expected_version`, so this is the one place that fallback ## lives. func _resolve_expected_version(supplied: String) -> String: if not supplied.is_empty(): return supplied return _expected_server_version() func _expected_server_version() -> String: return ClientConfigurator.get_plugin_version() ## Called by McpServerVersionCheck when handshake_ack carries a version ## string. Decides compatible vs incompatible and transitions the state. func handle_server_version_verified(expected_version: String, version: String) -> void: _server_actual_name = "godot-ai" _server_actual_version = version var expected := _resolve_expected_version(expected_version) _server_expected_version = expected var compatibility := _server_version_compatibility(version, expected) if compatibility.get("compatible", false): _can_recover_incompatible = false ## Foreign-port and post-spawn handshakes both clear to READY ## on a successful handshake. Late re-arms from READY also land ## here and self-confirm. transition_state(McpServerStateScript.READY) _host._update_process_enabled() return var live := {"version": version, "status_code": 200, "name": "godot-ai"} ## Connection propagation + version-check disarm + process re-evaluation ## all live inside _set_incompatible_server now (#691) so the startup-walk ## recovery-failure and force-restart-failure paths get them too. _set_incompatible_server(live, expected, ClientConfigurator.http_port()) func handle_server_version_unverified(expected_version: String) -> void: var expected := _resolve_expected_version(expected_version) _server_expected_version = expected var live := {"version": "", "status_code": 0, "error": "missing_handshake_ack"} _set_incompatible_server(live, expected, ClientConfigurator.http_port()) # ---- Compatibility / version helpers (pure) --------------------------- ## Plugin and server speak a single, version-coupled protocol — new commands ## and response fields are added together. Treating dev-mode mismatches as ## "compatible" silently adopts a stale server whose code may differ from the ## live source tree (e.g. another worktree on a different branch holding ## port 8000). Strict match in all modes routes mismatches through ## `recover_strong_port_occupant`, which kills the branded port-holder and ## lets `start_server` spawn fresh against the current source. static func _server_version_compatibility( actual_version: String, expected_version: String ) -> Dictionary: if actual_version.is_empty(): return {"compatible": false, "reason": "unknown"} if actual_version == expected_version: return {"compatible": true, "reason": "exact"} return {"compatible": false, "reason": "version_mismatch"} static func _server_status_compatibility( actual_version: String, expected_version: String, actual_ws_port: int, expected_ws_port: int, ) -> Dictionary: var version_result := _server_version_compatibility(actual_version, expected_version) if not bool(version_result.get("compatible", false)): return version_result if actual_ws_port != expected_ws_port: return {"compatible": false, "reason": "ws_port_mismatch"} return version_result static func _managed_record_has_version_drift(record_version: String, current_version: String) -> bool: return not record_version.is_empty() and record_version != current_version # ---- Incompatible-server bookkeeping ---------------------------------- func _set_incompatible_server( live: Dictionary, expected_version: String, port: int, caller_owns_worker_slot := false ) -> void: ## Latches the incompatible diagnosis into manager state and asks ## the dock to re-sweep client rows so they don't show stale green. ## Threads the caller's `live` snapshot through the recovery proof ## helper so we don't double-probe the port (~500ms each). ## ## Coroutine (#712): the recovery-proof evaluation (port scrapes + ## per-PID brand shells) and the free-port bind probes run via ## `_run_blocking` — these fire in exactly the contended/crashed ## scenarios #678 de-blocked, so they must not stall the main thread ## either. Everything user-visible (status message, connection block, ## version-check disarm) is latched synchronously before the first ## await; only the recovery verdict and the suggested-port diagnostic ## arrive with the worker. ## ## `_run_blocking` tracks a single active worker, so the tail below ## needs exclusive ownership of that slot. The startup walk awaits ## this call with `caller_owns_worker_slot=true` — it already owns the ## slot and serializes the tail behind its own blocking ops. Sync ## callers (the handshake verdicts via `handle_server_version_*`, the ## force-restart failure arm) fire-and-forget the tail and leave the ## flag false, so the head takes ownership for them: a handshake ## verdict lands from `_process` while a startup walk can still be ## suspended in `_run_blocking`, and starting the tail's worker then ## would steal the slot — the walk's op is orphaned from the ## `_invalidate_async_startup` join guarantee and its resume gets a ## null without a generation bump (the Nil-into-Dictionary crash on ## the incompatible-occupant walk). Cancelling the walk first mirrors ## the recovery click (#712): the diagnosis in hand supersedes ## whatever the walk was still probing for. if not caller_owns_worker_slot: _invalidate_async_startup() transition_state(McpServerStateScript.INCOMPATIBLE) _connection_blocked = true _server_expected_version = expected_version _server_actual_name = str(live.get("name", "")) _server_actual_version = _live_version_for_message(live) _server_status_message = _incompatible_server_message( live, expected_version, port, int(_host._resolved_ws_port) ) ## Conservative default until the off-thread proof lands: the dock ## paints "not recoverable" rather than offering a kill we have not ## yet proven ownership for. _can_recover_incompatible = false _host._refresh_dock_client_statuses() ## Propagate the verdict to the live connection (#691). Pre-#678 the ## startup walk finished synchronously before `_connection` existed, so ## plugin.gd captured the INCOMPATIBLE verdict when constructing it. ## Post-#678 the walk suspends at its first `_run_blocking` and the ## plugin snapshots the pre-walk defaults (`connect_blocked=false`) — so ## a verdict landing later (startup-walk recovery failure, handshake ## mismatch, force-restart failure) must reach the connection here, or ## it keeps dialing the WS port forever. Also disarm the version check: ## the diagnosis already landed, so leaving the check armed keeps ## per-frame `_process` on for the plugin's whole lifetime. if _host._connection != null: _host._connection.connect_blocked = true _host._connection.connect_block_reason = _server_status_message _host._connection.disconnect_from_server() disarm_version_check() _host._update_process_enabled() ## Off-thread recovery proof (#712), mirroring recover_strong_port_occupant: ## the EditorSettings record is read on the main thread up front and ## injected as record_override — EditorSettings is main-thread-only. var async_gen := _async_generation var record: Dictionary = _host._read_managed_server_record() var proof_result: Variant = await _run_blocking(func() -> Variant: if not is_instance_valid(_host): return {"proof": "", "pids": []} return _host._evaluate_recovery_port_occupant_proof(port, live, record) ) if _async_stale(async_gen) or proof_result == null: return var proof: Dictionary = proof_result var proof_name := str(proof.get("proof", "")) _can_recover_incompatible = not proof_name.is_empty() print("MCP | proof: %s" % (proof_name if _can_recover_incompatible else "(none)")) if not _can_recover_incompatible: ## Non-recoverable: a foreign / unprovable occupant holds the port and ## we have no ownership proof, so we must NOT kill it — surface a ## concrete free port the user can switch to instead (the same hint ## the dock crash body renders). Logging it to the editor output also ## lets `ci-stale-server-smoke --mode foreign` assert this upstream ## classification from CI. Reservation-aware on Windows; the bind ## probes behind suggest_free_port also run off-thread (#712). var suggested_result: Variant = await _run_blocking(func() -> Variant: return ClientConfigurator.suggest_free_port(port + 1) ) if _async_stale(async_gen) or suggested_result == null: return print("MCP | port %d occupant not recoverable (no ownership proof); suggested free port %d (set godot_ai/http_port)" % [port, int(suggested_result)]) ## Second sweep so the dock's recovery affordance reflects the verdict ## that just landed. _host._refresh_dock_client_statuses() static func _incompatible_server_message( live: Dictionary, expected_version: String, port: int, expected_ws_port: int ) -> String: var version := _live_version_for_message(live) var actual_ws_port := _live_ws_port_for_message(live) ## `package_path` is a v2.4.4+ field — older servers omit it. Suffix ## the message with "(loaded from )" when present so the user ## can tell *which* `src/godot_ai/` is serving the port without ## walking the process tree. See #416. var package_path := _live_package_path_for_message(live) var path_suffix := " (loaded from %s)" % package_path if not package_path.is_empty() else "" ## After a plugin update, the usual occupant is a backend kept alive by ## AI-client attach bridges still pinned to the previous version (their ## leases outrank us — #669/#839, we must not kill it). Name that repair ## first; "stop the old server" alone reads as a dead end when the server ## respawns the moment the user kills it. var repair := ( "If AI-client attach bridges are keeping it alive, run Configure all to " + "repin them, then restart those client apps — the old server exits on " + "its own. Otherwise stop it manually or change both HTTP and WS ports." ) if not version.is_empty(): if actual_ws_port > 0 and actual_ws_port != expected_ws_port: return ( "Port %d is occupied by godot-ai server v%s using WS port %d%s; " + "plugin expects v%s with WS port %d. %s" ) % [port, version, actual_ws_port, path_suffix, expected_version, expected_ws_port, repair] return ( "Port %d is occupied by godot-ai server v%s%s; plugin expects v%s. %s" ) % [port, version, path_suffix, expected_version, repair] var status_code := int(live.get("status_code", 0)) if status_code > 0: return ( "Port %d is occupied by an unverified server (status endpoint returned HTTP %d); " + "plugin expects godot-ai v%s. Stop the other server or change both HTTP and WS ports." ) % [port, status_code, expected_version] return ( "Port %d is occupied by another process; plugin expects godot-ai v%s. " + "Stop the other process or change both HTTP and WS ports." ) % [port, expected_version] static func _live_status_identifies_godot_ai(live: Dictionary) -> bool: return str(live.get("name", "")) == "godot-ai" static func _live_version_for_message(live: Dictionary) -> String: if live.has("name") and str(live.get("name", "")) != "godot-ai": return "" return str(live.get("version", "")) static func _live_ws_port_for_message(live: Dictionary) -> int: if live.has("name") and str(live.get("name", "")) != "godot-ai": return 0 return int(live.get("ws_port", 0)) static func _live_package_path_for_message(live: Dictionary) -> String: ## Only trust the path when the live snapshot confirms a godot-ai ## server — a probe of some unrelated HTTP service could in theory ## return a `package_path` JSON field, and we don't want to mislabel ## that as "godot-ai loaded from …" in the incompatible banner. if live.has("name") and str(live.get("name", "")) != "godot-ai": return "" return str(live.get("package_path", "")) # ---- start_server / spawn watch / respawn ----------------------------- ## Sets GODOT_AI_DISABLE_TELEMETRY in the process environment for the ## upcoming OS.create_process call if: (a) neither GODOT_AI_DISABLE_TELEMETRY ## nor DISABLE_TELEMETRY is already set to a *truthy* value (a falsey "0" does ## NOT count — it must not suppress a dock UI opt-out), and (b) the effective ## McpSettings.telemetry_enabled() is false. Returns true if the var was ## injected so the caller can unset it after spawning. func _inject_telemetry_env() -> bool: ## If telemetry is already disabled by a *truthy* env var, leave the env as ## the user/CI set it — the post-spawn cleanup unsets what we inject, so ## injecting here would strip their own var from the editor process. A ## *falsey* value (e.g. DISABLE_TELEMETRY=0) must NOT count as "handled": ## fall through so a dock UI opt-out still reaches the spawned server. The ## truthy test mirrors McpSettings.telemetry_enabled() and the Python server. if McpSettings.env_truthy("GODOT_AI_DISABLE_TELEMETRY") or McpSettings.env_truthy("DISABLE_TELEMETRY"): return false if not McpSettings.telemetry_enabled(): OS.set_environment("GODOT_AI_DISABLE_TELEMETRY", "true") return true return false ## Set GODOT_AI_OWNER_PID to this editor's PID for the next OS.create_process, ## so the spawned server can self-reap if this editor crashes. Returns true if ## set (caller must unset right after spawning — keep it out of the persistent ## editor env). No-op on Windows, where the server's reaper is disabled. func _set_owner_pid_env() -> bool: if OS.get_name() == "Windows": return false ## keep_server_on_exit (#800): a server meant to outlive editors must not ## self-reap when this editor dies — don't hand it an owner pid at all. if ClientConfigurator.keep_server_on_exit(): return false OS.set_environment("GODOT_AI_OWNER_PID", str(OS.get_process_id())) return true ## Mark the next OS.create_process as plugin-spawned so the server arms its ## session-idle self-terminate backstop (#498): with zero editor sessions for ## a grace window, it exits on its own. Unlike the owner-PID reaper this is ## pure session-count on the server side, so it is set on EVERY platform — ## including Windows, where owner-PID is skipped; this marker is what finally ## gives Windows orphan coverage (#497). Same env-channel rationale and same ## tight scoping as _set_owner_pid_env: callers unset it right after spawning ## so a later manually-started dev server can never inherit it and idle-kill ## itself. func _set_plugin_spawned_env() -> void: OS.set_environment("GODOT_AI_PLUGIN_SPAWNED", "1") ## keep_server_on_exit (#800): opt the spawned server out of the ## session-idle self-terminate backstop (#498) via its existing ## GODOT_AI_NO_IDLE_EXIT escape hatch — a keep-alive server sits at zero ## sessions between editor runs by design, which is exactly what the ## backstop reaps. Returns true if set (same tight scoping as ## _set_owner_pid_env: callers unset right after spawning, and only when ## WE set it, so a user's own NO_IDLE_EXIT env is never stripped). func _set_keep_alive_env() -> bool: if not ClientConfigurator.keep_server_on_exit(): return false OS.set_environment("GODOT_AI_NO_IDLE_EXIT", "1") return true ## Generate a fresh per-launch WS handshake auth token (#690) and stage it ## in the env for the next OS.create_process, same channel and same tight ## scoping as _set_owner_pid_env (callers unset right after spawning — the ## secret must not linger in the editor env). The caller hands the returned ## token to the host on successful spawn so the connection echoes it in the ## handshake and the managed-server record persists it across reloads. func _set_ws_token_env() -> String: var token := Crypto.new().generate_random_bytes(32).hex_encode() OS.set_environment("GODOT_AI_WS_TOKEN", token) return token ## Branch table (recorded version is the "is this ours?" signal — uvx ## launcher PIDs go stale; #135/#137): ## port free -> spawn fresh, record PID ## port in use, record matches + live ok -> adopt port owner (heals PID) ## port in use, record drifts -> kill owner + respawn ## port in use, no verified live match -> block adoption + warn ## ## #678: this is a coroutine in production (`defer_blocking_work`) — the ## port scrapes, status probes, and kill-drain waits run off the main ## thread and the state machine resumes between frames, so the editor ## stays responsive when the port is contended. With the flag off (unit ## tests) nothing suspends and the call completes synchronously. func start_server() -> void: if _start_in_flight: return _start_in_flight = true var gen := _async_generation await _start_server_impl(gen) ## Only release the guard if this walk is still the current one — a ## cancelled (stale) walk unwinding here must not clobber the guard a ## newer walk armed after `_invalidate_async_startup`. if gen == _async_generation: _start_in_flight = false ## Walk-completion continuation lives HERE — on the RefCounted ## manager, kept alive by its own suspended state — never on the ## plugin: resuming a coroutine of a freed Node errors out, and ## reload churn frees plugin instances while walks are suspended. if is_instance_valid(_host) and _host.has_method("_finish_startup_trace_after_walk"): _host._finish_startup_trace_after_walk() func _start_server_impl(async_gen: int) -> void: if _host._server_started_this_session: ## Static flag persists across disable/enable cycles in one editor ## session — re-entrant spawn guard for plugin-reload-during-update. _startup_path = McpStartupPathScript.GUARDED transition_state(McpServerStateScript.GUARDED) return _refresh_retried = false if _readopt_walk_pending: ## #805: this walk was triggered by the fast-exit re-adopt arm. ## Keep the spent budget: if this walk ends up spawning and that ## spawn fast-exits against a live godot-ai again, the occupant is ## flapping and the diagnosis must latch terminal instead of ## re-walking forever. Recovery paths (adoption, healthy spawn) ## refresh the budget explicitly. _readopt_walk_pending = false else: _readopt_after_spawn_exit_retried = false _conflict_port = 0 var port := ClientConfigurator.http_port() var ws_port := ClientConfigurator.ws_port() var current_version := _expected_server_version() _server_expected_version = current_version ## The worker closures re-check the host: the plugin can be freed while ## a bounded shell probe is still running, and the generation check only ## protects state after resume, not calls inside the task (#682 review). var port_in_use_result: Variant = await _run_blocking(func() -> Variant: return is_instance_valid(_host) and _host._is_port_in_use(port) ) if _async_stale(async_gen) or port_in_use_result == null: return var port_in_use := bool(port_in_use_result) if not port_in_use: ## #745: after an editor crash (or under multi-editor churn) the ## managed server keeps running, yet the bind probe can still say ## "free" (Windows lets a SO_REUSEADDR bind succeed over a live ## listener; the scrape fallback can fail transiently). The HTTP ## status probe is the authoritative tie-breaker and runs ## UNCONDITIONALLY: the pid-file evidence gate that used to guard it ## goes stale exactly when it's needed most — same-named test ## projects share one app_userdata dir, so another editor's walk can ## clear or overwrite the pid-file, and blind-spawning here produced ## the reproduced duplicate-spawn + 4003 token loop. A live godot-ai ## answer forces the adopt/recover branch below; an unresponsive ## port falls through to the normal spawn path at the cost of one ## fast connection-refused probe (off-thread in production). var evidence_result: Variant = await _run_blocking(func() -> Variant: if not is_instance_valid(_host): return {} return _host._probe_live_server_status_for_port(port) ) if _async_stale(async_gen) or evidence_result == null: return var evidence: Dictionary = evidence_result if _live_status_identifies_godot_ai(evidence): port_in_use = true if port_in_use: var record: Dictionary = _host._read_managed_server_record() var record_version := str(record.get("version", "")) var record_ws_port := int(record.get("ws_port", 0)) _host._set_resolved_ws_port(PortResolver.resolved_ws_port_for_existing_server( record_ws_port, record_version, current_version, int(_host._resolve_ws_port()) )) ws_port = int(_host._resolved_ws_port) ## Untyped first: a cancelled walk gets null back (see _run_blocking) ## and must reach the staleness check before any typed cast. var live_result: Variant = await _run_blocking(func() -> Variant: if not is_instance_valid(_host): return {} return _host._probe_live_server_status_for_port(port) ) if _async_stale(async_gen) or live_result == null: return var live: Dictionary = live_result var live_version := str(_host._verified_status_version(live)) var live_ws_port := int(_host._verified_status_ws_port(live)) var compatibility: Dictionary = _server_status_compatibility( live_version, current_version, live_ws_port, ws_port, ) if compatibility.get("compatible", false): _server_actual_name = "godot-ai" _server_actual_version = live_version _can_recover_incompatible = false ## A matching version is compatibility evidence, not ownership ## evidence (#759/#764). A stale EditorSettings record can name a ## dead PID while an unrelated compatible server owns the port. ## Retain managed ownership only when the recorded PID is itself ## the live, branded listener. var adoption_proof_result: Variant = await _run_blocking(func() -> Variant: if not is_instance_valid(_host): return {"proof": "", "pids": []} return _host._evaluate_strong_port_occupant_proof(port, live, record) ) if _async_stale(async_gen) or adoption_proof_result == null: return var adoption_proof: Dictionary = adoption_proof_result var proof_pids: Array[int] = [] proof_pids.assign(adoption_proof.get("pids", [])) var owner := int(proof_pids[0]) if not proof_pids.is_empty() else 0 var record_owns_listener := str(adoption_proof.get("proof", "")) == "managed_record" var owner_label := adopt_compatible_server( record_version, current_version, owner, record_owns_listener ) _host._server_started_this_session = true _startup_path = McpStartupPathScript.ADOPTED transition_state(McpServerStateScript.READY) print(_compatible_adoption_log_message( owner_label, int(_server_pid), owner, str(_server_actual_version), live_ws_port, current_version )) return if bool(_managed_record_has_version_drift(record_version, current_version)): print("MCP | managed server v%s does not match plugin v%s, restarting" % [record_version, current_version]) ## Forward `live` so the recovery proof helper reuses our snapshot. ## The kill invalidates it, so the failure arm re-probes below. var recovered: bool = await recover_strong_port_occupant(port, 3.0, live) if _async_stale(async_gen): return if not recovered: _host._server_started_this_session = true var post_recovery_result: Variant = await _run_blocking(func() -> Variant: if not is_instance_valid(_host): return {} return _host._probe_live_server_status_for_port(port) ) if _async_stale(async_gen) or post_recovery_result == null: return var post_recovery_live: Dictionary = post_recovery_result ## Awaited with caller_owns_worker_slot=true (#712): the ## diagnosis tail runs its own _run_blocking proof, and the walk ## stays the single owner of the active-worker slot by ## serializing that tail behind this await instead of letting it ## re-take the slot. The status message is latched before the ## tail's first await, so the push_warning below reads the final ## text either way. await _set_incompatible_server(post_recovery_live, current_version, port, true) if _async_stale(async_gen): return _startup_path = McpStartupPathScript.INCOMPATIBLE push_warning(str(_server_status_message)) return else: _startup_path = McpStartupPathScript.FREE _host._set_resolved_ws_port(_host._resolve_ws_port()) ws_port = _host._resolved_ws_port _host._startup_trace_count("server_command_discovery") ## CLI-finder discovery shells out (which/where, login shell) on cache ## misses — the same #238/#239 family the dock already runs off-thread. var server_cmd_result: Variant = await _run_blocking(func() -> Variant: return ClientConfigurator.get_server_command() ) if _async_stale(async_gen) or server_cmd_result == null: return var server_cmd: Array = server_cmd_result if server_cmd.is_empty(): set_terminal_diagnosis(McpServerStateScript.NO_COMMAND) _startup_path = McpStartupPathScript.NO_COMMAND push_warning("MCP | could not find server command") return var cmd: String = server_cmd[0] var args: Array[String] = [] args.assign(server_cmd.slice(1)) args.append_array(_host._build_server_flags(port, ws_port)) ## Wipe any stale pid-file so a failed launch can't leave last ## session's PID for `_find_managed_pid` to read. _host._clear_pid_file() ## Proactive Windows port-reservation check (#146) — bind would ## fail silently with WinError 10013 inside a Hyper-V / WSL2 / ## Docker exclusion range; netstat shows nothing. if WindowsPortReservation.is_port_excluded(port): _host._server_started_this_session = true set_terminal_diagnosis(McpServerStateScript.PORT_EXCLUDED) _startup_path = McpStartupPathScript.RESERVED push_warning("MCP | port %d is reserved by Windows (Hyper-V / WSL2 / Docker)" % port) return ## ---- Spawn-time env-mutation window (#691) ------------------------- ## From here to the post-spawn unsets below, the editor's process-global ## environment is mutated around OS.create_process (which has no ## per-child env parameter). Two invariants keep this safe: ## 1. The window is SYNCHRONOUS main-thread code — no `await` between ## the first setenv and the last unsetenv — and worker dispatch also ## only happens on the main thread, so no new worker can start inside ## the window. ## 2. Already-running workers never call OS.get_environment: every env ## read reachable from a worker (path templates, config_home_override, ## CLI finder, mode_override/startup-trace) routes through ## McpPathTemplate.env_lookup, which serves worker threads from a ## main-thread-warmed snapshot. A concurrent glibc getenv during ## setenv can return a freed pointer — process-fatal. ## Residual (accepted): a worker's own OS.execute child (CLI status ## probe) launched while this window is open inherits the temp vars — ## rare, and tame next to the crash class above. var injected_telemetry_env := _inject_telemetry_env() ## PYTHONPATH handling for dev checkouts: when the editor is launched ## against a worktree whose `src/godot_ai/__version__` differs from the ## root repo's editable install, the dev-venv python's `sitecustomize` ## adds the *root repo's* `src/` to `sys.path`. The spawned server then ## reports the root repo's version, the plugin's compatibility check ## flags it as incompatible, and the user gets a Restart-Server loop ## with no exit. `start_dev_server` already prepends the worktree's ## `src/` for its --reload spawn; mirror that here for the auto-spawn ## path so the same worktree-vs-root version skew is impossible. Gated ## on `is_dev_checkout()` so production user installs (no nearby `src/`) ## are untouched. See #418. var worktree_src := "" var prev_pythonpath := "" var pythonpath_set := false if ClientConfigurator.is_dev_checkout(): worktree_src = ClientConfigurator.find_worktree_src_dir( ProjectSettings.globalize_path("res://") ) if not worktree_src.is_empty(): prev_pythonpath = OS.get_environment("PYTHONPATH") var sep := ";" if OS.get_name() == "Windows" else ":" var new_pp := ( worktree_src if prev_pythonpath.is_empty() else worktree_src + sep + prev_pythonpath ) OS.set_environment("PYTHONPATH", new_pp) pythonpath_set = true ## Tell the spawned server which editor owns it so it can self-reap if we ## die without a clean stop_server (crash / hard-kill). Passed via env, not ## a CLI flag, so an older server (staggered user-mode upgrade) silently ## ignores an unknown var instead of failing argparse. Scoped tightly around ## create_process and unset right after (like PYTHONPATH below): the child ## inherits it, but it must NOT linger in the editor env, or a later ## non-reload `godot-ai` subprocess (dev server, future spawn) would inherit ## it and wrongly arm a reaper keyed to this editor. ## Skipped on Windows: the server's reaper is POSIX-only for now (Windows ## process-liveness/self-shutdown isn't live-validated yet). The server ## gates on this too. var owner_env_set := _set_owner_pid_env() _set_plugin_spawned_env() var keep_alive_env_set := _set_keep_alive_env() var ws_token := _set_ws_token_env() _server_pid = OS.create_process(cmd, args) var spawned_pid := int(_server_pid) if owner_env_set: OS.unset_environment("GODOT_AI_OWNER_PID") OS.unset_environment("GODOT_AI_PLUGIN_SPAWNED") if keep_alive_env_set: OS.unset_environment("GODOT_AI_NO_IDLE_EXIT") OS.unset_environment("GODOT_AI_WS_TOKEN") ## Restore PYTHONPATH immediately — the spawned child has already ## copied the env, so the editor's own process state returns to ## baseline. Leaving it set would leak to any later OS.create_process ## from unrelated paths. if pythonpath_set: if prev_pythonpath.is_empty(): OS.unset_environment("PYTHONPATH") else: OS.set_environment("PYTHONPATH", prev_pythonpath) if injected_telemetry_env: OS.unset_environment("GODOT_AI_DISABLE_TELEMETRY") if spawned_pid > 0: _server_spawn_ms = Time.get_ticks_msec() _server_exit_ms = 0 _spawn_dead_since_ms = 0 _server_keep_alive = keep_alive_env_set _host._server_started_this_session = true transition_state(McpServerStateScript.SPAWNING) ## The child copied the env, so this token is what the server will ## verify handshakes against — adopt it BEFORE writing the record ## (the record write persists _ws_auth_token). _host._set_ws_auth_token(ws_token) ## Record the launcher PID so same-session ## prepare_for_update_reload has something to kill. The next ## editor start's adopt branch heals it to the real port owner. _host._write_managed_server_record(spawned_pid, current_version, _server_keep_alive) _startup_path = McpStartupPathScript.SPAWNED ## Log "PYTHONPATH prefix=" rather than "PYTHONPATH=" so the line ## isn't misleading when an existing PYTHONPATH was present — ## we prepended `worktree_src`, not replaced. Keeps the log ## compact (worktree_src is the actionable piece; the full ## prev_pythonpath can be 5+ entries long on dev machines). var suffix := " (PYTHONPATH prefix=%s)" % worktree_src if not worktree_src.is_empty() else "" print("MCP | started server (PID %d, v%s): %s %s%s" % [spawned_pid, current_version, cmd, " ".join(args), suffix]) _host._start_server_watch() else: _server_status_message = "" set_terminal_diagnosis(McpServerStateScript.CRASHED) _startup_path = McpStartupPathScript.CRASHED push_warning("MCP | failed to start server") ## Is the watched spawn PID's death still explainable as a launcher handoff ## rather than a server exit? (#797) ## ## Observed on Windows 11 with a uv-created venv: one boot in four logged ## "server exited after 5146ms" while the real server kept running and was ## then adopted. The watched PID had died on a healthy boot, and because the ## server had not yet written its pid-file there was nothing to heal onto, so ## the watch crossed SPAWN_GRACE_MS and reported an exit — rescued only by the ## crash-survivor adoption path. ## ## A uv venv's `python.exe` is a shim rather than the interpreter, and the real ## server does run under a *different* PID than the one `OS.create_process` ## hands back. But the original report's suspected mechanism — that the shim ## exits once its child is up — is **disproven**, not merely unconfirmed. A ## 12-boot run on Windows 11 with a uv venv found the spawned trampoline alive ## on every boot, with the child owning both the pid-file and the listener; a ## CI runner showed the same. The shim is a live parent for the process's whole ## life, so it is not what kills the watched PID. ## ## Two consequences worth keeping straight. First, this gate is keyed to the ## observable condition — watched PID dead, no pid-file yet — not to any theory ## of why it died, so it stays correct whatever the cause. Second, and less ## comfortable: in that same 12-boot run the false "server exited" line never ## appeared AND the watched PID never died, so the guard never fired. Those ## clean boots are evidence the symptom did not reproduce, NOT evidence this ## guard fixes it. The true cause of the original 1-in-4 report is still ## unknown; if it resurfaces, start from that rather than from the trampoline. ## ## `real_pid <= 0` means no pid-file exists yet, and that reliably means "this ## server has not published one" rather than "stale leftover": `start_server` ## wipes the pid-file immediately before every spawn. So an absent pid-file ## plus a dead spawn PID inside the window is the handoff signature. ## ## Deliberately gated to Windows. POSIX uv venvs exec rather than trampoline, ## so a dead spawn PID there really is a dead server, and delaying its ## diagnosis would only slow down honest crash reporting on the platforms ## where this cannot happen. `os_name` is a parameter rather than an ## `OS.get_name()` call so the Windows path is exercisable from any host. static func is_spawn_handoff_pending( os_name: String, real_pid: int, elapsed_ms: int, window_ms: int ) -> bool: if os_name != "Windows": return false if real_pid > 0: return false return elapsed_ms < window_ms ## First-write-wins stamp for the elapsed time at which the spawn PID was first ## observed dead (#797). ## ## A diagnosis raised after waiting out a handoff must still report when the ## process actually exited, not when the wait gave up — the point of #797 is an ## honest log line. Returns the existing stamp once one is set, so later ticks ## in the same wait cannot overwrite it; `<= 0` means "not yet stamped", ## matching how the field is cleared per spawn. static func first_death_stamp(current_stamp_ms: int, elapsed_ms: int) -> int: return current_stamp_ms if current_stamp_ms > 0 else elapsed_ms ## One-line forensic snapshot taken the moment a spawn is judged to have ## fast-exited (#797). ## ## #797 reported `server exited after 5146ms` on a healthy Windows boot, once ## in four. It is still unexplained: a 12-boot run on the reported ## configuration reproduced neither the symptom nor its suspected mechanism — ## the uv trampoline was alive on every boot, with the child owning the ## pid-file and the listener, so the shim's exit is ruled out as the cause. ## What killed that watched PID is unknown, and the log line at the time ## carried no evidence to answer it with. ## ## So capture the state at the moment of judgement rather than asking the next ## person to reproduce a 1-in-4 bug under observation. Everything here is read ## through seams the surrounding diagnosis already uses, on a path that only ## runs when a spawn is being declared dead, so it costs nothing in the ## healthy case. ## Deliberately does NOT scrape the port for listener PIDs. This runs from the ## 1 Hz watch loop, on a live frame, so a `_find_all_pids_on_port` subprocess ## here would stall the editor for a diagnostic. Deferring it via ## `_run_blocking` was the alternative and is worse: that helper is ## `await`-based, so it would turn this, `_diagnose_spawn_fast_exit` and ## `check_server_health` into coroutines — making the watch callback resume ## across arbitrary frames while its branches set terminal state and trigger ## re-adoption walks. That is the teardown-ordering hazard ## `_invalidate_async_startup` exists to contain, and it is not worth taking ## on for a log line. ## ## Little is lost: the probe on the very next line already establishes whether ## a godot-ai server answers on the port, and `_diagnose_spawn_port_conflict` ## names a foreign occupant when there is one. If you are tempted to add the ## PID list back, put it behind that existing conflict path rather than here. func _log_spawn_exit_forensics() -> void: var spawn_pid := int(_server_pid) var pid_file_pid := int(_host._read_pid_file_for_proof()) ## Computed here rather than accepted as a parameter. The caller's ## `elapsed` IS `_spawn_dead_since_ms` — #837 passes the true death time so ## the user-facing "server exited after Nms" line stays honest — so taking ## it would make these two fields report the same number, collapsing the ## exact distinction they exist to record. var diagnosed_at_ms := 0 if int(_server_spawn_ms) > 0: diagnosed_at_ms = Time.get_ticks_msec() - int(_server_spawn_ms) _host._log_buffer.log(format_spawn_exit_forensics({ "os": OS.get_name(), "launch_mode": ClientConfigurator.get_server_launch_mode(), "elapsed_ms": diagnosed_at_ms, ## Differs from elapsed_ms when a Windows handoff window was waited out ## (#824/#837): the true death time versus when we gave up on it. "first_dead_ms": int(_spawn_dead_since_ms), "spawn_pid": spawn_pid, ## Re-read rather than trusted from the watch tick: if the spawn PID is ## alive HERE, the death that triggered this was transient, which is a ## different bug from a process that really exited. "spawn_alive": spawn_pid > 0 and bool(_host._pid_alive_for_proof(spawn_pid)), "pid_file_pid": pid_file_pid, "pid_file_alive": pid_file_pid > 0 and bool(_host._pid_alive_for_proof(pid_file_pid)), })) ## Render the forensic snapshot. Pure so the format is testable without a live ## editor, and kept to one line so it survives log truncation in a bug report. static func format_spawn_exit_forensics(facts: Dictionary) -> String: var spawn_pid := int(facts.get("spawn_pid", 0)) var pid_file_pid := int(facts.get("pid_file_pid", 0)) ## The single most diagnostic bit, stated rather than left to be inferred: ## a live pid-file process while the watched one is gone is the launcher ## handoff shape; both gone is a real crash. var shape := "unknown" var spawn_alive := bool(facts.get("spawn_alive", false)) var file_alive := bool(facts.get("pid_file_alive", false)) if spawn_alive: shape = "watched_pid_still_alive" elif file_alive and pid_file_pid != spawn_pid: shape = "handoff_child_alive" elif not file_alive and pid_file_pid <= 0: shape = "no_pid_file_published" else: shape = "all_dead" return ( "#797 spawn-exit forensics: shape=%s os=%s launch=%s elapsed=%dms " + "first_dead=%dms spawn_pid=%d(alive=%s) pid_file_pid=%d(alive=%s)" ) % [ shape, str(facts.get("os", "")), str(facts.get("launch_mode", "")), int(facts.get("elapsed_ms", 0)), int(facts.get("first_dead_ms", 0)), spawn_pid, str(spawn_alive), pid_file_pid, str(file_alive), ] ## Watch-loop callback (1 Hz, capped by SERVER_WATCH_MS). ## `--pid-file` is the source of truth on Windows / uvx where the ## launcher PID dies quickly after spawning the real interpreter. func check_server_health() -> void: if int(_server_pid) <= 0: _host._stop_server_watch() return var elapsed := Time.get_ticks_msec() - int(_server_spawn_ms) var real_pid := PortResolver.read_pid_file() var spawn_pid := int(_server_pid) if real_pid > 0 and real_pid != spawn_pid and PortResolver.pid_alive(real_pid): _spawn_dead_since_ms = 0 _server_pid = real_pid ## The spawn record initially contains the launcher PID so same-session ## teardown can kill it. Heal it as soon as the server publishes its ## authoritative PID; future adoption requires the recorded PID to be ## the actual live listener (#759). _host._write_managed_server_record(real_pid, _expected_server_version(), _server_keep_alive) ## #805: the spawn survived to publish its pid-file — proven ## recovery, so the fast-exit re-adopt budget refreshes. _readopt_after_spawn_exit_retried = false elif not PortResolver.pid_alive(spawn_pid): _spawn_dead_since_ms = first_death_stamp(_spawn_dead_since_ms, elapsed) if is_spawn_handoff_pending( OS.get_name(), real_pid, elapsed, int(_host.SPAWN_HANDOFF_MS) ): return if elapsed >= int(_host.SPAWN_GRACE_MS) and not McpServerStateScript.is_terminal_diagnosis(_server_state): _diagnose_spawn_fast_exit(_spawn_dead_since_ms) return if elapsed >= int(_host.SERVER_WATCH_MS): ## Survived startup — mid-session crashes surface via WebSocket disconnect. _host._stop_server_watch() ## The spawned server died inside the SPAWN_GRACE_MS window. Decide what ## that means, in order: ## 1. A live godot-ai server answers on the HTTP port -> our spawn lost ## a port race the bind probe never saw (#745 bind-trap: the walk ## thought the port was free, the duplicate exited unable to bind, ## and the token it staged in the record is now stale). Re-run the ## startup walk so the adopt/recover branch handles the survivor — ## latching CRASHED here left the connection redialing forever with ## a token the surviving server rejects (close code 4003). One ## re-adopt per recovery via `_readopt_after_spawn_exit_retried` ## (#805): the triggered walk preserves the spent budget, so a ## flapping occupant (alive at each fast-exit probe, gone by each ## walk's probes — sustained multi-editor churn) latches a specific ## CRASHED diagnosis on the second round instead of re-walking ## forever. ## 2. #647: foreign process on the HTTP or WS port -> FOREIGN_PORT with ## an actionable message (we can't read the child's "port already in ## use" stderr). Checked before the --refresh retry: respawning ## against an occupied port can only fail the same way. ## 3. #172: stale uvx index -> one `--refresh` respawn. ## 4. Otherwise -> CRASHED, pointing at the Godot output log. func _diagnose_spawn_fast_exit(elapsed: int) -> void: _log_spawn_exit_forensics() var live: Dictionary = _host._probe_live_server_status_for_port( ClientConfigurator.http_port() ) if _live_status_identifies_godot_ai(live): if not _readopt_after_spawn_exit_retried: _readopt_after_spawn_exit_retried = true _readopt_walk_pending = true _host._log_buffer.log( "server exited after %dms but a live godot-ai server answers on port %d — re-running adoption" % [elapsed, ClientConfigurator.http_port()] ) _host._stop_server_watch() _server_pid = -1 ## Clear the spawn guard so the re-walk isn't GUARDED away. The ## walk's adopt arm re-sets it and fixes the stale token/record ## (external adoption drops both; managed adoption re-records). _host._server_started_this_session = false ## Fire-and-forget (mirrors force_restart_server): the walk is a ## coroutine in production; its continuation lives on the manager. start_server() return ## #805: the re-adopt budget is spent and a live godot-ai still ## answers while our spawns keep dying — a flapping occupant ## (another editor's server starting/stopping under it). Re-walking ## or respawning can only repeat the cycle; latch a terminal ## diagnosis that names the actual conflict. Reload Plugin (a fresh ## walk) refreshes the budget for a deliberate retry. _server_exit_ms = elapsed _server_status_message = ( "The spawned server keeps exiting while another godot-ai server " + "answers on port %d, and re-adoption was already attempted. " + "Another editor may be repeatedly starting/stopping a server on " + "this port. Stop the other process or pick a different port, " + "then click Reload Plugin." ) % ClientConfigurator.http_port() set_terminal_diagnosis(McpServerStateScript.CRASHED) disarm_version_check() _host._update_process_enabled() _host._log_buffer.log(str(_server_status_message)) push_warning("MCP | %s" % _server_status_message) _host._stop_server_watch() return var conflict := _diagnose_spawn_port_conflict(live) if not conflict.is_empty(): _server_exit_ms = elapsed _server_status_message = str(conflict.get("message", "")) _conflict_port = int(conflict.get("port", 0)) set_terminal_diagnosis(McpServerStateScript.FOREIGN_PORT) disarm_version_check() _host._update_process_enabled() _host._log_buffer.log(str(_server_status_message)) push_warning("MCP | %s" % _server_status_message) _host._stop_server_watch() return if bool(_host._should_retry_with_refresh()): _refresh_retried = true respawn_with_refresh() return _server_exit_ms = elapsed ## Generic crash: clear any stale per-state message so the dock's ## CRASHED body falls back to its launch-mode copy instead of text ## from an earlier diagnosis. _server_status_message = "" set_terminal_diagnosis(McpServerStateScript.CRASHED) disarm_version_check() _host._update_process_enabled() _host._log_buffer.log("server exited after %dms — see Godot output log" % int(_server_exit_ms)) _host._stop_server_watch() ## #647: post-crash port-conflict probe. Returns `{}` when no foreign ## conflict is detected (fall through to the CRASHED / retry path), or ## `{"message": String, "port": int}` when the HTTP or WS port is held by ## a process we can't identify as godot-ai. An occupant that *does* ## identify as godot-ai is deliberately not diagnosed here — that's the ## stale-server / adoption territory handled by `_diagnose_spawn_fast_exit`'s ## re-adopt arm (or the next `start_server` walk), not a foreign conflict. ## `pre_probed_live`: an HTTP status snapshot the caller already has on ## hand; non-empty skips the internal ~500ms probe (the probe helper never ## returns a bare `{}`, so the sentinel is unambiguous). func _diagnose_spawn_port_conflict(pre_probed_live: Dictionary = {}) -> Dictionary: var http_port := ClientConfigurator.http_port() if bool(_host._is_port_in_use(http_port)): var live: Dictionary = ( pre_probed_live if not pre_probed_live.is_empty() else _host._probe_live_server_status_for_port(http_port) ) if _live_status_identifies_godot_ai(live): return {} return { "message": ( "Port %d is in use by another application. Stop it or change " + "the port in Editor Settings (godot_ai/http_port)." ) % http_port, "port": http_port, } var ws_port := int(_host._resolved_ws_port) if ws_port > 0 and bool(_host._is_port_in_use(ws_port)): return { "message": ( "WebSocket port %d is in use by another application. Stop it " + "or change the port in Editor Settings (godot_ai/ws_port)." ) % ws_port, "port": ws_port, } return {} ## Retry the spawn with uvx `--refresh` prepended (PyPI index can lag a ## fresh publish ~10 min — #172). One-shot per session via _refresh_retried. func respawn_with_refresh() -> void: _host._startup_trace_count("server_command_discovery") var server_cmd := ClientConfigurator.get_server_command(true) if server_cmd.is_empty(): return var cmd: String = server_cmd[0] var args: Array[String] = [] args.assign(server_cmd.slice(1)) args.append_array(_host._build_server_flags(ClientConfigurator.http_port(), int(_host._resolved_ws_port))) _host._clear_pid_file() _host._log_buffer.log("retrying with --refresh (PyPI index may be stale)") var injected_telemetry_env := _inject_telemetry_env() ## Set owner PID for THIS spawn too (don't rely on it lingering from ## start_server) — and unset right after, same scoping as start_server. var owner_env_set := _set_owner_pid_env() _set_plugin_spawned_env() var keep_alive_env_set := _set_keep_alive_env() var ws_token := _set_ws_token_env() _server_pid = OS.create_process(cmd, args) if owner_env_set: OS.unset_environment("GODOT_AI_OWNER_PID") OS.unset_environment("GODOT_AI_PLUGIN_SPAWNED") if keep_alive_env_set: OS.unset_environment("GODOT_AI_NO_IDLE_EXIT") OS.unset_environment("GODOT_AI_WS_TOKEN") if injected_telemetry_env: OS.unset_environment("GODOT_AI_DISABLE_TELEMETRY") var spawn_pid := int(_server_pid) if spawn_pid > 0: _server_spawn_ms = Time.get_ticks_msec() _server_exit_ms = 0 _spawn_dead_since_ms = 0 _server_keep_alive = keep_alive_env_set var current_version := _expected_server_version() _host._set_ws_auth_token(ws_token) _host._write_managed_server_record(spawn_pid, current_version, _server_keep_alive) print("MCP | retried server (PID %d, v%s): %s %s" % [spawn_pid, current_version, cmd, " ".join(args)]) else: ## OS.create_process returned -1 on the retry — surface CRASHED ## rather than loop. `_refresh_retried` is already true. _server_status_message = "" set_terminal_diagnosis(McpServerStateScript.CRASHED) disarm_version_check() _host._update_process_enabled() _host._log_buffer.log("refresh retry failed to spawn — see Godot output log") _host._stop_server_watch() func adopt_compatible_server( record_version: String, current_version: String, owner: int, record_owns_listener: bool = false ) -> String: _server_actual_name = "godot-ai" _can_recover_incompatible = false ## #805: adoption (managed or external) is a proven recovery — the ## session now has a live compatible server. Refresh the fast-exit ## re-adopt budget so a later, unrelated port race can heal again. _readopt_after_spawn_exit_retried = false if record_version == current_version and owner > 0 and record_owns_listener: ## Managed adoption keeps the record's token (loaded into ## _ws_auth_token at plugin startup) — the running server was ## spawned with it and still verifies against it (#690). Version ## equality alone is deliberately insufficient: the record must also ## identify the live branded listener (#759/#764). _server_pid = owner ## Recover the keep-alive launch flag from the record the spawning ## session persisted — a keep-alive survivor adopted here must ## detach again on THIS session's exit, and only the record knows ## how the process was actually launched. _server_keep_alive = bool(_host._read_managed_server_record().get("keep_alive", false)) _host._write_managed_server_record(owner, current_version, _server_keep_alive) return McpAdoptionLabelScript.MANAGED _server_pid = -1 _server_keep_alive = false ## External server: we didn't spawn it and don't know its token (it ## most likely has none — dev servers aren't launched with one). Drop ## ours so the handshake omits the field instead of sending a stale ## token the server would reject. _host._set_ws_auth_token("") _host._clear_managed_server_record() _host._clear_pid_file() return McpAdoptionLabelScript.EXTERNAL static func _compatible_adoption_log_message( owner_label: String, owned_pid: int, observed_owner_pid: int, live_version: String, live_ws_port: int, current_version: String ) -> String: if owner_label == McpAdoptionLabelScript.MANAGED: return "MCP | adopted managed server (PID %d, live v%s, WS %d, plugin v%s)" % [ owned_pid, live_version, live_ws_port, current_version ] return "MCP | adopted external server owner_pid=%d (live v%s, WS %d, plugin v%s)" % [ observed_owner_pid, live_version, live_ws_port, current_version ] ## `pre_kill_live` is forwarded into the proof helper so it doesn't ## re-probe a port the caller already probed. The kill invalidates the ## snapshot — callers MUST re-probe before consuming live-status data ## after this returns. ## ## #678: coroutine in production — the proof evaluation (port scrapes + ## per-PID brand shells) and the kill + port-drain wait run off the main ## thread. The EditorSettings record is read on the main thread up front ## and injected into the proof helper; record/pid-file clears stay on the ## main thread after the awaits. func recover_strong_port_occupant(port: int, wait_s: float, pre_kill_live: Dictionary = {}) -> bool: var async_gen := _async_generation var record: Dictionary = _host._read_managed_server_record() var proof_result: Variant = await _run_blocking(func() -> Variant: if not is_instance_valid(_host): return {"proof": "", "pids": []} return _host._evaluate_strong_port_occupant_proof(port, pre_kill_live, record) ) if _async_stale(async_gen) or proof_result == null: return false var proof: Dictionary = proof_result var targets: Array[int] = [] targets.assign(proof.get("pids", [])) if targets.is_empty(): return false print("MCP | strong proof: %s" % str(proof.get("proof", ""))) var freed_result: Variant = await _run_blocking(func() -> Variant: if not is_instance_valid(_host): return false ## verify_brand=true: the proof above ran in a separate _run_blocking ## task with main-thread frames in between — re-check each target at ## kill time so a PID recycled inside that gap isn't killed (#686). var killed: Array = _host._kill_processes_and_windows_spawn_children(targets, true) if not killed.is_empty(): print("MCP | killed pids %s on port %d" % [str(killed), port]) _host._wait_for_port_free(port, wait_s) return not bool(_host._is_port_in_use(port)) ) if _async_stale(async_gen) or freed_result == null: return false if not bool(freed_result): return false _host._clear_managed_server_record() _host._clear_pid_file() return true ## Editor-exit teardown chooser (#800): detach only when the RUNNING ## server was launched keep-alive (_server_keep_alive, set at spawn / ## recovered on adoption) — never on the live setting, which may have ## been toggled after spawn. Flag clear → stop_server kills as always, ## so enabling the setting mid-session takes effect on the next server ## start instead of leaving a record that points at a soon-reaped PID. func teardown_for_editor_exit() -> void: if _server_keep_alive: detach_server() return ## #824: a backend we spawned may be keeping one or more MCP clients alive ## through their `godot-ai attach` bridges. Killing it because *this* editor ## is closing takes the server out from under them: an in-flight call can ## become TRANSPORT_OUTCOME_UNKNOWN, and every bridge has to establish a new ## backend before the next editor can reconnect. A live lease means the ## backend has consumers beyond this editor, so hand it over instead. var leased := active_lease_count_at_exit() if leased > 0: ## Give up kill authority along with the process: dropping the managed ## record means the next editor adopts it through the external branch ## rather than as a managed server it may kill. The server's own ## pid-file is deliberately left in place — it is the backend's ## publication, not our claim on it, and adoption reads it. ## ## The Python side remains the reaper of record: a plugin-spawned ## backend keeps its idle backstop armed (only keep_server_on_exit ## disarms it) and that backstop is lease-aware, so this defers the ## stop to "no editors AND no leases AND grace elapsed" rather than ## leaking the process. _host._clear_managed_server_record() detach_server( "detaching server: %d attach lease(s) still held, leaving it to the " % leased + "server's own idle reaper" ) return stop_server() ## Active attach-bridge leases on the backend this editor manages, or 0 when ## there is nothing to consult (#824). ## ## Returns 0 — preserving the historical kill-on-exit behavior — for every ## uncertain case: no managed PID, a probe that fails or times out, a server ## that does not identify as godot-ai, or one too old to publish the field. ## That direction is deliberate. A false 0 costs what today already costs ## (the backend is stopped and bridges reconnect); a false positive would ## leave a process running on a guess. ## ## Bounded by the status probe's own timeout (SERVER_STATUS_PROBE_TIMEOUT_MS), ## which is what keeps editor exit from hanging on a wedged HTTP server. func active_lease_count_at_exit() -> int: var pid := int(_server_pid) if pid <= 0: return 0 ## Only a process we can still prove is our godot-ai server earns the ## benefit of the doubt. The lease count comes from whoever answers on the ## port, which is not by itself proof that it IS the process we are about ## to stop — another editor's backend, or an attach-owned one, could hold ## the port after ours died. Requiring the same alive+branded proof ## `stop_server` uses before its kill closes that gap: without it, a ## stranger's leases could talk this editor out of stopping its own server. ## ## Failing this check is harmless either way. A dead PID has nothing to ## kill, and a recycled-but-unbranded PID is rejected by stop_server's own ## gate (#686) — both land on the historical path. if not _host._pid_alive_for_proof(pid): return 0 if not _host._pid_cmdline_is_godot_ai_for_proof(pid): return 0 return active_lease_count( _host._probe_live_server_status_for_port(ClientConfigurator.http_port()) ) ## Read the advisory lease count out of a `/godot-ai/status` payload. ## ## Gated on the payload identifying as godot-ai, so an unrelated process ## answering on the port cannot talk this editor out of a clean stop. A ## missing field means an older backend that predates #824; it reads as 0, ## which keeps that pairing on today's behavior. static func active_lease_count(live: Dictionary) -> int: if not _live_status_identifies_godot_ai(live): return 0 var raw: Variant = live.get("active_lease_count") if raw == null: return 0 return maxi(0, int(raw)) ## keep_server_on_exit (#800): editor teardown that leaves the server ## running. Mirrors stop_server's bookkeeping — cancel in-flight async ## startup, stop the watch, settle on STOPPED — but kills nothing and ## PRESERVES the managed-server record + pid-file, so the next editor ## session's start_server walk adopts the survivor through the existing ## record-matches branch (#758/#774). Explicit stops (dock Restart, ## update reload) still route through stop_server and kill as before. ## `log_reason` names why the server is being left alive; the default is the ## keep_server_on_exit wording this function was written for. #824 reuses the ## same bookkeeping for the active-lease handover, and a shared log line would ## have reported the wrong cause for it. func detach_server( log_reason: String = "keep_server_on_exit: leaving server running" ) -> void: _invalidate_async_startup() _host._stop_server_watch() var detached_pid := int(_server_pid) _server_pid = -1 transition_state(McpServerStateScript.STOPPED) if detached_pid > 0: print("MCP | %s (PID %d)" % [log_reason, detached_pid]) func stop_server() -> void: ## Cancel any in-flight async startup (#678): a suspended start_server ## resuming after teardown must not resurrect state or spawn a server. _invalidate_async_startup() _host._stop_server_watch() if int(_server_pid) <= 0: transition_state(McpServerStateScript.STOPPED) return transition_state(McpServerStateScript.STOPPING) ## Kill the tracked PID AND the real Python PID — they differ for the ## uvx tier (the launcher exits before its child) and on Windows ## `OS.kill` is `TerminateProcess` which doesn't walk the child tree. var port := ClientConfigurator.http_port() var killed: Array = [] var candidates: Array[int] = [] ## Re-verify the tracked PID at kill time (#686): nothing clears ## `_server_pid` when the server dies mid-session (`check_server_health` ## stops watching after SERVER_WATCH_MS), so hours later the kernel may ## have recycled this PID to an unrelated process. Every other candidate ## in this function is brand-gated; the tracked seed must be too. A false ## negative is fail-safe: the port stays held and the record is preserved, ## so the next start_server's drift branch retries the kill. var tracked_pid := int(_server_pid) if ( tracked_pid > 0 and _host._pid_alive_for_proof(tracked_pid) and _host._pid_cmdline_is_godot_ai_for_proof(tracked_pid) ): candidates.append(tracked_pid) var real_pid := int(_host._find_managed_pid(port)) ## Add the real Python PID only if it isn't already tracked and proves out ## as ours — re-appending an already-present PID just produces a duplicate ## kill candidate. if real_pid > 0 and not candidates.has(real_pid) and _host._pid_cmdline_is_godot_ai_for_proof(real_pid): candidates.append(real_pid) var listener_pids: Array = _host._find_all_pids_on_port(port) for pid in listener_pids: var listener_pid := int(pid) if candidates.has(listener_pid): continue if _host._pid_cmdline_is_godot_ai_for_proof(listener_pid): candidates.append(listener_pid) killed = _host._kill_processes_and_windows_spawn_children(candidates) if not killed.is_empty(): print("MCP | stopped server (PID %s)" % str(killed)) _server_pid = -1 _server_keep_alive = false _host._wait_for_port_free(port, 2.0) ## Preserve record/pid-file when port is still held — the drift ## branch on the next start_server retries the kill (#159 follow-up). _host._finalize_stop_if_port_free(port) transition_state(McpServerStateScript.STOPPED) ## Server's `_pydantic_core.pyd` hard-link is now released — sweep ## stale uvx builds before they trip the next attach launcher. UvCacheCleanup.purge_stale_builds() ## Kill the server, reset the re-entrancy guard so the re-enabled plugin ## spawns fresh (#132). User-mode only kills via strong proof. func prepare_for_update_reload() -> void: stop_server() _host._server_started_this_session = false if ClientConfigurator.is_dev_checkout(): return var port := ClientConfigurator.http_port() if not bool(_host._is_port_in_use(port)): return var proof: Dictionary = _host._evaluate_strong_port_occupant_proof(port) var targets: Array[int] = [] targets.assign(proof.get("pids", [])) if targets.is_empty(): return _host._kill_processes_and_windows_spawn_children(targets) _host._wait_for_port_free(port, 3.0) if not bool(_host._is_port_in_use(port)): _host._clear_managed_server_record() _host._clear_pid_file() # ---- Recovery click ---------------------------------------------------- ## Returns true when a pure-state probe says recovery is allowed: ## current state is INCOMPATIBLE, the port is still held, and the ## incompatible diagnosis latched an ownership proof. Pure-state in the ## sense that nothing is killed — that's `recover_incompatible_server`. ## ## Consults the `_can_recover_incompatible` verdict that ## `_set_incompatible_server` computed off-thread instead of re-running ## the proof's port scrapes + per-PID brand shells on the main thread ## (#712): the dock polls this on refresh, and ## `recover_incompatible_server` re-proves at kill time anyway, so a ## stale latch can never kill an unproven occupant — worst case is a ## recovery click that comes back false. The port liveness re-check is ## a single local bind probe, cheap enough to stay synchronous. func can_recover_incompatible_server() -> bool: if _server_state != McpServerStateScript.INCOMPATIBLE: return false if not _can_recover_incompatible: return false return bool(_host._is_port_in_use(ClientConfigurator.http_port())) func recover_incompatible_server() -> bool: if _server_state != McpServerStateScript.INCOMPATIBLE: return false var port := ClientConfigurator.http_port() ## Cancel any suspended contended-port walk BEFORE the off-thread proof ## (#712): `_run_blocking` tracks a single active worker for the ## teardown join, so starting ours while another walk's worker is alive ## would orphan that thread from the join guarantee. This also releases ## the guard so the respawn at the bottom isn't silently swallowed ## (#682 review). The user's recovery click owns the flow from here. _invalidate_async_startup() var async_gen := _async_generation ## EditorSettings record read on the main thread, injected so the ## worker never touches EditorSettings (#712, mirroring ## recover_strong_port_occupant). var record: Dictionary = _host._read_managed_server_record() var proof_result: Variant = await _run_blocking(func() -> Variant: if not is_instance_valid(_host): return {"proof": "", "pids": []} return _host._evaluate_recovery_port_occupant_proof(port, {}, record) ) if _async_stale(async_gen) or proof_result == null: return false var proof: Dictionary = proof_result var targets: Array[int] = [] targets.assign(proof.get("pids", [])) if targets.is_empty(): return false print("MCP | proof: %s" % str(proof.get("proof", ""))) ## Move into STOPPING so the post-kill respawn passes the ## first-writer-wins guards. transition_state(McpServerStateScript.STOPPING) var freed_result: Variant = await _run_blocking(func() -> Variant: if not is_instance_valid(_host): return false ## verify_brand=true: the proof above ran in a separate ## _run_blocking task with main-thread frames in between — re-check ## each target at kill time so a PID recycled inside that gap isn't ## killed (#686, mirroring recover_strong_port_occupant). var killed: Array = _host._kill_processes_and_windows_spawn_children(targets, true) if not killed.is_empty(): print("MCP | killed pids %s on port %d" % [str(killed), port]) _host._wait_for_port_free(port, 5.0) return not bool(_host._is_port_in_use(port)) ) if _async_stale(async_gen) or freed_result == null: return false if not bool(freed_result): ## Kill failed; re-latch INCOMPATIBLE so the dock keeps the ## diagnostic UI. transition_state(McpServerStateScript.INCOMPATIBLE) return false UvCacheCleanup.purge_stale_builds() _host._clear_managed_server_record() _host._clear_pid_file() transition_state(McpServerStateScript.STOPPED) _connection_blocked = false _server_status_message = "" _conflict_port = 0 _server_actual_version = "" _server_actual_name = "" _can_recover_incompatible = false _host._server_started_this_session = false _server_pid = -1 ## Await the respawn walk: the plugin gates its connection unblock on ## the post-walk state (SPAWNING/READY), so returning true while the ## walk is still suspended would leave the connection blocked forever ## after a successful recovery click (#682 review). await start_server() return true ## Restart authorisation — a live PID means we spawned/adopted, a ## non-empty managed record is the cross-session proof used by the ## drift branch. func can_restart_managed_server() -> bool: if _server_pid > 0: return true var record: Dictionary = _host._read_managed_server_record() return not str(record.get("version", "")).is_empty() func has_managed_server() -> bool: return _server_pid > 0 ## Reset state for a force-restart. Drops the managed record, clears ## the pid-file, and resets the spawn guard so the follow-up ## `start_server()` walks the spawn arm. func reset_for_force_restart() -> void: ## The user's explicit restart takes over: cancel any suspended ## contended-port walk and release the re-entrancy guard so the ## follow-up start isn't silently swallowed (#682 review). _invalidate_async_startup() _host._clear_managed_server_record() _host._clear_pid_file() _host._server_started_this_session = false _server_pid = -1 transition_state(McpServerStateScript.UNINITIALIZED) ## Ownership-checked kill of the port occupant + respawn. Driven from ## the dock's "Restart Server" button when the plugin adopted a foreign ## server whose version drifted from the plugin. func force_restart_server() -> void: if not can_restart_managed_server(): push_warning("MCP | refusing to kill server on port %d without managed-server ownership proof" % ClientConfigurator.http_port()) return var port := ClientConfigurator.http_port() ## Kill every LISTENER on the port, not just the first one. A dev ## server run via `uvicorn --reload` owns port 8000 through both a ## reloader parent AND a worker child — killing only one (or zero, ## if the single-pid parse fell over on multi-line lsof output) leaves ## the other holding the port past `_wait_for_port_free`'s window. ## ## Brand-gate each raw listener PID (#686): `can_restart_managed_server()` ## only proves we once managed *a* server, not that the port's current ## occupants are ours — an adopted server that exited on its own can be ## replaced on the port by an unrelated dev tool before the user clicks ## Restart. Unbranded PIDs fall through to `_set_incompatible_server` ## below instead of being killed. transition_state(McpServerStateScript.STOPPING) var restart_targets: Array[int] = [] for pid in _host._find_all_pids_on_port(port): var listener_pid := int(pid) if _host._pid_cmdline_is_godot_ai_for_proof(listener_pid): restart_targets.append(listener_pid) _host._kill_processes_and_windows_spawn_children(restart_targets) _host._wait_for_port_free(port, 5.0) if _host._is_port_in_use(port): ## Kill failed; clean baseline for the follow-up ## `_set_incompatible_server`. transition_state(McpServerStateScript.UNINITIALIZED) _set_incompatible_server( _host._probe_live_server_status_for_port(port), _expected_server_version(), port ) return ## Same rationale as `stop_server`: the server child python just ## released its `pydantic_core` mapping, so this is the only window in ## which the hard-linked copies under `builds-v0\.tmp*` are deletable. ## Sweep before respawning so the next uvx attach build doesn't ## inherit the same cleanup-failure path that triggered the restart. UvCacheCleanup.purge_stale_builds() reset_for_force_restart() start_server()