Files
agame/addons/godot_ai/utils/plugin_reload.gd
T
2026-09-21 16:16:59 +08:00

126 lines
5.0 KiB
GDScript

@tool
extends RefCounted
## Ordinary in-editor reload only. Transaction activation keeps its own
## quiescence, lock and readiness protocol; this is not a hot-update grant.
const PLUGIN_CFG := "res://addons/godot_ai/plugin.cfg"
const ScriptWork := preload("res://addons/godot_ai/utils/script_work.gd")
## A reload waits for the filesystem scan it asks for. An editor that is
## still importing at startup can take well over 5 s to answer on a slow
## host, and a reload abandoned then leaves the plugin unchanged while the
## caller keeps waiting; stay under the server's 90 s reconnect budget.
const SCAN_TIMEOUT_SECONDS := 60.0
## The native timer's budget; tests shorten it to prove the timer itself
## (not its length) settles an unfinished scan under a paused time scale.
static var _scan_timeout_seconds: float = SCAN_TIMEOUT_SECONDS
## One command-scoped native-signal handoff, not a suspended GDScript frame.
## Named callbacks survive the source reload caused by the scan itself.
static var _pending_scan: Dictionary = {}
static func is_reload_pending() -> bool:
return not _pending_scan.is_empty()
## Reserve before deferring the scan so the current dispatcher tick cannot
## start another handler which pumps the already-scheduled reload callback.
static func reserve_reload() -> int:
if is_reload_pending():
return 0
var work := ScriptWork.begin("reload_plugin")
_pending_scan = {"work": work}
return work
static func reload_after_scan(work: int = 0) -> void:
if work == 0:
work = reserve_reload()
if work == 0:
return
elif _pending_scan.get("work", 0) != work or bool(_pending_scan.get("toggling", false)):
return # A direct reload or cancellation consumed this deferred request.
_start_scan(EditorInterface.get_resource_filesystem(), null, work)
static func _start_scan(filesystem: Object, timer: Object, work: int) -> void:
if not _pending_scan.is_empty() and (
_pending_scan.get("work", 0) != work or _pending_scan.has("filesystem")
or bool(_pending_scan.get("toggling", false))
):
if _pending_scan.get("work", 0) != work:
ScriptWork.finish(work)
push_error("MCP | a plugin reload is already waiting for its filesystem scan")
return
if timer == null:
# Editor deadlines must not inherit a project's pause or time scale.
timer = Engine.get_main_loop().create_timer(_scan_timeout_seconds, true, false, true)
var complete := _finish_scan.bind(work, false)
var timeout := _finish_scan.bind(work, true)
_pending_scan = {"work": work, "filesystem": filesystem, "timer": timer,
"complete": complete, "timeout": timeout}
filesystem.filesystem_changed.connect(complete, CONNECT_ONE_SHOT | CONNECT_DEFERRED)
timer.timeout.connect(timeout, CONNECT_ONE_SHOT)
filesystem.scan()
static func _take_scan() -> Dictionary:
var pending := _pending_scan
_pending_scan = {}
if pending.has("filesystem"):
if pending.filesystem.filesystem_changed.is_connected(pending.complete):
pending.filesystem.filesystem_changed.disconnect(pending.complete)
if pending.timer.timeout.is_connected(pending.timeout):
pending.timer.timeout.disconnect(pending.timeout)
return pending
static func _finish_scan(work: int, timed_out: bool) -> void:
# A queued callback from an earlier scan can never consume a later request.
if _pending_scan.get("work", 0) != work or not _pending_scan.has("filesystem"):
return
if timed_out:
var pending := _take_scan()
ScriptWork.finish(pending.work)
push_error(
"MCP | filesystem scan did not finish within %d s; plugin left unchanged, retry reload"
% int(_scan_timeout_seconds)
)
else:
reload_enabled_plugin()
static func reload_enabled_plugin() -> Error:
# Keep the existing reservation through teardown: toggling the plugin can
# itself pump frames. Direct callers also gate dispatch during the toggle.
if not is_reload_pending():
reserve_reload()
elif bool(_pending_scan.get("toggling", false)):
return ERR_BUSY
# Disconnect scan signals before a toggle pumps frames, but retain the
# work reservation so already-queued callbacks cannot start another toggle.
var pending := _take_scan()
_pending_scan = {"work": pending.work, "toggling": true}
var result := _toggle_enabled_plugin()
_take_scan()
ScriptWork.finish(pending.work)
return result
static func _toggle_enabled_plugin() -> Error:
if not EditorInterface.is_plugin_enabled(PLUGIN_CFG):
push_error("MCP | cannot reload a disabled plugin")
return ERR_UNAVAILABLE
EditorInterface.set_plugin_enabled(PLUGIN_CFG, false)
EditorInterface.set_plugin_enabled(PLUGIN_CFG, true)
if not EditorInterface.is_plugin_enabled(PLUGIN_CFG):
push_error("MCP | plugin could not be re-enabled after reload")
return FAILED
## Plugin callbacks save autoload changes while the temporary disabled state
## is still visible. Persist only after set_plugin_enabled has completed, or
## the next editor process can reopen with this working plugin disabled.
var error := ProjectSettings.save()
if error != OK:
push_error("MCP | reloaded plugin enablement could not be saved: %s" % error_string(error))
return error