@tool extends EditorPlugin const GAME_HELPER_AUTOLOAD_NAME := "_mcp_game_helper" const GAME_HELPER_AUTOLOAD_PATH := "res://addons/godot_ai/runtime/game_helper.gd" ## Editor-process Logger subclass — captures parse errors, @tool runtime ## errors, and push_error/push_warning so the LLM can read them via ## `logs_read(source="editor")`. Loaded dynamically because ## `extends Logger` requires Godot 4.5+. The logger script lives in the ## `.gdignore`'d `runtime/loggers/` folder so Godot's editor scan never ## parses it (no "Could not find base class Logger" error on < 4.5), and ## LoggerLoader compiles it from source at runtime only after the ## ClassDB.class_exists("Logger") gate below. See issue #231 / #475. const LoggerLoader := preload("res://addons/godot_ai/runtime/logger_loader.gd") ## EditorSettings keys used to remember which server process the plugin ## spawned — survives editor restarts, lets a later editor session adopt ## and manage a server it didn't spawn itself. See #135. const MANAGED_SERVER_PID_SETTING := "godot_ai/managed_server_pid" const MANAGED_SERVER_VERSION_SETTING := "godot_ai/managed_server_version" const MANAGED_SERVER_WS_PORT_SETTING := "godot_ai/managed_server_ws_port" const UPDATE_RELOAD_RUNNER_SCRIPT := preload("res://addons/godot_ai/update_reload_runner.gd") ## Preloaded so `_stop_server` / `force_restart_server` have a local script ## dependency for the cleanup helper. See utils/uv_cache_cleanup.gd for what ## this does and why it lives next to the server-stop hot path. const UvCacheCleanup := preload("res://addons/godot_ai/utils/uv_cache_cleanup.gd") ## Server lifecycle + port discovery extracted from this file (#297 PR 5). ## State enums + version-check seam extracted in PR 6 (#297). Plugin.gd ## keeps thin shims so the dock and characterization tests see an ## unchanged public surface; spawn-machinery state now lives in the ## lifecycle manager. const ServerLifecycleManager := preload("res://addons/godot_ai/utils/server_lifecycle.gd") const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd") const ServerStateScript := preload("res://addons/godot_ai/utils/mcp_server_state.gd") const StartupPathScript := preload("res://addons/godot_ai/utils/mcp_startup_path.gd") ## Plugin-class scripts used by this file. The script-local preload aliases ## are ordinary dependency shorthand and keep construction sites compact. ## They are not the self-update safety boundary; #398 was stale Script-object ## content from a mixed old/new snapshot, fixed by the runner's single-phase ## write-before-scan model. const Connection := preload("res://addons/godot_ai/connection.gd") const Dispatcher := preload("res://addons/godot_ai/dispatcher.gd") const Telemetry := preload("res://addons/godot_ai/telemetry.gd") const LogBuffer := preload("res://addons/godot_ai/utils/log_buffer.gd") const GameLogBuffer := preload("res://addons/godot_ai/utils/game_log_buffer.gd") const EditorLogBuffer := preload("res://addons/godot_ai/utils/editor_log_buffer.gd") const Dock := preload("res://addons/godot_ai/mcp_dock.gd") const DebuggerPlugin := preload("res://addons/godot_ai/debugger/mcp_debugger_plugin.gd") const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd") ## Handlers — preloaded as consts instead of registered via `class_name` so ## they don't pollute the project-wide global scope. A user project that ## happens to define its own `InputHandler`, `SceneHandler`, etc. would ## otherwise hard-error on plugin enable. const EditorHandler := preload("res://addons/godot_ai/handlers/editor_handler.gd") const SceneHandler := preload("res://addons/godot_ai/handlers/scene_handler.gd") const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd") const ProjectHandler := preload("res://addons/godot_ai/handlers/project_handler.gd") const ClientHandler := preload("res://addons/godot_ai/handlers/client_handler.gd") const ScriptHandler := preload("res://addons/godot_ai/handlers/script_handler.gd") const ResourceHandler := preload("res://addons/godot_ai/handlers/resource_handler.gd") const ApiHandler := preload("res://addons/godot_ai/handlers/api_handler.gd") const FilesystemHandler := preload("res://addons/godot_ai/handlers/filesystem_handler.gd") const SignalHandler := preload("res://addons/godot_ai/handlers/signal_handler.gd") const AutoloadHandler := preload("res://addons/godot_ai/handlers/autoload_handler.gd") const InputHandler := preload("res://addons/godot_ai/handlers/input_handler.gd") const TestHandler := preload("res://addons/godot_ai/handlers/test_handler.gd") const BatchHandler := preload("res://addons/godot_ai/handlers/batch_handler.gd") const UiHandler := preload("res://addons/godot_ai/handlers/ui_handler.gd") const ThemeHandler := preload("res://addons/godot_ai/handlers/theme_handler.gd") const AnimationHandler := preload("res://addons/godot_ai/handlers/animation_handler.gd") const MaterialHandler := preload("res://addons/godot_ai/handlers/material_handler.gd") const ParticleHandler := preload("res://addons/godot_ai/handlers/particle_handler.gd") const CameraHandler := preload("res://addons/godot_ai/handlers/camera_handler.gd") const AudioHandler := preload("res://addons/godot_ai/handlers/audio_handler.gd") const PhysicsShapeHandler := preload("res://addons/godot_ai/handlers/physics_shape_handler.gd") const EnvironmentHandler := preload("res://addons/godot_ai/handlers/environment_handler.gd") const TextureHandler := preload("res://addons/godot_ai/handlers/texture_handler.gd") const CurveHandler := preload("res://addons/godot_ai/handlers/curve_handler.gd") const ControlDrawRecipeHandler := preload("res://addons/godot_ai/handlers/control_draw_recipe_handler.gd") ## The Python server writes its own PID here on startup (passed as ## `--pid-file`) and unlinks on clean exit. Deterministic replacement ## for scraping `netstat -ano` to find the port owner — especially on ## Windows where `OS.kill` on the uvx launcher doesn't take the Python ## child with it, and the scrape was the only path to the real PID. ## See issue for #154-era Windows update friction. ## Re-export of PortResolver.SERVER_PID_FILE so the spawn flags, the ## resolver, and characterization tests share one source of truth. const SERVER_PID_FILE := PortResolver.SERVER_PID_FILE ## How long we watch the spawned server for early exit. If the process is ## still alive when this expires, we stop watching. Mid-session crashes ## after this point get caught by the WebSocket disconnect flow. const SERVER_WATCH_MS := 30 * 1000 ## Python's import graph (FastMCP + Rich + uvicorn) plus the pid-file write ## take a beat on cold starts, especially on Windows. Hold off on declaring ## a spawn a crash until this window elapses so the watch loop has time to ## observe either the pid-file (dev venv) or the port listening (uvx). const SPAWN_GRACE_MS := 5 * 1000 const SERVER_STATUS_PATH := "/godot-ai/status" const SERVER_STATUS_PROBE_TIMEOUT_MS := 800 const SERVER_HANDSHAKE_VERSION_TIMEOUT_MS := 5 * 1000 const STARTUP_TRACE_COUNTER_NAMES := [ "powershell", "netstat", "netsh", "lsof", "http_status_probe", "server_command_discovery", ] ## Untyped on purpose — see policy below. Type fences move to handler `_init` ## sites that take typed parameters. ## ## Self-update field and load-surface policy: plugin entry-load fields that ## survive reload stay untyped. Typed fields against plugin-defined classes ## were the #242 / #244 crash class: Godot can reparse a long-lived script ## while its old field storage and the new type shape disagree. Static-var ## initializers are the most dangerous form because they execute at ## script-load; a top-level typed Dictionary/Array storage change can fail ## before `_enter_tree` runs. ## ## The mitigation is two-part: ## (1) Field declarations are untyped (this block). ## (2) Construction and static access use local names declared at the top ## of the file (e.g. `Connection`, `Dispatcher`, `LogBuffer`, ## `ClientConfigurator`, `WindowsPortReservation`, ...), which keeps ## this entry script's load surface explicit and reviewable. ## ## Constructors, constants, and static methods on `Mcp*` classes are not the ## self-update safety metric under the single-phase runner. The old syntactic ## lint counted bare `Mcp*.MEMBER` references, but #398 was caused by the ## runner scanning a mixed old/new snapshot and reusing stale Script-object ## content. Bare names and preload aliases can both be parsed against stale ## content under an old two-phase runner; from the fixed runner onward the ## full v(N+1) snapshot is written before the scan. In short: preload aliases ## are not the self-update safety metric. ## ## `tests/unit/test_plugin_self_update_safety.py` locks this wording in. ## ## `_editor_logger` is untyped because its script extends Godot 4.5+'s Logger ## class: `logger_loader.gd` compiles it at runtime from on-disk source ## (FileAccess + `GDScript.new()`) past the `ClassDB.class_exists("Logger")` ## gate in `_attach_editor_logger`, so the plugin still parses on 4.4. Null on ## Godot < 4.5 or before `_attach_editor_logger` runs; "attached" state IS ## exactly "non-null". var _connection var _dispatcher var _telemetry var _log_buffer var _game_log_buffer var _editor_log_buffer var _editor_logger var _dock var _handlers: Array = [] # prevent GC of RefCounted handlers var _debugger_plugin ## Spawn / stop / adopt orchestration plus state machine; allocated in ## `_init` so test fixtures (which never enter the tree) can drive ## `_start_server`. Owns `_server_pid`, `_server_state`, the version- ## check seam, and the adoption-confirmation deadline — see ## `utils/server_lifecycle.gd`. var _lifecycle static var _server_started_this_session := false # guard against re-entrant spawns static var _resolved_ws_port := ClientConfigurator.DEFAULT_WS_PORT ## Server-watch timer lives on the plugin because it's a Node — the ## manager is RefCounted and can't host children. var _server_watch_timer: Timer = null var _headless_disabled := false var _startup_trace_enabled := false var _startup_trace_start_ms := 0 var _startup_trace_last_ms := 0 var _startup_trace_counters: Dictionary = {} var _startup_trace_netsh_start_count := 0 func _init() -> void: _lifecycle = ServerLifecycleManager.new(self) func _enter_tree() -> void: _startup_trace_begin() ## `_process` is only used by the adoption-confirmation watcher; keep ## it off until `_watch_for_adoption_confirmation` arms it, so the ## plugin has zero per-frame cost in the common case. set_process(false) if _mcp_disabled_for_headless_launch(): _headless_disabled = true print("MCP | plugin disabled in headless mode") return ## Self-update from a pre-loggers/ version leaves the old logger scripts ## orphaned at runtime/*.gd (the runner only writes files in the new ZIP, ## it doesn't prune). Those still `extends Logger` and re-emit the parse ## errors on Godot < 4.5. Delete them once so upgraders match a fresh ## install. No-op on fresh installs and dev checkouts (files absent). _cleanup_legacy_logger_scripts() ## Register port overrides before spawn so `http_port()` / `ws_port()` ## return the user's configured values (if any) when `_start_server` ## builds the CLI args. ClientConfigurator.ensure_settings_registered() _startup_trace_phase("settings_registered") _log_buffer = LogBuffer.new() _start_server() _startup_trace_phase("server_start") _game_log_buffer = GameLogBuffer.new() _editor_log_buffer = EditorLogBuffer.new() _attach_editor_logger() _dispatcher = Dispatcher.new(_log_buffer) _startup_trace_phase("core_objects") _connection = Connection.new() _connection.log_buffer = _log_buffer _connection.ws_port = _resolved_ws_port _connection.connect_blocked = _lifecycle.is_connection_blocked() _connection.connect_block_reason = _lifecycle.get_status_dict().get("message", "") if ( not _lifecycle.is_connection_blocked() and not ServerStateScript.is_terminal_diagnosis(_lifecycle.get_state()) ): _arm_server_version_check() _telemetry = Telemetry.new(_connection) _debugger_plugin = DebuggerPlugin.new(_log_buffer, _game_log_buffer, _editor_log_buffer) add_debugger_plugin(_debugger_plugin) _ensure_game_helper_autoload() var editor_handler := EditorHandler.new(_log_buffer, _connection, _debugger_plugin, _game_log_buffer, _editor_log_buffer) var scene_handler := SceneHandler.new(_connection) var node_handler := NodeHandler.new(get_undo_redo()) var project_handler := ProjectHandler.new(_connection, _debugger_plugin, _editor_log_buffer) var client_handler := ClientHandler.new() var script_handler := ScriptHandler.new(get_undo_redo(), _connection) var resource_handler := ResourceHandler.new(get_undo_redo(), _connection) var api_handler := ApiHandler.new() var filesystem_handler := FilesystemHandler.new() var signal_handler := SignalHandler.new(get_undo_redo()) var autoload_handler := AutoloadHandler.new() var input_handler := InputHandler.new() var test_handler := TestHandler.new(get_undo_redo(), _log_buffer) var batch_handler := BatchHandler.new(_dispatcher, get_undo_redo()) var ui_handler := UiHandler.new(get_undo_redo()) var theme_handler := ThemeHandler.new(get_undo_redo()) var animation_handler := AnimationHandler.new(get_undo_redo()) var material_handler := MaterialHandler.new(get_undo_redo()) var particle_handler := ParticleHandler.new(get_undo_redo()) var camera_handler := CameraHandler.new(get_undo_redo()) var audio_handler := AudioHandler.new(get_undo_redo()) var physics_shape_handler := PhysicsShapeHandler.new(get_undo_redo()) var environment_handler := EnvironmentHandler.new(get_undo_redo(), _connection) var texture_handler := TextureHandler.new(get_undo_redo(), _connection) var curve_handler := CurveHandler.new(get_undo_redo(), _connection) var control_draw_recipe_handler := ControlDrawRecipeHandler.new(get_undo_redo()) _handlers = [editor_handler, scene_handler, node_handler, project_handler, client_handler, script_handler, resource_handler, api_handler, filesystem_handler, signal_handler, autoload_handler, input_handler, test_handler, batch_handler, ui_handler, theme_handler, animation_handler, material_handler, particle_handler, camera_handler, audio_handler, physics_shape_handler, environment_handler, texture_handler, curve_handler, control_draw_recipe_handler] _dispatcher.register("get_editor_state", editor_handler.get_editor_state) _dispatcher.register("get_scene_tree", scene_handler.get_scene_tree) _dispatcher.register("get_open_scenes", scene_handler.get_open_scenes) _dispatcher.register("find_nodes", scene_handler.find_nodes) _dispatcher.register("create_scene", scene_handler.create_scene) _dispatcher.register("open_scene", scene_handler.open_scene) _dispatcher.register("save_scene", scene_handler.save_scene) _dispatcher.register("save_scene_as", scene_handler.save_scene_as) _dispatcher.register("get_selection", editor_handler.get_selection) _dispatcher.register("create_node", node_handler.create_node) _dispatcher.register("delete_node", node_handler.delete_node) _dispatcher.register("reparent_node", node_handler.reparent_node) _dispatcher.register("set_property", node_handler.set_property) _dispatcher.register("rename_node", node_handler.rename_node) _dispatcher.register("duplicate_node", node_handler.duplicate_node) _dispatcher.register("move_node", node_handler.move_node) _dispatcher.register("add_to_group", node_handler.add_to_group) _dispatcher.register("remove_from_group", node_handler.remove_from_group) _dispatcher.register("set_selection", node_handler.set_selection) _dispatcher.register("get_node_properties", node_handler.get_node_properties) _dispatcher.register("get_children", node_handler.get_children) _dispatcher.register("get_groups", node_handler.get_groups) _dispatcher.register("get_logs", editor_handler.get_logs) _dispatcher.register("clear_logs", editor_handler.clear_logs) _dispatcher.register("take_screenshot", editor_handler.take_screenshot) _dispatcher.register("get_performance_monitors", editor_handler.get_performance_monitors) _dispatcher.register("reload_plugin", editor_handler.reload_plugin) _dispatcher.register("quit_editor", editor_handler.quit_editor) _dispatcher.register("game_eval", editor_handler.game_eval) _dispatcher.register("game_command", editor_handler.game_command) _dispatcher.register("get_project_setting", project_handler.get_project_setting) _dispatcher.register("set_project_setting", project_handler.set_project_setting) _dispatcher.register("run_project", project_handler.run_project) _dispatcher.register("stop_project", project_handler.stop_project) _dispatcher.register("search_filesystem", project_handler.search_filesystem) _dispatcher.register("configure_client", client_handler.configure_client) _dispatcher.register("remove_client", client_handler.remove_client) _dispatcher.register("check_client_status", client_handler.check_client_status) _dispatcher.register("create_script", script_handler.create_script) _dispatcher.register("patch_script", script_handler.patch_script) _dispatcher.register("read_script", script_handler.read_script) _dispatcher.register("attach_script", script_handler.attach_script) _dispatcher.register("detach_script", script_handler.detach_script) _dispatcher.register("find_symbols", script_handler.find_symbols) _dispatcher.register("search_resources", resource_handler.search_resources) _dispatcher.register("load_resource", resource_handler.load_resource) _dispatcher.register("assign_resource", resource_handler.assign_resource) _dispatcher.register("create_resource", resource_handler.create_resource) _dispatcher.register("get_resource_info", resource_handler.get_resource_info) _dispatcher.register("get_class_info", api_handler.get_class_info) _dispatcher.register("read_file", filesystem_handler.read_file) _dispatcher.register("write_file", filesystem_handler.write_file) _dispatcher.register("reimport", filesystem_handler.reimport) _dispatcher.register("list_signals", signal_handler.list_signals) _dispatcher.register("connect_signal", signal_handler.connect_signal) _dispatcher.register("disconnect_signal", signal_handler.disconnect_signal) _dispatcher.register("list_autoloads", autoload_handler.list_autoloads) _dispatcher.register("add_autoload", autoload_handler.add_autoload) _dispatcher.register("remove_autoload", autoload_handler.remove_autoload) _dispatcher.register("list_actions", input_handler.list_actions) _dispatcher.register("add_action", input_handler.add_action) _dispatcher.register("remove_action", input_handler.remove_action) _dispatcher.register("bind_event", input_handler.bind_event) _dispatcher.register("run_tests", test_handler.run_tests) _dispatcher.register("get_test_results", test_handler.get_test_results) _dispatcher.register("batch_execute", batch_handler.batch_execute) _dispatcher.register("set_anchor_preset", ui_handler.set_anchor_preset) _dispatcher.register("set_text", ui_handler.set_text) _dispatcher.register("build_layout", ui_handler.build_layout) _dispatcher.register("create_theme", theme_handler.create_theme) _dispatcher.register("theme_set_color", theme_handler.set_color) _dispatcher.register("theme_set_constant", theme_handler.set_constant) _dispatcher.register("theme_set_font_size", theme_handler.set_font_size) _dispatcher.register("theme_set_stylebox_flat", theme_handler.set_stylebox_flat) _dispatcher.register("apply_theme", theme_handler.apply_theme) _dispatcher.register("animation_player_create", animation_handler.create_player) _dispatcher.register("animation_create", animation_handler.create_animation) _dispatcher.register("animation_add_property_track", animation_handler.add_property_track) _dispatcher.register("animation_add_method_track", animation_handler.add_method_track) _dispatcher.register("animation_set_autoplay", animation_handler.set_autoplay) _dispatcher.register("animation_play", animation_handler.play) _dispatcher.register("animation_stop", animation_handler.stop) _dispatcher.register("animation_list", animation_handler.list_animations) _dispatcher.register("animation_get", animation_handler.get_animation) _dispatcher.register("animation_create_simple", animation_handler.create_simple) _dispatcher.register("animation_delete", animation_handler.delete_animation) _dispatcher.register("animation_validate", animation_handler.validate_animation) _dispatcher.register("animation_preset_fade", animation_handler.preset_fade) _dispatcher.register("animation_preset_slide", animation_handler.preset_slide) _dispatcher.register("animation_preset_shake", animation_handler.preset_shake) _dispatcher.register("animation_preset_pulse", animation_handler.preset_pulse) _dispatcher.register("material_create", material_handler.create_material) _dispatcher.register("material_set_param", material_handler.set_param) _dispatcher.register("material_set_shader_param", material_handler.set_shader_param) _dispatcher.register("material_get", material_handler.get_material) _dispatcher.register("material_list", material_handler.list_materials) _dispatcher.register("material_assign", material_handler.assign_material) _dispatcher.register("material_apply_to_node", material_handler.apply_to_node) _dispatcher.register("material_apply_preset", material_handler.apply_preset) _dispatcher.register("particle_create", particle_handler.create_particle) _dispatcher.register("particle_set_main", particle_handler.set_main) _dispatcher.register("particle_set_process", particle_handler.set_process) _dispatcher.register("particle_set_draw_pass", particle_handler.set_draw_pass) _dispatcher.register("particle_restart", particle_handler.restart_particle) _dispatcher.register("particle_get", particle_handler.get_particle) _dispatcher.register("particle_apply_preset", particle_handler.apply_preset) _dispatcher.register("camera_create", camera_handler.create_camera) _dispatcher.register("camera_configure", camera_handler.configure) _dispatcher.register("camera_set_limits_2d", camera_handler.set_limits_2d) _dispatcher.register("camera_set_damping_2d", camera_handler.set_damping_2d) _dispatcher.register("camera_follow_2d", camera_handler.follow_2d) _dispatcher.register("camera_get", camera_handler.get_camera) _dispatcher.register("camera_list", camera_handler.list_cameras) _dispatcher.register("camera_apply_preset", camera_handler.apply_preset) _dispatcher.register("audio_player_create", audio_handler.create_player) _dispatcher.register("audio_player_set_stream", audio_handler.set_stream) _dispatcher.register("audio_player_set_playback", audio_handler.set_playback) _dispatcher.register("audio_play", audio_handler.play) _dispatcher.register("audio_stop", audio_handler.stop) _dispatcher.register("audio_list", audio_handler.list_streams) _dispatcher.register("physics_shape_autofit", physics_shape_handler.autofit) _dispatcher.register("environment_create", environment_handler.create_environment) _dispatcher.register("gradient_texture_create", texture_handler.create_gradient_texture) _dispatcher.register("noise_texture_create", texture_handler.create_noise_texture) _dispatcher.register("curve_set_points", curve_handler.set_points) _dispatcher.register( "control_draw_recipe", control_draw_recipe_handler.control_draw_recipe ) _connection.dispatcher = _dispatcher add_child(_connection) _startup_trace_phase("handlers_registered") # Dock panel _dock = Dock.new() _dock.name = "Godot AI" _dock.setup(_connection, _log_buffer, self) add_control_to_dock(DOCK_SLOT_RIGHT_BL, _dock) _startup_trace_phase("dock_attached") _log_buffer.log("plugin loaded") if _telemetry != null: _telemetry.record_dock_startup() _flush_pending_self_update_telemetry() _telemetry.flush_pending_plugin_reload() var startup_path: String = str(_lifecycle.get_startup_path()) _startup_trace_finish(startup_path if not startup_path.is_empty() else "loaded") ## Public wrapper around the dev-server-toggle telemetry emit. Lets the ## dock (or any other caller) record without reaching into ``_telemetry`` ## directly — keeps the plugin's internal field encapsulated. The dev ## server is a Python subprocess unrelated to the plugin's own ## lifecycle, so emission can be synchronous (no EditorSettings persist ## dance like ``plugin_reload`` / ``self_update``). func record_dev_server_toggle(action: String) -> void: if _telemetry == null: return _telemetry.record_dev_server_toggle(action) ## Drain any self_update event written by `update_reload_runner` during the ## previous disable -> enable window. func _flush_pending_self_update_telemetry() -> void: var key := UPDATE_RELOAD_RUNNER_SCRIPT.PENDING_SELF_UPDATE_TELEMETRY_KEY var parsed = Telemetry._drain_editor_setting_dict(key) if parsed == null: return var status := str(parsed.get("status", "unknown")) var error := str(parsed.get("error", "")) ## Positional args: GDScript doesn't support keyword args in calls ## (unlike Python). from_version + to_version are empty strings here ## — only ``status`` and ``error`` are known at flush time. _telemetry.record_self_update(status, "", "", error) func _exit_tree() -> void: if _headless_disabled: _server_started_this_session = false _headless_disabled = false return ## Outer-to-inner teardown. Dispatcher Callables hold RefCounted handlers ## alive past the point where Godot reloads their class_name scripts — the ## first post-reload call into a typed-array-holding handler (e.g. ## McpGameLogBuffer._storage) then SIGSEGVs against a stale class descriptor. ## See issue #46. # Stop inbound work first so _process can't enqueue new commands or # null-deref log_buffer on the next tick mid-teardown. if _connection: _connection.teardown() # Break the Callable -> handler ref chain before dropping _handlers, so the # array clear actually decrefs the handler RefCounteds to zero. if _dispatcher: _dispatcher.clear() # Handler destructors run here, while their class_name scripts are still loaded. _handlers.clear() if _dock: remove_control_from_docks(_dock) _dock.queue_free() _dock = null if _connection: _connection.queue_free() _connection = null if _debugger_plugin: remove_debugger_plugin(_debugger_plugin) _debugger_plugin = null ## Detach the editor logger BEFORE nulling the buffer. After remove_logger ## returns, Godot guarantees no further virtual calls — so the logger's ## next access to `_buffer` (if any in flight) lands on a still-live ## ref-counted buffer, not a freed one. _detach_editor_logger() _dispatcher = null _log_buffer = null _game_log_buffer = null _editor_log_buffer = null _stop_server() ## Symmetric with prepare_for_update_reload: the static guard persists ## across disable/enable within a single editor session, so the re-enabled ## plugin instance's _start_server would short-circuit and never respawn. ## Pre-#159 this was masked — the old kill path usually left Python alive ## and the new instance adopted it on port 8000. Now that _stop_server is ## deterministic, nothing is left to adopt and the reload hangs. _server_started_this_session = false print("MCP | plugin unloaded") ## Attach editor_logger.gd as a Godot logger so editor-process script ## errors (parse errors, @tool runtime errors, EditorPlugin errors, ## push_error/push_warning) flow into _editor_log_buffer for ## logs_read(source="editor"). Logger subclassing is 4.5+ only; the ## ClassDB gate keeps the plugin loadable on 4.4 with no-op editor logs ## (the buffer stays empty, logs_read returns no entries). ## ## Limitation called out in the issue: parse errors fired *before* the ## plugin's _enter_tree (e.g. during the editor's initial filesystem ## scan, or for scripts that fail on first project open) happen before ## add_logger is called and are not captured. There's no public API to ## drain the editor's already-emitted error history; rescanning the ## file would re-emit them but at the cost of disrupting the user's ## editing state, so we accept the gap. func _attach_editor_logger() -> void: if not (ClassDB.class_exists("Logger") and OS.has_method("add_logger")): return var logger_script := LoggerLoader.build(LoggerLoader.EDITOR_LOGGER_PATH) if logger_script == null: return _editor_logger = logger_script.new(_editor_log_buffer) OS.call("add_logger", _editor_logger) ## Remove the pre-2.5.8 logger scripts left at runtime/*.gd by a self-update ## (the runner doesn't prune files dropped between versions). They `extends ## Logger` and would re-emit "Could not find base class Logger" parse errors ## on Godot < 4.5 even though the live copies now live in the .gdignore'd ## runtime/loggers/ folder. Idempotent: existence-guarded, so it's a no-op on ## fresh installs and symlinked dev checkouts. func _cleanup_legacy_logger_scripts() -> void: var legacy := [ "res://addons/godot_ai/runtime/editor_logger.gd", "res://addons/godot_ai/runtime/editor_logger.gd.uid", "res://addons/godot_ai/runtime/game_logger.gd", "res://addons/godot_ai/runtime/game_logger.gd.uid", ] for res_path in legacy: if FileAccess.file_exists(res_path): DirAccess.remove_absolute(ProjectSettings.globalize_path(res_path)) func _detach_editor_logger() -> void: if _editor_logger != null and OS.has_method("remove_logger"): OS.call("remove_logger", _editor_logger) _editor_logger = null ## Register the game-side autoload on plugin enable. Runs the helper inside ## the game process so the editor-side debugger plugin can request ## framebuffer captures over EngineDebugger messages. Removed on ## _disable_plugin so disabling the plugin leaves project.godot clean. func _enable_plugin() -> void: if _mcp_disabled_for_headless_launch(): return _ensure_game_helper_autoload() static func _mcp_disabled_for_headless_launch() -> bool: return _mcp_disabled_for_headless( OS.get_cmdline_args(), DisplayServer.get_name(), OS.get_environment("GODOT_AI_ALLOW_HEADLESS") ) static func _mcp_disabled_for_headless(args: PackedStringArray, display_name: String, allow_value: String) -> bool: if McpSettings.truthy(allow_value): return false return _args_request_headless(args) or display_name.to_lower() == "headless" static func _args_request_headless(args: PackedStringArray) -> bool: for i in range(args.size()): var arg := args[i] if arg == "--headless": return true if arg == "--display-driver" and i + 1 < args.size() and args[i + 1] == "headless": return true if arg.begins_with("--display-driver=") and arg.get_slice("=", 1) == "headless": return true return false func _disable_plugin() -> void: var key := "autoload/" + GAME_HELPER_AUTOLOAD_NAME if not ProjectSettings.has_setting(key): return ProjectSettings.clear(key) ProjectSettings.save() func _ensure_game_helper_autoload() -> void: ## Write the autoload directly to ProjectSettings and save immediately. ## EditorPlugin.add_autoload_singleton only mutates in-memory settings — ## the on-disk project.godot is only persisted when the editor saves ## (e.g. on quit). CI spawns the game subprocess before any save fires, ## so the child process never sees the autoload and the capture times ## out. Mirror AutoloadHandler's pattern: set_setting + save(). var key := "autoload/" + GAME_HELPER_AUTOLOAD_NAME var value := "*" + GAME_HELPER_AUTOLOAD_PATH # "*" prefix = singleton if ProjectSettings.get_setting(key, "") == value: return ## already registered with the right target ProjectSettings.set_setting(key, value) ProjectSettings.set_initial_value(key, "") ProjectSettings.set_as_basic(key, true) var err := ProjectSettings.save() if err != OK: push_warning("MCP: failed to save project.godot after registering %s autoload (error %d)" % [GAME_HELPER_AUTOLOAD_NAME, err]) func _startup_trace_begin() -> void: _startup_trace_enabled = ClientConfigurator.startup_trace_enabled() if not _startup_trace_enabled: return _startup_trace_start_ms = Time.get_ticks_msec() _startup_trace_last_ms = _startup_trace_start_ms _startup_trace_netsh_start_count = WindowsPortReservation.netsh_query_count() _startup_trace_counters.clear() for counter in STARTUP_TRACE_COUNTER_NAMES: _startup_trace_counters[counter] = 0 print( "MCP startup trace | begin platform=%s http_port=%d ws_port=%d" % [ OS.get_name(), ClientConfigurator.http_port(), ClientConfigurator.ws_port(), ] ) func _startup_trace_count(counter: String, amount: int = 1) -> void: if not _startup_trace_enabled: return _startup_trace_counters[counter] = int(_startup_trace_counters.get(counter, 0)) + amount func _startup_trace_phase(name: String) -> void: if not _startup_trace_enabled: return var now := Time.get_ticks_msec() print( "MCP startup trace | phase=%s delta_ms=%d total_ms=%d" % [name, now - _startup_trace_last_ms, now - _startup_trace_start_ms] ) _startup_trace_last_ms = now func _startup_trace_finish(path: String) -> void: if not _startup_trace_enabled: return var now := Time.get_ticks_msec() _startup_trace_counters["netsh"] = ( WindowsPortReservation.netsh_query_count() - _startup_trace_netsh_start_count ) print( "MCP startup trace | done path=%s total_ms=%d counters=%s" % [path, now - _startup_trace_start_ms, str(_startup_trace_counters)] ) func _start_server() -> void: _lifecycle.start_server() ## Test-fixture shim — characterization tests in test_plugin_lifecycle ## reach for this instance method directly. Delegates to the manager's ## state-owning copy. func _set_incompatible_server(live: Dictionary, expected_version: String, port: int) -> void: _lifecycle._set_incompatible_server(live, expected_version, port) ## Static shim — kept on the plugin class because the characterization ## tests assert against `GodotAiPlugin._incompatible_server_message`. ## Implementation moved to ServerLifecycleManager. static func _incompatible_server_message( live: Dictionary, expected_version: String, port: int, expected_ws_port: int ) -> String: return ServerLifecycleManager._incompatible_server_message( live, expected_version, port, expected_ws_port ) static func _server_version_compatibility( actual_version: String, expected_version: String ) -> Dictionary: return ServerLifecycleManager._server_version_compatibility( actual_version, expected_version ) static func _server_status_compatibility( actual_version: String, expected_version: String, actual_ws_port: int, expected_ws_port: int, ) -> Dictionary: return ServerLifecycleManager._server_status_compatibility( actual_version, expected_version, actual_ws_port, expected_ws_port ) static func _managed_record_has_version_drift(record_version: String, current_version: String) -> bool: return ServerLifecycleManager._managed_record_has_version_drift(record_version, current_version) static func _probe_live_server_status(port: int, timeout_ms: int = SERVER_STATUS_PROBE_TIMEOUT_MS) -> Dictionary: var result := { "reachable": false, "version": "", "name": "", "ws_port": 0, "status_code": 0, "error": "", } var client := HTTPClient.new() var err := client.connect_to_host("127.0.0.1", port) if err != OK: result["error"] = "connect_%d" % err return result var deadline := Time.get_ticks_msec() + timeout_ms while client.get_status() == HTTPClient.STATUS_RESOLVING or client.get_status() == HTTPClient.STATUS_CONNECTING: client.poll() if Time.get_ticks_msec() >= deadline: result["error"] = "connect_timeout" return result OS.delay_msec(10) if client.get_status() != HTTPClient.STATUS_CONNECTED: result["error"] = "connect_status_%d" % client.get_status() return result err = client.request(HTTPClient.METHOD_GET, SERVER_STATUS_PATH, ["Accept: application/json"]) if err != OK: result["error"] = "request_%d" % err return result var body := PackedByteArray() while true: var status := client.get_status() if status == HTTPClient.STATUS_REQUESTING: client.poll() elif status == HTTPClient.STATUS_BODY: client.poll() var chunk := client.read_response_body_chunk() if chunk.size() > 0: body.append_array(chunk) elif status == HTTPClient.STATUS_CONNECTED: break else: result["error"] = "response_status_%d" % status return result if Time.get_ticks_msec() >= deadline: result["error"] = "response_timeout" return result OS.delay_msec(10) var response_code := client.get_response_code() result["status_code"] = response_code if response_code != 200: result["error"] = "http_%d" % response_code return result var parsed = JSON.parse_string(body.get_string_from_utf8()) if not (parsed is Dictionary): result["error"] = "invalid_json" return result result["reachable"] = true result["name"] = str(parsed.get("name", "")) result["version"] = _extract_server_version(parsed) result["ws_port"] = int(parsed.get("ws_port", 0)) ## `package_path` was added in v2.4.4 (#416) so the dock's ## "Incompatible server" banner can name the source of a version ## skew. Older servers omit it; treat the missing field as "". result["package_path"] = str(parsed.get("package_path", "")) return result func _probe_live_server_status_for_port(port: int) -> Dictionary: _startup_trace_count("http_status_probe") return _probe_live_server_status(port) static func _extract_server_version(payload: Dictionary) -> String: var version := str(payload.get("server_version", "")) if version.is_empty(): version = str(payload.get("version", "")) return version static func _live_status_identifies_godot_ai(live: Dictionary) -> bool: return ServerLifecycleManager._live_status_identifies_godot_ai(live) func _verified_status_version(live: Dictionary) -> String: if not ServerLifecycleManager._live_status_identifies_godot_ai(live): return "" return str(live.get("version", "")) func _verified_status_ws_port(live: Dictionary) -> int: if not ServerLifecycleManager._live_status_identifies_godot_ai(live): return 0 return int(live.get("ws_port", 0)) func _refresh_dock_client_statuses() -> bool: if _dock == null: return false if not _dock.has_method("_refresh_all_client_statuses"): return false _dock.call("_refresh_all_client_statuses") return true ## Test-fixture shim — characterization tests in test_plugin_lifecycle ## still drive the first-writer-wins terminal-diagnosis behaviour through ## this method. Delegates to the manager's `set_terminal_diagnosis` ## (which preserves the same first-writer-wins contract). func _set_spawn_state(state: int) -> void: _lifecycle.set_terminal_diagnosis(state) ## Arm the one-shot connection watcher. Called from `_start_server`'s ## FOREIGN_PORT branch: we flagged the diagnostic preemptively assuming ## the port holder doesn't speak MCP, but if it turns out to be another ## editor's server our WebSocket will open and we need to retract the ## diagnostic. ## ## We intentionally poll `_connection.is_connected` from `_process` ## instead of wiring a new signal on McpConnection. A signal added in the ## same release as a new consumer would be another shape-coupled update: ## old two-phase runners can parse the consumer while the McpConnection ## Script object still reflects v(N). Polling only reads `is_connected` ## (present on every shipped McpConnection), so old-runner upgrade windows ## do not depend on a same-release signal addition. ## ## The watch self-disarms after SPAWN_GRACE_MS so per-frame cost drops ## back to zero if it is ever armed by a legacy adoption path. func _watch_for_adoption_confirmation() -> void: _lifecycle.arm_adoption_watch() _update_process_enabled() func _arm_server_version_check() -> void: ## `arm_version_check` resolves an empty expected via the plugin ## version, so we can pass the raw field value through. _lifecycle.arm_version_check(_connection, str(_lifecycle._server_expected_version)) _update_process_enabled() func _update_process_enabled() -> void: set_process( _lifecycle.get_adoption_watch_deadline_ms() > 0 or _lifecycle.is_awaiting_server_version() ) func _process(_delta: float) -> void: var now := Time.get_ticks_msec() var version_check = _lifecycle.get_version_check() if version_check != null: version_check.tick(now) _lifecycle.tick_adoption_watch(now) _update_process_enabled() ## A WebSocket opening only proves the occupant speaks enough of the editor ## protocol to accept a session. Compatibility is decided by the server ## version in `handshake_ack`, so this only arms that check. func _on_connection_established() -> void: if _lifecycle.get_state() == ServerStateScript.FOREIGN_PORT: _arm_server_version_check() ## Test-fixture shim — characterization tests poke the verified path ## directly. Delegates to the version-check seam; the manager resolves ## an empty expected version via `_resolve_expected_version`. func _on_server_version_verified(version: String) -> void: _lifecycle.handle_server_version_verified( str(_lifecycle._server_expected_version), version ) _update_process_enabled() ## Test-fixture shim — same shape as `_on_server_version_verified`. func _on_server_version_unverified() -> void: _lifecycle.handle_server_version_unverified( str(_lifecycle._server_expected_version) ) _update_process_enabled() ## Start a 1s-tick timer that watches the spawned server for up to ## SERVER_WATCH_MS. If the process dies inside the window we drain the ## captured pipes and mark the server as crashed so the dock can surface ## what went wrong. After the window expires we close the pipes so they ## don't pin file descriptors or fill their kernel buffers. See #146. func _start_server_watch() -> void: _stop_server_watch() _server_watch_timer = Timer.new() _server_watch_timer.wait_time = 1.0 _server_watch_timer.one_shot = false _server_watch_timer.timeout.connect(_check_server_health) add_child(_server_watch_timer) _server_watch_timer.start() func _stop_server_watch() -> void: if _server_watch_timer != null: _server_watch_timer.stop() _server_watch_timer.queue_free() _server_watch_timer = null func _check_server_health() -> void: _lifecycle.check_server_health() ## True when the first spawn looks like a stale-uvx-index failure and we ## haven't already retried. Fail signal: launcher process already declared ## dead by the caller, pid-file was never written (Python never got to ## argparse), and we're on the uvx tier (the only tier where `--refresh` ## means anything). Bug #172 — after a fresh PyPI publish, uvx's local ## index metadata keeps saying the new version doesn't exist for ~10 min, ## which cascaded into an infinite reconnect loop pre-#171. Retry-at-spawn ## catches every entry path (Update, Reload Plugin, Reconnect, editor ## restart, crash recovery) — unlike the older Update-only precheck. func _should_retry_with_refresh() -> bool: return _retry_with_refresh_allowed( _lifecycle._refresh_retried, ClientConfigurator.get_server_launch_mode(), _read_pid_file(), ) ## Pure decision helper — environment-state readers stay in the instance ## method above, the logic lives here so tests can drive the three inputs ## directly without spoofing static caches or pid-files on disk. static func _retry_with_refresh_allowed(already_retried: bool, launch_mode: String, pid_from_file: int) -> bool: return ( not already_retried and launch_mode == "uvx" and pid_from_file == 0 ) func _respawn_with_refresh() -> void: _lifecycle.respawn_with_refresh() ## Snapshot of the server-spawn outcome for the dock. ## ## `state` is one of the `McpServerState.*` int constants; the dock owns ## the UI copy per state via its own `_crash_body_for_state`. `exit_ms` ## is only meaningful for `CRASHED`. func get_server_status() -> Dictionary: return _lifecycle.get_status_dict() func get_resolved_ws_port() -> int: return _resolved_ws_port func _set_resolved_ws_port(port: int) -> void: _resolved_ws_port = port if _connection != null: _connection.ws_port = port func _resolve_ws_port() -> int: return PortResolver.resolve_ws_port( ClientConfigurator.ws_port(), ClientConfigurator.MAX_PORT, _log_buffer, ) ## Test-compat shim — characterization tests call this static directly. static func _resolved_ws_port_for_existing_server( record_ws_port: int, record_version: String, current_version: String, fresh_resolved: int ) -> int: return PortResolver.resolved_ws_port_for_existing_server( record_ws_port, record_version, current_version, fresh_resolved, ) static func _resolve_ws_port_from_output( configured_port: int, netsh_output: String, span: int = 2048 ) -> int: return PortResolver.resolve_ws_port_from_output( configured_port, netsh_output, ClientConfigurator.MAX_PORT, span, ) ## Plugin-level shim around the resolver — keeps the startup-trace ## counter increment and the `_ProofPlugin` override hook on the plugin. func _is_port_in_use(port: int) -> bool: if PortResolver.can_bind_local_port(port): ## POSIX can still have an IPv6 wildcard listener on this port ## even when an IPv4 loopback bind succeeds. Confirm through ## lsof so startup and kill-path discovery agree. if OS.get_name() != "Windows": _startup_trace_count("lsof") return PortResolver.is_port_in_use_via_scrape(port) return false if OS.get_name() == "Windows": _startup_trace_count("netstat") else: _startup_trace_count("lsof") return PortResolver.is_port_in_use_via_scrape(port) ## Pass `_startup_trace_count` so the resolver bumps the right counter ## per scraper that actually ran (Windows can fall through netstat → ## PowerShell — counting both unconditionally would over-report). func _find_pid_on_port(port: int) -> int: return PortResolver.find_pid_on_port(port, _startup_trace_count) func _find_all_pids_on_port(port: int) -> Array[int]: return PortResolver.find_all_pids_on_port(port, _startup_trace_count) static func _execute_windows_powershell(script: String, output: Array) -> int: return PortResolver.execute_windows_powershell(script, output) static func _windows_listener_pids_from_execute_result(exit_code: int, output: Array) -> Array[int]: return PortResolver.windows_listener_pids_from_execute_result(exit_code, output) static func _windows_listener_execute_result_in_use(exit_code: int, output: Array) -> bool: return PortResolver.windows_listener_execute_result_in_use(exit_code, output) static func _parse_lsof_pids(raw: String) -> Array[int]: return PortResolver.parse_lsof_pids(raw) static func _parse_pid_lines(raw: String) -> Array[int]: return PortResolver.parse_pid_lines(raw) ## Find the managed server PID deterministically: prefer the pid-file ## the Python server writes on startup (see runtime_info.py), fall back ## to scraping `netstat -ano` / `lsof` only when the file is missing or ## stale. This is the replacement for raw port-scraping: on Windows the ## uvx launcher PID doesn't cover the Python child, and netstat parsing ## is fragile. ## ## Returns 0 when no server can be identified. func _find_managed_pid(port: int) -> int: var pid := _read_pid_file() if pid > 0 and _pid_alive(pid): return pid return _find_pid_on_port(port) ## `live` is the result of a prior `_probe_live_server_status_for_port` ## call that the caller already has on hand. When non-empty it short- ## circuits the internal probe at the bottom of this helper, so a single ## `_start_server` invocation that probes once at the top can thread the ## same snapshot through compatibility check + recovery without paying ## for a second ~500 ms localhost HTTPClient poll loop. Default `{}` ## preserves the historical behavior for callers outside the spawn flow ## (`can_recover_incompatible_server`, the dock's UI buttons), where a ## fresh probe is the right thing. func _evaluate_strong_port_occupant_proof(port: int, live: Dictionary = {}) -> Dictionary: var result := {"proof": "", "pids": []} var listener_pids := _find_all_pids_on_port(port) if listener_pids.is_empty(): return result var record := _read_managed_server_record() var record_pid := int(record.get("pid", 0)) var record_version := str(record.get("version", "")) if record_pid > 1 and record_pid != OS.get_process_id(): ## Brand-verify the recorded PID before trusting it as a kill target. ## A recorded PID can outlive the server it named and be recycled by ## the kernel for an unrelated process that happens to bind the same ## port — without the cmdline brand gate (the same one the ## `pidfile_listener` branch enforces) that process could be killed. ## See #525. if ( listener_pids.has(record_pid) and _pid_alive_for_proof(record_pid) and _pid_cmdline_is_godot_ai_for_proof(record_pid) ): return {"proof": "managed_record", "pids": [record_pid]} var legacy_targets := _legacy_pidfile_kill_targets(port, listener_pids) if not legacy_targets.is_empty(): return {"proof": "pidfile_listener", "pids": legacy_targets} var current_live: Dictionary = live if not live.is_empty() else _probe_live_server_status_for_port(port) if ( _live_status_identifies_godot_ai(current_live) and not record_version.is_empty() and str(current_live.get("version", "")) == record_version ): return {"proof": "status_matches_record", "pids": listener_pids} return result ## See `_evaluate_strong_port_occupant_proof` for the `live` contract. ## Threads `live` through the strong-proof delegate so neither helper ## probes when the caller already knows the port-owner status. func _evaluate_recovery_port_occupant_proof(port: int, live: Dictionary = {}) -> Dictionary: var proof := _evaluate_strong_port_occupant_proof(port, live) if not str(proof.get("proof", "")).is_empty(): return proof var current_live: Dictionary = live if not live.is_empty() else _probe_live_server_status_for_port(port) if _live_status_identifies_godot_ai(current_live): return {"proof": "status_name", "pids": _find_all_pids_on_port(port)} return {"proof": "", "pids": []} func _recover_strong_port_occupant(port: int, wait_s: float, pre_kill_live: Dictionary = {}) -> bool: return _lifecycle.recover_strong_port_occupant(port, wait_s, pre_kill_live) func _legacy_pidfile_kill_targets(_port: int, listener_pids: Array[int]) -> Array[int]: var targets: Array[int] = [] var pidfile_pid := _read_pid_file_for_proof() if pidfile_pid <= 1 or pidfile_pid == OS.get_process_id(): return targets ## An alive, branded pid-file PID is sufficient ownership proof. Under ## `uvicorn --reload` the reloader writes the pid-file but a child worker ## binds the port, so `listener_pids` never contains the reloader PID. ## Requiring `listener_pids.has(pidfile_pid)` here used to silently skip ## the kill path for the entire reload-shaped server family. The branded ## listener loop below still does the per-PID brand check so we never ## kill an unrelated process that happens to share the port. if not _pid_alive_for_proof(pidfile_pid) or not _pid_cmdline_is_godot_ai_for_proof(pidfile_pid): return targets for pid in listener_pids: if pid <= 1 or pid == OS.get_process_id(): continue ## Reuse the brand result already proven above when this listener is ## the same PID as the pidfile — saves a parent-chain walk and a ## shell-out (PowerShell on Windows, /proc on Linux, ps on macOS) per ## startup proof evaluation. if pid == pidfile_pid or _pid_cmdline_is_godot_ai_for_proof(pid): targets.append(pid) ## Also kill the reloader/launcher itself when it isn't already a listener. ## Without this, `--reload` workers would be killed but their parent would ## immediately respawn a replacement and the port would never free. if not targets.has(pidfile_pid): targets.append(pidfile_pid) return targets func _read_pid_file_for_proof() -> int: return _read_pid_file() func _pid_alive_for_proof(pid: int) -> bool: return _pid_alive(pid) func _pid_cmdline_is_godot_ai_for_proof(pid: int) -> bool: return _pid_cmdline_is_godot_ai(pid) static func _parse_windows_netstat_pid(stdout: String, port: int) -> int: return PortResolver.parse_windows_netstat_pid(stdout, port) static func _parse_windows_netstat_pids(stdout: String, port: int) -> Array[int]: return PortResolver.parse_windows_netstat_pids(stdout, port) static func _parse_windows_netstat_listening(stdout: String, port: int) -> bool: return PortResolver.parse_windows_netstat_listening(stdout, port) static func _split_on_whitespace(s: String) -> PackedStringArray: return PortResolver.split_on_whitespace(s) static func _read_pid_file() -> int: return PortResolver.read_pid_file() static func _clear_pid_file() -> void: PortResolver.clear_pid_file() func _stop_server() -> void: _lifecycle.stop_server() ## Clear the managed-server record and pid-file only if `port` is free. ## Returns true when state was cleared. Extracted from `_stop_server` so ## the "preserve on failed kill" contract is independently testable. func _finalize_stop_if_port_free(port: int) -> bool: if _is_port_in_use(port): return false _clear_managed_server_record() _clear_pid_file() return true ## Shared tail of the server CLI: transport, ports, and `--pid-file`. Both ## the initial spawn in `_start_server` and the `--refresh` retry in ## `_respawn_with_refresh` go through here so a new flag added in one place ## can't silently drop out of the other. static func _build_server_flags(port: int, ws_port: int) -> Array[String]: var flags: Array[String] = [] flags.assign([ "--transport", "streamable-http", "--port", str(port), "--ws-port", str(ws_port), "--pid-file", ProjectSettings.globalize_path(SERVER_PID_FILE), ]) ## Append `--exclude-domains` only when the user has actually picked at ## least one domain to drop. Skipping the empty case keeps spawns ## compatible with older (pre-1.4.2) servers that don't know the flag — ## relevant during staggered plugin/server upgrades in user-mode installs. var excluded := ClientConfigurator.excluded_domains() if not excluded.is_empty(): flags.append("--exclude-domains") flags.append(excluded) return flags ## Returns true only when we can prove `pid`'s command line carries the ## `godot-ai` brand AND a server flag (`--pid-file` / `--transport`). Used by ## automatic kill paths (`_legacy_pidfile_kill_targets`) so a stale pidfile ## whose PID has been recycled by an unrelated listener can't hand us a ## kill target. If the OS lookup fails or returns an empty cmdline we ## conservatively return false — better to surface incompatible-server and ## let the user click Restart than to kill the wrong process. func _pid_cmdline_is_godot_ai(pid: int) -> bool: ## Walks up the parent chain so a uvicorn `--reload` worker whose ## cmdline is just `multiprocessing.spawn` still matches when its ## parent reloader carries the godot_ai brand. Bound the walk so a ## hypothetical loop or runaway PPID can't stall the editor. var current := pid for _i in range(5): if current <= 1: return false var cmd := "" if OS.get_name() == "Windows": cmd = _windows_pid_commandline(current) else: cmd = _posix_pid_commandline(current) if _commandline_is_godot_ai_server(cmd): return true current = _pid_parent(current) return false func _pid_parent(pid: int) -> int: if pid <= 1: return 0 if OS.get_name() == "Windows": var output: Array = [] var script := ( "Get-CimInstance Win32_Process -Filter 'ProcessId = %d' | " + "Select-Object -ExpandProperty ParentProcessId" ) % pid _startup_trace_count("powershell") if _execute_windows_powershell(script, output) != 0 or output.is_empty(): return 0 return int(str(output[0]).strip_edges()) var output_posix: Array = [] if OS.execute("ps", ["-o", "ppid=", "-p", str(pid)], output_posix, true) != 0 or output_posix.is_empty(): return 0 return int(str(output_posix[0]).strip_edges()) static func _commandline_is_godot_ai_server(cmd: String) -> bool: if cmd.is_empty(): return false var lower := cmd.to_lower() ## The server is invoked with `--pid-file /godot_ai_server.pid`, ## so the path itself contains "godot_ai". A naive substring brand ## search would falsely match an unrelated process whose cmdline ## happens to reference a similarly-named pidfile path. Strip the ## value (but leave the bare flag for the has_flag check) before ## brand matching. var brand_search := _strip_pidfile_value(lower) var has_brand := brand_search.find("godot-ai") >= 0 or brand_search.find("godot_ai") >= 0 var has_flag := lower.find("--pid-file") >= 0 or lower.find("--transport") >= 0 return has_brand and has_flag static func _strip_pidfile_value(cmd: String) -> String: var rx := RegEx.new() ## Match `--pid-file=` and `--pid-file `; keep the bare ## flag so the flag-presence check still succeeds for a real server. if rx.compile("--pid-file(?:=|\\s+)\\S+") != OK: return cmd return rx.sub(cmd, "--pid-file ", true) func _windows_pid_commandline(pid: int) -> String: var output: Array = [] var script := ( "Get-CimInstance Win32_Process -Filter 'ProcessId = %d' | " + "Select-Object -ExpandProperty CommandLine" ) % pid _startup_trace_count("powershell") var exit_code := _execute_windows_powershell(script, output) if exit_code != 0 or output.is_empty(): return "" return str(output[0]) ## POSIX command-line lookup. Linux exposes `/proc//cmdline` as ## NUL-separated argv — read it directly so we avoid a `ps` fork on Linux ## and get the full argv rather than the truncated/quoted form some `ps` ## builds emit. Falls back to `ps -ww -p -o args=` on macOS / *BSD, ## which lack a Linux-style `/proc//cmdline`. Returns "" on failure ## so callers conservatively reject the PID rather than killing it blind. func _posix_pid_commandline(pid: int) -> String: var proc_path := "/proc/%d/cmdline" % pid if FileAccess.file_exists(proc_path): var f := FileAccess.open(proc_path, FileAccess.READ) if f != null: ## procfs pseudo-files report length 0 (the kernel generates ## content on read). `get_length()` therefore returns 0 and ## `get_buffer(0)` reads nothing. Read in chunks until EOF ## instead. Cap at ARG_MAX-class bound so a hypothetically ## misbehaving file can never stall the editor frame. var bytes := PackedByteArray() var max_bytes := 1 << 20 # 1 MiB while bytes.size() < max_bytes: var chunk := f.get_buffer(4096) if chunk.is_empty(): break bytes.append_array(chunk) if f.eof_reached(): break f.close() ## /proc cmdline is NUL-separated argv; convert NULs to spaces ## so the substring fingerprint matches the same way it does on ## the Windows path. Empty (kernel threads, exited processes) ## bubbles up as "" via the strip below. for i in range(bytes.size()): if bytes[i] == 0: bytes[i] = 0x20 return bytes.get_string_from_utf8().strip_edges() ## `-ww` removes ps's column-width truncation so trailing flags like ## --pid-file / --transport aren't dropped from the args= field. ## Both procps (Linux) and BSD ps (macOS / *BSD) accept the ## double-w form. var output: Array = [] var exit_code := OS.execute("ps", ["-ww", "-p", str(pid), "-o", "args="], output, true) if exit_code != 0 or output.is_empty(): return "" return str(output[0]).strip_edges() ## True if the given PID corresponds to a live (non-zombie) process. ## POSIX uses `ps -o stat=` (see inline comment for the zombie rationale); ## Windows uses `tasklist`. Called by `_start_server` to distinguish a live ## managed server that outlived its editor from a stale EditorSettings ## record, and by `_check_server_health` to detect a fast-failing launcher. static func _pid_alive(pid: int) -> bool: return PortResolver.pid_alive(pid) ## Calls `_is_port_in_use` (not `PortResolver.wait_for_port_free`) so ## `_ProofPlugin` overrides keep driving the loop. func _wait_for_port_free(port: int, timeout_s: float) -> void: var deadline := Time.get_ticks_msec() + int(timeout_s * 1000.0) while _is_port_in_use(port): if Time.get_ticks_msec() >= deadline: push_warning("MCP | port %d still in use after %.1fs — proceeding anyway" % [port, timeout_s]) return OS.delay_msec(100) func _read_managed_server_record() -> Dictionary: var es := EditorInterface.get_editor_settings() if es == null: return {"pid": 0, "version": "", "ws_port": 0} var pid: int = 0 if es.has_setting(MANAGED_SERVER_PID_SETTING): pid = int(es.get_setting(MANAGED_SERVER_PID_SETTING)) var version: String = "" if es.has_setting(MANAGED_SERVER_VERSION_SETTING): version = str(es.get_setting(MANAGED_SERVER_VERSION_SETTING)) var ws_port: int = 0 if es.has_setting(MANAGED_SERVER_WS_PORT_SETTING): ws_port = int(es.get_setting(MANAGED_SERVER_WS_PORT_SETTING)) return {"pid": pid, "version": version, "ws_port": ws_port} func _write_managed_server_record(pid: int, version: String) -> void: var es := EditorInterface.get_editor_settings() if es == null: return es.set_setting(MANAGED_SERVER_PID_SETTING, pid) es.set_setting(MANAGED_SERVER_VERSION_SETTING, version) es.set_setting(MANAGED_SERVER_WS_PORT_SETTING, _resolved_ws_port) func _clear_managed_server_record() -> void: var es := EditorInterface.get_editor_settings() if es == null: return if es.has_setting(MANAGED_SERVER_PID_SETTING): es.set_setting(MANAGED_SERVER_PID_SETTING, 0) if es.has_setting(MANAGED_SERVER_VERSION_SETTING): es.set_setting(MANAGED_SERVER_VERSION_SETTING, "") if es.has_setting(MANAGED_SERVER_WS_PORT_SETTING): es.set_setting(MANAGED_SERVER_WS_PORT_SETTING, 0) func prepare_for_update_reload() -> void: _lifecycle.prepare_for_update_reload() func _adopt_compatible_server(record_version: String, current_version: String, owner: int) -> String: return _lifecycle.adopt_compatible_server(record_version, current_version, owner) 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 == "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 ] ## Hand the self-update over to a tiny runner that is not owned by this ## EditorPlugin. The runner keeps the editor process alive, but disables this ## plugin before extracting/scanning the new scripts so every plugin-owned ## instance tears down on pre-update bytecode and pre-update field storage. func install_downloaded_update(zip_path: String, temp_dir: String, source_dock: Control) -> void: prepare_for_update_reload() var detached_dock = null if _dock != null and is_instance_valid(_dock): detached_dock = _dock remove_control_from_docks(_dock) _dock = null elif source_dock != null and is_instance_valid(source_dock): detached_dock = source_dock remove_control_from_docks(source_dock) var runner = UPDATE_RELOAD_RUNNER_SCRIPT.new() var parent: Node = EditorInterface.get_base_control() if parent == null: parent = get_tree().root parent.add_child(runner) runner.start(zip_path, temp_dir, detached_dock) func can_recover_incompatible_server() -> bool: return _lifecycle.can_recover_incompatible_server() func _resume_connection_after_recovery() -> void: if _connection == null: return var state: int = _lifecycle.get_state() if ( _lifecycle.is_connection_blocked() or ( state != ServerStateScript.SPAWNING and state != ServerStateScript.READY ) ): return _connection.connect_blocked = false _connection.connect_block_reason = "" _connection.server_version = "" _connection.set_process(true) _arm_server_version_check() func recover_incompatible_server() -> bool: if not _lifecycle.recover_incompatible_server(): return false _resume_connection_after_recovery() return true ## Kill whichever process is holding `http_port()` right now — by resolving ## the port-owning PID via pid-file / netstat / lsof, independent of whether ## we ever set the manager's `_server_pid` — then clear ownership state ## and respawn via the lifecycle manager. The dock's version-mismatch ## banner wires here when the plugin adopted a foreign server whose ## `server_version` drifts from the current plugin version. func force_restart_server() -> void: _lifecycle.force_restart_server() ## Single entry point for the dock's primary "Restart Dev Server" button. ## The user clicking Restart is explicit consent to take over the HTTP port, ## so this is aggressive: any PID holding the port gets killed (managed, ## branded-dev, or orphan multiprocessing.spawn workers whose parent died ## so brand detection misses them). After the port frees we spawn a fresh ## --reload dev server. Returns true if a kill happened, false if the port ## was already free and we just spawned. func force_restart_or_start_dev_server() -> bool: var port := ClientConfigurator.http_port() var killed := false if has_managed_server(): _lifecycle.reset_for_force_restart() if _is_port_in_use(port): _kill_processes_and_windows_spawn_children(_find_all_pids_on_port(port)) killed = true if killed: ## OS.kill returns synchronously but uvicorn's listener can take ## longer to release the port. Without this wait, start_dev_server's ## fixed 500ms timer races the old shutdown and the new --reload ## spawn fails to bind. _wait_for_port_free(port, 5.0) start_dev_server() return killed func start_dev_server() -> void: ## Start a dev server with --reload that survives plugin reloads. ## Kills any managed server first, waits for the port to free, then spawns. ## ## PYTHONPATH handling: when `res://` sits inside a checkout that owns a ## `src/godot_ai/` (root repo or a git worktree), prepend that `src/` to ## PYTHONPATH so `import godot_ai` and uvicorn's `reload_dirs` both pick ## up *this* tree's source rather than the root repo's editable install. ## On the root repo the path matches the installed package, so this is a ## no-op; in a worktree it's what makes `--reload` actually watch the ## worktree's Python. See #84. _stop_server() get_tree().create_timer(0.5).timeout.connect(func(): var server_cmd := ClientConfigurator.get_server_command() if server_cmd.is_empty(): push_warning("MCP | could not find server command for dev server") return var cmd: String = server_cmd[0] _set_resolved_ws_port(_resolve_ws_port()) var inner_args: Array[String] = [] inner_args.assign(server_cmd.slice(1)) inner_args.append_array([ "--transport", "streamable-http", "--port", str(ClientConfigurator.http_port()), "--ws-port", str(_resolved_ws_port), "--reload", ]) var worktree_src := ClientConfigurator.find_worktree_src_dir(ProjectSettings.globalize_path("res://")) var prev_pythonpath := OS.get_environment("PYTHONPATH") if not worktree_src.is_empty(): 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) var injected_telemetry: bool = _lifecycle._inject_telemetry_env() var pid := OS.create_process(cmd, inner_args) if injected_telemetry: OS.unset_environment("GODOT_AI_DISABLE_TELEMETRY") ## 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 not worktree_src.is_empty(): if prev_pythonpath.is_empty(): OS.unset_environment("PYTHONPATH") else: OS.set_environment("PYTHONPATH", prev_pythonpath) if pid > 0: ## Match `server_lifecycle.gd::start_server`'s log wording — ## "prefix" since we prepended to any pre-existing PYTHONPATH, ## not replaced it. See #429 review. var suffix := " (PYTHONPATH prefix=%s)" % worktree_src if not worktree_src.is_empty() else "" print("MCP | started dev server with --reload (PID %d): %s %s%s" % [pid, cmd, " ".join(inner_args), suffix]) else: push_warning("MCP | failed to start dev server") ) func stop_dev_server() -> void: ## Stop any server running on the HTTP port (by port, not PID). ## Used for dev servers whose PID we don't track across reloads. if _lifecycle.get_server_pid() > 0: # We have a managed server — use normal stop _stop_server() return var port := ClientConfigurator.http_port() var candidates: Array[int] = [] for pid in _find_all_pids_on_port(port): var candidate := int(pid) if _pid_cmdline_is_godot_ai(candidate): candidates.append(candidate) var killed := _kill_processes_and_windows_spawn_children(candidates) if not killed.is_empty(): print("MCP | stopped dev server on port %d" % port) func _kill_processes_and_windows_spawn_children(pids: Array[int]) -> Array[int]: var unique: Array[int] = [] for pid in pids: if pid > 0 and not unique.has(pid): unique.append(pid) if OS.get_name() == "Windows": for child_pid in _find_windows_spawn_children(unique): if not unique.has(child_pid): unique.append(child_pid) var killed: Array[int] = [] for pid in unique: if OS.get_name() == "Windows": var output: Array = [] var exit_code := OS.execute("taskkill", ["/PID", str(pid), "/T", "/F"], output, true) if exit_code == 0 or not _pid_alive(pid): killed.append(pid) else: OS.kill(pid) killed.append(pid) return killed func _find_windows_spawn_children(parent_pids: Array[int]) -> Array[int]: if parent_pids.is_empty(): var empty: Array[int] = [] return empty var found: Array[int] = [] for parent_pid in parent_pids: var output: Array = [] var script := ( "Get-CimInstance Win32_Process | " + "Where-Object { $_.CommandLine -like '*spawn_main(parent_pid=%d*' } | " + "ForEach-Object { $_.ProcessId }" ) % parent_pid _startup_trace_count("powershell") var exit_code := _execute_windows_powershell(script, output) if exit_code != 0 or output.is_empty(): continue for pid in _parse_pid_lines(str(output[0])): if not found.has(pid): found.append(pid) return found func is_dev_server_running() -> bool: ## Returns true if a branded dev server is running on the HTTP port ## that we didn't start as managed. if _lifecycle.get_server_pid() > 0: return false for pid in _find_all_pids_on_port(ClientConfigurator.http_port()): if _pid_cmdline_is_godot_ai(int(pid)): return true return false func has_managed_server() -> bool: ## Returns true if the plugin is currently managing a server process it spawned. return _lifecycle.has_managed_server() func can_restart_managed_server() -> bool: ## Restart is allowed only when we have ownership proof. A live PID ## means this plugin spawned/adopted a managed server; a non-empty ## managed record is the cross-session proof used by the drift branch. return _lifecycle.can_restart_managed_server()