417 lines
18 KiB
GDScript
417 lines
18 KiB
GDScript
@tool
|
|
extends RefCounted
|
|
## Coordinates the time-bounded per-scene editing lease + the scene-affinity queue
|
|
## that keep concurrent peers targeting different scenes from contaminating each
|
|
## other's edited tab. It owns the lease state (holder / scene / renewal stamp), the
|
|
## per-peer scene affinity, and the FIFO of tab-dependent commands waiting on the
|
|
## lease — plus the full mechanism over them: acquire / renew / release / steal
|
|
## (TTL expiry) / drain, scene.open contention handling, and the queued-command
|
|
## execution path. The dispatch layer ROUTES into this child's public API; the
|
|
## routing itself lives in the scene-lease lane (dispatch_lane.gd), not here.
|
|
##
|
|
## Editor-side ONLY — and TAINTED by design: it names EditorInterface (via the
|
|
## injected root-resolver) and reaches Modules.CommandHelpers.open_scene_deferred. That is
|
|
## allowed because the Mode-B runtime autoload has NO scene lease and must NEVER
|
|
## preload this file (it has no affinity/tab concept — a running game always owns
|
|
## its single SceneTree). #91713 therefore does not gate this child, but a runtime
|
|
## reference to it WOULD taint the autoload, so keep it strictly editor-only: only
|
|
## mcp_server.gd (the editor server) preloads it.
|
|
##
|
|
## Seams the dispatcher / mutation lane inject (so this child does not reach back
|
|
## into not-yet-extracted server internals, mirroring mutation_watchdog's
|
|
## force_clear hook): a root-resolver Callable (the testability seam — the editor
|
|
## injects EditorInterface.get_edited_scene_root, a unit test injects a stub), the
|
|
## command_received re-emit, the read-only execute core, the mutation busy-enqueue,
|
|
## and the mutation-lane entry. The registry is held directly (set_registry), like
|
|
## the server holds it, because the drain + scene.open paths query it and call
|
|
## commands. Notifier / FileGuard / MCPToolkitError / open_scene_deferred are
|
|
## reached directly (this child is tainted, so the clean-closure rule does not bind
|
|
## it) exactly as the server's _send_* wrappers did.
|
|
|
|
const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd")
|
|
const Notifier := preload("res://addons/godot_mcp_toolkit/transport/notifier.gd")
|
|
|
|
# Pending tab-dependent command waiting for the lease (one queued JSON-RPC request).
|
|
class _SceneQueueEntry:
|
|
var peer: WebSocketPeer
|
|
var id # int or null (JSON-RPC id)
|
|
var method: String
|
|
var params: Dictionary
|
|
var queued_ms: int = 0
|
|
var cancelled: bool = false
|
|
|
|
# Late-bound collaborators. The registry is set by the server's set_registry (which
|
|
# runs before start() builds this child, then is re-pushed here at construction).
|
|
var _registry: MCPToolkitCommandRegistry = null
|
|
|
|
# Injected seams (see the class docstring). Wired once in mcp_server._init_scene_lease.
|
|
# _root_resolver: func() -> Node — the active edited-scene root (EditorInterface in
|
|
# the editor; a stub in unit tests). The ONLY way this child learns the active tab.
|
|
var _root_resolver: Callable = Callable()
|
|
# _on_command: func(method: String) -> void — re-emit the server's command_received.
|
|
var _on_command: Callable = Callable()
|
|
# _run_read: func(peer, method: String, params: Dictionary, id) -> Dictionary (await)
|
|
# — the dispatcher's read-only execute core (peer-scoped context bookkeeping +
|
|
# call_command); stays in the dispatcher so _active_contexts is not shared across
|
|
# the seam.
|
|
var _run_read: Callable = Callable()
|
|
# _enqueue_mutation_if_busy: func(peer, id, method, params, queued_ms) -> bool —
|
|
# if a mutation is in flight, append to the mutation FIFO + send _queued and return
|
|
# true; else false. The mutation-lane state (_mutation_in_flight / _mutation_queue)
|
|
# stays in the dispatcher; this keeps it out of the lease child.
|
|
var _enqueue_mutation_if_busy: Callable = Callable()
|
|
# _execute_mutation: func(peer, id, method, params, scene_queued_ms) -> void — the
|
|
# mutation-lane entry (it re-drains the mutation queue on completion).
|
|
var _execute_mutation: Callable = Callable()
|
|
|
|
# -- Lease state ---------------------------------------------------------------
|
|
|
|
# Scene affinity per peer: peer → scene path (or "" if no affinity).
|
|
var _peer_scene_affinity: Dictionary = {} # WebSocketPeer → String
|
|
|
|
# Scene lease state.
|
|
var _lease_holder: WebSocketPeer = null
|
|
var _lease_scene: String = ""
|
|
var _lease_renewed_ms: int = 0
|
|
|
|
# Pending tab-dependent commands waiting for lease.
|
|
var _scene_queue: Array = [] # of _SceneQueueEntry
|
|
|
|
|
|
## Wire the late-bound collaborators. Call once at construction, before any dispatch.
|
|
## See the per-field docstrings above for each seam's contract.
|
|
func set_handlers(root_resolver: Callable, on_command: Callable,
|
|
run_read: Callable, enqueue_mutation_if_busy: Callable,
|
|
execute_mutation: Callable) -> void:
|
|
_root_resolver = root_resolver
|
|
_on_command = on_command
|
|
_run_read = run_read
|
|
_enqueue_mutation_if_busy = enqueue_mutation_if_busy
|
|
_execute_mutation = execute_mutation
|
|
|
|
|
|
func set_registry(registry: MCPToolkitCommandRegistry) -> void:
|
|
_registry = registry
|
|
|
|
|
|
## Release the registry + handler references during plugin teardown, so the
|
|
## Callable → GDScript chains this child holds (the registry's command handlers and
|
|
## the seams bound back to the server) are broken before the server node is freed.
|
|
func clear_registry() -> void:
|
|
_registry = null
|
|
_root_resolver = Callable()
|
|
_on_command = Callable()
|
|
_run_read = Callable()
|
|
_enqueue_mutation_if_busy = Callable()
|
|
_execute_mutation = Callable()
|
|
|
|
|
|
# -- Dispatch entry points (the dispatcher routes into these) -------------------
|
|
|
|
|
|
## scene.open: intercepted at dispatch level because under contention we must NOT
|
|
## call open_scene_from_path — the tab switch would interfere with the lease holder.
|
|
## Validation mirrors _cmd_scene_open.
|
|
func handle_scene_open(peer: WebSocketPeer, id, params: Dictionary) -> void:
|
|
# Validate file_path — mirrors _cmd_scene_open checks. Intercepted here
|
|
# because under contention we must NOT call open_scene_from_path (the tab
|
|
# switch would interfere with the lease holder).
|
|
var err = MCPToolkitError.require(params, ["file_path"])
|
|
if err != null:
|
|
Notifier.send_result(peer, id, err, "[MCPServer]")
|
|
return
|
|
var file_path := str(params.get("file_path", ""))
|
|
var guard := Modules.FileGuard.resolve_safe(file_path)
|
|
if guard["error"] != null:
|
|
Notifier.send_result(peer, id,
|
|
MCPToolkitError.fail("PATH_DENIED", str(guard["reason"])), "[MCPServer]")
|
|
return
|
|
if not FileAccess.file_exists(file_path):
|
|
Notifier.send_result(peer, id, MCPToolkitError.fail("NOT_FOUND",
|
|
"scene not found: %s" % file_path, MCPToolkitError.HINT_FILE_PATH), "[MCPServer]")
|
|
return
|
|
|
|
# Set affinity.
|
|
_peer_scene_affinity[peer] = file_path
|
|
|
|
# Attempt lease.
|
|
var acquired := try_acquire(peer, file_path)
|
|
_on_command.call("scene.open")
|
|
if acquired:
|
|
# Lease acquired — call the actual handler (opens the scene).
|
|
var result: Dictionary = await _registry.call_command("scene.open", params)
|
|
Notifier.send_result(peer, id, result, "[MCPServer]")
|
|
else:
|
|
# Contended — don't open the scene, return success + contention hint.
|
|
var hint := _build_contention_hint()
|
|
var result := {"success": true, "path": file_path}
|
|
if not hint.is_empty():
|
|
result["hint"] = hint
|
|
Notifier.send_result(peer, id, result, "[MCPServer]")
|
|
|
|
|
|
## Route a tab-dependent command through the scene lease. Returns true when it was
|
|
## queued (the caller must then return — no response yet); false when the command
|
|
## should proceed to the mutation/read lanes now. Renews the lease if this peer
|
|
## holds it and is already on the active tab. Commands that don't require the active
|
|
## scene short-circuit to false.
|
|
func try_queue_for_lease(peer: WebSocketPeer, id, method: String,
|
|
params: Dictionary) -> bool:
|
|
if not _registry.is_active_scene_required(method):
|
|
return false
|
|
var peer_scene := str(_peer_scene_affinity.get(peer, ""))
|
|
var active_scene := _active_scene_path()
|
|
if not peer_scene.is_empty() and peer_scene != active_scene:
|
|
# Target doesn't match active tab — queue for lease.
|
|
var entry := _SceneQueueEntry.new()
|
|
entry.peer = peer
|
|
entry.id = id
|
|
entry.method = method
|
|
entry.params = params
|
|
entry.queued_ms = Time.get_ticks_msec()
|
|
_scene_queue.append(entry)
|
|
Notifier.send_notification(peer, "_queued", {"request_id": id}, "[MCPServer]")
|
|
return true
|
|
# Target matches active tab (or no affinity) — execute normally.
|
|
# Renew lease if this peer holds it.
|
|
if _lease_holder == peer:
|
|
_lease_renewed_ms = Time.get_ticks_msec()
|
|
return false
|
|
|
|
|
|
## Flag a queued (not-yet-executing) scene-queue command for skip-on-drain — the
|
|
## scene-queue fallback of cancellation, for targets not found among the in-flight
|
|
## contexts or the mutation queue. Matches on the requesting [param peer] AND the id
|
|
## (ids are only unique per client, so an id-only match could cancel another peer's
|
|
## queued command). Returns true if a matching entry was found.
|
|
func cancel_queued(peer: WebSocketPeer, target_id: String) -> bool:
|
|
for entry in _scene_queue:
|
|
if entry.peer == peer and str(entry.id) == target_id:
|
|
entry.cancelled = true
|
|
return true
|
|
return false
|
|
|
|
|
|
## Per-peer cleanup on disconnect: drop the affinity, release the lease if this peer
|
|
## held it (which drains the next waiter), and remove the peer's queued scene
|
|
## commands. The transport has already erased its own peer maps.
|
|
func on_peer_closed(peer: WebSocketPeer) -> void:
|
|
_peer_scene_affinity.erase(peer)
|
|
if _lease_holder == peer:
|
|
release() # Immediate release → drain queue.
|
|
# Remove queued scene commands for this peer.
|
|
_scene_queue = _scene_queue.filter(func(entry: _SceneQueueEntry):
|
|
return entry.peer != peer)
|
|
|
|
|
|
# -- Lease mechanism -----------------------------------------------------------
|
|
|
|
|
|
# The active edited-scene path via the injected root-resolver (EditorInterface in
|
|
# the editor; a stub in tests). "" when there is no edited scene.
|
|
func _active_scene_path() -> String:
|
|
var root: Node = _root_resolver.call()
|
|
if root == null:
|
|
return ""
|
|
return root.scene_file_path
|
|
|
|
|
|
func _build_contention_hint() -> String:
|
|
if _lease_holder == null or _lease_scene.is_empty():
|
|
return ""
|
|
return (
|
|
"Note: another session is currently editing %s. "
|
|
+ "Work that doesn't target this scene executes immediately "
|
|
+ "without waiting — for example, reading or writing scripts "
|
|
+ "for your own scenes, managing your files, or querying project "
|
|
+ "info. Work that modifies nodes or reads scene trees will queue "
|
|
+ "until the editor tab becomes available — wait times are variable."
|
|
) % _lease_scene
|
|
|
|
|
|
## Acquire (or renew) the lease for this peer's affinity scene. Pure bookkeeping —
|
|
## deliberately NO raw open_scene_from_path here (a raw open violates the #75669
|
|
## deferred-open rule and is unguarded against scans); tab activation happens in
|
|
## the guarded _execute_scene_queued_* paths via _switch_to_affinity_scene. Returns
|
|
## true on acquire/renew, false when another peer holds it (or the scene was deleted).
|
|
func try_acquire(peer: WebSocketPeer, scene: String) -> bool:
|
|
if _lease_holder == null:
|
|
# No current holder — acquire.
|
|
if not scene.is_empty() and not FileAccess.file_exists(scene):
|
|
_peer_scene_affinity.erase(peer)
|
|
return false # Scene was deleted.
|
|
_lease_holder = peer
|
|
_lease_scene = scene
|
|
_lease_renewed_ms = Time.get_ticks_msec()
|
|
# Lease acquisition stays pure bookkeeping — no raw open_scene_from_path
|
|
# (it would violate the #75669 deferred-open rule, unguarded against
|
|
# scans). Tab activation belongs to the guarded _execute_scene_queued_*
|
|
# paths via _switch_to_affinity_scene.
|
|
return true
|
|
if _lease_holder == peer:
|
|
# Same peer — renew.
|
|
_lease_renewed_ms = Time.get_ticks_msec()
|
|
return true
|
|
return false # Lease held by another peer.
|
|
|
|
|
|
## Release the lease and drain the next waiter. Public so the unit test can exercise
|
|
## the release leg; in production it is reached via expiry / disconnect / scene.close.
|
|
func release() -> void:
|
|
_lease_holder = null
|
|
_lease_scene = ""
|
|
_lease_renewed_ms = 0
|
|
drain()
|
|
|
|
|
|
## TTL steal: if a waiter is queued and the holder has exceeded the lease TTL, release
|
|
## so the next waiter can acquire. Runs every poll tick from the server's _process.
|
|
func check_expiry() -> void:
|
|
# Don't steal/drain during a save's re-entry (a lease-steal would drain a
|
|
# scene-queued command mid-save).
|
|
if MCPToolkitSafeSceneOps.is_dispatching():
|
|
return
|
|
if _lease_holder == null:
|
|
return
|
|
if _scene_queue.is_empty():
|
|
return # No waiters — don't expire the lease.
|
|
var ttl_ms: int = ProjectSettings.get_setting(
|
|
"mcp_toolkit/concurrency/scene_lease_ttl_ms", 8000)
|
|
var elapsed := Time.get_ticks_msec() - _lease_renewed_ms
|
|
if elapsed >= ttl_ms:
|
|
# Steal: a peer has been waiting and the lease exceeded TTL.
|
|
release() # Triggers drain → next waiter gets lease.
|
|
|
|
|
|
## Hand the lease to the next eligible waiter and execute its command (one at a time;
|
|
## the next drain is triggered on completion). Skips cancelled / disconnected /
|
|
## unregistered / affinity-cleared entries. Called by the dispatcher after the
|
|
## mutation queue drains, and internally on release / read completion.
|
|
func drain() -> void:
|
|
while not _scene_queue.is_empty():
|
|
var entry: _SceneQueueEntry = _scene_queue.pop_front()
|
|
# Skip cancelled entries.
|
|
if entry.cancelled:
|
|
continue
|
|
# Skip disconnected peers.
|
|
if entry.peer.get_ready_state() != WebSocketPeer.STATE_OPEN:
|
|
continue
|
|
# Skip unregistered commands (hot-reload race).
|
|
if not _registry.has_command(entry.method):
|
|
Notifier.send_error(entry.peer, entry.id, -32601,
|
|
"Method unregistered while queued: %s" % entry.method)
|
|
continue
|
|
|
|
# Acquire lease for this peer's affinity scene.
|
|
var peer_scene := str(_peer_scene_affinity.get(entry.peer, ""))
|
|
if peer_scene.is_empty():
|
|
# Peer cleared affinity while queued — skip.
|
|
continue
|
|
if not try_acquire(entry.peer, peer_scene):
|
|
# Lease held by another peer — put entry back and stop.
|
|
_scene_queue.push_front(entry)
|
|
return
|
|
|
|
# Calculate how long this entry was queued.
|
|
var queued_ms := Time.get_ticks_msec() - entry.queued_ms
|
|
|
|
# Type-aware dispatch: mutations → mutation lane (respects mutation lock);
|
|
# reads → execute directly (no lock needed).
|
|
if _registry.needs_serialization(entry.method):
|
|
_execute_scene_queued_mutation(entry, queued_ms)
|
|
else:
|
|
_execute_scene_queued_read(entry, queued_ms)
|
|
return # One at a time; next drain triggered on completion.
|
|
# Scene queue fully drained — nothing left.
|
|
|
|
|
|
# Switch the editor to the peer's affinity scene before executing a
|
|
# scene-queued command (try_acquire deliberately does no open). Guarded against
|
|
# an active EditorFileSystem scan. Returns false — and sends the peer a TIMEOUT — if
|
|
# it can't switch, so the caller aborts this one entry (the rest of the queue stays
|
|
# for the next drain trigger).
|
|
func _switch_to_affinity_scene(peer: WebSocketPeer, id) -> bool:
|
|
var scene := str(_peer_scene_affinity.get(peer, ""))
|
|
if scene.is_empty() or _active_scene_path() == scene:
|
|
return true
|
|
if await Modules.CommandHelpers.open_scene_deferred(scene):
|
|
return true
|
|
Notifier.send_result(peer, id, MCPToolkitError.fail("TIMEOUT",
|
|
"could not switch to %s — EditorFileSystem still scanning" % scene), "[MCPServer]")
|
|
return false
|
|
|
|
|
|
func _execute_scene_queued_mutation(entry: _SceneQueueEntry,
|
|
queued_ms: int) -> void:
|
|
# Re-queue (no tab switch) BEFORE activating the tab, to avoid an unnecessary
|
|
# switch when the mutation lock is busy.
|
|
if _enqueue_mutation_if_busy.call(entry.peer, entry.id, entry.method,
|
|
entry.params, queued_ms):
|
|
return
|
|
# Activate the peer's affinity scene here, guarded against a scan.
|
|
if not await _switch_to_affinity_scene(entry.peer, entry.id):
|
|
return
|
|
_execute_mutation.call(entry.peer, entry.id, entry.method, entry.params, queued_ms)
|
|
|
|
|
|
func _execute_scene_queued_read(entry: _SceneQueueEntry,
|
|
queued_ms: int) -> void:
|
|
# Activate the peer's affinity scene (guarded) before the read.
|
|
if not await _switch_to_affinity_scene(entry.peer, entry.id):
|
|
return
|
|
var result: Dictionary = await _run_read.call(entry.peer, entry.method, entry.params, entry.id)
|
|
if queued_ms > 0:
|
|
inject_concurrency_metadata(result, queued_ms)
|
|
Notifier.send_result(entry.peer, entry.id, result, "[MCPServer]")
|
|
# Check for more entries from the same peer/scene.
|
|
drain()
|
|
|
|
|
|
## After a successful scene.close: drop the peer's affinity for that scene and
|
|
## release the lease if this peer held it on that scene. Called by the mutation lane
|
|
## once a scene.close mutation completes.
|
|
func post_mutation_cleanup(peer: WebSocketPeer, method: String,
|
|
params: Dictionary, result: Dictionary) -> void:
|
|
if method != "scene.close":
|
|
return
|
|
if not result.get("success", false):
|
|
return
|
|
var file_path := str(params.get("file_path", ""))
|
|
if _peer_scene_affinity.get(peer, "") == file_path:
|
|
_peer_scene_affinity.erase(peer)
|
|
if _lease_holder == peer and _lease_scene == file_path:
|
|
release()
|
|
|
|
|
|
## Annotate a result with how long it waited on the scene lease: a zero-token _meta
|
|
## block always, plus a content sentence past a 3 s threshold. Called by the mutation
|
|
## lane and the queued-read path for commands that waited. No-op for queued_ms <= 0.
|
|
func inject_concurrency_metadata(result: Dictionary, queued_ms: int) -> void:
|
|
if queued_ms <= 0:
|
|
return
|
|
# Tier 1: _meta (zero LLM tokens — bridge can extract to MCP _meta).
|
|
result["_meta"] = {
|
|
"concurrency": {
|
|
"queued_ms": queued_ms,
|
|
"reason": "scene_lease_wait",
|
|
"lease_holder_scene": _lease_scene,
|
|
}
|
|
}
|
|
# Tier 3: threshold-gated content sentence (>3s only).
|
|
if queued_ms > 3000:
|
|
var note := (
|
|
"(Scene access waited %.1fs — another session holds the "
|
|
+ "active tab. Scene-independent tools execute without waiting.)"
|
|
) % (queued_ms / 1000.0)
|
|
var existing_hint := str(result.get("hint", ""))
|
|
if not existing_hint.is_empty():
|
|
result["hint"] = existing_hint + " " + note
|
|
else:
|
|
result["hint"] = note
|
|
|
|
|
|
## The current lease holder (or null). Read-only accessor for the unit test's
|
|
## acquire/release assertions; production callers route through the methods above.
|
|
func lease_holder() -> WebSocketPeer:
|
|
return _lease_holder
|