更新godot ai 到最新版本
This commit is contained in:
@@ -1676,7 +1676,7 @@ static func _is_symlink(path: String) -> bool:
|
|||||||
return dir.is_link(path)
|
return dir.is_link(path)
|
||||||
|
|
||||||
|
|
||||||
static func get_server_command() -> Array[String]:
|
static func get_server_command(trace: Callable = Callable()) -> Array[String]:
|
||||||
## `mode_override() == "user"` skips the dev_venv tier even when a nearby
|
## `mode_override() == "user"` skips the dev_venv tier even when a nearby
|
||||||
## .venv exists — the override then becomes an actual workaround for
|
## .venv exists — the override then becomes an actual workaround for
|
||||||
## the "user venv misidentified as dev checkout" bug, not just a
|
## the "user venv misidentified as dev checkout" bug, not just a
|
||||||
@@ -1687,7 +1687,7 @@ static func get_server_command() -> Array[String]:
|
|||||||
print("MCP | using dev venv: %s" % venv_python)
|
print("MCP | using dev venv: %s" % venv_python)
|
||||||
return [venv_python, "-m", "godot_ai"]
|
return [venv_python, "-m", "godot_ai"]
|
||||||
|
|
||||||
var uvx := find_uvx()
|
var uvx := find_uvx(trace)
|
||||||
if not uvx.is_empty():
|
if not uvx.is_empty():
|
||||||
var version := get_plugin_version()
|
var version := get_plugin_version()
|
||||||
## PEP 440 local build tags (e.g. 3.0.2+local.1) are not on PyPI.
|
## PEP 440 local build tags (e.g. 3.0.2+local.1) are not on PyPI.
|
||||||
@@ -1715,7 +1715,7 @@ static func get_server_command() -> Array[String]:
|
|||||||
cmd.append_array(["--from", "godot-ai==%s" % pypi_version, "godot-ai"])
|
cmd.append_array(["--from", "godot-ai==%s" % pypi_version, "godot-ai"])
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
var system_cmd := _find_system_install()
|
var system_cmd := _find_system_install(trace)
|
||||||
if not system_cmd.is_empty():
|
if not system_cmd.is_empty():
|
||||||
print("MCP | using system install: %s" % system_cmd)
|
print("MCP | using system install: %s" % system_cmd)
|
||||||
return [system_cmd]
|
return [system_cmd]
|
||||||
@@ -1746,8 +1746,8 @@ static func get_server_launch_mode() -> String:
|
|||||||
const PREWARM_TIMEOUT_MS := 180000
|
const PREWARM_TIMEOUT_MS := 180000
|
||||||
|
|
||||||
|
|
||||||
static func find_uvx() -> String:
|
static func find_uvx(trace: Callable = Callable()) -> String:
|
||||||
return CliFinder.find(_uvx_cli_names())
|
return CliFinder.find(_uvx_cli_names(), trace)
|
||||||
|
|
||||||
|
|
||||||
## Pure argv builder for the pre-warm spawn, split out so tests can pin the
|
## Pure argv builder for the pre-warm spawn, split out so tests can pin the
|
||||||
@@ -2085,11 +2085,11 @@ static func find_worktree_src_dir(start_dir: String) -> String:
|
|||||||
## directly: the finder adds the well-known-install-dirs and login-shell
|
## directly: the finder adds the well-known-install-dirs and login-shell
|
||||||
## PATH tiers plus its per-exe cache, and this drops the private
|
## PATH tiers plus its per-exe cache, and this drops the private
|
||||||
## `_pick_best_path` cross-class reach (#711).
|
## `_pick_best_path` cross-class reach (#711).
|
||||||
static func _find_system_install() -> String:
|
static func _find_system_install(trace: Callable = Callable()) -> String:
|
||||||
## Built with append, not a ternary of untyped literals — assigning a
|
## Built with append, not a ternary of untyped literals — assigning a
|
||||||
## ternary's Array to Array[String] is a runtime error on newer Godot
|
## ternary's Array to Array[String] is a runtime error on newer Godot
|
||||||
## builds (same idiom as _uvx_cli_names above).
|
## builds (same idiom as _uvx_cli_names above).
|
||||||
var names: Array[String] = ["godot-ai"]
|
var names: Array[String] = ["godot-ai"]
|
||||||
if OS.get_name() == "Windows":
|
if OS.get_name() == "Windows":
|
||||||
names.push_front("godot-ai.exe")
|
names.push_front("godot-ai.exe")
|
||||||
return CliFinder.find(names)
|
return CliFinder.find(names, trace)
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ const _LOOKUP_TIMEOUT_MS := 3000
|
|||||||
|
|
||||||
## Find any of the supplied exe names; returns the first hit.
|
## Find any of the supplied exe names; returns the first hit.
|
||||||
## On Windows pass the .exe variant in `exe_names` if relevant.
|
## On Windows pass the .exe variant in `exe_names` if relevant.
|
||||||
static func find(exe_names: Array[String]) -> String:
|
static func find(exe_names: Array[String], trace: Callable = Callable()) -> String:
|
||||||
for name in exe_names:
|
for name in exe_names:
|
||||||
var hit := _find_one(name)
|
var hit := _find_one(name, trace)
|
||||||
if not hit.is_empty():
|
if not hit.is_empty():
|
||||||
return hit
|
return hit
|
||||||
return ""
|
return ""
|
||||||
@@ -49,19 +49,21 @@ static func invalidate(exe_name: String = "") -> void:
|
|||||||
_mutex.unlock()
|
_mutex.unlock()
|
||||||
|
|
||||||
|
|
||||||
static func _find_one(exe_name: String) -> String:
|
static func _find_one(exe_name: String, trace: Callable = Callable()) -> String:
|
||||||
|
var started := Time.get_ticks_msec()
|
||||||
_mutex.lock()
|
_mutex.lock()
|
||||||
var already_searched: bool = _searched.get(exe_name, false)
|
var already_searched: bool = _searched.get(exe_name, false)
|
||||||
var cached: String = _cache.get(exe_name, "")
|
var cached: String = _cache.get(exe_name, "")
|
||||||
_mutex.unlock()
|
_mutex.unlock()
|
||||||
if already_searched:
|
if already_searched:
|
||||||
|
_trace_lookup(trace, "cache", "resolved" if not cached.is_empty() else "cached_miss", started, {}, true)
|
||||||
return cached
|
return cached
|
||||||
# `_resolve()` does FileAccess + bounded subprocess lookup (forks
|
# `_resolve()` does FileAccess + bounded subprocess lookup (forks
|
||||||
# `bash -lc` / `which`), which can take 100ms-1s. Holding the mutex across that
|
# `bash -lc` / `which`), which can take 100ms-1s. Holding the mutex across that
|
||||||
# would let a concurrent `invalidate()` on the main thread freeze the
|
# would let a concurrent `invalidate()` on the main thread freeze the
|
||||||
# editor for the duration of the subprocess — which defeats the whole
|
# editor for the duration of the subprocess — which defeats the whole
|
||||||
# point of running CLI lookup off the main thread.
|
# point of running CLI lookup off the main thread.
|
||||||
var hit := _resolve(exe_name)
|
var hit := _resolve(exe_name, trace)
|
||||||
_mutex.lock()
|
_mutex.lock()
|
||||||
_cache[exe_name] = hit
|
_cache[exe_name] = hit
|
||||||
_searched[exe_name] = true
|
_searched[exe_name] = true
|
||||||
@@ -69,14 +71,17 @@ static func _find_one(exe_name: String) -> String:
|
|||||||
return hit
|
return hit
|
||||||
|
|
||||||
|
|
||||||
static func _resolve(exe_name: String) -> String:
|
static func _resolve(exe_name: String, trace: Callable = Callable()) -> String:
|
||||||
|
var started := Time.get_ticks_msec()
|
||||||
var is_windows := OS.get_name() == "Windows"
|
var is_windows := OS.get_name() == "Windows"
|
||||||
|
|
||||||
# 1. Well-known locations
|
# 1. Well-known locations
|
||||||
for dir in _well_known_dirs():
|
for dir in _well_known_dirs():
|
||||||
var full := dir.path_join(exe_name)
|
var full := dir.path_join(exe_name)
|
||||||
if FileAccess.file_exists(full):
|
if FileAccess.file_exists(full):
|
||||||
|
_trace_lookup(trace, "well_known", "resolved", started)
|
||||||
return full
|
return full
|
||||||
|
_trace_lookup(trace, "well_known", "no_match", started)
|
||||||
|
|
||||||
# 2. Login shell lookup (Unix only)
|
# 2. Login shell lookup (Unix only)
|
||||||
if not is_windows:
|
if not is_windows:
|
||||||
@@ -87,24 +92,58 @@ static func _resolve(exe_name: String) -> String:
|
|||||||
if shell.is_empty():
|
if shell.is_empty():
|
||||||
shell = "/bin/bash"
|
shell = "/bin/bash"
|
||||||
var stripped := exe_name.trim_suffix(".exe")
|
var stripped := exe_name.trim_suffix(".exe")
|
||||||
|
started = Time.get_ticks_msec()
|
||||||
var login_result := McpCliExec.run(shell, ["-lc", "command -v %s" % stripped], _LOOKUP_TIMEOUT_MS, false)
|
var login_result := McpCliExec.run(shell, ["-lc", "command -v %s" % stripped], _LOOKUP_TIMEOUT_MS, false)
|
||||||
if int(login_result.get("exit_code", -1)) == 0:
|
if int(login_result.get("exit_code", -1)) == 0:
|
||||||
var login_found: String = str(login_result.get("stdout", "")).strip_edges()
|
var login_found: String = str(login_result.get("stdout", "")).strip_edges()
|
||||||
if not login_found.is_empty() and FileAccess.file_exists(login_found):
|
if not login_found.is_empty() and FileAccess.file_exists(login_found):
|
||||||
|
_trace_lookup(trace, "login_shell", "resolved", started, login_result)
|
||||||
return login_found
|
return login_found
|
||||||
|
_trace_lookup(trace, "login_shell", _lookup_failure(login_result, false), started, login_result)
|
||||||
|
|
||||||
# 3. which / where with inherited PATH
|
# 3. which / where with inherited PATH
|
||||||
var lookup := "where" if is_windows else "which"
|
var lookup := "where" if is_windows else "which"
|
||||||
|
started = Time.get_ticks_msec()
|
||||||
var result := McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false)
|
var result := McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false)
|
||||||
if int(result.get("exit_code", -1)) == 0:
|
if int(result.get("exit_code", -1)) == 0:
|
||||||
var output := str(result.get("stdout", ""))
|
var output := str(result.get("stdout", ""))
|
||||||
var lines := PackedStringArray(output.split("\n"))
|
var lines := PackedStringArray(output.split("\n"))
|
||||||
var found := _pick_best_path(lines) if is_windows else lines[0].strip_edges()
|
var found := _pick_best_path(lines) if is_windows else lines[0].strip_edges()
|
||||||
if not found.is_empty():
|
if not found.is_empty():
|
||||||
|
_trace_lookup(trace, "inherited_path", "resolved", started, result)
|
||||||
return found
|
return found
|
||||||
|
_trace_lookup(trace, "inherited_path", _lookup_failure(result, true), started, result)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
static func _lookup_failure(result: Dictionary, absence_exit: bool) -> String:
|
||||||
|
for flag in ["termination_failed", "cancelled", "timed_out", "spawn_failed"]:
|
||||||
|
if bool(result.get(flag, false)):
|
||||||
|
return flag
|
||||||
|
var code := int(result.get("exit_code", -1))
|
||||||
|
if code == 0:
|
||||||
|
return "empty_output" if str(result.get("stdout", "")).strip_edges().is_empty() else "unusable_output"
|
||||||
|
return "not_found" if absence_exit and code == 1 else "nonzero_exit"
|
||||||
|
|
||||||
|
|
||||||
|
static func _trace_lookup(
|
||||||
|
trace: Callable, tier: String, status: String, started: int,
|
||||||
|
result: Dictionary = {}, cache_hit := false,
|
||||||
|
) -> void:
|
||||||
|
if not trace.is_valid():
|
||||||
|
return
|
||||||
|
trace.call({
|
||||||
|
"tier": tier, "status": status,
|
||||||
|
"elapsed_ms": clampi(Time.get_ticks_msec() - started, 0, 2147483647),
|
||||||
|
"cache_hit": cache_hit,
|
||||||
|
"exit_code": clampi(int(result.get("exit_code", -1)), -2147483648, 2147483647),
|
||||||
|
"timed_out": bool(result.get("timed_out", false)),
|
||||||
|
"spawn_failed": bool(result.get("spawn_failed", false)),
|
||||||
|
"cancelled": bool(result.get("cancelled", false)),
|
||||||
|
"termination_failed": bool(result.get("termination_failed", false)),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
## Executable extensions Windows' CreateProcessW can launch from a path
|
## Executable extensions Windows' CreateProcessW can launch from a path
|
||||||
## (after the cmd.exe wrap in `_cli_exec.gd`). Order is preference: `.exe`
|
## (after the cmd.exe wrap in `_cli_exec.gd`). Order is preference: `.exe`
|
||||||
## is a native PE binary; `.cmd` / `.bat` go through the shell; `.com` is
|
## is a native PE binary; `.cmd` / `.bat` go through the shell; `.com` is
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
name="Godot AI"
|
name="Godot AI"
|
||||||
description="MCP server and AI tools for Godot"
|
description="MCP server and AI tools for Godot"
|
||||||
author="Godot AI"
|
author="Godot AI"
|
||||||
version="4.2.2"
|
version="4.2.3"
|
||||||
script="plugin.gd"
|
script="plugin.gd"
|
||||||
|
|||||||
@@ -1355,6 +1355,11 @@ func _startup_trace_call_end(name: String, started_ms: int) -> void:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _startup_trace_lookup(record: Dictionary) -> void:
|
||||||
|
if _startup_trace_enabled:
|
||||||
|
print("MCP startup trace | lookup=%s" % JSON.stringify(record))
|
||||||
|
|
||||||
|
|
||||||
func _startup_trace_finish(path: String) -> void:
|
func _startup_trace_finish(path: String) -> void:
|
||||||
if not _startup_trace_enabled:
|
if not _startup_trace_enabled:
|
||||||
return
|
return
|
||||||
@@ -1392,7 +1397,8 @@ func _capture_lifecycle_plan() -> Dictionary:
|
|||||||
_startup_trace_call_end("worktree_source", worktree_started)
|
_startup_trace_call_end("worktree_source", worktree_started)
|
||||||
var expected_version := ClientConfigurator.get_plugin_version()
|
var expected_version := ClientConfigurator.get_plugin_version()
|
||||||
var command_started := _startup_trace_call_begin("server_command")
|
var command_started := _startup_trace_call_begin("server_command")
|
||||||
var server_command := ClientConfigurator.get_server_command()
|
var lookup_trace := _startup_trace_lookup if _startup_trace_enabled else Callable()
|
||||||
|
var server_command := ClientConfigurator.get_server_command(lookup_trace)
|
||||||
_startup_trace_call_end("server_command", command_started)
|
_startup_trace_call_end("server_command", command_started)
|
||||||
var pid_file := ProjectSettings.globalize_path(PortResolver.SERVER_PID_FILE)
|
var pid_file := ProjectSettings.globalize_path(PortResolver.SERVER_PID_FILE)
|
||||||
var startup_report := ProjectSettings.globalize_path(PortResolver.SERVER_STARTUP_REPORT)
|
var startup_report := ProjectSettings.globalize_path(PortResolver.SERVER_STARTUP_REPORT)
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ const SERVER_PID_FILE := "user://godot_ai_server.pid"
|
|||||||
const SERVER_STARTUP_REPORT := "user://godot_ai_server_startup.json"
|
const SERVER_STARTUP_REPORT := "user://godot_ai_server_startup.json"
|
||||||
const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd")
|
const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd")
|
||||||
const LinuxProc := preload("res://addons/godot_ai/utils/linux_proc.gd")
|
const LinuxProc := preload("res://addons/godot_ai/utils/linux_proc.gd")
|
||||||
|
const SNAPSHOT_DIAGNOSTIC_STAGES := ["single", "pair", "first", "final"]
|
||||||
|
const SNAPSHOT_DIAGNOSTIC_CATEGORIES := [
|
||||||
|
"invalid_pid", "shell_exit", "empty_output", "outer_size", "outer_json", "outer_shape",
|
||||||
|
"snapshot_size", "snapshot_json", "snapshot_shape", "collector_null", "row_shape",
|
||||||
|
"row_pid", "row_chain", "row_identity", "lineage_cycle",
|
||||||
|
]
|
||||||
static var _process_spawn_mutex := Mutex.new()
|
static var _process_spawn_mutex := Mutex.new()
|
||||||
|
|
||||||
enum PortOccupancy { UNKNOWN, FREE, OCCUPIED }
|
enum PortOccupancy { UNKNOWN, FREE, OCCUPIED }
|
||||||
@@ -392,18 +398,23 @@ static func pid_alive(pid: int, snapshot: Variant = null) -> bool:
|
|||||||
## Linux captures the same bounded ancestry using procfs; macOS keeps ps.
|
## Linux captures the same bounded ancestry using procfs; macOS keeps ps.
|
||||||
## The process enumeration stays inside PowerShell; only the bounded target
|
## The process enumeration stays inside PowerShell; only the bounded target
|
||||||
## ancestry crosses back into Godot, without logging command-line metadata.
|
## ancestry crosses back into Godot, without logging command-line metadata.
|
||||||
static func capture_process_snapshot(pid: int) -> Variant:
|
static func capture_process_snapshot(pid: int, diagnostics: Variant = null) -> Variant:
|
||||||
if OS.get_name() == "Linux":
|
if OS.get_name() == "Linux":
|
||||||
return LinuxProc.process_snapshot(pid)
|
return LinuxProc.process_snapshot(pid)
|
||||||
if OS.get_name() != "Windows":
|
if OS.get_name() != "Windows":
|
||||||
return null
|
return null
|
||||||
if pid <= 1:
|
if pid <= 1:
|
||||||
return {"capture_error": true}
|
return _snapshot_failure(diagnostics, "invalid_pid", "single")
|
||||||
var script := _windows_process_snapshot_script(pid)
|
var script := _windows_process_snapshot_script(pid)
|
||||||
var output: Array = []
|
var output: Array = []
|
||||||
if execute_windows_powershell(script, output) != 0 or output.is_empty():
|
var began := Time.get_ticks_msec()
|
||||||
return {"capture_error": true}
|
var code := execute_windows_powershell(script, output)
|
||||||
return parse_process_snapshot(str(output[0]), pid)
|
if code != 0 or output.is_empty():
|
||||||
|
return _snapshot_failure(
|
||||||
|
diagnostics, "shell_exit" if code != 0 else "empty_output",
|
||||||
|
"single", -1, Time.get_ticks_msec() - began
|
||||||
|
)
|
||||||
|
return parse_process_snapshot(str(output[0]), pid, diagnostics)
|
||||||
|
|
||||||
|
|
||||||
static func _windows_process_snapshot_script(pid: int) -> String:
|
static func _windows_process_snapshot_script(pid: int) -> String:
|
||||||
@@ -426,63 +437,101 @@ static func _windows_process_snapshot_script(pid: int) -> String:
|
|||||||
|
|
||||||
## Independent CIM maps in separate scriptblock scopes are
|
## Independent CIM maps in separate scriptblock scopes are
|
||||||
## captured within one shell invocation. No snapshot crosses a grant call.
|
## captured within one shell invocation. No snapshot crosses a grant call.
|
||||||
static func _capture_windows_process_snapshot_pair(pid: int) -> Array:
|
static func _capture_windows_process_snapshot_pair(pid: int, diagnostics: Variant = null) -> Array:
|
||||||
var query := _windows_process_snapshot_script(pid)
|
var query := _windows_process_snapshot_script(pid)
|
||||||
var script := (
|
var script := (
|
||||||
"$first = & { %s }; $final = & { %s }; "
|
"$first = & { %s }; $final = & { %s }; "
|
||||||
+ "ConvertTo-Json -InputObject @([string]$first,[string]$final) -Compress"
|
+ "ConvertTo-Json -InputObject @([string]$first,[string]$final) -Compress"
|
||||||
) % [query, query]
|
) % [query, query]
|
||||||
var output: Array = []
|
var output: Array = []
|
||||||
if execute_windows_powershell(script, output) != 0 or output.is_empty():
|
var began := Time.get_ticks_msec()
|
||||||
|
var code := execute_windows_powershell(script, output)
|
||||||
|
if code != 0 or output.is_empty():
|
||||||
|
_snapshot_failure(
|
||||||
|
diagnostics, "shell_exit" if code != 0 else "empty_output",
|
||||||
|
"pair", -1, Time.get_ticks_msec() - began
|
||||||
|
)
|
||||||
return [{"capture_error": true}, {"capture_error": true}]
|
return [{"capture_error": true}, {"capture_error": true}]
|
||||||
return _parse_process_snapshot_pair(str(output[0]), pid)
|
return _parse_process_snapshot_pair(str(output[0]), pid, diagnostics)
|
||||||
|
|
||||||
|
|
||||||
static func _parse_process_snapshot_pair(raw: String, pid: int) -> Array:
|
static func _parse_process_snapshot_pair(
|
||||||
|
raw: String, pid: int, diagnostics: Variant = null
|
||||||
|
) -> Array:
|
||||||
if raw.length() > 4 * 1024 * 1024 + 16:
|
if raw.length() > 4 * 1024 * 1024 + 16:
|
||||||
|
_snapshot_failure(diagnostics, "outer_size", "pair")
|
||||||
return [{"capture_error": true}, {"capture_error": true}]
|
return [{"capture_error": true}, {"capture_error": true}]
|
||||||
var json := JSON.new()
|
var json := JSON.new()
|
||||||
if json.parse(raw) != OK:
|
if json.parse(raw) != OK:
|
||||||
|
_snapshot_failure(diagnostics, "outer_json", "pair")
|
||||||
return [{"capture_error": true}, {"capture_error": true}]
|
return [{"capture_error": true}, {"capture_error": true}]
|
||||||
var pair: Variant = json.data
|
var pair: Variant = json.data
|
||||||
if not (pair is Array) or pair.size() != 2 or not (pair[0] is String and pair[1] is String):
|
if not (pair is Array) or pair.size() != 2 or not (pair[0] is String and pair[1] is String):
|
||||||
|
_snapshot_failure(diagnostics, "outer_shape", "pair")
|
||||||
return [{"capture_error": true}, {"capture_error": true}]
|
return [{"capture_error": true}, {"capture_error": true}]
|
||||||
return [parse_process_snapshot(pair[0], pid), parse_process_snapshot(pair[1], pid)]
|
return [
|
||||||
|
parse_process_snapshot(pair[0], pid, diagnostics, "first"),
|
||||||
|
parse_process_snapshot(pair[1], pid, diagnostics, "final"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
static func parse_process_snapshot(raw: String, expected_pid: int) -> Dictionary:
|
static func parse_process_snapshot(
|
||||||
|
raw: String, expected_pid: int, diagnostics: Variant = null, stage := "single"
|
||||||
|
) -> Dictionary:
|
||||||
if raw.length() > 1024 * 1024:
|
if raw.length() > 1024 * 1024:
|
||||||
return {"capture_error": true}
|
return _snapshot_failure(diagnostics, "snapshot_size", stage, -1)
|
||||||
var json := JSON.new()
|
var json := JSON.new()
|
||||||
if json.parse(raw) != OK:
|
if json.parse(raw) != OK:
|
||||||
return {"capture_error": true}
|
return _snapshot_failure(diagnostics, "snapshot_json", stage, -1)
|
||||||
var rows: Variant = json.data
|
var rows: Variant = json.data
|
||||||
if not (rows is Array) or rows.size() > 16:
|
if not (rows is Array) or rows.size() > 16:
|
||||||
return {"capture_error": true}
|
return _snapshot_failure(diagnostics, "collector_null" if rows == null else "snapshot_shape", stage)
|
||||||
var snapshot := {}
|
var snapshot := {}
|
||||||
var next_pid := expected_pid
|
var next_pid := expected_pid
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if not (row is Dictionary):
|
if not (row is Dictionary):
|
||||||
return {"capture_error": true}
|
return _snapshot_failure(diagnostics, "row_shape", stage, snapshot.size())
|
||||||
var value: Variant = row.get("pid")
|
var value: Variant = row.get("pid")
|
||||||
if (
|
if (
|
||||||
not (value is int or value is float) or not is_finite(float(value))
|
not (value is int or value is float) or not is_finite(float(value))
|
||||||
or float(value) <= 1.0 or float(value) > 4294967295.0
|
or float(value) <= 1.0 or float(value) > 4294967295.0
|
||||||
or float(value) != float(int(value))
|
or float(value) != float(int(value))
|
||||||
):
|
):
|
||||||
return {"capture_error": true}
|
return _snapshot_failure(diagnostics, "row_pid", stage, snapshot.size())
|
||||||
var pid := int(value)
|
var pid := int(value)
|
||||||
if pid <= 1 or pid != next_pid or snapshot.has(pid):
|
if pid <= 1 or pid != next_pid or snapshot.has(pid):
|
||||||
return {"capture_error": true}
|
return _snapshot_failure(diagnostics, "row_chain", stage, snapshot.size())
|
||||||
snapshot[pid] = row
|
snapshot[pid] = row
|
||||||
if _process_snapshot_row(snapshot, pid).is_empty():
|
if _process_snapshot_row(snapshot, pid).is_empty():
|
||||||
return {"capture_error": true}
|
return _snapshot_failure(diagnostics, "row_identity", stage, snapshot.size() - 1)
|
||||||
next_pid = int(row.parent_pid)
|
next_pid = int(row.parent_pid)
|
||||||
if snapshot.has(next_pid):
|
if snapshot.has(next_pid):
|
||||||
return {"capture_error": true}
|
return _snapshot_failure(diagnostics, "lineage_cycle", stage, snapshot.size() - 1)
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
## Diagnostics never carry proof rows, command lines, or shell output. The
|
||||||
|
## caller owns this sink: at most eight distinct failures, with bounded counts.
|
||||||
|
## Snapshots and authority remain unchanged.
|
||||||
|
static func _snapshot_failure(
|
||||||
|
diagnostics: Variant, category: String, stage: String, depth := -1, elapsed_ms := -1
|
||||||
|
) -> Dictionary:
|
||||||
|
stage = stage if stage in SNAPSHOT_DIAGNOSTIC_STAGES else "unknown"
|
||||||
|
category = category if category in SNAPSHOT_DIAGNOSTIC_CATEGORIES else "unknown"
|
||||||
|
if diagnostics is Array:
|
||||||
|
for item in diagnostics:
|
||||||
|
if item.category == category and item.stage == stage and item.depth == depth:
|
||||||
|
item.count = mini(int(item.count) + 1, 10000)
|
||||||
|
return {"capture_error": true}
|
||||||
|
if diagnostics.size() < 8:
|
||||||
|
diagnostics.append({
|
||||||
|
"category": category, "stage": stage, "depth": clampi(depth, -1, 16),
|
||||||
|
"elapsed_ms": clampi(elapsed_ms, -1, 600000), "count": 1,
|
||||||
|
})
|
||||||
|
return {"capture_error": true}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## An unavailable capture is not evidence that its PID has exited.
|
## An unavailable capture is not evidence that its PID has exited.
|
||||||
static func capture_failed(snapshot: Variant) -> bool:
|
static func capture_failed(snapshot: Variant) -> bool:
|
||||||
return snapshot is Dictionary and bool(snapshot.get("capture_error", false))
|
return snapshot is Dictionary and bool(snapshot.get("capture_error", false))
|
||||||
@@ -666,12 +715,12 @@ static func process_fingerprint(pid: int, snapshot: Variant = null) -> String:
|
|||||||
## `diagnostics`, when given, receives one word naming the check that
|
## `diagnostics`, when given, receives one word naming the check that
|
||||||
## refused the grant. It costs no extra probe and never changes the result.
|
## refused the grant. It costs no extra probe and never changes the result.
|
||||||
static func capture_process_kill_grant(
|
static func capture_process_kill_grant(
|
||||||
pid: int, require_brand := false, diagnostics: Array = []
|
pid: int, require_brand := false, diagnostics: Array = [], snapshot_diagnostics: Variant = null
|
||||||
) -> Dictionary:
|
) -> Dictionary:
|
||||||
if pid <= 1 or pid == OS.get_process_id():
|
if pid <= 1 or pid == OS.get_process_id():
|
||||||
diagnostics.append("invalid_pid")
|
diagnostics.append("invalid_pid")
|
||||||
return {}
|
return {}
|
||||||
var pair: Array = _capture_windows_process_snapshot_pair(pid) if OS.get_name() == "Windows" else []
|
var pair: Array = _capture_windows_process_snapshot_pair(pid, snapshot_diagnostics) if OS.get_name() == "Windows" else []
|
||||||
var first: Variant = pair[0] if not pair.is_empty() else capture_process_snapshot(pid)
|
var first: Variant = pair[0] if not pair.is_empty() else capture_process_snapshot(pid)
|
||||||
if capture_failed(first):
|
if capture_failed(first):
|
||||||
diagnostics.append("identity_unavailable")
|
diagnostics.append("identity_unavailable")
|
||||||
|
|||||||
@@ -850,17 +850,19 @@ func _effect_launch(payload: Dictionary) -> Dictionary:
|
|||||||
## stable across two reads; otherwise a legitimate exec looks like PID reuse.
|
## stable across two reads; otherwise a legitimate exec looks like PID reuse.
|
||||||
var capture_started := Time.get_ticks_msec()
|
var capture_started := Time.get_ticks_msec()
|
||||||
var attempts: Array = []
|
var attempts: Array = []
|
||||||
var exact_grant := PortResolver.capture_process_kill_grant(pid, true, attempts)
|
var snapshot_diagnostics: Array = []
|
||||||
|
var exact_grant := PortResolver.capture_process_kill_grant(pid, true, attempts, snapshot_diagnostics)
|
||||||
var fingerprint_deadline := capture_started + LAUNCH_FINGERPRINT_TIMEOUT_MS
|
var fingerprint_deadline := capture_started + LAUNCH_FINGERPRINT_TIMEOUT_MS
|
||||||
while exact_grant.is_empty() and Time.get_ticks_msec() < fingerprint_deadline:
|
while exact_grant.is_empty() and Time.get_ticks_msec() < fingerprint_deadline:
|
||||||
OS.delay_msec(100)
|
OS.delay_msec(100)
|
||||||
exact_grant = PortResolver.capture_process_kill_grant(pid, true, attempts)
|
exact_grant = PortResolver.capture_process_kill_grant(pid, true, attempts, snapshot_diagnostics)
|
||||||
var fingerprint := str(exact_grant.get("fingerprint", ""))
|
var fingerprint := str(exact_grant.get("fingerprint", ""))
|
||||||
return {
|
return {
|
||||||
"ok": not fingerprint.is_empty(),
|
"ok": not fingerprint.is_empty(),
|
||||||
"reason": "launch_unproven" if fingerprint.is_empty() else "",
|
"reason": "launch_unproven" if fingerprint.is_empty() else "",
|
||||||
"message": (
|
"message": (
|
||||||
_launch_unproven_message(pid, attempts, Time.get_ticks_msec() - capture_started)
|
_launch_unproven_message(pid, attempts, Time.get_ticks_msec() - capture_started)
|
||||||
|
+ _snapshot_diagnostic_summary(snapshot_diagnostics)
|
||||||
if fingerprint.is_empty()
|
if fingerprint.is_empty()
|
||||||
else ""
|
else ""
|
||||||
),
|
),
|
||||||
@@ -873,6 +875,23 @@ func _effect_launch(payload: Dictionary) -> Dictionary:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static func _snapshot_diagnostic_summary(diagnostics: Array) -> String:
|
||||||
|
var labels: Array[String] = []
|
||||||
|
for item in diagnostics.slice(0, 8):
|
||||||
|
var stage: String = item.stage if item.stage in PortResolver.SNAPSHOT_DIAGNOSTIC_STAGES else "unknown"
|
||||||
|
var category: String = item.category if item.category in PortResolver.SNAPSHOT_DIAGNOSTIC_CATEGORIES else "unknown"
|
||||||
|
var label := "launch_grant/%s/%s" % [stage, category]
|
||||||
|
if int(item.depth) >= 0:
|
||||||
|
label += " at depth %d" % clampi(int(item.depth), 0, 16)
|
||||||
|
if int(item.count) > 1:
|
||||||
|
label += " x%d" % clampi(int(item.count), 1, 10000)
|
||||||
|
if int(item.elapsed_ms) >= 0:
|
||||||
|
label += " (first query %d ms)" % clampi(int(item.elapsed_ms), 0, 600000)
|
||||||
|
if not labels.has(label):
|
||||||
|
labels.append(label)
|
||||||
|
return "" if labels.is_empty() else " Snapshot diagnostics: " + "; ".join(labels) + "."
|
||||||
|
|
||||||
|
|
||||||
## Name which identity check failed so a Windows report can be acted on
|
## Name which identity check failed so a Windows report can be acted on
|
||||||
## (#988, #1012 both surfaced as this bare sentence). Diagnostic only: the
|
## (#988, #1012 both surfaced as this bare sentence). Diagnostic only: the
|
||||||
## values are read once more after the capture budget and never grant
|
## values are read once more after the capture budget and never grant
|
||||||
|
|||||||
Reference in New Issue
Block a user