1701 lines
70 KiB
GDScript
1701 lines
70 KiB
GDScript
@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 <user>/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=<token>` and `--pid-file <token>`; 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/<pid>/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 <pid> -o args=` on macOS / *BSD,
|
|
## which lack a Linux-style `/proc/<pid>/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()
|