fix(gateway): serialize shared runtime lifecycle

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent 7384fbfffc
commit eafc0bc6eb
6 changed files with 263 additions and 52 deletions
+1 -10
View File
@@ -14,7 +14,6 @@ from rich.console import Console
from nanobot.config.schema import Config from nanobot.config.schema import Config
from nanobot.gateway import ( from nanobot.gateway import (
GatewayClientLease,
GatewayRuntime, GatewayRuntime,
GatewayRuntimePaths, GatewayRuntimePaths,
GatewayStartOptions, GatewayStartOptions,
@@ -209,18 +208,12 @@ def create_gateway_app(
) )
print_status(result.status) print_status(result.status)
raise typer.Exit(1) raise typer.Exit(1)
promoted = False
if result.ok or result.message == "gateway_already_running":
promoted = GatewayClientLease(
runtime,
kind="gateway-background",
).mark_persistent()
if result.ok: if result.ok:
console.print("[green]Gateway started in the background.[/green]") console.print("[green]Gateway started in the background.[/green]")
print_status(runtime.status()) print_status(runtime.status())
return return
if result.message == "gateway_already_running": if result.message == "gateway_already_running":
if promoted: if result.promoted:
console.print( console.print(
"[green]Existing on-demand gateway promoted to persistent " "[green]Existing on-demand gateway promoted to persistent "
"background mode.[/green]" "background mode.[/green]"
@@ -290,8 +283,6 @@ def create_gateway_app(
"""Stop the background gateway.""" """Stop the background gateway."""
runtime = runtime_for_instance(workspace=workspace, config=config) runtime = runtime_for_instance(workspace=workspace, config=config)
result = runtime.stop(timeout_s=timeout) result = runtime.stop(timeout_s=timeout)
if result.ok or result.message == "gateway_not_running":
GatewayClientLease(runtime, kind="gateway-stop").clear()
if result.ok: if result.ok:
console.print("[green]Gateway stopped.[/green]") console.print("[green]Gateway stopped.[/green]")
else: else:
+112 -37
View File
@@ -1,5 +1,7 @@
"""Gateway-specific configuration for the shared background process runtime.""" """Gateway-specific configuration for the shared background process runtime."""
# pyright: reportPrivateUsage=false
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -57,6 +59,7 @@ class RuntimeResult(ProcessResult):
"""Result of a gateway lifecycle operation.""" """Result of a gateway lifecycle operation."""
status: GatewayStatus status: GatewayStatus
promoted: bool = False
def build_gateway_command(python_executable: str, options: GatewayStartOptions) -> list[str]: def build_gateway_command(python_executable: str, options: GatewayStartOptions) -> list[str]:
@@ -133,8 +136,15 @@ class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]):
def start_background(self, options: ProcessStartOptions) -> RuntimeResult: def start_background(self, options: ProcessStartOptions) -> RuntimeResult:
"""Start the gateway detached from the current terminal.""" """Start the gateway detached from the current terminal."""
with self._lifecycle_lock(): lease = GatewayClientLease(self, kind="gateway-background")
return self._start_background(options) while True:
lease.wait_for_shutdown()
with self._lifecycle_lock():
promoted = lease._try_mark_persistent_locked()
if promoted is None:
continue
result = self._start_background(options)
return RuntimeResult(result.ok, result.message, result.status, promoted)
def start_on_demand(self, options: ProcessStartOptions) -> RuntimeResult: def start_on_demand(self, options: ProcessStartOptions) -> RuntimeResult:
"""Atomically reuse a gateway or start one owned by local client leases.""" """Atomically reuse a gateway or start one owned by local client leases."""
@@ -142,7 +152,7 @@ class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]):
status = self.status() status = self.status()
if status.running: if status.running:
return RuntimeResult(False, "gateway_already_running", status) return RuntimeResult(False, "gateway_already_running", status)
GatewayClientLease(self, kind="gateway-start").mark_ephemeral() GatewayClientLease(self, kind="gateway-start")._mark_ephemeral_locked()
return self._start_background(options) return self._start_background(options)
def _start_background(self, options: ProcessStartOptions) -> RuntimeResult: def _start_background(self, options: ProcessStartOptions) -> RuntimeResult:
@@ -158,7 +168,10 @@ class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]):
def stop(self, *, timeout_s: int = 20) -> RuntimeResult: def stop(self, *, timeout_s: int = 20) -> RuntimeResult:
"""Stop the gateway recorded by this runtime.""" """Stop the gateway recorded by this runtime."""
with self._lifecycle_lock(): with self._lifecycle_lock():
return self._result(self._stop(timeout_s=timeout_s)) result = self._stop(timeout_s=timeout_s)
if result.ok or result.message in {"gateway_not_running", "gateway_state_stale"}:
GatewayClientLease(self, kind="gateway-stop")._clear_locked()
return self._result(result)
def status(self, *, reason: str | None = None) -> GatewayStatus: def status(self, *, reason: str | None = None) -> GatewayStatus:
"""Return process, launch, and client lifetime state in one snapshot.""" """Return process, launch, and client lifetime state in one snapshot."""
@@ -186,9 +199,7 @@ class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]):
@contextmanager @contextmanager
def foreground_instance(self, options: ProcessStartOptions) -> Generator[None]: def foreground_instance(self, options: ProcessStartOptions) -> Generator[None]:
"""Publish this foreground gateway while it is available to local clients.""" """Publish this foreground gateway while it is available to local clients."""
launch_mode = self._claim_current_process(options) self._claim_current_process(options)
if launch_mode == "foreground":
GatewayClientLease(self, kind="gateway-foreground").mark_persistent()
try: try:
yield yield
finally: finally:
@@ -218,6 +229,11 @@ class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]):
} }
) )
self._write_state(state) self._write_state(state)
if launch_mode == "foreground":
GatewayClientLease(
self,
kind="gateway-foreground",
)._try_mark_persistent_locked()
return launch_mode return launch_mode
def _release_current_process(self) -> None: def _release_current_process(self) -> None:
@@ -225,6 +241,7 @@ class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]):
state = self._read_state() state = self._read_state()
if state and self._record_matches_process(state, os.getpid()): if state and self._record_matches_process(state, os.getpid()):
self._clear_state() self._clear_state()
GatewayClientLease(self, kind="gateway-exit")._finish_shutdown_locked()
def restart(self, options: ProcessStartOptions, *, timeout_s: int = 20) -> RuntimeResult: def restart(self, options: ProcessStartOptions, *, timeout_s: int = 20) -> RuntimeResult:
"""Restart an existing gateway without creating a new persistent instance.""" """Restart an existing gateway without creating a new persistent instance."""
@@ -264,21 +281,20 @@ class GatewayClientLease:
self.state_path = state_path.with_name( self.state_path = state_path.with_name(
f"{state_path.stem}.clients{state_path.suffix}" f"{state_path.stem}.clients{state_path.suffix}"
) )
self.lifecycle_lock = FileLock(f"{state_path}.lock")
self.lock = FileLock(f"{self.state_path}.lock") self.lock = FileLock(f"{self.state_path}.lock")
self._acquired = False self._acquired = False
def acquire(self) -> None: def acquire(self) -> None:
"""Register this client before it starts or attaches to the gateway.""" """Register this client before it starts or attaches to the gateway."""
with self.lock: while True:
state = self._live_state() self.wait_for_shutdown()
clients = self._clients(state) with self.lifecycle_lock, self.lock:
clients[self.token] = { state = self._live_state()
"pid": self.pid, if state.get("stopping"):
"kind": self.kind, continue
"identity": self._process_identity(self.pid), self._register(state)
} return
self._write_state(state)
self._acquired = True
def ensure_on_demand_gateway(self, options: GatewayStartOptions) -> RuntimeResult: def ensure_on_demand_gateway(self, options: GatewayStartOptions) -> RuntimeResult:
"""Atomically reuse a gateway or start one owned by local client leases.""" """Atomically reuse a gateway or start one owned by local client leases."""
@@ -288,6 +304,10 @@ class GatewayClientLease:
def mark_ephemeral(self) -> None: def mark_ephemeral(self) -> None:
"""Mark a gateway started by a client for last-client shutdown.""" """Mark a gateway started by a client for last-client shutdown."""
with self.lifecycle_lock:
self._mark_ephemeral_locked()
def _mark_ephemeral_locked(self) -> None:
with self.lock: with self.lock:
state = self._live_state() state = self._live_state()
state["auto_stop"] = True state["auto_stop"] = True
@@ -295,8 +315,18 @@ class GatewayClientLease:
def mark_persistent(self) -> bool: def mark_persistent(self) -> bool:
"""Keep an explicitly backgrounded gateway alive; return whether it was promoted.""" """Keep an explicitly backgrounded gateway alive; return whether it was promoted."""
while True:
self.wait_for_shutdown()
with self.lifecycle_lock:
promoted = self._try_mark_persistent_locked()
if promoted is not None:
return promoted
def _try_mark_persistent_locked(self) -> bool | None:
with self.lock: with self.lock:
state = self._live_state() state = self._live_state()
if state.get("stopping"):
return None
promoted = bool(state.get("auto_stop")) promoted = bool(state.get("auto_stop"))
state["auto_stop"] = False state["auto_stop"] = False
self._write_or_clear(state) self._write_or_clear(state)
@@ -304,6 +334,10 @@ class GatewayClientLease:
def clear(self) -> None: def clear(self) -> None:
"""Forget leases after an explicit gateway stop.""" """Forget leases after an explicit gateway stop."""
with self.lifecycle_lock:
self._clear_locked()
def _clear_locked(self) -> None:
with self.lock: with self.lock:
self.state_path.unlink(missing_ok=True) self.state_path.unlink(missing_ok=True)
@@ -317,35 +351,76 @@ class GatewayClientLease:
clients=len(self._clients(state)), clients=len(self._clients(state)),
) )
def orphaned_on_demand(self) -> bool: def begin_orphan_shutdown(self) -> bool:
"""Return whether an on-demand gateway has lost every live client.""" """Commit shutdown only while an on-demand gateway still has no clients."""
snapshot = self.snapshot() with self.lifecycle_lock, self.lock:
return snapshot.auto_stop and snapshot.clients == 0 state = self._live_state()
if not bool(state.get("auto_stop")) or self._clients(state):
self._write_or_clear(state)
return False
state["stopping"] = True
self._write_state(state)
return True
def release(self, *, timeout_s: int = 20) -> bool: def release(self, *, timeout_s: int = 20) -> bool:
"""Release this client and stop an ephemeral gateway when it was the last.""" """Release this client and stop an ephemeral gateway when it was the last."""
if not self._acquired: if not self._acquired:
return False return False
should_stop = False with self.lifecycle_lock:
with self.lock:
state = self._live_state()
clients = self._clients(state)
clients.pop(self.token, None)
self._acquired = False
should_stop = not clients and bool(state.get("auto_stop"))
self._write_or_clear(state)
if not should_stop:
return False
result = self.runtime._stop(timeout_s=timeout_s)
stopped = result.ok or result.message in {
"gateway_not_running",
"gateway_state_stale",
}
if stopped:
self._clear_locked()
else:
self._mark_ephemeral_locked()
return stopped
def wait_for_shutdown(self, *, timeout_s: float = 20) -> None:
"""Wait until a committed orphan shutdown can no longer accept clients."""
deadline = time.monotonic() + timeout_s
while True:
with self.lifecycle_lock:
with self.lock:
state = self._live_state()
if not state.get("stopping"):
return
if not self.runtime.status().running:
self._finish_shutdown_locked()
return
if time.monotonic() >= deadline:
raise RuntimeError("gateway is still shutting down; try again shortly")
time.sleep(0.05)
def _finish_shutdown_locked(self) -> None:
with self.lock: with self.lock:
state = self._live_state() state = self._live_state()
clients = self._clients(state) state.pop("stopping", None)
clients.pop(self.token, None) if not self._clients(state):
self._acquired = False
should_stop = not clients and bool(state.get("auto_stop"))
self._write_or_clear(state)
if not should_stop:
return False
result = self.runtime.stop(timeout_s=timeout_s)
with self.lock:
stopped = result.ok or result.message == "gateway_not_running"
if stopped:
self.state_path.unlink(missing_ok=True) self.state_path.unlink(missing_ok=True)
else: else:
state = self._live_state()
state["auto_stop"] = True
self._write_state(state) self._write_state(state)
return stopped
def _register(self, state: dict[str, object]) -> None:
clients = self._clients(state)
clients[self.token] = {
"pid": self.pid,
"kind": self.kind,
"identity": self._process_identity(self.pid),
}
self._write_state(state)
self._acquired = True
def _live_state(self) -> dict[str, object]: def _live_state(self) -> dict[str, object]:
state = self._read_state() state = self._read_state()
@@ -432,7 +507,7 @@ async def monitor_gateway_clients(
try: try:
await asyncio.wait_for(shutdown_event.wait(), timeout=poll_interval_s) await asyncio.wait_for(shutdown_event.wait(), timeout=poll_interval_s)
except TimeoutError: except TimeoutError:
if lease.orphaned_on_demand(): if lease.begin_orphan_shutdown():
shutdown_event.set() shutdown_event.set()
return True return True
return False return False
+2
View File
@@ -1991,6 +1991,8 @@ def _patch_webui_managed_gateway(
self.running = False self.running = False
return RuntimeResult(True, "gateway_stopped", self.status()) return RuntimeResult(True, "gateway_stopped", self.status())
_stop = stop
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime) monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.cli.webui._prepare_webui_bundle_for_gateway", "nanobot.cli.webui._prepare_webui_bundle_for_gateway",
+9 -1
View File
@@ -57,6 +57,7 @@ class FakeRuntime:
def stop(self, *, timeout_s: int) -> RuntimeResult: def stop(self, *, timeout_s: int) -> RuntimeResult:
self.stop_timeout = timeout_s self.stop_timeout = timeout_s
self.paths.state_path.with_name("gateway.clients.json").unlink(missing_ok=True)
return RuntimeResult(True, "gateway_stopped", self.status_value) return RuntimeResult(True, "gateway_stopped", self.status_value)
def status(self) -> GatewayStatus: def status(self) -> GatewayStatus:
@@ -223,7 +224,13 @@ def test_gateway_background_adopts_an_existing_on_demand_gateway(tmp_path):
lease_state.write_text('{"auto_stop": true, "clients": {}}', encoding="utf-8") lease_state.write_text('{"auto_stop": true, "clients": {}}', encoding="utf-8")
def already_running(_options: GatewayStartOptions) -> RuntimeResult: def already_running(_options: GatewayStartOptions) -> RuntimeResult:
return RuntimeResult(False, "gateway_already_running", fake_runtime.status_value) lease_state.unlink(missing_ok=True)
return RuntimeResult(
False,
"gateway_already_running",
fake_runtime.status_value,
promoted=True,
)
fake_runtime.start_background = already_running # type: ignore[method-assign] fake_runtime.start_background = already_running # type: ignore[method-assign]
@@ -311,6 +318,7 @@ def test_gateway_stop_treats_not_running_as_clean(tmp_path):
def fake_stop(*, timeout_s: int) -> RuntimeResult: def fake_stop(*, timeout_s: int) -> RuntimeResult:
fake_runtime.stop_timeout = timeout_s fake_runtime.stop_timeout = timeout_s
lease_state.unlink(missing_ok=True)
return RuntimeResult(False, "gateway_not_running", fake_runtime.status_value) return RuntimeResult(False, "gateway_not_running", fake_runtime.status_value)
fake_runtime.stop = fake_stop # type: ignore[method-assign] fake_runtime.stop = fake_stop # type: ignore[method-assign]
+2
View File
@@ -537,6 +537,8 @@ def test_gateway_started_for_tui_stops_when_its_last_lease_exits(
stopped = True stopped = True
return SimpleNamespace(ok=True, message="gateway_stopped") return SimpleNamespace(ok=True, message="gateway_stopped")
_stop = stop
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", FakeRuntime) monkeypatch.setattr("nanobot.gateway.GatewayRuntime", FakeRuntime)
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.cli.tui_launcher._webui_endpoint_reachable", "nanobot.cli.tui_launcher._webui_endpoint_reachable",
+137 -4
View File
@@ -4,6 +4,7 @@ import os
import signal import signal
import subprocess import subprocess
import sys import sys
import threading
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@@ -284,7 +285,7 @@ def test_last_interactive_client_stops_an_on_demand_gateway(tmp_path, monkeypatc
stopped.append(timeout_s) stopped.append(timeout_s)
return SimpleNamespace(ok=True, message="gateway_stopped") return SimpleNamespace(ok=True, message="gateway_stopped")
monkeypatch.setattr(runtime, "stop", stop) monkeypatch.setattr(runtime, "_stop", stop)
tui = GatewayClientLease(runtime, kind="tui", pid=os.getpid(), token="tui") tui = GatewayClientLease(runtime, kind="tui", pid=os.getpid(), token="tui")
webui = GatewayClientLease(runtime, kind="webui", pid=os.getpid(), token="webui") webui = GatewayClientLease(runtime, kind="webui", pid=os.getpid(), token="webui")
@@ -299,6 +300,95 @@ def test_last_interactive_client_stops_an_on_demand_gateway(tmp_path, monkeypatc
assert not webui.state_path.exists() assert not webui.state_path.exists()
def test_last_client_shutdown_preserves_a_replacement_lease(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid)
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
stop_started = threading.Event()
finish_stop = threading.Event()
replacement_acquired = threading.Event()
def stop(*, timeout_s: int):
assert timeout_s == 20
stop_started.set()
assert finish_stop.wait(timeout=2)
return SimpleNamespace(
ok=True,
message="gateway_stopped",
status=runtime.status(),
)
monkeypatch.setattr(runtime, "_stop", stop)
original = GatewayClientLease(runtime, kind="tui", token="original")
replacement = GatewayClientLease(runtime, kind="webui", token="replacement")
original.acquire()
original.mark_ephemeral()
release_thread = threading.Thread(target=original.release)
release_thread.start()
assert stop_started.wait(timeout=2)
acquire_thread = threading.Thread(
target=lambda: (replacement.acquire(), replacement_acquired.set())
)
acquire_thread.start()
assert not replacement_acquired.wait(timeout=0.05)
finish_stop.set()
release_thread.join(timeout=2)
acquire_thread.join(timeout=2)
assert replacement_acquired.is_set()
state = json.loads(replacement.state_path.read_text(encoding="utf-8"))
assert set(state["clients"]) == {"replacement"}
def test_explicit_stop_clears_leases_before_accepting_a_replacement(
tmp_path,
monkeypatch,
):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid)
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
stop_started = threading.Event()
finish_stop = threading.Event()
replacement_acquired = threading.Event()
stale = GatewayClientLease(runtime, kind="tui", token="stale")
replacement = GatewayClientLease(runtime, kind="webui", token="replacement")
stale.acquire()
stale.mark_ephemeral()
def stop(*, timeout_s: int):
assert timeout_s == 20
stop_started.set()
assert finish_stop.wait(timeout=2)
return SimpleNamespace(
ok=True,
message="gateway_stopped",
status=runtime.status(),
)
monkeypatch.setattr(runtime, "_stop", stop)
stop_thread = threading.Thread(target=runtime.stop)
stop_thread.start()
assert stop_started.wait(timeout=2)
acquire_thread = threading.Thread(
target=lambda: (replacement.acquire(), replacement_acquired.set())
)
acquire_thread.start()
assert not replacement_acquired.wait(timeout=0.05)
finish_stop.set()
stop_thread.join(timeout=2)
acquire_thread.join(timeout=2)
assert replacement_acquired.is_set()
state = json.loads(replacement.state_path.read_text(encoding="utf-8"))
assert set(state["clients"]) == {"replacement"}
def test_on_demand_lifetime_is_recorded_before_the_gateway_spawns(tmp_path, monkeypatch): def test_on_demand_lifetime_is_recorded_before_the_gateway_spawns(tmp_path, monkeypatch):
observed_auto_stop: list[bool] = [] observed_auto_stop: list[bool] = []
@@ -331,18 +421,28 @@ def test_explicit_background_gateway_survives_the_last_client(tmp_path, monkeypa
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux") runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid) monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid)
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True) monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
runtime._write_state(
{
"pid": os.getpid(),
"identity": os.getpid(),
"launch_mode": "background",
}
)
stopped: list[int] = [] stopped: list[int] = []
monkeypatch.setattr( monkeypatch.setattr(
runtime, runtime,
"stop", "_stop",
lambda *, timeout_s: stopped.append(timeout_s), lambda *, timeout_s: stopped.append(timeout_s),
) )
client = GatewayClientLease(runtime, kind="webui", pid=os.getpid()) client = GatewayClientLease(runtime, kind="webui", pid=os.getpid())
client.acquire() client.acquire()
client.mark_ephemeral() client.mark_ephemeral()
assert GatewayClientLease(runtime, kind="gateway-background").mark_persistent() is True result = runtime.start_background(GatewayStartOptions(port=18790))
assert result.ok is False
assert result.message == "gateway_already_running"
assert result.promoted is True
assert client.release() is False assert client.release() is False
assert stopped == [] assert stopped == []
assert not client.state_path.exists() assert not client.state_path.exists()
@@ -354,7 +454,7 @@ def test_failed_last_client_shutdown_remains_retryable(tmp_path, monkeypatch):
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True) monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
monkeypatch.setattr( monkeypatch.setattr(
runtime, runtime,
"stop", "_stop",
lambda *, timeout_s: SimpleNamespace(ok=False, message="gateway_stop_timeout"), lambda *, timeout_s: SimpleNamespace(ok=False, message="gateway_stop_timeout"),
) )
client = GatewayClientLease(runtime, kind="tui", pid=os.getpid()) client = GatewayClientLease(runtime, kind="tui", pid=os.getpid())
@@ -400,6 +500,39 @@ async def test_client_monitor_stops_an_orphaned_on_demand_gateway(tmp_path):
assert orphaned is True assert orphaned is True
assert shutdown_event.is_set() assert shutdown_event.is_set()
assert json.loads(lease.state_path.read_text(encoding="utf-8"))["stopping"] is True
async def test_client_monitor_blocks_replacement_until_gateway_exit(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid)
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
runtime._write_state({"pid": os.getpid(), "identity": os.getpid()})
monitor = GatewayClientLease(runtime, kind="gateway-monitor")
monitor.mark_ephemeral()
shutdown_event = asyncio.Event()
assert await monitor_gateway_clients(
monitor,
shutdown_event,
poll_interval_s=0.001,
) is True
replacement = GatewayClientLease(runtime, kind="webui", token="replacement")
replacement_acquired = threading.Event()
acquire_thread = threading.Thread(
target=lambda: (replacement.acquire(), replacement_acquired.set())
)
acquire_thread.start()
assert not replacement_acquired.wait(timeout=0.05)
runtime._release_current_process()
acquire_thread.join(timeout=2)
assert replacement_acquired.is_set()
state = json.loads(replacement.state_path.read_text(encoding="utf-8"))
assert set(state["clients"]) == {"replacement"}
assert "stopping" not in state
def test_start_background_uses_windows_process_group_flags(tmp_path, monkeypatch): def test_start_background_uses_windows_process_group_flags(tmp_path, monkeypatch):