New atlas_standalone scene/script: attach-or-spawn boot (one REAL
connect_to_sim attempt at SR_PORT-or-9876 — a separate throwaway TCP
probe was proven by live run to kill a pre-accept-loop server via
broken handshake pipe; never abandon a connected socket), else spawn
--port 0 via new ServerProcess.start_with_pipe + LISTENING:{port}
stdout parse, retry against the resolved port. Reader role wired end
to end: protocol.encode_startup_message optional role param (empty
omits the wire key — byte-identical for all existing callers),
sim_bridge.connection_role suppresses the post-handshake
RequestAllSettings auto-send, hud_groups skips AutoPause/AutoResume
sends for readers (all three would otherwise burn Reader violation
strikes per the T-1130 matrix — endorsed by Oscar).
Generic implant host per D-254 SS3: the shell instantiates ALL
registered implant apps; implant_app_manifest gains
available_in_companion (opt-out, default true) and
implant_registry.instantiate_all a standalone filter param (default
preserves hud.gd behavior byte-identically). Boot order is
instantiate_all THEN open_app (reverse renders a permanently black
window — app_changed fires with no listener; matches hud.gd's order).
Owned-server lifecycle: _exit_tree stops a spawned child, attached
servers survive companion close. Known engine limitation documented:
raw SIGTERM bypasses all Godot notifications and orphans a spawned
server; WM close paths verified clean.
Live-verified: spawn-mode (301 systems rendered from systems.db over
the wire), attach-mode, two simultaneous readers, clean shutdown with
zero orphan processes. 14 new gdUnit tests (port/LISTENING parsing).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
88 lines
3.3 KiB
GDScript
88 lines
3.3 KiB
GDScript
class_name ServerProcess
|
|
## Manages the Rust simulation server as a subprocess (D-020).
|
|
|
|
var _pid: int = -1
|
|
## Populated only by start_with_pipe() — the stdout FileAccess handle used to
|
|
## read the LISTENING:{port} signal (D-254 §1/§3). Null after plain start().
|
|
var _stdio: FileAccess = null
|
|
|
|
|
|
## Start the server process. Returns the PID, or -1 on failure.
|
|
## Validates that server_path exists and is accessible before spawning.
|
|
func start(server_path: String, args: Array = []) -> int:
|
|
if _pid > 0 and is_alive():
|
|
push_warning("ServerProcess: server already running (pid %d)" % _pid)
|
|
return _pid
|
|
if not FileAccess.file_exists(server_path):
|
|
push_error("ServerProcess: server binary not found at %s" % server_path)
|
|
return -1
|
|
_pid = OS.create_process(server_path, args)
|
|
if _pid <= 0:
|
|
push_error("ServerProcess: failed to start server at %s" % server_path)
|
|
return _pid
|
|
|
|
|
|
## Start the server process with its stdout piped back to this process
|
|
## (D-254 §1/§3: the companion app's --port 0 spawn-mode, which needs to read
|
|
## the LISTENING:{port} signal main.rs prints — see server/src/main.rs). Unlike
|
|
## start()/OS.create_process, this uses OS.execute_with_pipe, which does not
|
|
## block (the shell precedent, tests/run-visual, achieves the same read via a
|
|
## redirected log file + `grep` from a wrapper script; this is the Godot-native
|
|
## equivalent for a caller with no shell wrapper). Returns the PID, or -1 on
|
|
## failure. Call read_stdout_line() from a per-frame poll to drain the pipe.
|
|
func start_with_pipe(server_path: String, args: Array = []) -> int:
|
|
if _pid > 0 and is_alive():
|
|
push_warning("ServerProcess: server already running (pid %d)" % _pid)
|
|
return _pid
|
|
if not FileAccess.file_exists(server_path):
|
|
push_error("ServerProcess: server binary not found at %s" % server_path)
|
|
return -1
|
|
var result: Dictionary = OS.execute_with_pipe(server_path, args)
|
|
if not result.has("pid") or int(result.get("pid", -1)) <= 0:
|
|
push_error("ServerProcess: failed to start server (pipe) at %s" % server_path)
|
|
return -1
|
|
_pid = int(result["pid"])
|
|
_stdio = result.get("stdio", null)
|
|
return _pid
|
|
|
|
|
|
## Non-blocking read of one line from the piped server's stdout, or "" if no
|
|
## complete line is buffered yet (FileAccess.get_line() on a pipe never
|
|
## blocks — it returns immediately with whatever is currently available).
|
|
## Only meaningful after start_with_pipe(); returns "" if _stdio was never set
|
|
## (plain start()) or the process has exited (stream at EOF).
|
|
func read_stdout_line() -> String:
|
|
if _stdio == null:
|
|
return ""
|
|
if _stdio.get_error() != OK:
|
|
return ""
|
|
return _stdio.get_line()
|
|
|
|
|
|
## Check if the server process is still running.
|
|
## NOTE: OS.is_process_running() doesn't distinguish starting/healthy/zombie.
|
|
## TODO: Add health check (e.g. TCP readiness probe) when server supports it.
|
|
func is_alive() -> bool:
|
|
if _pid <= 0:
|
|
return false
|
|
return OS.is_process_running(_pid)
|
|
|
|
|
|
## Stop the server process.
|
|
## TODO: Send graceful shutdown signal before SIGKILL when server supports it.
|
|
func stop() -> void:
|
|
if _pid > 0 and is_alive():
|
|
OS.kill(_pid)
|
|
_pid = -1
|
|
|
|
|
|
## Get the server PID (-1 if not started).
|
|
func get_pid() -> int:
|
|
return _pid
|
|
|
|
|
|
## Clean up on destruction — kill the server if still running.
|
|
func _notification(what: int) -> void:
|
|
if what == NOTIFICATION_PREDELETE:
|
|
stop()
|