fix(network): prevent null multiplayer peer crashes offline/teardown and fix Nakama RPC session validation

This commit is contained in:
2026-07-10 17:36:26 +08:00
parent 2b4c9d9dcb
commit b30709de3d
84 changed files with 5291 additions and 982 deletions
+42 -190
View File
@@ -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 extractreload 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()