126 lines
5.0 KiB
GDScript
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
|