2026-10-09 12:49:07 +02:00

499 lines
22 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@tool
extends RefCounted
## Owns a bound TCP listener and its WebSocket peers: scan/listen (incl. the
## ERR_ALREADY_IN_USE discard-recreate), accept, the auth-handshake framing, peer
## lifecycle, and the auth timeout — driven by an externally-scheduled pump()
## primitive (the CALLER decides cadence). The "when" and the side-effects stay in
## the owning server, injected as Callables; this base only gets bytes on/off the
## wire and tracks peer state.
##
## Export-clean by contract: the Mode-B runtime autoload preloads this file, and
## GDScript resolves identifiers at parse time (before any is_editor_hint() guard),
## so naming ANY editor-only class — or preloading a script that does — would
## parse-fail the autoload in an export template (godotengine/godot#91713, unfixed
## 4.2–4.6). The entire identifier set here is TCPServer / WebSocketPeer / JSON /
## Time / ProjectSettings / MCPAuth, and it preloads only export-clean files. Keep
## it that way: a grep for "Editor" must return zero non-comment hits.
const MCPAuth := preload("res://addons/godot_mcp_toolkit/security/auth.gd")
# Pinned mode retries the SAME occupied port this many throttled times (a bounded
# "grace" that rides out an old instance still releasing it on restart) before
# logging the precise error. It keeps watching the port afterwards, so a later
# free still recovers — the bound is only on the loud-log phase, not on recovery.
const _PIN_GRACE_ATTEMPTS := 5
# Injected by the owning server after construction. The base is mechanical; every
# decision/side-effect that differs between the editor (Mode A) and the runtime
# (Mode B) rides one of these seams.
#
# _on_message: func(peer: WebSocketPeer, text: String) -> void
# The server's orchestrator: parses the frame and routes auth-vs-dispatch
# (calling validate_auth() for the auth branch, its own dispatch otherwise).
# May return a coroutine — poll_peers() awaits it, so the editor's per-tick
# sequential dispatch is preserved (awaiting a synchronous call is a no-op, so
# the runtime's non-await behaviour is preserved too).
# _build_auth_ack: func(message: Dictionary) -> Dictionary
# The server supplies the auth-ack payload (editor adds godot_version+version;
# runtime returns {"authed": true}). Pure — no side effects.
# _on_peer_authed: func(count: int) -> void
# Fired after a successful auth (editor → unfocused boost on the first peer +
# client_connected; runtime → noop).
# _on_peer_closed: func(peer: WebSocketPeer, was_authed: bool) -> void
# Fired once per closed peer during cleanup (editor → lease cleanup + unfocused
# restore + client_disconnected; runtime → noop).
# _on_bound: func(port: int) -> void
# Fired the moment a fresh port scan binds (NOT on an idempotent rebind), so a
# server can publish the new port (runtime → RegistryClient.set_runtime; editor
# → noop). Mirrors where the pre-extraction runtime called set_runtime.
# _on_listen_conflict: func(port: int, active: bool) -> void
# Fired on a listen-conflict STATE CHANGE — the listener tried and failed to bind
# anywhere it is allowed to (pinned port occupied, scan band exhausted, or a lost
# rebind), or that condition cleared (active=false on a successful bind). port is
# the pinned port / band base. The editor routes it to the dock warning + status
# label; the runtime leaves it empty (a child game process can't touch the dock)
# and relies on the transport's push_warning/push_error in the game console.
var _on_message: Callable = Callable()
var _build_auth_ack: Callable = Callable()
var _on_peer_authed: Callable = Callable()
var _on_peer_closed: Callable = Callable()
var _on_bound: Callable = Callable()
var _on_listen_conflict: Callable = Callable()
# Tagging prefix for the listen/accept warnings, supplied by the owning server so
# the editor and the runtime keep their distinct console labels.
var _log_prefix: String = "[MCPServer]"
# The warning logged when the whole port range is exhausted. Server-supplied
# (verbatim) because the editor and the runtime word it differently — a "%d/%d"
# format the base fills with the low/high port. The prefix is prepended.
var _exhausted_warning: String = "no free port in %d-%d; will retry every ~1s"
# Port scan range and the bind address, supplied at construction (the editor and
# the runtime listen on different port ranges).
var _port_base: int = 6550
var _port_range: int = 11
var _bind: String = "127.0.0.1"
# Throttle re-listen retries to avoid log spam while a port is briefly held by
# another process. ~1s at 60fps; the bridge's reconnect backoff sits on the same
# order so we don't pile retries on top of it.
var _relisten_frame_interval: int = 60
# Peers that don't send a valid auth message within this window are closed with WS
# code 1008 (Policy Violation).
var _auth_timeout_ms: int = 2000
# Whether poll_peers() AWAITS each _on_message delivery. The editor (Mode A)
# awaits so dispatch stays strictly sequential within a poll tick (its mutation
# serialisation depends on this); the runtime (Mode B) does NOT await — it
# fire-and-forgets, so a coroutine handler (e.g. runtime.screenshot, which awaits
# frame_post_draw) runs independently and the poll loop completes its pass. This
# matches each server's pre-extraction behaviour exactly.
var _await_messages: bool = true
# Pinned mode: bind the exact _port_base or fail — never scan to another port
# (deterministic multi-instance). A pinned-but-occupied port surfaces immediately
# and retries the SAME port for the bounded _PIN_GRACE_ATTEMPTS grace. Scanned
# mode (the default) is unchanged.
var _pinned: bool = false
# Server-supplied "how to resolve" tail appended to the precise pinned-conflict
# error, so the editor and the runtime word the fix for their own env var.
var _pin_conflict_hint: String = ""
var _tcp_server: TCPServer = null
var _peers: Array[WebSocketPeer] = []
var _peer_authed: Dictionary = {} # WebSocketPeer -> true (authed peers only)
var _peer_connect_ms: Dictionary = {} # WebSocketPeer -> int (ticks_msec at accept)
var _relisten_countdown: int = 0
# Tracks the current run of consecutive listen failures so we log the first one
# (with a hint), stay silent during retries, and announce the recovery with the
# attempt count. Reset on every successful listen.
var _consecutive_failures: int = 0
# -1 = never bound.
var _bound_port: int = -1
var _session_token: String = ""
# True while the listener has tried and failed to bind anywhere it is allowed to
# — a pinned port occupied, the scan band exhausted, or a lost rebind. Drives the
# editor dock warning + status label (via _on_listen_conflict). Cleared on any
# successful bind.
var _listen_conflict: bool = false
# Remaining bounded same-port grace attempts before the precise error is logged.
var _pin_grace_remaining: int = 0
# One-shot latch so the precise post-grace error logs once, not on every retry.
var _pin_error_logged: bool = false
## Configure the listener parameters and the console label. Called once by the
## owning server right after construction, before the first pump(). await_messages
## selects the dispatch semantics (see the field doc): editor true, runtime false.
## exhausted_warning is the server's verbatim port-range-exhausted message (a
## "%d/%d" format for the low/high port; prefix prepended). When pinned is true,
## port_range must be 1 and port_base is the exact pin — the listener binds it or
## fails (bind-exact-or-fail), never scanning elsewhere; pin_conflict_hint is the
## server's "how to resolve" tail appended to the precise conflict error.
func configure(log_prefix: String, port_base: int, port_range: int, bind: String,
relisten_frame_interval: int, auth_timeout_ms: int,
await_messages: bool, exhausted_warning: String,
pinned: bool = false, pin_conflict_hint: String = "") -> void:
_log_prefix = log_prefix
_port_base = port_base
_port_range = port_range
_bind = bind
_relisten_frame_interval = relisten_frame_interval
_auth_timeout_ms = auth_timeout_ms
_await_messages = await_messages
_exhausted_warning = exhausted_warning
_pinned = pinned
_pin_conflict_hint = pin_conflict_hint
if pinned:
_pin_grace_remaining = _PIN_GRACE_ATTEMPTS
## Inject the per-server seams (see the field docs above). Called once by the
## owning server right after configure(). A server that has no use for a seam
## (e.g. the runtime needs no auth-ack override or close/authed side-effects)
## passes an empty Callable and the base falls back to its mechanical default.
func set_handlers(on_message: Callable, build_auth_ack: Callable,
on_peer_authed: Callable, on_peer_closed: Callable,
on_bound: Callable = Callable(),
on_listen_conflict: Callable = Callable()) -> void:
_on_message = on_message
_build_auth_ack = build_auth_ack
_on_peer_authed = on_peer_authed
_on_peer_closed = on_peer_closed
_on_bound = on_bound
_on_listen_conflict = on_listen_conflict
func set_token(token: String) -> void:
_session_token = token
## The current session token, so a server that must re-write it to a new user://
## path (after a config/name change) can read it without holding its own copy.
func get_token() -> String:
return _session_token
func is_listening() -> bool:
return _tcp_server != null and _tcp_server.is_listening()
func get_bound_port() -> int:
return _bound_port
## True while the listener has tried and failed to bind anywhere it is allowed to
## (pinned port occupied, scan band exhausted, or a lost rebind). The editor
## server surfaces this as a dock warning + status-label state.
func is_listen_conflict() -> bool:
return _listen_conflict
## The exact port this transport must bind in pinned mode, else -1.
func get_pinned_port() -> int:
return _port_base if _pinned else -1
func is_authed(peer: WebSocketPeer) -> bool:
return _peer_authed.has(peer)
func get_authed_count() -> int:
return _peer_authed.size()
## The authenticated peers (for broadcast). Returned as the raw key array the
## Notifier.broadcast helper expects; callers must not mutate the maps through it.
func get_authed_peers() -> Array:
return _peer_authed.keys()
# -- Pump primitive ------------------------------------------------------------
## Drive ONE listen-maintain + accept + poll-peers + cleanup cycle. The CALLER
## decides cadence (editor: call_deferred @ ~15 Hz, gated by is_dispatching();
## runtime: inline per frame). Returns after one pass; never loops on its own.
## Returns true if an authed peer closed this pass, so the caller can emit a single
## aggregated disconnect per tick (the editor uses this; the runtime ignores it).
##
## Ordering mirrors the pre-extraction servers exactly: if the listener is down,
## try to (re)listen and return WITHOUT accepting/polling this tick; otherwise
## accept pending connections, poll the peers, and clean up the ones that closed.
func pump() -> bool:
if _tcp_server == null or not _tcp_server.is_listening():
ensure_listening()
return false
accept_pending()
var closed := await poll_peers()
return cleanup(closed)
# -- Listen / re-listen --------------------------------------------------------
## First-time port scan OR idempotent re-listen, matching the pre-extraction
## split: with no bound port yet, scan the range; with a known port, retry it
## (throttled). On a failed retry, discard the latched TCPServer and recreate it
## next attempt (a TCPServer that failed listen() latches
## ERR_ALREADY_IN_USE if reused).
func ensure_listening() -> void:
# Already listening → nothing to ensure. Without this guard a call while live
# would stop + re-listen the same socket (listen() on an already-open TCPServer
# fails ERR_ALREADY_IN_USE), briefly dropping the listener and flapping the
# conflict state. pump() gates on is_listening() so it never hits this; the
# guard makes the method safe for ANY caller.
if _tcp_server != null and _tcp_server.is_listening():
return
# Throttle the retry FIRST (matches the pre-extraction _try_listen ordering),
# so an armed countdown gates BOTH a re-scan (no port bound yet) and a rebind.
# At the initial start() call the countdown is 0, so the first scan is immediate.
if _relisten_countdown > 0:
_relisten_countdown -= 1
return
if _bound_port < 0:
if _pinned:
_pin_listen()
else:
_scan_and_listen()
return
if _tcp_server == null:
_tcp_server = TCPServer.new()
var error := _tcp_server.listen(_bound_port, _bind)
if error == OK:
_note_listen_success(true)
return
var hint := ""
if error == ERR_ALREADY_IN_USE:
hint = " (ERR_ALREADY_IN_USE — will retry silently every ~1s)"
_tcp_server.stop()
_note_listen_failure("%s rebind %s:%d failed (err %d)%s" % [
_log_prefix, _bind, _bound_port, error, hint])
# Tries _port_base.._port_base+_port_range-1 and binds the first free port. Sets
# _bound_port on success. If none is free, arms the throttled retry.
func _scan_and_listen() -> void:
for offset in range(_port_range):
var candidate := _port_base + offset
var server := TCPServer.new()
var err := server.listen(candidate, _bind)
if err == OK:
_tcp_server = server
_bound_port = candidate
_note_listen_success(false)
print("%s listening on %s:%d" % [_log_prefix, _bind, _bound_port])
if _on_bound.is_valid():
_on_bound.call(_bound_port)
return
server.stop()
# Server-supplied verbatim message (editor and runtime word it differently).
_note_listen_failure("%s %s" % [
_log_prefix, _exhausted_warning % [_port_base, _port_base + _port_range - 1]])
# Pinned mode: bind the exact _port_base or fail — never scans to another port.
# The first conflict surfaces immediately (dock warning via _on_listen_conflict +
# a push_warning); the SAME port is retried for a bounded grace — with a fresh
# TCPServer per attempt, because an instance that failed listen() latches
# ERR_ALREADY_IN_USE if reused — to ride out a prior instance still releasing it;
# grace exhausted → one precise push_error; a later success clears the conflict
# and rebinds. Loud and deterministic — never a silent fallback to a different
# port, never a silent hang.
func _pin_listen() -> void:
if _tcp_server == null:
_tcp_server = TCPServer.new()
var error := _tcp_server.listen(_port_base, _bind)
if error == OK:
_bound_port = _port_base
_consecutive_failures = 0
_relisten_countdown = 0
print("%s listening on %s:%d" % [_log_prefix, _bind, _bound_port])
if _on_bound.is_valid():
_on_bound.call(_bound_port)
if _listen_conflict:
print("%s pinned port %d is now free - bound after the conflict cleared" % [
_log_prefix, _port_base])
_set_listen_conflict(false)
_pin_grace_remaining = _PIN_GRACE_ATTEMPTS
_pin_error_logged = false
return
# Occupied (or another bind failure) — drop the failed server so the next
# attempt starts from a fresh instance (a reused one keeps failing with the
# latched ERR_ALREADY_IN_USE), then arm the throttled retry.
_tcp_server.stop()
_tcp_server = null
if not _listen_conflict:
push_warning("%s pinned port %d in use - retrying the same port briefly in case a prior instance is still releasing it" % [
_log_prefix, _port_base])
_set_listen_conflict(true)
if _pin_grace_remaining > 0:
_pin_grace_remaining -= 1
if _pin_grace_remaining == 0 and not _pin_error_logged:
_pin_error_logged = true
push_error("%s could not bind pinned port %d - it is still in use. %s" % [
_log_prefix, _port_base, _pin_conflict_hint])
_relisten_countdown = _relisten_frame_interval
# Flip the listen-conflict flag and notify the owner on a state CHANGE only (the
# editor surfaces/clears the dock warning + status label; the runtime passes no
# handler). Kept idempotent so repeated failures while the state holds don't
# re-fire the seam.
func _set_listen_conflict(active: bool) -> void:
if _listen_conflict == active:
return
_listen_conflict = active
if _on_listen_conflict.is_valid():
_on_listen_conflict.call(_port_base, active)
# Records a failed listen (scan-band exhausted, or a lost rebind): bumps the
# failure count, warns once on the first failure of the streak, drops the dead
# server (a TCPServer that failed listen() latches ERR_ALREADY_IN_USE — the next
# attempt needs a fresh instance), raises the listen-conflict state for the dock,
# and arms the throttled retry.
func _note_listen_failure(warning: String) -> void:
_consecutive_failures += 1
if _consecutive_failures == 1:
push_warning(warning)
_tcp_server = null
_set_listen_conflict(true)
_relisten_countdown = _relisten_frame_interval
# Records a successful listen: clears the failure streak, the retry latch, and
# the listen-conflict state. When recovering from a prior failure run, logs the
# recovery (the fresh first-boot scan passes false and skips that line).
func _note_listen_success(emit_recovery_log: bool) -> void:
if emit_recovery_log and _consecutive_failures > 0:
print("%s listening on %s:%d (recovered after %d failed attempts)" % [
_log_prefix, _bind, _bound_port, _consecutive_failures])
_consecutive_failures = 0
_relisten_countdown = 0
_set_listen_conflict(false)
# -- Accept / poll / cleanup ---------------------------------------------------
## Accept every pending connection: take the stream, wrap it in a WebSocketPeer
## with its inbound/outbound buffer sized from ws_buffer_kb (read ONCE here —
## this is the single home for that ProjectSetting read), track the peer and its
## connect time. ProjectSettings is a core singleton (present at runtime), so
## reading it keeps the base export-clean.
func accept_pending() -> void:
while _tcp_server.is_connection_available():
var stream := _tcp_server.take_connection()
var peer := WebSocketPeer.new()
var buffer_kb: int = ProjectSettings.get_setting("mcp_toolkit/limits/ws_buffer_kb", 1024)
peer.inbound_buffer_size = buffer_kb * 1024
peer.outbound_buffer_size = buffer_kb * 1024
var accept_error := peer.accept_stream(stream)
if accept_error != OK:
push_warning("%s accept_stream failed (%d)" % [_log_prefix, accept_error])
continue
_peers.append(peer)
_peer_connect_ms[peer] = Time.get_ticks_msec()
## Poll each peer; enforce the auth timeout (close 1008 if a peer hasn't authed in
## time); drain available packets, delivering each frame to _on_message (awaited
## so the editor's per-tick sequential dispatch is preserved). Returns the peers
## that have closed, for cleanup().
func poll_peers() -> Array[WebSocketPeer]:
var closed: Array[WebSocketPeer] = []
var now_ms := Time.get_ticks_msec()
for peer in _peers:
peer.poll()
var state := peer.get_ready_state()
if state == WebSocketPeer.STATE_CLOSED:
closed.append(peer)
continue
if state != WebSocketPeer.STATE_OPEN:
continue
# Auth timeout — close peers that haven't authed in time.
if not _peer_authed.has(peer):
if now_ms - int(_peer_connect_ms.get(peer, 0)) > _auth_timeout_ms:
peer.close(1008, "auth timeout")
closed.append(peer)
continue
while peer.get_available_packet_count() > 0:
var text := peer.get_packet().get_string_from_utf8()
# Editor awaits (sequential dispatch within the tick); runtime fires
# and forgets (a coroutine handler runs independently). See _await_messages.
if _await_messages:
await _on_message.call(peer, text)
else:
_on_message.call(peer, text)
return closed
## Erase the peer maps for the closed peers and fire _on_peer_closed for each (the
## server does its lease/unfocused/signal cleanup there). was_authed is read
## BEFORE the erase so the server can tell whether an authed peer dropped. Returns
## true if ANY closed peer was authed, so the server can emit a single aggregated
## disconnect per batch (the pre-extraction shape) with a reentrancy-safe local.
func cleanup(closed: Array[WebSocketPeer]) -> bool:
var had_authed_disconnect := false
for peer in closed:
var was_authed := _peer_authed.has(peer)
if was_authed:
had_authed_disconnect = true
_peers.erase(peer)
_peer_authed.erase(peer)
_peer_connect_ms.erase(peer)
if _on_peer_closed.is_valid():
_on_peer_closed.call(peer, was_authed)
return had_authed_disconnect
# -- Auth handshake ------------------------------------------------------------
## Validate a parsed auth message against the session token. On success: mark the
## peer authed, send the server-supplied auth-ack frame, fire _on_peer_authed with
## the new authed count, and return true. On failure: close 1008 and return false.
## The ack PAYLOAD is server-supplied (editor vs runtime diverge); the framing is
## the base's.
func validate_auth(peer: WebSocketPeer, message: Dictionary) -> bool:
if not MCPAuth.validate(message, _session_token):
peer.close(1008, "invalid token")
return false
_peer_authed[peer] = true
var ack: Dictionary = {"authed": true}
if _build_auth_ack.is_valid():
ack = _build_auth_ack.call(message)
peer.send_text(JSON.stringify(ack))
if _on_peer_authed.is_valid():
_on_peer_authed.call(_peer_authed.size())
return true
# -- Teardown ------------------------------------------------------------------
## Close every peer with the given code/reason and clear the peer maps. Used by
## the server's stop() (1000) and regenerate_token() (1008 — peers must re-auth).
## Does NOT touch the TCPServer; the server's stop() owns that.
func close_all(code: int, reason: String) -> void:
for peer in _peers:
if peer != null:
if reason.is_empty():
peer.close(code)
else:
peer.close(code, reason)
_peers.clear()
_peer_authed.clear()
_peer_connect_ms.clear()
## Stop and discard the TCPServer and reset the listen bookkeeping — the final
## teardown step, for after every peer has been closed via close_all().
func shutdown_listener() -> void:
if _tcp_server != null:
_tcp_server.stop()
_tcp_server = null
_relisten_countdown = 0
_consecutive_failures = 0
_bound_port = -1
_set_listen_conflict(false)
_pin_grace_remaining = _PIN_GRACE_ATTEMPTS if _pinned else 0
_pin_error_logged = false