fix(network): prevent null multiplayer peer crashes offline/teardown and fix Nakama RPC session validation
This commit is contained in:
@@ -26,6 +26,7 @@ extends McpStructuredLogRing
|
||||
const MAX_LINES := 500
|
||||
|
||||
var _mutex := Mutex.new()
|
||||
var _error_appended_total := 0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
@@ -33,9 +34,10 @@ func _init() -> void:
|
||||
|
||||
|
||||
func append(level: String, text: String, path: String = "", line: int = 0, function: String = "", details: Dictionary = {}) -> void:
|
||||
var coerced_level := _coerce_level(level)
|
||||
var entry := {
|
||||
"source": "editor",
|
||||
"level": _coerce_level(level),
|
||||
"level": coerced_level,
|
||||
"text": text,
|
||||
"path": path,
|
||||
"line": line,
|
||||
@@ -45,6 +47,8 @@ func append(level: String, text: String, path: String = "", line: int = 0, funct
|
||||
entry["details"] = details.duplicate(true)
|
||||
_mutex.lock()
|
||||
_append_entry(entry)
|
||||
if coerced_level == "error":
|
||||
_error_appended_total += 1
|
||||
_mutex.unlock()
|
||||
|
||||
|
||||
@@ -96,9 +100,17 @@ func appended_total() -> int:
|
||||
return n
|
||||
|
||||
|
||||
func error_appended_total() -> int:
|
||||
_mutex.lock()
|
||||
var n := _error_appended_total
|
||||
_mutex.unlock()
|
||||
return n
|
||||
|
||||
|
||||
func clear() -> int:
|
||||
_mutex.lock()
|
||||
var n := _total_count_unlocked()
|
||||
_clear_storage()
|
||||
_error_appended_total = 0
|
||||
_mutex.unlock()
|
||||
return n
|
||||
|
||||
@@ -6,9 +6,8 @@ extends McpStructuredLogRing
|
||||
## ferried back from the playing game over the EngineDebugger channel.
|
||||
##
|
||||
## Larger cap than McpEditorLogBuffer because games can be noisy. `run_id`
|
||||
## rotates each time clear_for_new_run() fires (called on the game's
|
||||
## mcp:hello boot beacon), giving agents a stable cursor for "lines since
|
||||
## this play started".
|
||||
## rotates at play-start, giving agents a stable cursor for "lines from
|
||||
## this run" even when the game never reaches the mcp:hello boot beacon.
|
||||
##
|
||||
## Single-threaded — game_helper.gd drains its logger from `_process` and
|
||||
## calls `append` from the main thread, so this subclass can use the base
|
||||
@@ -17,6 +16,9 @@ extends McpStructuredLogRing
|
||||
const MAX_LINES := 2000
|
||||
|
||||
var _run_id := ""
|
||||
var _run_seq := 0
|
||||
var _error_warn_total := 0
|
||||
var _error_total := 0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
@@ -24,18 +26,30 @@ func _init() -> void:
|
||||
|
||||
|
||||
func append(level: String, text: String, details: Dictionary = {}) -> void:
|
||||
var entry := {"source": "game", "level": _coerce_level(level), "text": text}
|
||||
var coerced_level := _coerce_level(level)
|
||||
var entry := {
|
||||
"source": "game",
|
||||
"level": coerced_level,
|
||||
"text": text,
|
||||
"run_id": _run_id,
|
||||
}
|
||||
if not details.is_empty():
|
||||
entry["details"] = details.duplicate(true)
|
||||
_append_entry(entry)
|
||||
if coerced_level in ["warn", "error"]:
|
||||
_error_warn_total += 1
|
||||
if coerced_level == "error":
|
||||
_error_total += 1
|
||||
|
||||
|
||||
## Rotate the run identifier and drop all buffered entries. Called when the
|
||||
## game-side autoload sends its mcp:hello beacon, marking a fresh play cycle.
|
||||
## Returns the new run_id.
|
||||
## Rotate the run identifier without dropping buffered entries. Called at
|
||||
## play-start so even no-hello parse failures get a fresh current-run identity.
|
||||
## Historical lines stay tagged with their original run_id and can still be
|
||||
## queried explicitly.
|
||||
func clear_for_new_run() -> String:
|
||||
_clear_storage()
|
||||
_run_id = _generate_run_id()
|
||||
_error_warn_total = 0
|
||||
_error_total = 0
|
||||
return _run_id
|
||||
|
||||
|
||||
@@ -43,8 +57,46 @@ func run_id() -> String:
|
||||
return _run_id
|
||||
|
||||
|
||||
static func _generate_run_id() -> String:
|
||||
func error_warn_total() -> int:
|
||||
return _error_warn_total
|
||||
|
||||
|
||||
func error_total() -> int:
|
||||
return _error_total
|
||||
|
||||
|
||||
func get_run_range(run_id: String, offset: int, count: int) -> Array[Dictionary]:
|
||||
return get_run_page(run_id, offset, count).entries
|
||||
|
||||
|
||||
func run_total_count(run_id: String) -> int:
|
||||
return int(get_run_page(run_id, 0, 0).total_count)
|
||||
|
||||
|
||||
func get_run_page(run_id: String, offset: int, count: int) -> Dictionary:
|
||||
var entries := _entries_for_run(run_id)
|
||||
var start := mini(maxi(0, offset), entries.size())
|
||||
var stop := mini(entries.size(), start + maxi(0, count))
|
||||
var out: Array[Dictionary] = []
|
||||
for i in range(start, stop):
|
||||
out.append(entries[i])
|
||||
return {
|
||||
"entries": out,
|
||||
"total_count": entries.size(),
|
||||
}
|
||||
|
||||
|
||||
func _entries_for_run(run_id: String) -> Array[Dictionary]:
|
||||
var out: Array[Dictionary] = []
|
||||
for entry in get_range(0, total_count()):
|
||||
if str(entry.get("run_id", "")) == run_id:
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
func _generate_run_id() -> String:
|
||||
## Opaque to agents — they only check equality. Time-based is plenty
|
||||
## unique within a single editor session and avoids the RNG-seed
|
||||
## reproducibility footgun.
|
||||
return "r%d" % Time.get_ticks_msec()
|
||||
## unique within a single editor session; the local sequence protects
|
||||
## fast back-to-back test runs within the same millisecond.
|
||||
_run_seq += 1
|
||||
return "r%d-%d" % [Time.get_ticks_msec(), _run_seq]
|
||||
|
||||
@@ -26,9 +26,13 @@ var _total_logged: int = 0
|
||||
var enabled := true
|
||||
|
||||
|
||||
func log(msg: String) -> void:
|
||||
## `echo=false` records the line into the ring (so the dock's log panel shows
|
||||
## it) without printing to the Godot console. Used for high-frequency
|
||||
## machine-driven lines like readiness flips, which spammed the console of
|
||||
## every install (#626) — filesystem scans toggle readiness on each import.
|
||||
func log(msg: String, echo: bool = true) -> void:
|
||||
var line := "MCP | %s" % msg
|
||||
if enabled and console_echo:
|
||||
if enabled and console_echo and echo:
|
||||
print(line)
|
||||
_lines.append(line)
|
||||
if _lines.size() > MAX_LINES:
|
||||
|
||||
@@ -57,6 +57,14 @@ static func has_worker_alive(state: int) -> bool:
|
||||
return state == RUNNING or state == RUNNING_TIMED_OUT
|
||||
|
||||
|
||||
## True while the status worker is still within its healthy budget. Once a
|
||||
## refresh has timed out, the dock keeps the warning badge but must let users
|
||||
## retry Configure / Configure all instead of stranding the controls behind an
|
||||
## orphaned, uninterruptible worker.
|
||||
static func should_disable_client_actions(state: int) -> bool:
|
||||
return state == RUNNING
|
||||
|
||||
|
||||
## True when the dock should reject new refresh spawns. Used by the
|
||||
## focus-in / manual button / cooldown-timer entrypoints.
|
||||
static func is_blocked_for_spawn(state: int) -> bool:
|
||||
|
||||
@@ -300,6 +300,15 @@ func _set_incompatible_server(live: Dictionary, expected_version: String, port:
|
||||
var proof_name := str(proof.get("proof", ""))
|
||||
_can_recover_incompatible = not proof_name.is_empty()
|
||||
print("MCP | proof: %s" % (proof_name if _can_recover_incompatible else "(none)"))
|
||||
if not _can_recover_incompatible:
|
||||
## Non-recoverable: a foreign / unprovable occupant holds the port and
|
||||
## we have no ownership proof, so we must NOT kill it — surface a
|
||||
## concrete free port the user can switch to instead (the same hint
|
||||
## the dock crash body renders). Logging it to the editor output also
|
||||
## lets `ci-stale-server-smoke --mode foreign` assert this upstream
|
||||
## classification from CI. Reservation-aware on Windows.
|
||||
var suggested := ClientConfigurator.suggest_free_port(port + 1)
|
||||
print("MCP | port %d occupant not recoverable (no ownership proof); suggested free port %d (set godot_ai/http_port)" % [port, suggested])
|
||||
_host._refresh_dock_client_statuses()
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ const SETTING_HTTP_PORT := "godot_ai/http_port"
|
||||
## Comma-separated list of tool domains excluded from the server at spawn time.
|
||||
const SETTING_EXCLUDED_DOMAINS := "godot_ai/excluded_domains"
|
||||
const SETTING_TELEMETRY_ENABLED := "godot_ai/telemetry_enabled"
|
||||
## Whether MCP log lines echo to the Godot console (dock "Log" toggle).
|
||||
## The dock's ring-buffer log panel keeps recording regardless.
|
||||
const SETTING_MCP_LOGGING := "godot_ai/mcp_logging"
|
||||
|
||||
|
||||
## Returns true if the string value is truthy
|
||||
@@ -37,3 +40,21 @@ static func telemetry_enabled() -> bool:
|
||||
if es != null and es.has_setting(SETTING_TELEMETRY_ENABLED):
|
||||
return bool(es.get_setting(SETTING_TELEMETRY_ENABLED))
|
||||
return true
|
||||
|
||||
|
||||
## Returns whether MCP log lines should echo to the Godot console. Read at
|
||||
## plugin startup (to apply the persisted choice to the log buffer and
|
||||
## dispatcher) and by the dock's LogViewer toggle for its initial state.
|
||||
## Defaults to true when the user has never touched the toggle.
|
||||
static func mcp_logging_enabled() -> bool:
|
||||
var es := EditorInterface.get_editor_settings()
|
||||
if es != null and es.has_setting(SETTING_MCP_LOGGING):
|
||||
return bool(es.get_setting(SETTING_MCP_LOGGING))
|
||||
return true
|
||||
|
||||
|
||||
## Persist the dock "Log" toggle so the choice survives editor restarts (#626).
|
||||
static func set_mcp_logging_enabled(enabled: bool) -> void:
|
||||
var es := EditorInterface.get_editor_settings()
|
||||
if es != null:
|
||||
es.set_setting(SETTING_MCP_LOGGING, enabled)
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
@tool
|
||||
class_name McpSurfacedErrorTracker
|
||||
extends RefCounted
|
||||
|
||||
## Central source for "errors the agent should know exist".
|
||||
##
|
||||
## Editor log cursors only cover McpEditorLogBuffer. Runtime errors from the
|
||||
## game subprocess can land solely in the Debugger Errors tab, so this tracker
|
||||
## promotes visible Debugger-tab rows into a monotonic sequence before the
|
||||
## dispatcher stamps a watermark on each response envelope.
|
||||
|
||||
const MAX_PROMOTED_DEBUGGER_ENTRIES := 500
|
||||
const MAX_PROMOTED_DEBUGGER_KEYS := 5000
|
||||
const DEBUGGER_REFRESH_MIN_INTERVAL_MS := 250
|
||||
const DEBUGGER_SCAN_AFTER_STOP_MS := 5000
|
||||
## #641: delays for the self-scheduled forced scans armed on run stop (and on
|
||||
## game-helper hello, via McpDebuggerPlugin). Two ticks: an early one for rows
|
||||
## the remote debugger delivers right around the event, and a late one past
|
||||
## Godot's per-frame Errors-tab insertion throttle for error floods.
|
||||
const DEFERRED_SCAN_DELAYS_SEC: Array[float] = [1.0, 5.0]
|
||||
|
||||
var _editor_log_buffer
|
||||
var _game_log_buffer
|
||||
var _debugger_errors_root: Node
|
||||
var _debugger_search_root_cache: Node
|
||||
var _promoted_debugger_keys: Dictionary = {}
|
||||
var _promoted_debugger_key_order: Array[String] = []
|
||||
var _promoted_debugger_entries: Array[Dictionary] = []
|
||||
var _debugger_promoted_total := 0
|
||||
var _run_seq := 0
|
||||
var _oldest_retained_debugger_sequence := 1
|
||||
var _last_debugger_refresh_msec := -DEBUGGER_REFRESH_MIN_INTERVAL_MS
|
||||
var _debugger_scan_active := false
|
||||
var _debugger_scan_until_msec := 0
|
||||
var _deferred_scans_scheduled_total := 0
|
||||
|
||||
|
||||
func _init(editor_log_buffer = null, game_log_buffer = null, debugger_errors_root: Node = null) -> void:
|
||||
_editor_log_buffer = editor_log_buffer
|
||||
_game_log_buffer = game_log_buffer
|
||||
_debugger_errors_root = debugger_errors_root
|
||||
|
||||
|
||||
func note_game_run_started(sticky_scan: bool = true) -> void:
|
||||
_run_seq += 1
|
||||
_debugger_scan_active = sticky_scan
|
||||
_debugger_scan_until_msec = 0
|
||||
if not sticky_scan:
|
||||
_debugger_scan_until_msec = Time.get_ticks_msec() + DEBUGGER_SCAN_AFTER_STOP_MS
|
||||
refresh_debugger_errors(true)
|
||||
|
||||
|
||||
func note_game_run_stopped() -> void:
|
||||
_debugger_scan_active = false
|
||||
_debugger_scan_until_msec = Time.get_ticks_msec() + DEBUGGER_SCAN_AFTER_STOP_MS
|
||||
schedule_deferred_scans()
|
||||
|
||||
|
||||
## #641: promotion into the watermark used to depend on a tool call arriving
|
||||
## while the scan gate was open (run active, or within DEBUGGER_SCAN_AFTER_STOP_MS
|
||||
## of stop). Boot parse errors that landed in the Errors tab with no tool call
|
||||
## in that window were never promoted, so the agent never got the
|
||||
## new_errors_since_last_call hint. These editor-side timers force a scan
|
||||
## regardless of tool-call cadence; the next stamped response then carries the
|
||||
## already-promoted count even after the gate closes. Scans are content-keyed
|
||||
## and idempotent, so a timer firing after an unrelated new run is harmless.
|
||||
func schedule_deferred_scans(delays: Array = DEFERRED_SCAN_DELAYS_SEC) -> void:
|
||||
var tree := Engine.get_main_loop() as SceneTree
|
||||
if tree == null:
|
||||
return
|
||||
for delay in delays:
|
||||
var timer := tree.create_timer(maxf(0.05, float(delay)))
|
||||
timer.timeout.connect(_on_deferred_scan_timeout)
|
||||
_deferred_scans_scheduled_total += 1
|
||||
|
||||
|
||||
func deferred_scans_scheduled_total() -> int:
|
||||
return _deferred_scans_scheduled_total
|
||||
|
||||
|
||||
func _on_deferred_scan_timeout() -> void:
|
||||
refresh_debugger_errors(true)
|
||||
|
||||
|
||||
func refresh_debugger_errors(force: bool = true) -> void:
|
||||
var now := Time.get_ticks_msec()
|
||||
if not force and not _should_scan_debugger_for_cached_watermark(now):
|
||||
return
|
||||
_last_debugger_refresh_msec = now
|
||||
var current_by_key: Dictionary = {}
|
||||
for entry in _raw_debugger_error_entries():
|
||||
if str(entry.get("level", "")) != "error":
|
||||
continue
|
||||
var key := _log_entry_key(entry)
|
||||
var info: Dictionary = current_by_key.get(key, {"count": 0, "entry": entry})
|
||||
info["count"] = int(info.get("count", 0)) + 1
|
||||
current_by_key[key] = info
|
||||
for key in _promoted_debugger_keys.keys():
|
||||
if not current_by_key.has(key):
|
||||
_promoted_debugger_keys[key] = 0
|
||||
for key in current_by_key.keys():
|
||||
var info: Dictionary = current_by_key[key]
|
||||
var current := int(info.get("count", 0))
|
||||
var stored := int(_promoted_debugger_keys.get(key, 0))
|
||||
if current < stored:
|
||||
_promoted_debugger_keys[key] = current
|
||||
continue
|
||||
if current == stored:
|
||||
continue
|
||||
if not _promoted_debugger_keys.has(key):
|
||||
_promoted_debugger_key_order.append(key)
|
||||
_promoted_debugger_keys[key] = current
|
||||
_debugger_promoted_total += current - stored
|
||||
var source_entry: Dictionary = info.get("entry", {})
|
||||
var promoted := source_entry.duplicate(true)
|
||||
promoted["_debugger_key"] = key
|
||||
promoted["_debugger_occurrences"] = current
|
||||
promoted["_debugger_sequence"] = _debugger_promoted_total
|
||||
_remove_promoted_debugger_entry(key)
|
||||
_promoted_debugger_entries.append(promoted)
|
||||
_trim_promoted_debugger_entries()
|
||||
_trim_promoted_debugger_key_counts()
|
||||
|
||||
|
||||
## #645: promote an error record that has no Errors-tab row to scrape — e.g. a
|
||||
## boot-time parse error that parked the game in a remote-debugger break before
|
||||
## any surface got a record. The entry joins the same promoted sequence as
|
||||
## scraped Debugger rows, so run-scoping (editor_entries_since), the retained
|
||||
## fallback, and the response watermark all see it with no extra plumbing.
|
||||
## Re-recording the same key later (the same script still broken on the next
|
||||
## run) re-promotes it with a fresh sequence, mirroring how re-appearing
|
||||
## Errors-tab rows behave; scan reconciliation zeroes the key's count once the
|
||||
## break ends since the row never exists in the live tab.
|
||||
func record_synthetic_error(entry: Dictionary) -> void:
|
||||
var key := _log_entry_key(entry)
|
||||
var occurrences := int(_promoted_debugger_keys.get(key, 0)) + 1
|
||||
if not _promoted_debugger_keys.has(key):
|
||||
_promoted_debugger_key_order.append(key)
|
||||
_promoted_debugger_keys[key] = occurrences
|
||||
_debugger_promoted_total += 1
|
||||
var promoted := entry.duplicate(true)
|
||||
promoted["_debugger_key"] = key
|
||||
promoted["_debugger_occurrences"] = occurrences
|
||||
promoted["_debugger_sequence"] = _debugger_promoted_total
|
||||
promoted["_debugger_synthetic"] = true
|
||||
_remove_promoted_debugger_entry(key)
|
||||
_promoted_debugger_entries.append(promoted)
|
||||
_trim_promoted_debugger_entries()
|
||||
_trim_promoted_debugger_key_counts()
|
||||
|
||||
|
||||
func watermark(force_debugger_scan: bool = false) -> Dictionary:
|
||||
refresh_debugger_errors(force_debugger_scan)
|
||||
return {
|
||||
"run_seq": _run_seq,
|
||||
"editor_ring": _error_appended_total(),
|
||||
"debugger_promoted": _debugger_promoted_total,
|
||||
"game_error_warn": _game_error_total(),
|
||||
}
|
||||
|
||||
|
||||
static func stamp_watermark(response: Dictionary, tracker) -> void:
|
||||
if tracker == null:
|
||||
return
|
||||
if not tracker.has_method("watermark"):
|
||||
return
|
||||
response["error_watermark"] = tracker.watermark()
|
||||
|
||||
|
||||
func debugger_promoted_total(force_debugger_scan: bool = true) -> int:
|
||||
refresh_debugger_errors(force_debugger_scan)
|
||||
return _debugger_promoted_total
|
||||
|
||||
|
||||
func collect_editor_log_entries() -> Array[Dictionary]:
|
||||
refresh_debugger_errors(true)
|
||||
var entries: Array[Dictionary] = []
|
||||
var seen_keys: Dictionary = {}
|
||||
if _editor_log_buffer != null:
|
||||
for entry in _editor_log_buffer.get_range(0, _editor_log_buffer.total_count()):
|
||||
seen_keys[_log_entry_key(entry)] = true
|
||||
entries.append(entry)
|
||||
for entry in read_debugger_error_entries():
|
||||
var key := _log_entry_key(entry)
|
||||
if seen_keys.has(key):
|
||||
continue
|
||||
seen_keys[key] = true
|
||||
entries.append(entry)
|
||||
## #645: synthesized break records have no live Errors-tab row to scrape —
|
||||
## merge them from the promoted list so logs_read(source="editor") shows
|
||||
## the record that run/game responses point at.
|
||||
for entry in _promoted_debugger_entries:
|
||||
if not bool(entry.get("_debugger_synthetic", false)):
|
||||
continue
|
||||
var key := _log_entry_key(entry)
|
||||
if seen_keys.has(key):
|
||||
continue
|
||||
seen_keys[key] = true
|
||||
entries.append(_strip_promotion_bookkeeping(entry))
|
||||
return entries
|
||||
|
||||
|
||||
static func _strip_promotion_bookkeeping(entry: Dictionary) -> Dictionary:
|
||||
var clean := entry.duplicate(true)
|
||||
for key in ["_debugger_key", "_debugger_occurrences", "_debugger_sequence", "_debugger_synthetic"]:
|
||||
clean.erase(key)
|
||||
return clean
|
||||
|
||||
|
||||
func editor_entries_since(editor_cursor: int, debugger_cursor: int, force_debugger_scan: bool = true) -> Dictionary:
|
||||
refresh_debugger_errors(force_debugger_scan)
|
||||
var entries: Array[Dictionary] = []
|
||||
var seen_keys: Dictionary = {}
|
||||
var truncated := false
|
||||
if _editor_log_buffer != null:
|
||||
var captured: Dictionary = _editor_log_buffer.get_since(maxi(0, editor_cursor), -1)
|
||||
truncated = bool(captured.get("truncated", false))
|
||||
for entry in captured.get("entries", []):
|
||||
seen_keys[_log_entry_key(entry)] = true
|
||||
entries.append(entry)
|
||||
if debugger_cursor < _oldest_retained_debugger_sequence - 1:
|
||||
truncated = true
|
||||
for entry in _promoted_debugger_entries:
|
||||
if int(entry.get("_debugger_sequence", 0)) <= debugger_cursor:
|
||||
continue
|
||||
var key := _log_entry_key(entry)
|
||||
if seen_keys.has(key):
|
||||
continue
|
||||
seen_keys[key] = true
|
||||
entries.append(entry)
|
||||
return {
|
||||
"entries": entries,
|
||||
"truncated": truncated,
|
||||
}
|
||||
|
||||
|
||||
func retained_recent_editor_entries() -> Array[Dictionary]:
|
||||
## There is no shared timestamp across the editor logger ring and Godot's
|
||||
## Debugger Errors tree. Preserve the pre-PR fallback contract: newest
|
||||
## buffered editor entries first, then debugger-only rows that were not in
|
||||
## the ring, so stale Debugger rows cannot outrank newer ring entries.
|
||||
var entries: Array[Dictionary] = []
|
||||
var seen_keys: Dictionary = {}
|
||||
if _editor_log_buffer != null:
|
||||
entries = _editor_log_buffer.get_recent(_editor_log_buffer.total_count())
|
||||
entries.reverse()
|
||||
for entry in entries:
|
||||
seen_keys[_log_entry_key(entry)] = true
|
||||
for entry in collect_editor_log_entries():
|
||||
var key := _log_entry_key(entry)
|
||||
if seen_keys.has(key):
|
||||
continue
|
||||
seen_keys[key] = true
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
func read_debugger_error_entries() -> Array[Dictionary]:
|
||||
var entries: Array[Dictionary] = []
|
||||
var seen_keys: Dictionary = {}
|
||||
for entry in _raw_debugger_error_entries():
|
||||
var key := _log_entry_key(entry)
|
||||
if seen_keys.has(key):
|
||||
continue
|
||||
seen_keys[key] = true
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
func locate_debugger_error_trees() -> Array[Tree]:
|
||||
var trees: Array[Tree] = []
|
||||
var root: Node = _debugger_errors_root
|
||||
## #641: a deferred-scan timer can outlive an injected root (tests,
|
||||
## teardown). A freed root must not fall through to the live editor UI —
|
||||
## that would promote unrelated real errors into a tracker scoped to the
|
||||
## dead root — so treat it as "nothing to scan".
|
||||
if root != null and not is_instance_valid(root):
|
||||
return trees
|
||||
if root == null:
|
||||
root = _debugger_search_root()
|
||||
if root == null:
|
||||
return trees
|
||||
_collect_debugger_error_trees(root, trees)
|
||||
return trees
|
||||
|
||||
|
||||
func clear_debugger_error_trees() -> int:
|
||||
var cleared := 0
|
||||
for tree in locate_debugger_error_trees():
|
||||
cleared += entries_from_debugger_error_tree(tree).size()
|
||||
if not _press_debugger_clear_button(tree):
|
||||
## Synthetic roots in tests do not have Godot's Clear button.
|
||||
tree.clear()
|
||||
return cleared
|
||||
|
||||
|
||||
func _debugger_search_root() -> Node:
|
||||
if is_instance_valid(_debugger_search_root_cache):
|
||||
return _debugger_search_root_cache
|
||||
_debugger_search_root_cache = null
|
||||
var base := EditorInterface.get_base_control()
|
||||
if base == null:
|
||||
return null
|
||||
_debugger_search_root_cache = _find_first_of_class(base, "EditorDebuggerNode")
|
||||
if _debugger_search_root_cache == null:
|
||||
return base
|
||||
return _debugger_search_root_cache
|
||||
|
||||
|
||||
static func _find_first_of_class(node: Node, klass: String) -> Node:
|
||||
if node.get_class() == klass:
|
||||
return node
|
||||
for child in node.get_children():
|
||||
var found := _find_first_of_class(child, klass)
|
||||
if found != null:
|
||||
return found
|
||||
return null
|
||||
|
||||
|
||||
static func _collect_debugger_error_trees(node: Node, out: Array[Tree]) -> void:
|
||||
if node is Tree and _tree_has_debugger_errors(node as Tree):
|
||||
out.append(node as Tree)
|
||||
for child in node.get_children():
|
||||
if child is Node:
|
||||
_collect_debugger_error_trees(child as Node, out)
|
||||
|
||||
|
||||
static func _tree_has_debugger_errors(tree: Tree) -> bool:
|
||||
var root := tree.get_root()
|
||||
if root == null:
|
||||
return false
|
||||
var item := root.get_first_child()
|
||||
while item != null:
|
||||
if _is_debugger_error_item(item):
|
||||
return true
|
||||
item = item.get_next()
|
||||
return false
|
||||
|
||||
|
||||
static func _press_debugger_clear_button(tree: Tree) -> bool:
|
||||
var parent := tree.get_parent()
|
||||
if parent == null:
|
||||
return false
|
||||
var stack: Array[Node] = [parent]
|
||||
while not stack.is_empty():
|
||||
var node: Node = stack.pop_back()
|
||||
if node is BaseButton:
|
||||
for conn in node.get_signal_connection_list("pressed"):
|
||||
if str(conn.get("callable", "")).contains("_clear_errors_list"):
|
||||
node.emit_signal("pressed")
|
||||
return true
|
||||
for child in node.get_children():
|
||||
stack.push_back(child)
|
||||
return false
|
||||
|
||||
|
||||
static func entries_from_debugger_error_tree(tree: Tree) -> Array[Dictionary]:
|
||||
var entries: Array[Dictionary] = []
|
||||
var root := tree.get_root()
|
||||
if root == null:
|
||||
return entries
|
||||
var item := root.get_first_child()
|
||||
while item != null:
|
||||
if _is_debugger_error_item(item):
|
||||
entries.append(_entry_from_debugger_error_item(item))
|
||||
item = item.get_next()
|
||||
return entries
|
||||
|
||||
|
||||
static func _entry_from_debugger_error_item(item: TreeItem) -> Dictionary:
|
||||
var title := item.get_text(1)
|
||||
var loc := _location_from_metadata(item.get_metadata(0))
|
||||
var function := _function_from_title(title)
|
||||
return {
|
||||
"source": "editor",
|
||||
"level": "warn" if item.has_meta("_is_warning") else "error",
|
||||
"text": title,
|
||||
"path": str(loc.get("path", "")),
|
||||
"line": int(loc.get("line", 0)),
|
||||
"function": function,
|
||||
"details": _details_from_debugger_error_item(item, loc, function),
|
||||
}
|
||||
|
||||
|
||||
static func _details_from_debugger_error_item(item: TreeItem, loc: Dictionary, function: String) -> Dictionary:
|
||||
var children: Array[Dictionary] = []
|
||||
var child := item.get_first_child()
|
||||
while child != null:
|
||||
var child_loc := _location_from_metadata(child.get_metadata(0))
|
||||
children.append({
|
||||
"label": child.get_text(0),
|
||||
"text": child.get_text(1),
|
||||
"path": str(child_loc.get("path", "")),
|
||||
"line": int(child_loc.get("line", 0)),
|
||||
})
|
||||
child = child.get_next()
|
||||
return {
|
||||
"debugger_tab": "Errors",
|
||||
"time": item.get_text(0),
|
||||
"message": item.get_text(1),
|
||||
"error_type_name": "warning" if item.has_meta("_is_warning") else "error",
|
||||
"source": {
|
||||
"path": str(loc.get("path", "")),
|
||||
"line": int(loc.get("line", 0)),
|
||||
"function": function,
|
||||
},
|
||||
"resolved": {
|
||||
"path": str(loc.get("path", "")),
|
||||
"line": int(loc.get("line", 0)),
|
||||
"function": function,
|
||||
},
|
||||
"children": children,
|
||||
"frames": _frames_from_error_children(children),
|
||||
}
|
||||
|
||||
|
||||
static func _is_debugger_error_item(item: TreeItem) -> bool:
|
||||
return item.has_meta("_is_warning") or item.has_meta("_is_error")
|
||||
|
||||
|
||||
static func _frames_from_error_children(children: Array[Dictionary]) -> Array[Dictionary]:
|
||||
var start := -1
|
||||
for i in children.size():
|
||||
if str(children[i].label).contains("Stack Trace"):
|
||||
start = i
|
||||
break
|
||||
if start < 0:
|
||||
for i in children.size():
|
||||
if str(children[i].label).is_empty() and not str(children[i].path).is_empty():
|
||||
start = maxi(i - 1, 0)
|
||||
break
|
||||
if start < 0:
|
||||
return []
|
||||
var frames: Array[Dictionary] = []
|
||||
for i in range(start, children.size()):
|
||||
if str(children[i].path).is_empty():
|
||||
continue
|
||||
frames.append({
|
||||
"path": children[i].path,
|
||||
"line": children[i].line,
|
||||
"function": _function_from_frame_text(children[i].text),
|
||||
})
|
||||
return frames
|
||||
|
||||
|
||||
static func _location_from_metadata(meta: Variant) -> Dictionary:
|
||||
if meta is Array and meta.size() >= 2:
|
||||
return {"path": str(meta[0]), "line": int(meta[1])}
|
||||
return {"path": "", "line": 0}
|
||||
|
||||
|
||||
static func _function_from_title(title: String) -> String:
|
||||
var colon := title.find(": ")
|
||||
if colon <= 0:
|
||||
return ""
|
||||
return title.substr(0, colon)
|
||||
|
||||
|
||||
static func _function_from_frame_text(text: String) -> String:
|
||||
var marker := text.find(" @ ")
|
||||
if marker < 0:
|
||||
return ""
|
||||
var fn := text.substr(marker + 3).strip_edges()
|
||||
if fn.ends_with("()"):
|
||||
fn = fn.substr(0, fn.length() - 2)
|
||||
return fn
|
||||
|
||||
|
||||
## Shared one-line rendering of a compact editor-error entry for messages and
|
||||
## hints ("text (path:line)"). Single home so the debugger plugin, project
|
||||
## handler, and editor handler can't drift apart.
|
||||
static func format_editor_error_summary(entry: Dictionary) -> String:
|
||||
var text := str(entry.get("text", "editor error"))
|
||||
var path := str(entry.get("path", ""))
|
||||
var line := int(entry.get("line", 0))
|
||||
if not path.is_empty() and line > 0:
|
||||
return "%s (%s:%d)" % [text, path, line]
|
||||
if not path.is_empty():
|
||||
return "%s (%s)" % [text, path]
|
||||
return text
|
||||
|
||||
|
||||
static func _log_entry_key(entry: Dictionary) -> String:
|
||||
return "%s|%s|%s|%s" % [
|
||||
str(entry.get("level", "")),
|
||||
str(entry.get("text", "")),
|
||||
str(entry.get("path", "")),
|
||||
str(entry.get("line", 0)),
|
||||
]
|
||||
|
||||
|
||||
func _error_appended_total() -> int:
|
||||
if _editor_log_buffer == null:
|
||||
return 0
|
||||
if _editor_log_buffer.has_method("error_appended_total"):
|
||||
return int(_editor_log_buffer.call("error_appended_total"))
|
||||
return 0
|
||||
|
||||
|
||||
func _game_error_total() -> int:
|
||||
if _game_log_buffer == null:
|
||||
return 0
|
||||
if _game_log_buffer.has_method("error_total"):
|
||||
return int(_game_log_buffer.call("error_total"))
|
||||
return 0
|
||||
|
||||
|
||||
func _should_scan_debugger_for_cached_watermark(now_msec: int) -> bool:
|
||||
if not _debugger_scan_active and now_msec > _debugger_scan_until_msec:
|
||||
return false
|
||||
return now_msec - _last_debugger_refresh_msec >= DEBUGGER_REFRESH_MIN_INTERVAL_MS
|
||||
|
||||
|
||||
func _trim_promoted_debugger_entries() -> void:
|
||||
while _promoted_debugger_entries.size() > MAX_PROMOTED_DEBUGGER_ENTRIES:
|
||||
_promoted_debugger_entries.pop_front()
|
||||
if _promoted_debugger_entries.is_empty():
|
||||
_oldest_retained_debugger_sequence = _debugger_promoted_total + 1
|
||||
else:
|
||||
_oldest_retained_debugger_sequence = int(_promoted_debugger_entries[0].get("_debugger_sequence", 1))
|
||||
|
||||
|
||||
func _trim_promoted_debugger_key_counts() -> void:
|
||||
while _promoted_debugger_key_order.size() > MAX_PROMOTED_DEBUGGER_KEYS:
|
||||
var key := _promoted_debugger_key_order.pop_front()
|
||||
_promoted_debugger_keys.erase(key)
|
||||
|
||||
|
||||
func _remove_promoted_debugger_entry(key: String) -> void:
|
||||
for i in range(_promoted_debugger_entries.size() - 1, -1, -1):
|
||||
if str(_promoted_debugger_entries[i].get("_debugger_key", "")) == key:
|
||||
_promoted_debugger_entries.remove_at(i)
|
||||
return
|
||||
|
||||
|
||||
func _raw_debugger_error_entries() -> Array[Dictionary]:
|
||||
var entries: Array[Dictionary] = []
|
||||
for tree in locate_debugger_error_trees():
|
||||
entries.append_array(entries_from_debugger_error_tree(tree))
|
||||
return entries
|
||||
@@ -0,0 +1 @@
|
||||
uid://o0ulahkt83re
|
||||
@@ -124,13 +124,9 @@ func clear_pending_download() -> void:
|
||||
_latest_checksum_url = ""
|
||||
|
||||
|
||||
## True when the running Godot can self-update in place. Godot < 4.4 takes
|
||||
## the `_install_zip_inline` extract-then-restart path, and that engine's
|
||||
## stricter `GDScript::reload()` (`!p_keep_state && has_instances` ->
|
||||
## `ERR_ALREADY_IN_USE`) turns the extract-over-live-scripts into a reload
|
||||
## error flood plus a SIGSEGV in `EditorDockManager::remove_dock` /
|
||||
## `SceneTree::finalize` on the restart/quit (#475). So on < 4.4 we don't
|
||||
## run the in-editor pipeline at all — the user updates manually.
|
||||
## True when the running Godot is within the supported self-update floor.
|
||||
## Godot < 4.5 must not be offered a one-click update to a release whose
|
||||
## always-loaded scripts depend on 4.5 APIs/classes.
|
||||
## Guards `major` too so a future Godot 5.x (minor 0) isn't misclassified.
|
||||
func _can_self_update() -> bool:
|
||||
var v := Engine.get_version_info()
|
||||
@@ -138,49 +134,36 @@ func _can_self_update() -> bool:
|
||||
|
||||
|
||||
## Pure version predicate, split out so it's testable without faking the
|
||||
## running engine. In-editor self-update needs Godot >= 4.4.
|
||||
## running engine. In-editor self-update needs Godot >= 4.5.
|
||||
static func _version_can_self_update(major: int, minor: int) -> bool:
|
||||
return major > 4 or (major == 4 and minor >= 4)
|
||||
return major > 4 or (major == 4 and minor >= 5)
|
||||
|
||||
|
||||
## Banner guidance for the gated (< 4.4) path. Shown up-front at check time
|
||||
## (with the available version) and again on click, so the user understands
|
||||
## the manual-update flow before they press anything. Single source of truth
|
||||
## so check-time and click-time text never drift.
|
||||
## Banner guidance for engines below the support floor. Shown up-front at
|
||||
## check time so those users do not install an incompatible latest release.
|
||||
static func _manual_update_label(version: String) -> String:
|
||||
var prefix := "Update available"
|
||||
var release_noun := "release"
|
||||
var suffix := ""
|
||||
if not version.is_empty():
|
||||
prefix = "Update v%s available" % version
|
||||
release_noun = "version"
|
||||
suffix = " (latest: v%s)" % version
|
||||
return (
|
||||
prefix
|
||||
+ " — in-editor update needs Godot 4.4+. Open the download page, then "
|
||||
+ "replace addons/godot_ai/ manually and relaunch."
|
||||
"This is the last Godot AI %s for this Godot%s. " % [release_noun, suffix]
|
||||
+ "Upgrade to Godot 4.5+ to keep receiving updates."
|
||||
)
|
||||
|
||||
|
||||
## Driven by the dock's Update button. On Godot < 4.4 (see `_can_self_update`)
|
||||
## the in-editor install is disabled — we open the release page for a manual
|
||||
## download instead, never entering the extract pipeline that crashes those
|
||||
## engines. With no resolved download URL — either the check never completed,
|
||||
## or the release didn't ship a matching asset — also falls back to opening
|
||||
## the release page. Otherwise kicks off the download → extract → reload
|
||||
## pipeline.
|
||||
## Driven by the dock's Update button. On Godot < 4.5 (see _can_self_update)
|
||||
## the in-editor install is disabled so users cannot install an incompatible
|
||||
## latest release. With no resolved download URL, falls back to opening the
|
||||
## release page. Otherwise kicks off the download -> extract -> reload pipeline.
|
||||
func start_install() -> void:
|
||||
if not _can_self_update():
|
||||
## Only claim success + lock the button if the browser actually opened.
|
||||
## On failure (no handler, headless) keep the button enabled so the
|
||||
## user can retry. Either way, leave the version-bearing guidance label
|
||||
## from check time in place — don't re-emit label_text.
|
||||
if OS.shell_open(RELEASES_PAGE) == OK:
|
||||
install_state_changed.emit({
|
||||
"button_text": "Opened download page",
|
||||
"button_disabled": true,
|
||||
})
|
||||
else:
|
||||
install_state_changed.emit({
|
||||
"button_text": "Couldn't open browser — retry",
|
||||
"button_disabled": false,
|
||||
})
|
||||
install_state_changed.emit({
|
||||
"button_text": "Upgrade Godot",
|
||||
"button_disabled": true,
|
||||
"label_text": _manual_update_label(""),
|
||||
"banner_visible": true,
|
||||
})
|
||||
return
|
||||
|
||||
if _latest_download_url.is_empty():
|
||||
@@ -232,7 +215,6 @@ func start_install() -> void:
|
||||
"button_disabled": false,
|
||||
})
|
||||
|
||||
|
||||
## Consulted by the dock's spawn paths (focus-in refresh, manual button,
|
||||
## deferred initial refresh) — true while plugin scripts are being
|
||||
## overwritten. A worker mid-`GDScriptFunction::call` into a half-
|
||||
@@ -314,6 +296,8 @@ static func _is_trusted_download_url(url: String) -> bool:
|
||||
const SCHEME := "https://"
|
||||
if not url.begins_with(SCHEME):
|
||||
return false
|
||||
if url.find("\\") >= 0:
|
||||
return false
|
||||
var rest := url.substr(SCHEME.length())
|
||||
var authority := rest
|
||||
var slash := rest.find("/")
|
||||
@@ -353,17 +337,17 @@ func _on_update_check_completed(
|
||||
var parsed := parse_releases_response(result, response_code, body)
|
||||
if not bool(parsed.get("has_update", false)):
|
||||
return
|
||||
if not _can_self_update():
|
||||
install_state_changed.emit({
|
||||
"button_text": "Upgrade Godot",
|
||||
"button_disabled": true,
|
||||
"label_text": _manual_update_label(String(parsed.get("version", ""))),
|
||||
"banner_visible": true,
|
||||
})
|
||||
return
|
||||
_latest_download_url = String(parsed.get("download_url", ""))
|
||||
_latest_checksum_url = String(parsed.get("checksum_url", ""))
|
||||
update_check_completed.emit(parsed)
|
||||
## On engines that can't self-update (Godot < 4.4, #475), surface the
|
||||
## full manual-update guidance AND relabel the button up-front — before
|
||||
## any click — so the user knows what the button does and why.
|
||||
if not _can_self_update():
|
||||
install_state_changed.emit({
|
||||
"button_text": "Open download page",
|
||||
"label_text": _manual_update_label(String(parsed.get("version", ""))),
|
||||
})
|
||||
|
||||
|
||||
func _on_download_completed(
|
||||
@@ -457,7 +441,7 @@ func _on_checksum_completed(
|
||||
|
||||
print("MCP | self-update checksum verified (sha256 %s)" % actual)
|
||||
install_state_changed.emit({"button_text": "Installing..."})
|
||||
_install_zip()
|
||||
_install_zip.call_deferred()
|
||||
|
||||
|
||||
## Surface an integrity-check failure and drop the staged zip so the bad
|
||||
@@ -483,8 +467,9 @@ static func _parse_sha256_digest(text: String) -> String:
|
||||
if trimmed.is_empty():
|
||||
return ""
|
||||
## First whitespace-delimited token; `sha256sum` separates digest and
|
||||
## filename with two spaces, so allow_empty=false collapses the run.
|
||||
var tokens := trimmed.split(" ", false)
|
||||
## filename with two spaces, but some tools use tabs.
|
||||
var normalized := trimmed.replace("\t", " ").replace("\n", " ").replace("\r", " ")
|
||||
var tokens := normalized.split(" ", false)
|
||||
if tokens.is_empty():
|
||||
return ""
|
||||
var digest := String(tokens[0]).strip_edges().to_lower()
|
||||
@@ -512,167 +497,34 @@ func _install_zip() -> void:
|
||||
return
|
||||
|
||||
## Drain in-flight workers + block new ones BEFORE any disk write.
|
||||
## Without this, focus-in landing in the extract→reload window spawns
|
||||
## Without this, focus-in landing in the extract -> reload window spawns
|
||||
## a worker that walks into a partially-overwritten script and
|
||||
## SIGABRTs in `GDScriptFunction::call`.
|
||||
_install_in_flight = true
|
||||
_drain_dock_workers()
|
||||
|
||||
var version := Engine.get_version_info()
|
||||
var has_runner: bool = (
|
||||
_plugin != null
|
||||
and _plugin.has_method("install_downloaded_update")
|
||||
)
|
||||
## Same major-aware predicate as the _can_self_update() gate, so a future
|
||||
## Godot 5.x (minor 0) takes the runner path the gate promised — not the
|
||||
## pre-4.4 inline extract. A bare `minor >= 4` here would route 5.0 to the
|
||||
## crash-prone inline path even though the gate let it in.
|
||||
if _version_can_self_update(int(version.get("major", 0)), int(version.get("minor", 0))) and has_runner:
|
||||
if has_runner:
|
||||
install_state_changed.emit({"button_text": "Reloading..."})
|
||||
## Runner takes over: plugin tears down, runner extracts + scans +
|
||||
## re-enables. `install_downloaded_update` calls
|
||||
## `prepare_for_update_reload()` internally (kills the server,
|
||||
## resets the spawn guard) — see plugin.gd::install_downloaded_update.
|
||||
## resets the spawn guard) - see plugin.gd::install_downloaded_update.
|
||||
_plugin.install_downloaded_update(UPDATE_TEMP_ZIP, UPDATE_TEMP_DIR, _dock)
|
||||
return
|
||||
|
||||
_install_zip_inline(version)
|
||||
|
||||
|
||||
func _install_zip_inline(version: Dictionary) -> void:
|
||||
## Pre-4.4 fallback. EditorInterface.set_plugin_enabled off/on is
|
||||
## re-entry-unsafe on older Godot; we extract in-process and ask the
|
||||
## user to restart.
|
||||
var zip_path := ProjectSettings.globalize_path(UPDATE_TEMP_ZIP)
|
||||
var install_base := ProjectSettings.globalize_path("res://")
|
||||
|
||||
var reader := ZIPReader.new()
|
||||
if reader.open(zip_path) != OK:
|
||||
_install_in_flight = false
|
||||
install_state_changed.emit({
|
||||
"button_text": "Extract failed",
|
||||
"button_disabled": false,
|
||||
})
|
||||
return
|
||||
|
||||
var files := reader.get_files()
|
||||
for file_path in files:
|
||||
if not file_path.begins_with("addons/godot_ai/"):
|
||||
continue
|
||||
## Skip zip dir entries; parent dirs are created from each validated
|
||||
## file's base dir below — the same shape the runner uses. Creating a
|
||||
## dir from an unvalidated entry would itself be a traversal hole.
|
||||
if file_path.ends_with("/"):
|
||||
continue
|
||||
## Reject path-traversal / absolute / backslash entries BEFORE any
|
||||
## path_join + write. The modern runner enforces this via
|
||||
## `update_reload_runner.gd::_is_safe_zip_addon_file`; the pre-4.4
|
||||
## inline path used to gate only on the `addons/godot_ai/` prefix, so
|
||||
## `addons/godot_ai/../../evil.gd` escaped the addon dir. This guard
|
||||
## closes that gap so the weaker path runs the same checks. See #522.
|
||||
if not _is_safe_zip_addon_file(file_path):
|
||||
_abort_inline_install(reader, "unsafe zip path: %s" % file_path)
|
||||
return
|
||||
var dir := file_path.get_base_dir()
|
||||
DirAccess.make_dir_recursive_absolute(install_base.path_join(dir))
|
||||
var content := reader.read_file(file_path)
|
||||
var target := install_base.path_join(file_path)
|
||||
var f := FileAccess.open(target, FileAccess.WRITE)
|
||||
## Unlike the runner (tmp+rename+per-file backup+rollback), this pre-4.4
|
||||
## path writes directly over live files and can't roll back. It used to
|
||||
## skip a null open and ignore store_buffer errors silently, leaving a
|
||||
## partially-overwritten addons tree while still telling the user to
|
||||
## restart onto it. Check both error surfaces and abort loudly instead.
|
||||
## See #524.
|
||||
if f == null:
|
||||
_abort_inline_install(
|
||||
reader,
|
||||
"could not open %s for write (error %d)" % [target, FileAccess.get_open_error()],
|
||||
)
|
||||
return
|
||||
f.store_buffer(content)
|
||||
var write_error := f.get_error()
|
||||
f.close()
|
||||
if write_error != OK:
|
||||
_abort_inline_install(reader, "write error %d for %s" % [write_error, target])
|
||||
return
|
||||
|
||||
reader.close()
|
||||
|
||||
DirAccess.remove_absolute(zip_path)
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_ZIP))
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_DIR))
|
||||
|
||||
## Kill the old server before the reload so the re-enabled plugin spawns
|
||||
## a fresh one against the new plugin version (#132).
|
||||
if _plugin != null and _plugin.has_method("prepare_for_update_reload"):
|
||||
_plugin.prepare_for_update_reload()
|
||||
|
||||
if _version_can_self_update(int(version.get("major", 0)), int(version.get("minor", 0))):
|
||||
install_state_changed.emit({"button_text": "Scanning..."})
|
||||
## Filesystem scan must complete before plugin reload — otherwise
|
||||
## plugin.gd re-parses against a ClassDB that hasn't seen the new
|
||||
## files yet, parse errors, dock tears down silently. See #127.
|
||||
var fs := EditorInterface.get_resource_filesystem()
|
||||
if fs != null:
|
||||
fs.filesystem_changed.connect(
|
||||
_on_filesystem_scanned_for_update, CONNECT_ONE_SHOT
|
||||
)
|
||||
fs.scan()
|
||||
else:
|
||||
_reload_after_update.call_deferred()
|
||||
else:
|
||||
## Pre-4.4: no plugin reload; refreshes resume on the old dock
|
||||
## instance until the user restarts.
|
||||
_install_in_flight = false
|
||||
install_state_changed.emit({
|
||||
"button_text": "Restart editor to apply",
|
||||
"button_disabled": true,
|
||||
"label_text": "Updated! Restart the editor.",
|
||||
"outcome": "success",
|
||||
})
|
||||
|
||||
|
||||
## Abort the inline (pre-4.4) extract on a path-safety or write failure.
|
||||
## Closes the ZIP reader, drops the in-flight gate so dock spawn paths
|
||||
## un-block, and surfaces the failure loudly: this path has no rollback, so
|
||||
## the addons tree may be partially overwritten and the user must reinstall
|
||||
## from the download page rather than relaunch onto a half-written plugin.
|
||||
## See #522 / #524.
|
||||
func _abort_inline_install(reader: ZIPReader, reason: String) -> void:
|
||||
reader.close()
|
||||
_install_in_flight = false
|
||||
push_error(
|
||||
"MCP | self-update extract failed: %s. addons/godot_ai/ may be"
|
||||
% reason
|
||||
+ " partially updated — reinstall the plugin from the download page"
|
||||
+ " before relaunching."
|
||||
)
|
||||
print("MCP | self-update extract aborted: %s" % reason)
|
||||
install_state_changed.emit({
|
||||
"button_text": "Extract failed — reinstall",
|
||||
"button_text": "Reload runner missing",
|
||||
"button_disabled": false,
|
||||
})
|
||||
|
||||
|
||||
## Mirror of `update_reload_runner.gd::_is_safe_zip_addon_file`. Rejects any
|
||||
## entry that could escape `addons/godot_ai/` — absolute paths, backslashes,
|
||||
## and `.`/`..`/empty path segments — before it reaches a `path_join` + write
|
||||
## on the inline (pre-4.4) extract path, which has no rollback. Static so the
|
||||
## guard is unit-testable without instancing the manager. See #522.
|
||||
static func _is_safe_zip_addon_file(file_path: String) -> bool:
|
||||
if file_path.is_absolute_path() or file_path.contains("\\"):
|
||||
return false
|
||||
if not file_path.begins_with("addons/godot_ai/"):
|
||||
return false
|
||||
var rel_path := file_path.trim_prefix("addons/godot_ai/")
|
||||
if rel_path.is_empty() or rel_path.ends_with("/"):
|
||||
return false
|
||||
for segment in rel_path.split("/", true):
|
||||
if segment.is_empty() or segment == "." or segment == "..":
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _on_filesystem_scanned_for_update() -> void:
|
||||
install_state_changed.emit({"button_text": "Reloading..."})
|
||||
_reload_after_update.call_deferred()
|
||||
|
||||
Reference in New Issue
Block a user