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

139 lines
6.8 KiB
GDScript

@tool
extends RefCounted
## Recovers the mutation dispatch lock when an in-flight mutation's coroutine
## aborts or never resolves — the sole safety net that keeps one wedged mutation
## from blocking ALL mutations permanently. It owns the in-flight identity
## (peer/id/method/ctx), the adaptive deadline, and the generation counter that
## lets a wedged coroutine's tail bail when it eventually resumes; the mutation
## lane (dispatch_lane.gd) owns the single-flight flag + FIFO queue. The two talk
## through this tiny surface: arm() at execution-start, disarm() on normal
## completion, tick() every frame, current_generation() for the lane's tail guard,
## and an injected force_clear Callable the trip invokes to recover the lane lock.
##
## Why split from the lane: the watchdog is the one piece that must tick EVERY
## _process frame unconditionally, independent of poll cadence and lane state — a
## different lifecycle cadence than the lane's queue/in-flight bookkeeping.
## Splitting it gives a clean, headless-unit-testable timer+generation child.
##
## Editor-side only (the runtime autoload has no mutation lane → it never preloads
## this), so #91713 does not apply here — but it is kept a CLEAN pure-logic child
## anyway for SRP: it names zero Editor* symbols. The ONLY reach back into the
## editor server is the injected force_clear Callable; everything else is core
## (WebSocketPeer/Time/Callable) plus the shared-clean Notifier and the
## MCPToolkitToolContext value type. The deadline VALUE is computed by the lane
## (it owns the registry + the grace ProjectSetting) and passed into arm(); this
## child only stores it and decides when it has tripped.
const Notifier := preload("res://addons/godot_mcp_toolkit/transport/notifier.gd")
# Injected recovery hook: func(trapped_peer, trapped_id) -> void. Invoked LAST on a
# trip, after this child has bumped the generation and cleared its own identity, to
# recover the lane: erase the trapped request's active context (the peer-scoped key
# needs both halves of the identity), clear the single-flight flag, and drain the
# mutation queue. Draining re-arms a successor synchronously, so it MUST run after
# this child has nulled its own identity (else the drain's fresh arm() would be
# clobbered).
var _force_clear: Callable = Callable()
# True while a mutation is in flight (mirrors the lane's _mutation_in_flight exactly:
# set in arm(), cleared in disarm() and on a trip). Tracked here so tick() is
# self-contained and never reaches into the lane to read the flag.
var _armed := false
# In-flight identity + adaptive deadline, stamped by arm() synchronously before the
# mutation's first await (so the deadline tracks only in-flight time, never the
# queued wait, and can't race).
var _started_ms := 0
var _deadline_ms := 0
var _peer: WebSocketPeer = null
var _id # int or null — the in-flight mutation's JSON-RPC id
var _method := ""
# The cancellable command's context, tracked so a trip can cooperatively cancel a
# slow-but-alive handler (one that polls ctx.is_cancelled()).
var _ctx: MCPToolkitToolContext = null
# Bumped on every trip so a force-cleared coroutine, if it ever resumes, sees a
# generation mismatch via current_generation() and abandons its whole tail.
var _generation := 0
## Wire the recovery hook the trip invokes to recover the lane lock. Call once at
## construction (from the mutation lane), before the first arm().
func set_force_clear(force_clear: Callable) -> void:
_force_clear = force_clear
## Stamp a mutation as in-flight: record its identity + the pre-computed deadline,
## and return the current generation so the lane can capture it for its tail guard.
## Called synchronously at execution-start, before the mutation's first await. The
## lane passes the deadline it computed (started + the command's watchdog timeout +
## grace) — this child does not know the registry or the grace setting.
func arm(peer: WebSocketPeer, id, method: String, started_ms: int,
deadline_ms: int, ctx: MCPToolkitToolContext) -> int:
_armed = true
_peer = peer
_id = id
_method = method
_started_ms = started_ms
_deadline_ms = deadline_ms
_ctx = ctx
return _generation
## Clear the in-flight identity on normal completion. Does NOT bump the generation
## (no abandonment happened) and does NOT touch the lane's single-flight flag (the
## lane owns that). Mirrors the lane clearing _mutation_in_flight = false at the
## same point.
func disarm() -> void:
_armed = false
_peer = null
_ctx = null
## The current generation counter — the lane captures this at arm() and re-reads it
## after its handler await to detect a mid-await force-clear (generation mismatch →
## abandon the tail).
func current_generation() -> int:
return _generation
## Recover the mutation lock if the in-flight mutation has blown its deadline. Runs
## EVERY _process frame, unconditionally — it is the only recovery for a wedged
## lock, so it must never be gated behind the poll cadence or lease state. The
## deadline is adaptive + stamped at execution-start, so a legitimately slow (even
## maxed-out extension) mutation never trips it; only an aborted/never-resolving one
## does. A fire means the C1 save-reentrancy guard failed to prevent the wedge, so
## it warns loudly.
func tick() -> void:
if not _armed:
return
if Time.get_ticks_msec() <= _deadline_ms:
return
push_warning(("[MCPToolkit] mutation watchdog: '%s' (id %s) exceeded its "
+ "deadline (%d ms in flight) - force-clearing the dispatch lock. If this "
+ "recurs, the save-reentrancy guard (C1) is not holding.") % [
_method, str(_id), Time.get_ticks_msec() - _started_ms])
if _peer != null and _peer.get_ready_state() == WebSocketPeer.STATE_OPEN:
Notifier.send_error(_peer, _id, -32000,
"mutation watchdog timeout — the editor did not complete the operation in time")
# Cooperatively cancel the in-flight handler (if cancellable + still alive): one
# that polls ctx.is_cancelled() bails at its next check, shrinking the window
# where a slow-but-alive mutation runs concurrently with its watchdog-started
# successor. A hung handler ignores this (harmless).
if _ctx != null:
_ctx.cancel()
# Bump generation FIRST so the wedged coroutine, if it ever resumes, skips its
# whole tail (the lane's generation guard in _execute_mutation).
_generation += 1
# Capture the identity BEFORE nulling it — force_clear needs peer + id to erase
# the trapped request's peer-scoped active context.
var trapped_peer := _peer
var trapped_id = _id
# Null our OWN identity before force_clear: force_clear drains the queue, which
# re-arms a successor synchronously; clearing here first means that fresh arm()
# is never clobbered.
_armed = false
_peer = null
_ctx = null
# Recover the lane LAST (erase active context + clear single-flight flag + drain).
if _force_clear.is_valid():
_force_clear.call(trapped_peer, trapped_id)