更新godot ai 到最新版本
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user