mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 16:21:50 +03:00
fix(gateway): preserve runtime identity compatibility
This commit is contained in:
@@ -28,6 +28,7 @@ from nanobot.process_runtime import (
|
|||||||
ProcessRuntimePaths,
|
ProcessRuntimePaths,
|
||||||
ProcessStartOptions,
|
ProcessStartOptions,
|
||||||
ProcessStatus,
|
ProcessStatus,
|
||||||
|
process_identity_record,
|
||||||
process_is_running,
|
process_is_running,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -300,7 +301,6 @@ class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]):
|
|||||||
state.update(
|
state.update(
|
||||||
{
|
{
|
||||||
"pid": pid,
|
"pid": pid,
|
||||||
"identity": self._process_identity(pid),
|
|
||||||
"started_at": datetime.now(UTC).isoformat(),
|
"started_at": datetime.now(UTC).isoformat(),
|
||||||
"platform": self.platform_name,
|
"platform": self.platform_name,
|
||||||
"port": options.port,
|
"port": options.port,
|
||||||
@@ -311,6 +311,8 @@ class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]):
|
|||||||
"launch_mode": launch_mode,
|
"launch_mode": launch_mode,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
state.pop("stable_identity", None)
|
||||||
|
state.update(self.process_identity_record(pid))
|
||||||
self._write_state(state)
|
self._write_state(state)
|
||||||
if launch_mode == "foreground":
|
if launch_mode == "foreground":
|
||||||
lease._try_mark_persistent_locked()
|
lease._try_mark_persistent_locked()
|
||||||
@@ -513,11 +515,12 @@ class GatewayClientLease:
|
|||||||
|
|
||||||
def _register(self, state: dict[str, object]) -> None:
|
def _register(self, state: dict[str, object]) -> None:
|
||||||
clients = self._clients(state)
|
clients = self._clients(state)
|
||||||
clients[self.token] = {
|
record: dict[str, object] = {
|
||||||
"pid": self.pid,
|
"pid": self.pid,
|
||||||
"kind": self.kind,
|
"kind": self.kind,
|
||||||
"identity": self._process_identity(self.pid),
|
|
||||||
}
|
}
|
||||||
|
record.update(process_identity_record(self._process_identity(self.pid), lease=True))
|
||||||
|
clients[self.token] = record
|
||||||
self._write_state(state)
|
self._write_state(state)
|
||||||
self._acquired = True
|
self._acquired = True
|
||||||
|
|
||||||
@@ -531,6 +534,8 @@ class GatewayClientLease:
|
|||||||
continue
|
continue
|
||||||
record = cast(dict[str, object], value)
|
record = cast(dict[str, object], value)
|
||||||
pid = record.get("pid")
|
pid = record.get("pid")
|
||||||
|
identity = record.get("stable_identity")
|
||||||
|
if identity is None:
|
||||||
identity = record.get("identity")
|
identity = record.get("identity")
|
||||||
if not isinstance(pid, int) or not self._process_is_running(pid):
|
if not isinstance(pid, int) or not self._process_is_running(pid):
|
||||||
stale.append(token)
|
stale.append(token)
|
||||||
|
|||||||
+50
-10
@@ -107,7 +107,8 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]):
|
|||||||
return
|
return
|
||||||
state["pid"] = os.getpid()
|
state["pid"] = os.getpid()
|
||||||
runtime = cls(paths=paths)
|
runtime = cls(paths=paths)
|
||||||
state["identity"] = runtime._process_identity(os.getpid())
|
state.pop("stable_identity", None)
|
||||||
|
state.update(runtime.process_identity_record(os.getpid()))
|
||||||
state["started_at"] = _utc_now()
|
state["started_at"] = _utc_now()
|
||||||
runtime._write_state(state)
|
runtime._write_state(state)
|
||||||
|
|
||||||
@@ -140,10 +141,8 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]):
|
|||||||
if not self._is_pid_running(pid):
|
if not self._is_pid_running(pid):
|
||||||
return ProcessResult(False, self._message("exited_during_startup"), self.status())
|
return ProcessResult(False, self._message("exited_during_startup"), self.status())
|
||||||
|
|
||||||
self._write_state(
|
state: dict[str, object] = {
|
||||||
{
|
|
||||||
"pid": pid,
|
"pid": pid,
|
||||||
"identity": self._process_identity(pid),
|
|
||||||
"started_at": _utc_now(),
|
"started_at": _utc_now(),
|
||||||
"platform": self.platform_name,
|
"platform": self.platform_name,
|
||||||
"port": options.port,
|
"port": options.port,
|
||||||
@@ -152,7 +151,8 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]):
|
|||||||
"command": command,
|
"command": command,
|
||||||
"log_path": str(self.paths.log_path),
|
"log_path": str(self.paths.log_path),
|
||||||
}
|
}
|
||||||
)
|
state.update(self.process_identity_record(pid))
|
||||||
|
self._write_state(state)
|
||||||
return ProcessResult(True, self._message("started_background"), self.status())
|
return ProcessResult(True, self._message("started_background"), self.status())
|
||||||
|
|
||||||
def stop(self, *, timeout_s: int = 20) -> ProcessResult:
|
def stop(self, *, timeout_s: int = 20) -> ProcessResult:
|
||||||
@@ -273,6 +273,15 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]):
|
|||||||
"""Return an identity that changes when an operating-system PID is reused."""
|
"""Return an identity that changes when an operating-system PID is reused."""
|
||||||
return self._process_identity(pid)
|
return self._process_identity(pid)
|
||||||
|
|
||||||
|
def process_identity_record(
|
||||||
|
self,
|
||||||
|
pid: int,
|
||||||
|
*,
|
||||||
|
lease: bool = False,
|
||||||
|
) -> dict[str, str | int | None]:
|
||||||
|
"""Serialize an identity without breaking pre-upgrade macOS readers."""
|
||||||
|
return process_identity_record(self._process_identity(pid), lease=lease)
|
||||||
|
|
||||||
def process_identity_match(
|
def process_identity_match(
|
||||||
self,
|
self,
|
||||||
recorded: object,
|
recorded: object,
|
||||||
@@ -436,6 +445,8 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]):
|
|||||||
) -> Literal["match", "mismatch", "unknown"]:
|
) -> Literal["match", "mismatch", "unknown"]:
|
||||||
if not state:
|
if not state:
|
||||||
return "mismatch"
|
return "mismatch"
|
||||||
|
recorded = state.get("stable_identity")
|
||||||
|
if recorded is None:
|
||||||
recorded = state.get("identity")
|
recorded = state.get("identity")
|
||||||
return self.process_identity_match(recorded, pid)
|
return self.process_identity_match(recorded, pid)
|
||||||
|
|
||||||
@@ -549,13 +560,14 @@ def _darwin_identity_match(
|
|||||||
"""Compare the new numeric identity with a pre-upgrade ``ps`` identity."""
|
"""Compare the new numeric identity with a pre-upgrade ``ps`` identity."""
|
||||||
if not isinstance(recorded, str) or not isinstance(current, str):
|
if not isinstance(recorded, str) or not isinstance(current, str):
|
||||||
return "mismatch"
|
return "mismatch"
|
||||||
current_match = re.fullmatch(r"darwin:(\d+):(\d+):(\d+)", current)
|
current_identity = _parse_darwin_identity(current)
|
||||||
if current_match is None:
|
if current_identity is None:
|
||||||
return "mismatch"
|
return "mismatch"
|
||||||
|
current_group, current_seconds, _ = current_identity
|
||||||
recorded_group, separator, recorded_started_at = recorded.partition(":")
|
recorded_group, separator, recorded_started_at = recorded.partition(":")
|
||||||
if not separator or not recorded_group.isdigit():
|
if not separator or not recorded_group.isdigit():
|
||||||
return "mismatch"
|
return "mismatch"
|
||||||
if int(recorded_group) != int(current_match.group(1)):
|
if int(recorded_group) != current_group:
|
||||||
return "mismatch"
|
return "mismatch"
|
||||||
legacy_epoch = _legacy_darwin_started_at(recorded_started_at)
|
legacy_epoch = _legacy_darwin_started_at(recorded_started_at)
|
||||||
if legacy_epoch is None:
|
if legacy_epoch is None:
|
||||||
@@ -563,7 +575,35 @@ def _darwin_identity_match(
|
|||||||
# locale produced a date we cannot safely parse. Keep the record until
|
# locale produced a date we cannot safely parse. Keep the record until
|
||||||
# the owning client exits instead of killing a live gateway.
|
# the owning client exits instead of killing a live gateway.
|
||||||
return "unknown"
|
return "unknown"
|
||||||
return "match" if legacy_epoch == int(current_match.group(2)) else "mismatch"
|
return "match" if legacy_epoch == current_seconds else "mismatch"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_darwin_identity(value: object) -> tuple[int, int, int] | None:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
match = re.fullmatch(r"darwin:(\d+):(\d+):(\d+)", value)
|
||||||
|
if match is None:
|
||||||
|
return None
|
||||||
|
return int(match.group(1)), int(match.group(2)), int(match.group(3))
|
||||||
|
|
||||||
|
|
||||||
|
def process_identity_record(
|
||||||
|
identity: str | int | None,
|
||||||
|
*,
|
||||||
|
lease: bool = False,
|
||||||
|
) -> dict[str, str | int | None]:
|
||||||
|
"""Serialize an identity without breaking pre-upgrade macOS readers."""
|
||||||
|
darwin = _parse_darwin_identity(identity)
|
||||||
|
if darwin is None:
|
||||||
|
return {"identity": identity}
|
||||||
|
process_group, _, _ = darwin
|
||||||
|
# Old process-state readers understand a PGID-only integer. Old lease
|
||||||
|
# readers raw-compare identities, so ``None`` asks them to rely on the
|
||||||
|
# still-live PID while upgraded readers use the stable native value.
|
||||||
|
return {
|
||||||
|
"identity": None if lease else process_group,
|
||||||
|
"stable_identity": identity,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _legacy_darwin_started_at(value: str) -> int | None:
|
def _legacy_darwin_started_at(value: str) -> int | None:
|
||||||
@@ -603,7 +643,7 @@ def _legacy_darwin_started_at(value: str) -> int | None:
|
|||||||
month, day, hour, minute, second, year = map(int, numeric.groups())
|
month, day, hour, minute, second, year = map(int, numeric.groups())
|
||||||
try:
|
try:
|
||||||
return int(time.mktime((year, month, day, hour, minute, second, -1, -1, -1)))
|
return int(time.mktime((year, month, day, hour, minute, second, -1, -1, -1)))
|
||||||
except (OverflowError, ValueError):
|
except (OSError, OverflowError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -660,6 +660,28 @@ def test_lease_snapshot_keeps_a_legacy_localized_darwin_client(tmp_path, monkeyp
|
|||||||
assert snapshot.clients == 1
|
assert snapshot.clients == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_darwin_lease_keeps_old_readers_compatible_and_detects_pid_reuse(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Darwin")
|
||||||
|
identity = "darwin:42:1786992348:123456"
|
||||||
|
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
|
||||||
|
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: identity)
|
||||||
|
client = GatewayClientLease(runtime, kind="tui", pid=12345, token="client")
|
||||||
|
|
||||||
|
client.acquire()
|
||||||
|
client.mark_ephemeral()
|
||||||
|
state = json.loads(client.state_path.read_text(encoding="utf-8"))
|
||||||
|
record = state["clients"]["client"]
|
||||||
|
|
||||||
|
assert record["identity"] is None # Pre-upgrade lease readers keep the live PID.
|
||||||
|
assert record["stable_identity"] == identity
|
||||||
|
|
||||||
|
identity = "darwin:42:1786992348:654321"
|
||||||
|
assert client.snapshot().clients == 0
|
||||||
|
|
||||||
|
|
||||||
def test_lease_snapshot_keeps_a_client_when_identity_probe_is_unavailable(
|
def test_lease_snapshot_keeps_a_client_when_identity_probe_is_unavailable(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
@@ -925,16 +947,74 @@ def test_darwin_process_identity_is_locale_independent(tmp_path, monkeypatch):
|
|||||||
)
|
)
|
||||||
monkeypatch.setenv("LANG", "zh_CN.UTF-8")
|
monkeypatch.setenv("LANG", "zh_CN.UTF-8")
|
||||||
monkeypatch.setenv("LC_ALL", "zh_CN.UTF-8")
|
monkeypatch.setenv("LC_ALL", "zh_CN.UTF-8")
|
||||||
|
monkeypatch.setattr("nanobot.process_runtime._platform_name", lambda: "Darwin")
|
||||||
started_at = int(time.mktime((2026, 8, 18, 2, 17, 54, -1, -1, -1)))
|
started_at = int(time.mktime((2026, 8, 18, 2, 17, 54, -1, -1, -1)))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.process_runtime._darwin_process_birth",
|
"nanobot.process_runtime._darwin_process_birth",
|
||||||
lambda _pid: (42, started_at, 123456),
|
lambda _pid: (42, started_at, 123456),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert runtime.process_identity(12345) == f"darwin:42:{started_at}:123456"
|
identity = f"darwin:42:{started_at}:123456"
|
||||||
|
assert runtime.process_identity(12345) == identity
|
||||||
|
assert runtime.process_identity_record(12345) == {
|
||||||
|
"identity": 42,
|
||||||
|
"stable_identity": identity,
|
||||||
|
}
|
||||||
assert runtime._record_matches_process({"identity": 42}, 12345) is True
|
assert runtime._record_matches_process({"identity": 42}, 12345) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_darwin_status_discovers_a_legacy_localized_state(tmp_path, monkeypatch):
|
||||||
|
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Darwin")
|
||||||
|
started_at = int(time.mktime((2026, 8, 18, 2, 17, 54, -1, -1, -1)))
|
||||||
|
runtime.paths.run_dir.mkdir(parents=True)
|
||||||
|
runtime.paths.state_path.write_text(
|
||||||
|
'{"pid": 12345, "identity": "42:二 8/18 02:17:54 2026"}',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
runtime,
|
||||||
|
"_process_identity",
|
||||||
|
lambda _pid: f"darwin:42:{started_at}:123456",
|
||||||
|
)
|
||||||
|
|
||||||
|
status = runtime.status()
|
||||||
|
|
||||||
|
assert status.running is True
|
||||||
|
assert status.reason == "running"
|
||||||
|
assert runtime.paths.state_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_darwin_status_prefers_stable_identity_over_compatibility_pgid(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Darwin")
|
||||||
|
runtime.paths.run_dir.mkdir(parents=True)
|
||||||
|
runtime.paths.state_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"pid": 12345,
|
||||||
|
"identity": 42,
|
||||||
|
"stable_identity": "darwin:42:1786992348:123456",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
runtime,
|
||||||
|
"_process_identity",
|
||||||
|
lambda _pid: "darwin:42:1786992348:654321",
|
||||||
|
)
|
||||||
|
|
||||||
|
status = runtime.status()
|
||||||
|
|
||||||
|
assert status.running is False
|
||||||
|
assert status.reason == "stale_state"
|
||||||
|
assert not runtime.paths.state_path.exists()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(sys.platform != "darwin", reason="requires macOS proc_pidinfo")
|
@pytest.mark.skipif(sys.platform != "darwin", reason="requires macOS proc_pidinfo")
|
||||||
def test_darwin_live_process_identity_is_stable(tmp_path):
|
def test_darwin_live_process_identity_is_stable(tmp_path):
|
||||||
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Darwin")
|
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Darwin")
|
||||||
|
|||||||
Reference in New Issue
Block a user