feat: bullrush branch - mekton bulls arena, HUD, NPC managers, godot_ai updates
This commit is contained in:
@@ -34,6 +34,7 @@ extends RefCounted
|
||||
|
||||
const DEFAULT_TIMEOUT_MS := 8000
|
||||
const _POLL_INTERVAL_MS := 50
|
||||
const _KILL_GRACE_MS := 500
|
||||
|
||||
|
||||
static func run(
|
||||
@@ -44,6 +45,21 @@ static func run(
|
||||
) -> Dictionary:
|
||||
if exe.is_empty():
|
||||
return _spawn_failed_result()
|
||||
if _uses_blocking_legacy_path():
|
||||
## Godot 4.3 keeps the old blocking path because execute_with_pipe
|
||||
## capture/exit semantics differ there. The bounded timeout/kill
|
||||
## behavior is available on Godot 4.4+ only.
|
||||
return _run_blocking_legacy(exe, args)
|
||||
|
||||
return _run_piped(exe, args, timeout_ms, capture_stderr)
|
||||
|
||||
|
||||
static func _run_piped(
|
||||
exe: String,
|
||||
args: Array,
|
||||
timeout_ms: int,
|
||||
capture_stderr: bool,
|
||||
) -> Dictionary:
|
||||
|
||||
var spawn_exe := exe
|
||||
var spawn_args := args
|
||||
@@ -76,12 +92,18 @@ static func run(
|
||||
var deadline := Time.get_ticks_msec() + maxi(timeout_ms, _POLL_INTERVAL_MS)
|
||||
while OS.is_process_running(pid):
|
||||
if Time.get_ticks_msec() >= deadline:
|
||||
## Read whatever made it to the pipes before we kill the
|
||||
## process — partial output beats blank "timed out" when the
|
||||
## CLI was emitting useful diagnostics on its way to hanging.
|
||||
var partial_stdout := _drain_pipe(stdio)
|
||||
var partial_stderr := _drain_pipe(stderr_pipe) if capture_stderr else ""
|
||||
## Kill before draining: a pipe read can block while the child is
|
||||
## still alive. Once it exits, drain any buffered partial output.
|
||||
OS.kill(pid)
|
||||
var kill_deadline := Time.get_ticks_msec() + _KILL_GRACE_MS
|
||||
while OS.is_process_running(pid) and Time.get_ticks_msec() < kill_deadline:
|
||||
OS.delay_msec(_POLL_INTERVAL_MS)
|
||||
|
||||
var partial_stdout := ""
|
||||
var partial_stderr := ""
|
||||
if not OS.is_process_running(pid):
|
||||
partial_stdout = _drain_pipe(stdio)
|
||||
partial_stderr = _drain_pipe(stderr_pipe) if capture_stderr else ""
|
||||
_close_pipes(stdio, stderr_pipe)
|
||||
return {
|
||||
"exit_code": -1,
|
||||
@@ -107,6 +129,27 @@ static func run(
|
||||
}
|
||||
|
||||
|
||||
static func _run_blocking_legacy(exe: String, args: Array) -> Dictionary:
|
||||
## Godot 4.3's OS.execute_with_pipe has capture/exit-code differences
|
||||
## locked by the 4.3 canary skips in test_cli_exec.gd. Preserve the old
|
||||
## blocking discovery behavior there so startup-critical probes keep the
|
||||
## same semantics that worked before the bounded-pipe path landed.
|
||||
var output: Array = []
|
||||
var exit_code := OS.execute(exe, args, output, true)
|
||||
var lines := PackedStringArray()
|
||||
for line in output:
|
||||
lines.append(str(line))
|
||||
var stdout := "\n".join(lines)
|
||||
return {
|
||||
"exit_code": exit_code,
|
||||
"stdout": stdout,
|
||||
"stderr": "",
|
||||
"output": stdout,
|
||||
"timed_out": false,
|
||||
"spawn_failed": exit_code == -1,
|
||||
}
|
||||
|
||||
|
||||
static func _spawn_failed_result() -> Dictionary:
|
||||
return {
|
||||
"exit_code": -1,
|
||||
@@ -119,9 +162,19 @@ static func _spawn_failed_result() -> Dictionary:
|
||||
|
||||
|
||||
static func _drain_pipe(pipe: Variant) -> String:
|
||||
if pipe is FileAccess:
|
||||
return (pipe as FileAccess).get_as_text()
|
||||
return ""
|
||||
if not (pipe is FileAccess):
|
||||
return ""
|
||||
var f := pipe as FileAccess
|
||||
var bytes := PackedByteArray()
|
||||
var max_bytes := 1 << 20 # 1 MiB, far above expected client CLI output.
|
||||
while bytes.size() < max_bytes:
|
||||
var chunk := f.get_buffer(mini(4096, max_bytes - bytes.size()))
|
||||
if chunk.is_empty():
|
||||
break
|
||||
bytes.append_array(chunk)
|
||||
if f.eof_reached():
|
||||
break
|
||||
return bytes.get_string_from_utf8()
|
||||
|
||||
|
||||
static func _join_streams(stdout: String, stderr_text: String) -> String:
|
||||
@@ -141,3 +194,10 @@ static func _close_pipes(stdio: Variant, stderr_pipe: Variant) -> void:
|
||||
(stdio as FileAccess).close()
|
||||
if stderr_pipe is FileAccess:
|
||||
(stderr_pipe as FileAccess).close()
|
||||
|
||||
|
||||
static func _uses_blocking_legacy_path() -> bool:
|
||||
var version := Engine.get_version_info()
|
||||
var major := int(version.get("major", 4))
|
||||
var minor := int(version.get("minor", 0))
|
||||
return major < 4 or (major == 4 and minor < 4)
|
||||
|
||||
@@ -14,7 +14,7 @@ extends RefCounted
|
||||
## the main thread (manual Refresh path). Godot `Dictionary` is not safe for
|
||||
## concurrent mutation, so `_cache` / `_searched` access is guarded by
|
||||
## `_mutex`. The mutex is held only across dictionary read/write — the slow
|
||||
## `_resolve()` path (FileAccess + `OS.execute`) runs unlocked, so a
|
||||
## `_resolve()` path (FileAccess + bounded subprocess lookup) runs unlocked, so a
|
||||
## main-thread `invalidate()` can never block on a worker's subprocess.
|
||||
## Two workers racing the same exe both call `_resolve()` and both write
|
||||
## back the same answer; that's wasted work, not corruption.
|
||||
@@ -24,6 +24,8 @@ static var _mutex: Mutex = Mutex.new()
|
||||
static var _cache: Dictionary = {} # exe_name -> resolved path (or "")
|
||||
static var _searched: Dictionary = {}
|
||||
|
||||
const _LOOKUP_TIMEOUT_MS := 3000
|
||||
|
||||
|
||||
## Find any of the supplied exe names; returns the first hit.
|
||||
## On Windows pass the .exe variant in `exe_names` if relevant.
|
||||
@@ -54,8 +56,8 @@ static func _find_one(exe_name: String) -> String:
|
||||
_mutex.unlock()
|
||||
if already_searched:
|
||||
return cached
|
||||
# `_resolve()` does FileAccess + `OS.execute` (forks `bash -lc` /
|
||||
# `which`), which can take 100ms-1s. Holding the mutex across that
|
||||
# `_resolve()` does FileAccess + bounded subprocess lookup (forks
|
||||
# `bash -lc` / `which`), which can take 100ms-1s. Holding the mutex across that
|
||||
# would let a concurrent `invalidate()` on the main thread freeze the
|
||||
# editor for the duration of the subprocess — which defeats the whole
|
||||
# point of running CLI lookup off the main thread.
|
||||
@@ -81,20 +83,19 @@ static func _resolve(exe_name: String) -> String:
|
||||
var shell := OS.get_environment("SHELL")
|
||||
if shell.is_empty():
|
||||
shell = "/bin/bash"
|
||||
var login_output: Array = []
|
||||
var stripped := exe_name.trim_suffix(".exe")
|
||||
var login_exit := OS.execute(shell, ["-lc", "command -v %s" % stripped], login_output, true)
|
||||
if login_exit == 0 and login_output.size() > 0:
|
||||
var login_found: String = login_output[0].strip_edges()
|
||||
var login_result := McpCliExec.run(shell, ["-lc", "command -v %s" % stripped], _LOOKUP_TIMEOUT_MS, false)
|
||||
if int(login_result.get("exit_code", -1)) == 0:
|
||||
var login_found: String = str(login_result.get("stdout", "")).strip_edges()
|
||||
if not login_found.is_empty() and FileAccess.file_exists(login_found):
|
||||
return login_found
|
||||
|
||||
# 3. which / where with inherited PATH
|
||||
var lookup := "where" if is_windows else "which"
|
||||
var output: Array = []
|
||||
var exit_code := OS.execute(lookup, [exe_name], output, true)
|
||||
if exit_code == 0 and output.size() > 0:
|
||||
var lines := PackedStringArray(output[0].split("\n"))
|
||||
var result := McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false)
|
||||
if int(result.get("exit_code", -1)) == 0:
|
||||
var output := str(result.get("stdout", ""))
|
||||
var lines := PackedStringArray(output.split("\n"))
|
||||
var found := _pick_best_path(lines) if is_windows else lines[0].strip_edges()
|
||||
if not found.is_empty():
|
||||
return found
|
||||
|
||||
Reference in New Issue
Block a user