更新godot ai 到最新版本

This commit is contained in:
2026-09-22 16:07:50 +08:00
parent 9dc02dbb31
commit 020ab88606
46 changed files with 5816 additions and 400 deletions
+14
View File
@@ -168,3 +168,17 @@ static func _is_private_ipv4(addr: String) -> bool:
var second := int(addr.get_slice(".", 1))
return second >= 16 and second <= 31
return false
## Syntax and bind-family errors shared by settings and saved launch plans.
static func configuration_error(raw: String, platform: String = OS.get_name()) -> String:
var invalid := invalid_tokens(raw)
if not invalid.is_empty():
return "Invalid entries (must be a CIDR or bare IP, comma-separated): %s" % ", ".join(invalid)
var value := normalize(raw)
if platform != "Windows" or value.is_empty():
return ""
for token in value.split(","):
if not token.contains(":"):
return ""
return "IPv6-only remote access is unsupported on Windows: the editor and attach bridge require IPv4 loopback. Use an IPv4 allowlist or clear Allow remote hosts to disable remote access. Full IPv6 support is tracked in #1072."
+70 -6
View File
@@ -6,8 +6,8 @@ extends Node
## The Dock is replaceable UI. Threads cannot share that lifetime: a timed-out
## probe or package pre-warm can outlive the panel that requested it, and
## destroying a live Godot Thread corrupts the GDScript VM. This node therefore
## owns both worker pools, their cancellation, phases, and one action slot per
## client until its Thread has been joined. It publishes copied value snapshots
## owns both worker pools, their cancellation, phases, and one mutation slot
## until its Thread has been joined. It publishes copied value snapshots
## and outcomes; it never retains the Dock, plugin, lifecycle, or update manager.
const Client := preload("res://addons/godot_ai/clients/_base.gd")
@@ -57,6 +57,7 @@ var _refresh_generation := 0
var _mcp_status_waiters: Dictionary = {}
var _action_threads: Dictionary = {}
var _pending_actions: Array[Dictionary] = []
var _action_started_msec: Dictionary = {}
var _action_names: Dictionary = {}
var _action_request_ids: Dictionary = {}
@@ -106,7 +107,7 @@ func _ready() -> void:
func _has_work_in_flight() -> bool:
return _post_update_thread != null or _refresh_thread != null or not _action_threads.is_empty()
return _post_update_thread != null or _refresh_thread != null or not _action_threads.is_empty() or not _pending_actions.is_empty()
## Construction is inert. The composition root calls activate only after its
@@ -128,8 +129,15 @@ func set_client_health_blocked(blocked: bool) -> void:
func snapshot() -> Dictionary:
var busy: Array[String] = []
var names := _action_names.duplicate(true)
var phases := _read_action_phases()
for client_id in _action_threads:
busy.append(String(client_id))
for pending in _pending_actions:
var client_id := str(pending.client_id)
busy.append(client_id)
names[client_id] = pending.action
phases[client_id] = "queued"
busy.sort()
return {
"accepting_work": _accepting_work,
@@ -137,8 +145,8 @@ func snapshot() -> Dictionary:
"refresh_completed": _refresh_completed_msec > 0,
"post_update_repin_running": _post_update_thread != null,
"busy_actions": busy,
"action_names": _action_names.duplicate(true),
"action_phases": _read_action_phases(),
"action_names": names,
"action_phases": phases,
}
@@ -154,6 +162,7 @@ func _process(_delta: float) -> void:
_poll_post_update_repin()
_poll_refresh()
_poll_actions()
_start_next_action()
_check_refresh_timeout()
_check_action_timeouts()
_retry_deferred_refresh()
@@ -166,6 +175,7 @@ func _process(_delta: float) -> void:
func quiesce(deadline_msec: int = 0) -> Dictionary:
_accepting_work = false
_refresh_state = RefreshState.SHUTTING_DOWN
_pending_actions.clear()
_publish_snapshot()
for client_id in _action_threads:
_set_action_cancelled(String(client_id), true)
@@ -464,7 +474,7 @@ func _start_action(client_id: String, action: String, request_id: String) -> Dic
return {"ok": false, "error": "Client worker is unavailable."}
if action not in ["configure", "remove"]:
return {"ok": false, "error": "Unknown client action: %s" % action}
if _mutation_termination_unproven.has(client_id):
if _post_update_termination_unproven or not _mutation_termination_unproven.is_empty():
return {
"ok": false,
"error": MutationLock.recovery_message(),
@@ -472,8 +482,61 @@ func _start_action(client_id: String, action: String, request_id: String) -> Dic
}
if _action_threads.has(client_id):
return {"ok": false, "error": "A client action is already running for %s." % client_id}
for pending in _pending_actions:
if pending.client_id == client_id:
return {"ok": false, "error": "A client action is already queued for %s." % client_id}
if not ClientConfigurator.has_client(client_id):
return {"ok": false, "error": "Unknown client: %s" % client_id}
if request_id.is_empty():
_pending_actions.append({"client_id": client_id, "action": action})
_start_next_action()
_publish_snapshot()
return {"ok": true}
if not _action_threads.is_empty() or not _pending_actions.is_empty():
return {"ok": false, "error": "Another client action is running or queued. Retry when it finishes."}
return _begin_action(client_id, action, request_id)
func _start_next_action() -> void:
if not _accepting_work or not _action_threads.is_empty() or _pending_actions.is_empty():
return
if _post_update_termination_unproven or not _mutation_termination_unproven.is_empty() or MutationLock.is_locked():
_discard_pending_actions(MutationLock.recovery_message())
return
var pending: Dictionary = _pending_actions.pop_front()
var started := _begin_action(str(pending.client_id), str(pending.action), "")
if not bool(started.get("ok", false)):
action_completed.emit(str(pending.client_id), str(pending.action), {
"status": "error", "message": str(started.error),
}, {})
_publish_snapshot()
func cancel_pending_action(client_id: String) -> bool:
for index in range(_pending_actions.size()):
var entry := _pending_actions[index]
if str(entry.client_id) != client_id:
continue
_pending_actions.remove_at(index)
action_completed.emit(client_id, str(entry.action), {
"status": "cancelled", "message": "Client action cancelled before it started.",
}, {})
_publish_snapshot()
return true
return false
func _discard_pending_actions(message: String) -> void:
var pending := _pending_actions.duplicate(true)
_pending_actions.clear()
for entry in pending:
action_completed.emit(str(entry.client_id), str(entry.action), {
"status": "error", "message": message,
}, {})
_publish_snapshot()
func _begin_action(client_id: String, action: String, request_id: String) -> Dictionary:
ClientConfigurator.warm_env_snapshot()
_set_action_cancelled(client_id, false)
_clear_action_phase(client_id)
@@ -631,6 +694,7 @@ func _record_unproven_action_result(
return
if (
bool(result.get("termination_failed", false))
or bool(result.get("mutation_lock_release_failed", false))
or bool(prewarm.get("termination_failed", false))
):
_mark_mutation_termination_unproven(client_id, action)
+241
View File
@@ -18,6 +18,17 @@ extends RefCounted
const COLOR_KEYS: Array[String] = ["r", "g", "b"]
const VECTOR2_KEYS: Array[String] = ["x", "y"]
const VECTOR3_KEYS: Array[String] = ["x", "y", "z"]
const VECTOR3I_KEYS: Array[String] = ["x", "y", "z"]
const QUATERNION_KEYS: Array[String] = ["x", "y", "z", "w"]
const BASIS_KEYS: Array[String] = ["x", "y", "z"]
const RECT2_KEYS: Array[String] = ["position", "size"]
const AABB_KEYS: Array[String] = ["position", "size"]
## Vector3i components are 32-bit signed integers, and GDScript's int()
## conversion wraps (2147483648 -> -2147483648) or zeroes (1e40 -> 0)
## out-of-range floats, so the range is checked before any conversion.
const INT32_MIN := -2147483648
const INT32_MAX := 2147483647
static func parse_color(value: Variant) -> Variant:
@@ -104,5 +115,235 @@ static func parse_float(value: Variant) -> Variant:
return null
static func parse_vector3i(value: Variant) -> Variant:
if value is Vector3i:
return value
var components: Array = []
if value is Dictionary:
var d: Dictionary = value
if not d.has_all(VECTOR3I_KEYS):
return null
components = [d.x, d.y, d.z]
elif value is Array:
var arr: Array = value
if arr.size() != 3:
return null
components = arr
else:
return null
var parsed: Array = []
for component in components:
var number: Variant = _int32_component(component)
if number == null:
return null
parsed.append(number)
return Vector3i(parsed[0], parsed[1], parsed[2])
static func parse_quaternion(value: Variant) -> Variant:
if value is Quaternion:
return value
var components: Array = []
if value is Dictionary:
var d: Dictionary = value
if not d.has_all(QUATERNION_KEYS):
return null
components = [d.x, d.y, d.z, d.w]
elif value is Array:
var arr: Array = value
if arr.size() != 4:
return null
components = arr
else:
return null
var parsed: Array = []
for component in components:
if not _is_finite_number(component):
return null
parsed.append(float(component))
## Mathematical value only: a zero-length quaternion is returned as-is.
## The animation boundary (AnimationValues.coerce_for_type) owns the
## rotation contract — it rejects zero-length and normalizes the rest.
var quat := Quaternion(parsed[0], parsed[1], parsed[2], parsed[3])
## Components are float32: a finite double (e.g. 1e40) can still overflow
## to INF at construction, so finiteness is re-checked on the result.
if not _is_finite_quaternion(quat):
return null
return quat
## Basis: accepts the canonical serializer shape ({x:{x,y,z}, y:{…}, z:{…}})
## and a 3-element array of axis vectors. Each axis goes through
## `parse_vector3`, so the component strictness is shared.
static func parse_basis(value: Variant) -> Variant:
if value is Basis:
return value
var rows: Array = []
if value is Dictionary:
var d: Dictionary = value
if not d.has_all(BASIS_KEYS):
return null
rows = [d.x, d.y, d.z]
elif value is Array:
var arr: Array = value
if arr.size() != 3:
return null
rows = arr
else:
return null
var axes: Array = []
for row in rows:
var axis: Variant = parse_vector3(row)
if axis == null or not _is_finite_vector3(axis as Vector3):
return null
axes.append(axis)
return Basis(axes[0], axes[1], axes[2])
## Transform3D: accepts the canonical serializer shape
## ({basis:{x,y,z}, origin:{x,y,z}}) and the ergonomic
## ({position, rotation_degrees?, scale?}) shape — rotation defaults to zero
## and scale to one so a bare position is a valid translation.
static func parse_transform3d(value: Variant) -> Variant:
if value is Transform3D:
return value
if not value is Dictionary:
return null
var d: Dictionary = value
if d.has("basis") or d.has("origin"):
if not (d.has("basis") and d.has("origin")):
return null
var basis: Variant = parse_basis(d.basis)
var origin: Variant = parse_vector3(d.origin)
if basis == null or origin == null or not _is_finite_vector3(origin as Vector3):
return null
return Transform3D(basis, origin)
if d.has("position"):
var pos: Variant = parse_vector3(d.position)
if pos == null or not _is_finite_vector3(pos as Vector3):
return null
var rot_basis := Basis.IDENTITY
if d.has("rotation_degrees"):
var rot_deg: Variant = parse_vector3(d.rotation_degrees)
if rot_deg == null or not _is_finite_vector3(rot_deg as Vector3):
return null
rot_basis = Basis.from_euler((rot_deg as Vector3) * (PI / 180.0))
elif d.has("rotation"):
var rot: Variant = parse_vector3(d.rotation)
if rot == null or not _is_finite_vector3(rot as Vector3):
return null
rot_basis = Basis.from_euler(rot)
if d.has("scale"):
var scale: Variant = parse_vector3(d.scale)
if scale == null or not _is_finite_vector3(scale as Vector3):
return null
## Local axes: the ergonomic shape describes a node transform, and
## `scaled()` would apply the scale in global axes (skewing the
## rotation for a non-uniform scale).
rot_basis = rot_basis.scaled_local(scale)
return Transform3D(rot_basis, pos)
return null
static func parse_rect2(value: Variant) -> Variant:
if value is Rect2:
return value
if value is Dictionary:
var d: Dictionary = value
if not d.has_all(RECT2_KEYS):
return null
var pos: Variant = parse_vector2(d.position)
var size: Variant = parse_vector2(d.size)
if pos == null or size == null:
return null
if not (_is_finite_vector2(pos as Vector2) and _is_finite_vector2(size as Vector2)):
return null
return Rect2(pos, size)
if value is Array:
var arr: Array = value
if arr.size() != 4:
return null
for item in arr:
if not _is_number(item):
return null
var rect := Rect2(float(arr[0]), float(arr[1]), float(arr[2]), float(arr[3]))
if not (_is_finite_vector2(rect.position) and _is_finite_vector2(rect.size)):
return null
return rect
return null
static func parse_aabb(value: Variant) -> Variant:
if value is AABB:
return value
if value is Dictionary:
var d: Dictionary = value
if not d.has_all(AABB_KEYS):
return null
var pos: Variant = parse_vector3(d.position)
var size: Variant = parse_vector3(d.size)
if pos == null or size == null:
return null
if not (_is_finite_vector3(pos as Vector3) and _is_finite_vector3(size as Vector3)):
return null
return AABB(pos, size)
if value is Array:
var arr: Array = value
if arr.size() != 6:
return null
for item in arr:
if not _is_number(item):
return null
var pos := Vector3(float(arr[0]), float(arr[1]), float(arr[2]))
var size := Vector3(float(arr[3]), float(arr[4]), float(arr[5]))
if not (_is_finite_vector3(pos) and _is_finite_vector3(size)):
return null
return AABB(pos, size)
return null
static func parse_node_path(value: Variant) -> Variant:
if value is NodePath:
return value
if value is String:
return NodePath(value)
return null
static func parse_string_name(value: Variant) -> Variant:
if value is StringName:
return value
if value is String:
return StringName(value)
return null
static func _is_number(v: Variant) -> bool:
return v is int or v is float
## One Vector3i component: finite and within int32 range, else null.
## In-range fractional values keep the existing truncation policy.
static func _int32_component(v: Variant) -> Variant:
if not _is_number(v):
return null
var number := float(v)
if not is_finite(number) or number < float(INT32_MIN) or number > float(INT32_MAX):
return null
return int(number)
static func _is_finite_number(v: Variant) -> bool:
return _is_number(v) and is_finite(float(v))
static func _is_finite_vector2(v: Vector2) -> bool:
return is_finite(v.x) and is_finite(v.y)
static func _is_finite_vector3(v: Vector3) -> bool:
return is_finite(v.x) and is_finite(v.y) and is_finite(v.z)
static func _is_finite_quaternion(v: Quaternion) -> bool:
return is_finite(v.x) and is_finite(v.y) and is_finite(v.z) and is_finite(v.w)
+5 -2
View File
@@ -14,6 +14,7 @@ const PORT_EXCLUDED := 7
const FOREIGN_PORT := 8
const STOPPING := 10
const STOPPED := 11
const UNSUPPORTED_CONFIG := 12
const _NAMES := {
UNINITIALIZED: "uninitialized",
@@ -26,6 +27,7 @@ const _NAMES := {
FOREIGN_PORT: "foreign_port",
STOPPING: "stopping",
STOPPED: "stopped",
UNSUPPORTED_CONFIG: "unsupported_config",
}
@@ -45,13 +47,14 @@ static func is_terminal_diagnosis(state: int) -> bool:
or state == PORT_EXCLUDED
or state == INCOMPATIBLE
or state == FOREIGN_PORT
or state == UNSUPPORTED_CONFIG
)
## True when the dock should skip interpreting client health (incompatible
## tool surface). This must NOT block Configure writes — those take an
## explicit url and the live plugin version (#916). Currently just
## INCOMPATIBLE — FOREIGN_PORT is transitional and may resolve to READY
## INCOMPATIBLE and UNSUPPORTED_CONFIG — FOREIGN_PORT may resolve to READY
## if the foreign occupant turns out to speak our handshake.
static func blocks_client_health(state: int) -> bool:
return state == INCOMPATIBLE
return state in [INCOMPATIBLE, UNSUPPORTED_CONFIG]
+51
View File
@@ -0,0 +1,51 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const MAX_TRIANGLES := 2048
const MAX_VERTICES := 6144
const MAX_SURFACES := 32
static func estimate(mesh: Mesh) -> Dictionary:
if mesh.get_script() != null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Scripted meshes cannot be bounded before geometry extraction; use an ArrayMesh without a script")
var triangles := 0
var vertices := 0
if mesh is ArrayMesh:
var array_mesh := mesh as ArrayMesh
var surface_count := array_mesh.get_surface_count()
if surface_count > MAX_SURFACES:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Geometry extraction supports at most %d mesh surfaces; found %d" % [MAX_SURFACES, surface_count])
for surface in surface_count:
var vertex_count := array_mesh.surface_get_array_len(surface)
vertices += vertex_count
if array_mesh.surface_get_primitive_type(surface) != Mesh.PRIMITIVE_TRIANGLES:
continue
var index_count := array_mesh.surface_get_array_index_len(surface)
triangles += int((index_count if index_count > 0 else vertex_count) / 3)
return {"triangles": triangles, "vertices": vertices}
# PrimitiveMesh surface counters can generate the entire mesh on first use.
if mesh is BoxMesh:
var box := mesh as BoxMesh
var x := mini(box.subdivide_width, MAX_TRIANGLES) + 1
var y := mini(box.subdivide_height, MAX_TRIANGLES) + 1
var z := mini(box.subdivide_depth, MAX_TRIANGLES) + 1
triangles = 4 * (x * y + x * z + y * z)
elif mesh is PlaneMesh:
var plane := mesh as PlaneMesh
triangles = 2 * (mini(plane.subdivide_width, MAX_TRIANGLES) + 1) * (mini(plane.subdivide_depth, MAX_TRIANGLES) + 1)
elif mesh is SphereMesh or mesh is CylinderMesh or mesh is CapsuleMesh:
var radial := mini(int(mesh.get("radial_segments")), MAX_TRIANGLES)
var rings := mini(int(mesh.get("rings")), MAX_TRIANGLES)
triangles = 2 * radial * (rings + 2)
if mesh is CapsuleMesh:
triangles *= 3
elif mesh is PointMesh:
return {"triangles": 0, "vertices": 1}
else:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Mesh class %s has no bounded geometry preflight; use ArrayMesh, BoxMesh, PlaneMesh, SphereMesh, CylinderMesh or CapsuleMesh" % mesh.get_class())
return {"triangles": triangles, "vertices": triangles * 3}
@@ -0,0 +1 @@
uid://c7rw0m5m0i1x5
+108 -117
View File
@@ -16,6 +16,8 @@ const SERVER_STARTUP_REPORT := "user://godot_ai_server_startup.json"
const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd")
static var _process_spawn_mutex := Mutex.new()
enum PortOccupancy { UNKNOWN, FREE, OCCUPIED }
## Serialize plugin-owned subprocess creation across the brief process-global
## environment override used for server capabilities. Callers must keep this
@@ -51,18 +53,11 @@ static func can_bind_local_port(port: int) -> bool:
return false
## True when `port` is bound on 127.0.0.1. Probes via TCPServer first,
## falls back to OS scraping. Callers that want per-scraper trace
## counters should call `is_port_in_use_via_scrape` with a trace hook
## after their own `can_bind_local_port` probe.
## Windows permits a loopback bind beside a wildcard listener. Use the OS
## listener table, and treat failed queries as unavailable rather than free.
static func is_port_in_use(port: int) -> bool:
if can_bind_local_port(port):
## On POSIX, an IPv6 wildcard listener can coexist with a
## successful 127.0.0.1 bind probe. Confirm with lsof so startup
## sees the same listener set that shutdown/recovery would see.
if OS.get_name() != "Windows":
return is_port_in_use_via_scrape(port)
return false
if OS.get_name() == "Windows":
return windows_port_occupancy(port) != PortOccupancy.FREE
return is_port_in_use_via_scrape(port)
@@ -73,21 +68,7 @@ static func is_port_in_use(port: int) -> bool:
static func is_port_in_use_via_scrape(port: int, trace: Callable = Callable()) -> bool:
var output: Array = []
if OS.get_name() == "Windows":
_trace(trace, "netstat")
var exit_code := OS.execute("netstat", ["-ano"], output, true)
if exit_code == 0 and output.size() > 0:
var stdout := str(output[0])
if parse_windows_netstat_listening(stdout, port):
return true
## A healthy dump with no listener row IS the answer — don't
## pay the ~1.2s powershell.exe spawn to confirm "not in use"
## (see find_all_pids_on_port for the cost rationale).
if windows_netstat_dump_parseable(stdout):
return false
## Fallback: netstat can be absent or unparseable on
## stripped/locale-odd Windows installs.
_trace(trace, "powershell")
return not find_listener_pids_windows(port).is_empty()
return windows_port_occupancy(port, windows_listener_snapshot(trace)) != PortOccupancy.FREE
_trace(trace, "lsof")
var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true)
if exit_code == 0 and output.size() > 0 and not output[0].strip_edges().is_empty():
@@ -102,8 +83,89 @@ static func is_port_in_use_via_scrape(port: int, trace: Callable = Callable()) -
)
## One snapshot per selection operation avoids a subprocess for every candidate.
## Unknown observations never prove a port free. This is not ownership evidence.
static func windows_listener_snapshot(trace: Callable = Callable()) -> Dictionary:
var output: Array = []
_trace(trace, "netstat")
var code := OS.execute("netstat", ["-ano"], output, true)
var snapshot := windows_snapshot_from_netstat(code, output)
if snapshot.known:
return snapshot
_trace(trace, "powershell")
output.clear()
var script := "$ErrorActionPreference='Stop'; try { $listeners = @(Get-NetTCPConnection -ErrorAction Stop | Where-Object { $_.State -eq 'Listen' } | Select-Object LocalPort,OwningProcess); ConvertTo-Json -Compress -Depth 3 -InputObject @{ listeners = $listeners }; exit 0 } catch { exit 1 }"
code = execute_windows_powershell(script, output)
return windows_snapshot_from_powershell(code, output)
static func windows_port_occupancy(port: int, snapshot: Dictionary = {}) -> PortOccupancy:
if snapshot.is_empty():
snapshot = windows_listener_snapshot()
if not snapshot.known:
return PortOccupancy.UNKNOWN
return PortOccupancy.OCCUPIED if snapshot.listeners.has(port) else PortOccupancy.FREE
static func windows_snapshot_from_netstat(exit_code: int, output: Array) -> Dictionary:
var unknown := {"known": false, "listeners": {}}
if exit_code != 0 or output.is_empty():
return unknown
var listeners := {}
var saw_tcp := false
for line in str(output[0]).split("\n"):
var fields := split_on_whitespace(line.strip_edges())
if fields.is_empty() or fields[0].to_upper() != "TCP":
continue
if fields.size() < 5 or not fields[1].contains(":") or not fields[2].contains(":"):
return unknown
var port_text := fields[1].get_slice(":", fields[1].get_slice_count(":") - 1)
var remote_port_text := fields[2].get_slice(":", fields[2].get_slice_count(":") - 1)
var pid_text := fields[fields.size() - 1]
if not port_text.is_valid_int() or not remote_port_text.is_valid_int() or not pid_text.is_valid_int():
return unknown
var port := int(port_text)
var pid := int(pid_text)
if port < 1 or port > 65535 or pid < 0 or pid > 4294967295 or int(remote_port_text) < 0 or int(remote_port_text) > 65535:
return unknown
saw_tcp = true
## Windows localizes the state column, but listeners keep a wildcard foreign port.
if fields[2].ends_with(":0"):
_record_windows_listener(listeners, port, pid)
return {"known": true, "listeners": listeners} if saw_tcp else unknown
static func windows_snapshot_from_powershell(exit_code: int, output: Array) -> Dictionary:
var unknown := {"known": false, "listeners": {}}
if exit_code != 0 or output.is_empty():
return unknown
var parsed: Variant = JSON.parse_string(str(output[0]))
if not (parsed is Dictionary) or not (parsed.get("listeners") is Array):
return unknown
var listeners := {}
for row in parsed.listeners:
if not (row is Dictionary):
return unknown
var port: Variant = row.get("LocalPort")
var pid: Variant = row.get("OwningProcess")
if not (port is int or port is float) or not is_finite(float(port)) or float(port) != floor(float(port)) or port < 1 or port > 65535:
return unknown
if not (pid is int or pid is float) or not is_finite(float(pid)) or float(pid) != floor(float(pid)) or pid < 0 or pid > 4294967295:
return unknown
_record_windows_listener(listeners, int(port), int(pid))
return {"known": true, "listeners": listeners}
static func _record_windows_listener(listeners: Dictionary, port: int, pid: int) -> void:
if not listeners.has(port):
listeners[port] = []
## PID 0 still occupies the port, but cannot identify a process we may stop.
if pid > 0 and not listeners[port].has(pid):
listeners[port].append(pid)
## Return the PID currently listening on the given TCP port, or 0 if
## the port is free. Thin convenience wrapper around `find_all_pids_on_port`
## no positive listener PID could be observed. Thin wrapper around `find_all_pids_on_port`
## — the per-OS scraping logic lives in one place.
static func find_pid_on_port(port: int, trace: Callable = Callable()) -> int:
var pids := find_all_pids_on_port(port, trace)
@@ -119,28 +181,14 @@ static func find_pid_on_port(port: int, trace: Callable = Callable()) -> int:
## can keep its cold-start trace accurate. The Windows path may fall
## through netstat → PowerShell, and a wrapping caller can't see which
## scraper actually ran without the hook.
static func find_all_pids_on_port(port: int, trace: Callable = Callable()) -> Array[int]:
static func find_all_pids_on_port(port: int, trace: Callable = Callable(), snapshot: Dictionary = {}) -> Array[int]:
if OS.get_name() == "Windows":
var output: Array = []
_trace(trace, "netstat")
var exit_code := OS.execute("netstat", ["-ano"], output, true)
if exit_code == 0 and not output.is_empty():
var stdout := str(output[0])
var netstat_pids := parse_windows_netstat_pids(stdout, port)
if not netstat_pids.is_empty():
return netstat_pids
## An empty per-port parse from a healthy dump IS the answer
## ("no listener"). Confirming it through the PowerShell probe
## costs a powershell.exe spawn (~1.2s measured) against ~30ms
## for the netstat scrape — two such confirmations dominated a
## ~7s Windows startup walk. Only fall through when the dump
## itself is unusable (netstat absent, or so format-odd that
## zero TCP rows parse).
if windows_netstat_dump_parseable(stdout):
var no_listeners: Array[int] = []
return no_listeners
_trace(trace, "powershell")
return find_listener_pids_windows(port)
if snapshot.is_empty():
snapshot = windows_listener_snapshot(trace)
var pids: Array[int] = []
if snapshot.known:
pids.assign(snapshot.listeners.get(port, []))
return pids
var output: Array = []
_trace(trace, "lsof")
var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true)
@@ -164,17 +212,6 @@ static func _trace(trace: Callable, counter: String) -> void:
trace.call(counter)
static func find_listener_pids_windows(port: int) -> Array[int]:
var script := (
"Get-NetTCPConnection -LocalPort %d -State Listen "
+ "-ErrorAction SilentlyContinue | "
+ "Select-Object -ExpandProperty OwningProcess"
) % port
var output: Array = []
var exit_code := execute_windows_powershell(script, output)
return windows_listener_pids_from_execute_result(exit_code, output)
static func execute_windows_powershell(script: String, output: Array) -> int:
var args := ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script]
for exe in windows_powershell_candidates():
@@ -205,17 +242,6 @@ static func windows_powershell_candidates() -> Array[String]:
return candidates
static func windows_listener_pids_from_execute_result(exit_code: int, output: Array) -> Array[int]:
var empty: Array[int] = []
if exit_code == 0 and not output.is_empty():
return parse_pid_lines(str(output[0]))
return empty
static func windows_listener_execute_result_in_use(exit_code: int, output: Array) -> bool:
return not windows_listener_pids_from_execute_result(exit_code, output).is_empty()
## Pure parser for `lsof -ti` output — newline-separated decimal PIDs.
## Empty lines, non-positive values, non-numeric tokens, and duplicate
## IPv4/IPv6 rows for the same listener are dropped.
@@ -257,7 +283,7 @@ static func parse_pid_lines(raw: String) -> Array[int]:
## Parse a Windows `netstat -ano` dump and return PIDs of rows whose
## local address ends with `:port` AND state is `LISTENING`. Substring
## local address ends with `:port` and foreign port is zero. Substring
## matching the whole dump is wrong: a remote address containing
## `:port` would false-positive against an unrelated ESTABLISHED row.
static func parse_windows_netstat_pid(stdout: String, port: int) -> int:
@@ -266,54 +292,15 @@ static func parse_windows_netstat_pid(stdout: String, port: int) -> int:
static func parse_windows_netstat_pids(stdout: String, port: int) -> Array[int]:
var snapshot := windows_snapshot_from_netstat(0, [stdout])
var pids: Array[int] = []
var port_suffix := ":%d" % port
for line in stdout.split("\n"):
var s := line.strip_edges()
if s.is_empty():
continue
var fields := split_on_whitespace(s)
if fields.size() < 5: # proto, local, remote, state, pid
continue
## Locale-independent listener signal (mirrors script/_dev_env.py):
## the state column is localized ("LISTENING"/"ABHÖREN"/"ÉCOUTE"...),
## but a listener's FOREIGN address is always the wildcard ":0".
if not fields[2].ends_with(":0"):
continue
if not fields[1].ends_with(port_suffix):
continue
var pid_str := fields[fields.size() - 1]
if pid_str.is_valid_int():
var pid := int(pid_str)
if pid > 0 and not pids.has(pid):
pids.append(pid)
if snapshot.known:
pids.assign(snapshot.listeners.get(port, []))
return pids
static func parse_windows_netstat_listening(stdout: String, port: int) -> bool:
return parse_windows_netstat_pid(stdout, port) > 0
## True when `stdout` looks like a healthy `netstat -ano` dump: at least
## one row parses as a TCP connection (proto column literally "TCP", an
## address containing ":", an integer PID in the last column). Locale-
## independent — protocol names are never localized, unlike the state
## column. Gates whether an empty per-port parse can be trusted as "no
## listener": a live Windows host always carries TCP rows (svchost/RPC
## listen on 135 at minimum), so a dump with zero parseable rows means
## netstat itself is absent/broken and the PowerShell fallback must run.
static func windows_netstat_dump_parseable(stdout: String) -> bool:
for line in stdout.split("\n"):
var fields := split_on_whitespace(line.strip_edges())
if fields.size() < 5:
continue
if fields[0].to_upper() != "TCP":
continue
if fields[1].find(":") < 0:
continue
if fields[fields.size() - 1].is_valid_int():
return true
return false
return bool(windows_snapshot_from_netstat(0, [stdout]).known)
## `String.split(" ", false)` only splits on single spaces; netstat
@@ -727,13 +714,17 @@ static func kill_exact_processes(
## Poll until the given port is no longer bound, or the timeout elapses.
## Used after `OS.kill` so we don't race the port-in-use check on rebind.
static func wait_for_port_free(port: int, timeout_s: float) -> void:
static func wait_for_port_free(port: int, timeout_s: float) -> PortOccupancy:
var deadline := Time.get_ticks_msec() + int(timeout_s * 1000.0)
while is_port_in_use(port):
while true:
var occupancy := windows_port_occupancy(port) if OS.get_name() == "Windows" else (PortOccupancy.OCCUPIED if is_port_in_use(port) else PortOccupancy.FREE)
if occupancy != PortOccupancy.OCCUPIED:
return occupancy
if Time.get_ticks_msec() >= deadline:
push_warning("MCP | port %d still in use after %.1fs — proceeding anyway" % [port, timeout_s])
return
return occupancy
OS.delay_msec(100)
return PortOccupancy.UNKNOWN
## Choose a non-Windows-reserved WS port. Returns `configured` when free;
+182
View File
@@ -0,0 +1,182 @@
@tool
extends RefCounted
## Per-call, native-only resource graphs. Limits bound output and traversal,
## not time spent inside native engine getters.
const Serializer := preload("res://addons/godot_ai/utils/variant_serializer.gd")
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const FAMILIES: Array[String] = [
"Shape2D", "Shape3D", "Mesh", "Material", "PhysicsMaterial", "StyleBox",
"Gradient", "Curve", "Curve2D", "Curve3D", "GradientTexture1D",
"GradientTexture2D", "CurveTexture", "CurveXYZTexture",
]
const MAX_RESOURCES := 32
const MAX_PROPERTIES := 64
const MAX_ENTRIES := 64
const MAX_VALUES := 512
const MAX_DEPTH := 3
const MAX_CONTAINER_DEPTH := 8
const MAX_STRING_BYTES := 1024
const MAX_STRING_CHARS := 256
const MAX_TRUNCATIONS := 32
const MAX_RESULT_BYTES := 65536
const BUILD_BYTES := 49152
var _depth: int
var _resources: Array[Dictionary] = []
var _references: Dictionary = {}
var _pending: Array[Dictionary] = []
var _truncations: Array[Dictionary] = []
var _values := 0
var _bytes_left := BUILD_BYTES
static func supported(resource: Resource) -> bool:
if resource.get_script() != null:
return false
for family in FAMILIES:
if resource.is_class(family):
return true
return false
func inspect(resource: Resource, depth: int) -> Dictionary:
_depth = depth
var root := _resource(resource, 0, "root")
var index := 0
while index < _pending.size():
var item := _pending[index]
_expand(item.resource, item.level, item.reference, item.row)
index += 1
var graph := {"root": root, "resources": _resources, "truncations": _truncations}
var result := {"data": graph}
if JSON.stringify(result).to_utf8_buffer().size() > MAX_RESULT_BYTES:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Resource inspection exceeded the 65536-byte result limit; inspect at a lower depth")
return result
func _omit(location: String, reason: String) -> Dictionary:
if _truncations.size() < MAX_TRUNCATIONS:
_truncations.append({"location": location.substr(0, 96), "reason": reason})
else:
_truncations[MAX_TRUNCATIONS - 1] = {"location": "root", "reason": "truncation_limit"}
return {"omitted": reason}
func _charge(bytes: int) -> bool:
if bytes > _bytes_left:
return false
_bytes_left -= bytes
return true
func _string(value: String, location: String) -> String:
var bounded := value.substr(0, MAX_STRING_CHARS)
while JSON.stringify(bounded).to_utf8_buffer().size() > MAX_STRING_BYTES:
bounded = bounded.substr(0, bounded.length() - 1)
if bounded.length() < value.length():
_omit(location, "string_bytes")
return bounded
func _resource(resource: Resource, level: int, location: String) -> Dictionary:
var identity := resource.get_instance_id()
if _references.has(identity):
return {"ref": _references[identity]}
if _resources.size() >= MAX_RESOURCES:
return _omit(location, "resources")
var bounded_path := _string(resource.resource_path, location)
if not _charge(128 + resource.get_class().length() * 6 + JSON.stringify(bounded_path).to_utf8_buffer().size()):
return _omit(location, "result_bytes")
var reference := "r%d" % (_resources.size() + 1)
_references[identity] = reference
var row := {
"id": reference, "type": resource.get_class(),
"path": bounded_path, "properties": {},
}
_resources.append(row)
_pending.append({"resource": resource, "level": level, "reference": reference, "row": row})
return {"ref": reference}
func _expand(resource: Resource, level: int, reference: String, row: Dictionary) -> void:
if resource.get_script() != null:
row["omitted"] = _omit(reference, "scripted_resource").omitted
return
if not supported(resource):
row["omitted"] = _omit(reference, "unsupported_resource").omitted
return
if level > _depth:
row["omitted"] = _omit(reference, "depth").omitted
return
var count := 0
for property in ClassDB.class_get_property_list(resource.get_class()):
var name: String = property.name
if name == "script" or not (int(property.usage) & PROPERTY_USAGE_EDITOR) or int(property.type) == TYPE_NIL:
continue
if count >= MAX_PROPERTIES or _values >= MAX_VALUES:
_omit(reference, "properties" if count >= MAX_PROPERTIES else "values")
break
if not _charge(name.length() * 6 + 64):
_omit(reference, "result_bytes")
break
count += 1
if int(property.type) in [TYPE_CALLABLE, TYPE_SIGNAL, TYPE_RID]:
row.properties[name] = _omit(reference + "." + name, "unsupported_value")
continue
row.properties[name] = _value(ClassDB.class_get_property(resource, name), level, 0, reference + "." + name)
func _value(value: Variant, level: int, nesting: int, location: String) -> Variant:
if _values >= MAX_VALUES:
return _omit(location, "values")
_values += 1
if not _charge(64):
return _omit(location, "result_bytes")
match typeof(value):
TYPE_OBJECT:
if value == null:
return null
if value is Resource:
return _resource(value, level + 1, location)
return _omit(location, "unsupported_object")
TYPE_STRING, TYPE_STRING_NAME, TYPE_NODE_PATH:
var bounded := _string(str(value), location)
if not _charge(JSON.stringify(bounded).to_utf8_buffer().size()):
return _omit(location, "result_bytes")
return bounded
TYPE_ARRAY, TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY, TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, TYPE_PACKED_STRING_ARRAY, TYPE_PACKED_VECTOR2_ARRAY, TYPE_PACKED_VECTOR3_ARRAY, TYPE_PACKED_VECTOR4_ARRAY, TYPE_PACKED_COLOR_ARRAY:
if nesting >= MAX_CONTAINER_DEPTH:
return _omit(location, "container_depth")
var result: Array = []
for index in mini(value.size(), MAX_ENTRIES):
if _values >= MAX_VALUES or _bytes_left < 64:
_omit(location, "values" if _values >= MAX_VALUES else "result_bytes")
break
result.append(_value(value[index], level, nesting + 1, location + "[%d]" % index))
if value.size() > MAX_ENTRIES:
_omit(location, "collection_entries")
return result
TYPE_DICTIONARY:
if nesting >= MAX_CONTAINER_DEPTH:
return _omit(location, "container_depth")
var entries: Array = []
var examined := 0
for key in value:
if examined >= MAX_ENTRIES or _values >= MAX_VALUES or _bytes_left < 128:
_omit(location, "collection_entries" if examined >= MAX_ENTRIES else "values" if _values >= MAX_VALUES else "result_bytes")
break
examined += 1
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME, TYPE_INT, TYPE_BOOL]:
_omit(location, "unsupported_key")
continue
var at := location + "[%d]" % entries.size()
entries.append({"key": _value(key, level, nesting + 1, at), "value": _value(value[key], level, nesting + 1, at)})
return {"entries": entries}
TYPE_NIL, TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_VECTOR2, TYPE_VECTOR2I, TYPE_VECTOR3, TYPE_VECTOR3I, TYPE_VECTOR4, TYPE_VECTOR4I, TYPE_RECT2, TYPE_RECT2I, TYPE_AABB, TYPE_PLANE, TYPE_QUATERNION, TYPE_BASIS, TYPE_TRANSFORM2D, TYPE_TRANSFORM3D, TYPE_PROJECTION, TYPE_COLOR:
var serialized: Variant = Serializer.serialize(value)
if not _charge(JSON.stringify(serialized).to_utf8_buffer().size()):
return _omit(location, "result_bytes")
return serialized
_:
return _omit(location, "unsupported_value")
@@ -0,0 +1 @@
uid://ddbv5xpo41ed3
+8 -1
View File
@@ -14,7 +14,14 @@ extends RefCounted
## Downscale `image` in place so its longest edge is at most
## `max_resolution` (0 = no cap), then PNG-encode. Returns
## {base64, width, height, original_width, original_height}.
static func downscale_and_encode(image: Image, max_resolution: int) -> Dictionary:
static func downscale_and_encode(image: Image, max_resolution: int, use_hdr_2d := false) -> Dictionary:
## Compatibility readback stays sRGB, including when HDR 2D is enabled.
## Image.linear_to_srgb requires RGB8/RGBA8: converting first quantizes
## linear darks and clamps values above 1 rather than tone-mapping them.
## Midgray therefore truncates approximately 54.6 -> 54 -> 126.
if use_hdr_2d and RenderingServer.get_current_rendering_method() != "gl_compatibility":
image.convert(Image.FORMAT_RGBA8)
image.linear_to_srgb()
var original_width := image.get_width()
var original_height := image.get_height()
+6
View File
@@ -20,6 +20,12 @@ static func finish(id: int) -> void:
_active.erase(id)
static func active_count(label: String = "") -> int:
if label.is_empty():
return _active.size()
return _active.values().count(label)
static func quiescence() -> Dictionary:
if not _active.is_empty():
return {
+53 -17
View File
@@ -105,6 +105,11 @@ func _begin_start_episode(existing_id := 0, probe := true) -> void:
_cancel_effect()
_replacement_authorization = null
_transport = null
var config_error := McpAllowHosts.configuration_error(str(_plan.get("allow_hosts", "")))
if not config_error.is_empty():
_block_without_effect("unsupported_remote_access", config_error)
startup_finished.emit("blocked:unsupported_remote_access")
return
if existing_id <= 0:
_next_episode_id += 1
existing_id = _next_episode_id
@@ -313,6 +318,7 @@ func transport_lost(reason := "Authenticated server endpoint was lost.") -> void
_ready_since_msec = 0
var limit := ENDPOINT_RECOVERY_DELAYS_SECONDS.size()
if _endpoint_recovery_attempts >= limit:
_endpoint_recovery_pending_episode = 0
_block(
"endpoint_lost",
"%s Automatic re-probing gave up after %d attempts; click Restart." % [reason, limit],
@@ -320,6 +326,7 @@ func transport_lost(reason := "Authenticated server endpoint was lost.") -> void
return
_endpoint_recovery_attempts += 1
var delay := float(ENDPOINT_RECOVERY_DELAYS_SECONDS[_endpoint_recovery_attempts - 1])
_endpoint_recovery_pending_episode = int(_episode.get("id", 0))
_block(
"endpoint_lost",
"%s Re-probing in %ds (attempt %d of %d)." % [
@@ -330,7 +337,6 @@ func transport_lost(reason := "Authenticated server endpoint was lost.") -> void
"MCP | server endpoint lost (%s); re-probing in %ds (attempt %d of %d)"
% [reason, int(delay), _endpoint_recovery_attempts, limit]
)
_endpoint_recovery_pending_episode = int(_episode.get("id", 0))
if bool(_plan.get("automatic_effects", true)):
_recover_lost_endpoint_after(delay, _endpoint_recovery_pending_episode)
@@ -700,8 +706,9 @@ func _effect_probe(payload: Dictionary) -> Dictionary:
if disposition == "unproven":
return _blocked_probe_result("process_authority_mismatch", port, {}, false,
"managed process identity could not be proven; click Restart to retry")
var listeners := PortResolver.windows_listener_snapshot() if OS.get_name() == "Windows" else {}
var capability := _read_capability(port)
var live := _probe_with_capability(port, capability, int(payload.timeout_ms))
var live := _probe_with_capability(port, capability, int(payload.timeout_ms), listeners)
if _authenticated_status_matches_record(live, capability):
var version := str(live.get("version", ""))
var ws_port := int(live.get("ws_port", 0))
@@ -719,7 +726,15 @@ func _effect_probe(payload: Dictionary) -> Dictionary:
"transport": _transport_from(port, ws_port, live, capability),
}
return _blocked_probe_result("incompatible", port, live, true)
if PortResolver.is_port_in_use(port):
var occupied: bool
if OS.get_name() == "Windows":
var occupancy := PortResolver.windows_port_occupancy(port, listeners)
if occupancy == PortResolver.PortOccupancy.UNKNOWN:
return _blocked_probe_result("port_occupancy_unknown", port, live)
occupied = occupancy == PortResolver.PortOccupancy.OCCUPIED
else:
occupied = PortResolver.is_port_in_use(port)
if occupied:
var detail := _record_probe_failure_detail(capability, live)
var blocked := _blocked_probe_result("occupied", port, live, false, detail)
var pre_v4 := _untrusted_pre_v4_occupant_version(port, int(payload.timeout_ms))
@@ -730,8 +745,13 @@ func _effect_probe(payload: Dictionary) -> Dictionary:
## The server binds both ports before it publishes anything. A held
## WebSocket port (a server moved off the HTTP port, or another editor)
## would kill the launch at preflight; say so now and name the setting.
if expected_ws_port > 0 and PortResolver.is_port_in_use(expected_ws_port):
return ws_port_blocked_result(expected_ws_port)
if expected_ws_port > 0:
var ws_occupied := (
PortResolver.windows_port_occupancy(expected_ws_port, listeners) == PortResolver.PortOccupancy.OCCUPIED
if OS.get_name() == "Windows" else PortResolver.is_port_in_use(expected_ws_port)
)
if ws_occupied:
return ws_port_blocked_result(expected_ws_port)
return {
"outcome": "free",
"owned_disposition": disposition,
@@ -941,7 +961,8 @@ func _effect_prove(payload: Dictionary) -> Dictionary:
or str(capability.get("websocket", "")) != str(payload.get("ws_capability", ""))
):
return {"pending": true, "reason": "capability_pair"}
var live := _probe_with_capability(port, capability, int(payload.timeout_ms))
var listeners := PortResolver.windows_listener_snapshot() if OS.get_name() == "Windows" else {}
var live := _probe_with_capability(port, capability, int(payload.timeout_ms), listeners)
if not _authenticated_status_matches_record(live, capability):
return {"pending": true, "reason": "authenticated_status"}
var version := str(live.get("version", ""))
@@ -953,7 +974,7 @@ func _effect_prove(payload: Dictionary) -> Dictionary:
var pid := PortResolver.read_pid_file(str(payload.get("pid_file", "")))
if pid <= 1:
return {"pending": true, "reason": "pid_file"}
if not PortResolver.find_all_pids_on_port(port).has(pid):
if not PortResolver.find_all_pids_on_port(port, Callable(), listeners).has(pid):
return {"pending": true, "reason": "listener_pid"}
if first_snapshot == null or pid != hinted_pid:
first_snapshot = (
@@ -973,13 +994,14 @@ func _effect_prove(payload: Dictionary) -> Dictionary:
## record must still name the authenticated instance, and the process
## fingerprint must remain unchanged after the final probe.
var final_capability := _read_capability(port)
var final_live := _probe_with_capability(port, final_capability, int(payload.timeout_ms))
var final_live := _probe_with_capability(port, final_capability, int(payload.timeout_ms), listeners)
var final_snapshot: Variant = _capture_process_snapshot(pid)
if PortResolver.capture_failed(final_snapshot):
return {"pending": true, "reason": "identity_unavailable"}
var final_listeners := PortResolver.windows_listener_snapshot() if OS.get_name() == "Windows" else {}
if (
PortResolver.read_pid_file(str(payload.get("pid_file", ""))) != pid
or not PortResolver.find_all_pids_on_port(port).has(pid)
or not PortResolver.find_all_pids_on_port(port, Callable(), final_listeners).has(pid)
or not PortResolver.process_descends_from(pid, launch_pid, final_snapshot)
or not PortResolver.pid_cmdline_is_godot_ai(pid, final_snapshot)
or PortResolver.process_fingerprint(pid, final_snapshot) != fingerprint
@@ -1085,8 +1107,8 @@ func _effect_replace(payload: Dictionary) -> Dictionary:
)
return {"ok": false, "reason": "replacement_failed"}
if launch.is_empty():
PortResolver.wait_for_port_free(port, 5.0)
return {"ok": not PortResolver.is_port_in_use(port), "reason": ""}
var occupancy := PortResolver.wait_for_port_free(port, 5.0)
return {"ok": occupancy == PortResolver.PortOccupancy.FREE, "reason": ""}
return {"ok": true, "reason": "", "launch": launch}
@@ -1165,13 +1187,14 @@ func _effect_stop(payload: Dictionary) -> Dictionary:
if disposition == "owned":
grants.append({"pid": pid, "fingerprint": fingerprint})
PortResolver.kill_exact_processes(grants, true)
var occupancy := PortResolver.PortOccupancy.FREE
if port > 0 and not grants.is_empty():
PortResolver.wait_for_port_free(port, 3.0)
occupancy = PortResolver.wait_for_port_free(port, 3.0)
var targets_gone := true
for exact in grants:
if PortResolver.process_fingerprint(int(exact.pid)) == str(exact.fingerprint):
targets_gone = false
var port_free := port <= 0 or not PortResolver.is_port_in_use(port)
var port_free := port <= 0 or (occupancy != PortResolver.PortOccupancy.UNKNOWN and not PortResolver.is_port_in_use(port))
## Killing something of ours must also free its listener. When there was
## nothing of ours left to kill, whoever holds the port now is not ours to
## prove stopped; the next start's probe deals with that occupant.
@@ -1283,7 +1306,7 @@ static func probe_live_server_status(
return _probe_with_capability(port, capability, timeout_ms)
static func _probe_with_capability(port: int, capability: Dictionary, timeout_ms: int) -> Dictionary:
static func _probe_with_capability(port: int, capability: Dictionary, timeout_ms: int, listeners: Dictionary = {}) -> Dictionary:
var result := {"reachable": false, "name": "", "version": "", "ws_port": 0, "instance_id": "", "status_code": 0, "error": ""}
var http_capability := str(capability.get("http", ""))
if http_capability.is_empty():
@@ -1292,7 +1315,10 @@ static func _probe_with_capability(port: int, capability: Dictionary, timeout_ms
## Windows can otherwise spend the whole connect deadline on an unbound
## loopback port. This is only an unreachable probe; callers still check
## occupancy and prove any later listener before using it.
if OS.get_name() == "Windows" and PortResolver.can_bind_local_port(port):
if (
OS.get_name() == "Windows" and PortResolver.can_bind_local_port(port)
and PortResolver.windows_port_occupancy(port, listeners) == PortResolver.PortOccupancy.FREE
):
result.error = "port_unbound"
return result
var client := HTTPClient.new()
@@ -1412,7 +1438,9 @@ static func _blocked_probe_result(
var message := "Port %d is occupied by another process." % port
if not detail.is_empty():
message = "Port %d is occupied by another process (%s)." % [port, detail]
if replaceable:
if reason == "port_occupancy_unknown":
message = "Windows could not query listening ports; retry when port discovery is available."
elif replaceable:
message = "Port %d is occupied by godot-ai v%s; choose Replace to continue." % [port, version]
return {
"outcome": "blocked",
@@ -1627,6 +1655,13 @@ func get_status_dict() -> Dictionary:
return {
"episode_id": int(_episode.get("id", 0)),
"episode_state": state,
"reason": reason,
"recovery_pending": (
state == BLOCKED and reason == "endpoint_lost"
and _endpoint_recovery_pending_episode > 0
and _endpoint_recovery_pending_episode == int(_episode.get("id", 0))
),
"episode_reason": reason,
"phase": str(_episode.get("phase", "")),
"ready_kind": str(_episode.get("ready_kind", "")),
"state": _dock_state(state, reason),
@@ -1713,9 +1748,10 @@ static func _dock_state(state: String, reason: String) -> int:
return ServerState.STOPPING
BLOCKED:
match reason:
"unsupported_remote_access": return ServerState.UNSUPPORTED_CONFIG
"incompatible": return ServerState.INCOMPATIBLE
"no_command": return ServerState.NO_COMMAND
"port_reserved": return ServerState.PORT_EXCLUDED
"occupied", "replacement_unproven": return ServerState.FOREIGN_PORT
"occupied", "replacement_unproven", "port_occupancy_unknown": return ServerState.FOREIGN_PORT
_: return ServerState.CRASHED
return ServerState.UNINITIALIZED