From 99d5fa2908a16931bb30df108eaf5ae01a54279a Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Tue, 18 Aug 2026 03:07:10 +0800 Subject: [PATCH] fix(gateway): preserve runtime identity compatibility --- nanobot/gateway/runtime.py | 13 ++++-- nanobot/process_runtime.py | 80 +++++++++++++++++++++++++--------- tests/gateway/test_runtime.py | 82 ++++++++++++++++++++++++++++++++++- 3 files changed, 150 insertions(+), 25 deletions(-) diff --git a/nanobot/gateway/runtime.py b/nanobot/gateway/runtime.py index aaafca62f..828c2f604 100644 --- a/nanobot/gateway/runtime.py +++ b/nanobot/gateway/runtime.py @@ -28,6 +28,7 @@ from nanobot.process_runtime import ( ProcessRuntimePaths, ProcessStartOptions, ProcessStatus, + process_identity_record, process_is_running, ) @@ -300,7 +301,6 @@ class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]): state.update( { "pid": pid, - "identity": self._process_identity(pid), "started_at": datetime.now(UTC).isoformat(), "platform": self.platform_name, "port": options.port, @@ -311,6 +311,8 @@ class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]): "launch_mode": launch_mode, } ) + state.pop("stable_identity", None) + state.update(self.process_identity_record(pid)) self._write_state(state) if launch_mode == "foreground": lease._try_mark_persistent_locked() @@ -513,11 +515,12 @@ class GatewayClientLease: def _register(self, state: dict[str, object]) -> None: clients = self._clients(state) - clients[self.token] = { + record: dict[str, object] = { "pid": self.pid, "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._acquired = True @@ -531,7 +534,9 @@ class GatewayClientLease: continue record = cast(dict[str, object], value) pid = record.get("pid") - identity = record.get("identity") + identity = record.get("stable_identity") + if identity is None: + identity = record.get("identity") if not isinstance(pid, int) or not self._process_is_running(pid): stale.append(token) continue diff --git a/nanobot/process_runtime.py b/nanobot/process_runtime.py index 5b7233434..359fed8a6 100644 --- a/nanobot/process_runtime.py +++ b/nanobot/process_runtime.py @@ -107,7 +107,8 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]): return state["pid"] = os.getpid() 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() runtime._write_state(state) @@ -140,19 +141,18 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]): if not self._is_pid_running(pid): return ProcessResult(False, self._message("exited_during_startup"), self.status()) - self._write_state( - { - "pid": pid, - "identity": self._process_identity(pid), - "started_at": _utc_now(), - "platform": self.platform_name, - "port": options.port, - "workspace": options.workspace, - "config_path": options.config_path, - "command": command, - "log_path": str(self.paths.log_path), - } - ) + state: dict[str, object] = { + "pid": pid, + "started_at": _utc_now(), + "platform": self.platform_name, + "port": options.port, + "workspace": options.workspace, + "config_path": options.config_path, + "command": command, + "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()) 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 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( self, recorded: object, @@ -436,7 +445,9 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]): ) -> Literal["match", "mismatch", "unknown"]: if not state: return "mismatch" - recorded = state.get("identity") + recorded = state.get("stable_identity") + if recorded is None: + recorded = state.get("identity") return self.process_identity_match(recorded, pid) def _read_state(self) -> dict[str, Any] | None: @@ -549,13 +560,14 @@ def _darwin_identity_match( """Compare the new numeric identity with a pre-upgrade ``ps`` identity.""" if not isinstance(recorded, str) or not isinstance(current, str): return "mismatch" - current_match = re.fullmatch(r"darwin:(\d+):(\d+):(\d+)", current) - if current_match is None: + current_identity = _parse_darwin_identity(current) + if current_identity is None: return "mismatch" + current_group, current_seconds, _ = current_identity recorded_group, separator, recorded_started_at = recorded.partition(":") if not separator or not recorded_group.isdigit(): return "mismatch" - if int(recorded_group) != int(current_match.group(1)): + if int(recorded_group) != current_group: return "mismatch" legacy_epoch = _legacy_darwin_started_at(recorded_started_at) 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 # the owning client exits instead of killing a live gateway. 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: @@ -603,7 +643,7 @@ def _legacy_darwin_started_at(value: str) -> int | None: month, day, hour, minute, second, year = map(int, numeric.groups()) try: return int(time.mktime((year, month, day, hour, minute, second, -1, -1, -1))) - except (OverflowError, ValueError): + except (OSError, OverflowError, ValueError): return None diff --git a/tests/gateway/test_runtime.py b/tests/gateway/test_runtime.py index 484ceb8e3..01059154b 100644 --- a/tests/gateway/test_runtime.py +++ b/tests/gateway/test_runtime.py @@ -660,6 +660,28 @@ def test_lease_snapshot_keeps_a_legacy_localized_darwin_client(tmp_path, monkeyp 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( tmp_path, 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("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))) monkeypatch.setattr( "nanobot.process_runtime._darwin_process_birth", 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 +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") def test_darwin_live_process_identity_is_stable(tmp_path): runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Darwin")