ringo-task-queue 0.1.0.dev6__py3-none-win_amd64.whl → 0.1.0.dev7__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,305 +1,307 @@
1
- """Discovery and lifecycle helpers for opt-in shared local SQLite daemons."""
2
-
3
- from __future__ import annotations
4
-
5
- import asyncio
6
- import json
7
- import os
8
- import re
9
- import stat
10
- import subprocess
11
- import time
12
- from pathlib import Path
13
- from typing import cast
14
-
15
- from ._version import (
16
- PROTOCOL_MAJOR,
17
- PROTOCOL_MINOR,
18
- SCHEMA_VERSION,
19
- daemon_version_for_sdk,
20
- )
21
- from .daemon import _probe_binary_version, _resolve_daemon
22
- from .errors import IncompatibleVersionError, RingoError, UnavailableError
23
-
24
- _MAX_STATE_BYTES = 64 * 1024
25
- _STATE_NAME = "shared-daemon.json"
26
- _HEX_GENERATION = re.compile(r"^[0-9a-f]{32}$")
27
- _HEX_TOKEN = re.compile(r"^[0-9a-f]{64}$")
28
- _ENDPOINT = re.compile(r"^127\.0\.0\.1:(\d+)$")
29
-
30
-
31
- def _pid_alive(pid: int) -> bool:
32
- """Probe existence without sending a console control event on Windows."""
33
- if pid <= 0:
34
- return False
35
- if os.name == "nt" and pid > 0xFFFFFFFF:
36
- return False
37
- if os.name == "nt":
38
- import ctypes
39
- from ctypes import wintypes
40
-
41
- synchronize = 0x00100000
42
- query_limited_information = 0x1000
43
- still_active = 259
44
- access_denied = 5
45
- kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
46
- kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
47
- kernel32.OpenProcess.restype = wintypes.HANDLE
48
- kernel32.GetExitCodeProcess.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
49
- kernel32.GetExitCodeProcess.restype = wintypes.BOOL
50
- kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
51
- kernel32.CloseHandle.restype = wintypes.BOOL
52
- handle = kernel32.OpenProcess(
53
- synchronize | query_limited_information, False, pid
54
- )
55
- if not handle:
56
- return ctypes.get_last_error() == access_denied
57
- try:
58
- exit_code = wintypes.DWORD()
59
- if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
60
- return False
61
- return exit_code.value == still_active
62
- finally:
63
- kernel32.CloseHandle(handle)
64
- try:
65
- os.kill(pid, 0) # POSIX only: Windows zero is CTRL_C_EVENT.
66
- except ProcessLookupError:
67
- return False
68
- except PermissionError:
69
- return True
70
- except OSError:
71
- return False
72
- return True
73
-
74
-
75
- def _read_private(path: Path) -> bytes | None:
76
- """Read a bounded regular file without following symlinks where supported."""
77
- try:
78
- if path.is_symlink():
79
- return None
80
- flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
81
- fd = os.open(path, flags)
82
- try:
83
- info = os.fstat(fd)
84
- if not stat.S_ISREG(info.st_mode) or info.st_size > _MAX_STATE_BYTES:
85
- return None
86
- if os.name != "nt" and stat.S_IMODE(info.st_mode) != 0o600:
87
- return None
88
- data = os.read(fd, _MAX_STATE_BYTES + 1)
89
- finally:
90
- os.close(fd)
91
- if len(data) > _MAX_STATE_BYTES:
92
- return None
93
- return data
94
- except (OSError, ValueError):
95
- return None
96
-
97
-
98
- def validate_shared_state(value: object) -> dict[str, object] | None:
99
- """Validate the daemon's published state before trusting any endpoint/token."""
100
- if not isinstance(value, dict):
101
- return None
102
- required = {
103
- "format", "endpoint", "pid", "generation", "token", "version",
104
- "protocolMajor", "protocolMinor", "schemaVersion",
105
- }
106
- if set(value) != required:
107
- return None
108
- if value["format"] != 1:
109
- return None
110
- endpoint = value["endpoint"]
111
- match = _ENDPOINT.fullmatch(endpoint) if isinstance(endpoint, str) else None
112
- if match is None or not 1 <= int(match.group(1)) <= 65535:
113
- return None
114
- pid = value["pid"]
115
- if type(pid) is not int or pid <= 0:
116
- return None
117
- generation = value["generation"]
118
- token = value["token"]
119
- if not isinstance(generation, str) or _HEX_GENERATION.fullmatch(generation) is None:
120
- return None
121
- if not isinstance(token, str) or _HEX_TOKEN.fullmatch(token) is None:
122
- return None
123
- if value["version"] != daemon_version_for_sdk():
124
- return None
125
- if value["protocolMajor"] != PROTOCOL_MAJOR or value["protocolMinor"] != PROTOCOL_MINOR:
126
- return None
127
- if value["schemaVersion"] != SCHEMA_VERSION:
128
- return None
129
- return value
130
-
131
-
132
- class SharedLocalDaemon:
133
- """Discover or start a shared-local daemon without owning its process.
134
-
135
- The Go daemon owns ``daemon.lock`` and publishes ``shared-daemon.json``.
136
- This object deliberately never closes stdin, terminates, or kills the child:
137
- a shared daemon must outlive any one SDK client.
138
- """
139
-
140
- def __init__(
141
- self,
142
- data_dir: str | os.PathLike[str],
143
- *,
144
- daemon_path: str | os.PathLike[str] | None = None,
145
- startup_timeout: float = 15.0,
146
- ) -> None:
147
- self.data_dir = Path(data_dir).absolute()
148
- self._daemon_path = daemon_path
149
- self._startup_timeout = startup_timeout
150
- self._launcher: asyncio.subprocess.Process | None = None
151
- self.endpoint: str | None = None
152
- self.token: str | None = None
153
- self.generation: str | None = None
154
- self.pid: int | None = None
155
- self.daemon_version: str | None = None
156
- self._rejected_generation: str | None = None
157
-
158
- @property
159
- def state_path(self) -> Path:
160
- return self.data_dir / _STATE_NAME
161
-
162
- @property
163
- def is_dead(self) -> bool:
164
- return self.pid is None or not _pid_alive(self.pid)
165
-
166
- def _read_state(self) -> dict[str, object] | None:
167
- raw = _read_private(self.state_path)
168
- if raw is None:
169
- return None
170
- try:
171
- return validate_shared_state(json.loads(raw.decode("utf-8")))
172
- except (UnicodeDecodeError, ValueError):
173
- return None
174
-
175
- def _set_state(self, state: dict[str, object]) -> str:
176
- self.endpoint = str(state["endpoint"])
177
- self.token = str(state["token"])
178
- self.generation = str(state["generation"])
179
- self.pid = cast(int, state["pid"])
180
- self.daemon_version = str(state["version"])
181
- return self.endpoint
182
-
183
- def invalidate_current(self) -> None:
184
- """Never re-adopt a generation whose authenticated connection failed."""
185
- self._rejected_generation = self.generation
186
- self.endpoint = None
187
- self.pid = None
188
- self.token = None
189
-
190
- async def _prepare_directory(self) -> None:
191
- """Have the Go binary enforce local file permissions before reading a token."""
192
- binary, _ = _resolve_daemon(self._daemon_path)
193
- try:
194
- process = await asyncio.create_subprocess_exec(
195
- str(binary), "shared-local", "--data-dir", str(self.data_dir),
196
- "--prepare-dir", stdin=subprocess.DEVNULL,
197
- stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
198
- )
199
- try:
200
- _, stderr = await asyncio.wait_for(
201
- process.communicate(), timeout=max(5.0, self._startup_timeout)
202
- )
203
- except asyncio.TimeoutError:
204
- process.kill() # targets this child only; never signals a console
205
- await process.wait()
206
- raise UnavailableError("shared-local directory preparation timed out") from None
207
- except OSError as exc:
208
- raise RingoError(f"could not verify shared-local directory: {exc}") from exc
209
- if process.returncode != 0:
210
- raise RingoError(
211
- "shared-local directory permissions or discovery are unsafe: "
212
- + stderr[:2048].decode("utf-8", errors="replace")
213
- )
214
-
215
- async def _launch(self) -> None:
216
- binary, metadata = _resolve_daemon(self._daemon_path)
217
- info = await _probe_binary_version(
218
- binary, self._startup_timeout, allow_legacy=metadata is None
219
- )
220
- # Match LocalDaemon's release checks without relying on readiness stdout.
221
- if info.get("name") != "ringo-task-queue":
222
- raise IncompatibleVersionError(f"unexpected daemon name from {binary}")
223
- if (
224
- info.get("version") != daemon_version_for_sdk()
225
- or info.get("protocolMajor") != PROTOCOL_MAJOR
226
- or info.get("protocolMinor") != PROTOCOL_MINOR
227
- or info.get("schemaVersion") != SCHEMA_VERSION
228
- ):
229
- raise IncompatibleVersionError(
230
- f"daemon at {binary} is incompatible with protocol "
231
- f"{PROTOCOL_MAJOR}.{PROTOCOL_MINOR}, schema {SCHEMA_VERSION}"
232
- )
233
- command = [
234
- str(binary), "shared-local", "--data-dir", str(self.data_dir),
235
- "--listen", "127.0.0.1:0", "--storage", "sqlite",
236
- ]
237
- try:
238
- # DEVNULL is intentional: no lifecycle stdin and no inherited pipe
239
- # can keep either process alive or block after this SDK exits.
240
- detach = (
241
- {"creationflags": subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP}
242
- if os.name == "nt" else {"start_new_session": True}
243
- )
244
- self._launcher = await asyncio.create_subprocess_exec(
245
- *command,
246
- stdin=asyncio.subprocess.DEVNULL,
247
- stdout=asyncio.subprocess.DEVNULL,
248
- stderr=asyncio.subprocess.DEVNULL,
249
- **detach,
250
- )
251
- except OSError as exc:
252
- raise RingoError(f"failed to spawn shared daemon {binary}: {exc}") from exc
253
-
254
- async def _endpoint_reachable(self, endpoint: str) -> bool:
255
- try:
256
- _host, raw_port = endpoint.rsplit(":", 1)
257
- _reader, writer = await asyncio.wait_for(
258
- asyncio.open_connection(_host, int(raw_port)), timeout=0.2
259
- )
260
- writer.close()
261
- await writer.wait_closed()
262
- return True
263
- except (OSError, ValueError, asyncio.TimeoutError):
264
- return False
265
-
266
- async def start(self) -> str:
267
- """Discover a private endpoint, or boundedly compete to start one writer."""
268
- await self._prepare_directory()
269
- deadline = time.monotonic() + self._startup_timeout
270
- attempts = 0
271
- next_attempt = 0.0
272
- while time.monotonic() < deadline:
273
- state = self._read_state()
274
- if state is not None and state["generation"] != self._rejected_generation:
275
- # Neither PID nor TCP reachability establishes identity; the
276
- # caller must still authenticate AttachLocal and check its ack.
277
- if _pid_alive(cast(int, state["pid"])):
278
- endpoint = str(state["endpoint"])
279
- if await self._endpoint_reachable(endpoint):
280
- return self._set_state(state)
281
- # Conflict losers wait for the winner instead of spawning ~20
282
- # doomed daemon processes per second. A persistent startup fault
283
- # has a finite three-attempt budget per call to start().
284
- if attempts < 3 and time.monotonic() >= next_attempt and (
285
- self._launcher is None or self._launcher.returncode is not None
286
- ):
287
- attempts += 1
288
- await self._launch()
289
- next_attempt = time.monotonic() + (0.3 * (2 ** (attempts - 1)))
290
- await asyncio.sleep(0.1)
291
- # A live/hung lock owner must never be killed or bypassed.
292
- raise UnavailableError(
293
- f"shared daemon did not publish a usable state within {self._startup_timeout:.1f}s"
294
- )
295
-
296
- async def refresh(self) -> str:
297
- """Refresh state after generation rotation, or start a replacement."""
298
- return await self.start()
299
-
300
- async def stop(self) -> None:
301
- """Shared clients never stop the daemon."""
302
- return None
303
-
304
-
305
- __all__ = ["SharedLocalDaemon", "validate_shared_state"]
1
+ """Discovery and lifecycle helpers for opt-in shared local SQLite daemons."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import re
9
+ import stat
10
+ import subprocess
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any, cast
14
+
15
+ from ._version import (
16
+ PROTOCOL_MAJOR,
17
+ PROTOCOL_MINOR,
18
+ SCHEMA_VERSION,
19
+ daemon_version_for_sdk,
20
+ )
21
+ from .daemon import _probe_binary_version, _resolve_daemon
22
+ from .errors import IncompatibleVersionError, RingoError, UnavailableError
23
+
24
+ _MAX_STATE_BYTES = 64 * 1024
25
+ _STATE_NAME = "shared-daemon.json"
26
+ _HEX_GENERATION = re.compile(r"^[0-9a-f]{32}$")
27
+ _HEX_TOKEN = re.compile(r"^[0-9a-f]{64}$")
28
+ _ENDPOINT = re.compile(r"^127\.0\.0\.1:(\d+)$")
29
+
30
+
31
+ def _pid_alive(pid: int) -> bool:
32
+ """Probe existence without sending a console control event on Windows."""
33
+ if pid <= 0:
34
+ return False
35
+ if os.name == "nt" and pid > 0xFFFFFFFF:
36
+ return False
37
+ if os.name == "nt":
38
+ import ctypes
39
+ from ctypes import wintypes
40
+
41
+ synchronize = 0x00100000
42
+ query_limited_information = 0x1000
43
+ still_active = 259
44
+ access_denied = 5
45
+ # Typeshed omits Win32-only attributes on Linux, but this branch only
46
+ # executes on Windows. Keep the runtime API calls unchanged.
47
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) # type: ignore[attr-defined]
48
+ kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
49
+ kernel32.OpenProcess.restype = wintypes.HANDLE
50
+ kernel32.GetExitCodeProcess.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
51
+ kernel32.GetExitCodeProcess.restype = wintypes.BOOL
52
+ kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
53
+ kernel32.CloseHandle.restype = wintypes.BOOL
54
+ handle = kernel32.OpenProcess(
55
+ synchronize | query_limited_information, False, pid
56
+ )
57
+ if not handle:
58
+ return ctypes.get_last_error() == access_denied # type: ignore[attr-defined]
59
+ try:
60
+ exit_code = wintypes.DWORD()
61
+ if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
62
+ return False
63
+ return exit_code.value == still_active
64
+ finally:
65
+ kernel32.CloseHandle(handle)
66
+ try:
67
+ os.kill(pid, 0) # POSIX only: Windows zero is CTRL_C_EVENT.
68
+ except ProcessLookupError:
69
+ return False
70
+ except PermissionError:
71
+ return True
72
+ except OSError:
73
+ return False
74
+ return True
75
+
76
+
77
+ def _read_private(path: Path) -> bytes | None:
78
+ """Read a bounded regular file without following symlinks where supported."""
79
+ try:
80
+ if path.is_symlink():
81
+ return None
82
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
83
+ fd = os.open(path, flags)
84
+ try:
85
+ info = os.fstat(fd)
86
+ if not stat.S_ISREG(info.st_mode) or info.st_size > _MAX_STATE_BYTES:
87
+ return None
88
+ if os.name != "nt" and stat.S_IMODE(info.st_mode) != 0o600:
89
+ return None
90
+ data = os.read(fd, _MAX_STATE_BYTES + 1)
91
+ finally:
92
+ os.close(fd)
93
+ if len(data) > _MAX_STATE_BYTES:
94
+ return None
95
+ return data
96
+ except (OSError, ValueError):
97
+ return None
98
+
99
+
100
+ def validate_shared_state(value: object) -> dict[str, object] | None:
101
+ """Validate the daemon's published state before trusting any endpoint/token."""
102
+ if not isinstance(value, dict):
103
+ return None
104
+ required = {
105
+ "format", "endpoint", "pid", "generation", "token", "version",
106
+ "protocolMajor", "protocolMinor", "schemaVersion",
107
+ }
108
+ if set(value) != required:
109
+ return None
110
+ if value["format"] != 1:
111
+ return None
112
+ endpoint = value["endpoint"]
113
+ match = _ENDPOINT.fullmatch(endpoint) if isinstance(endpoint, str) else None
114
+ if match is None or not 1 <= int(match.group(1)) <= 65535:
115
+ return None
116
+ pid = value["pid"]
117
+ if type(pid) is not int or pid <= 0:
118
+ return None
119
+ generation = value["generation"]
120
+ token = value["token"]
121
+ if not isinstance(generation, str) or _HEX_GENERATION.fullmatch(generation) is None:
122
+ return None
123
+ if not isinstance(token, str) or _HEX_TOKEN.fullmatch(token) is None:
124
+ return None
125
+ if value["version"] != daemon_version_for_sdk():
126
+ return None
127
+ if value["protocolMajor"] != PROTOCOL_MAJOR or value["protocolMinor"] != PROTOCOL_MINOR:
128
+ return None
129
+ if value["schemaVersion"] != SCHEMA_VERSION:
130
+ return None
131
+ return value
132
+
133
+
134
+ class SharedLocalDaemon:
135
+ """Discover or start a shared-local daemon without owning its process.
136
+
137
+ The Go daemon owns ``daemon.lock`` and publishes ``shared-daemon.json``.
138
+ This object deliberately never closes stdin, terminates, or kills the child:
139
+ a shared daemon must outlive any one SDK client.
140
+ """
141
+
142
+ def __init__(
143
+ self,
144
+ data_dir: str | os.PathLike[str],
145
+ *,
146
+ daemon_path: str | os.PathLike[str] | None = None,
147
+ startup_timeout: float = 15.0,
148
+ ) -> None:
149
+ self.data_dir = Path(data_dir).absolute()
150
+ self._daemon_path = daemon_path
151
+ self._startup_timeout = startup_timeout
152
+ self._launcher: asyncio.subprocess.Process | None = None
153
+ self.endpoint: str | None = None
154
+ self.token: str | None = None
155
+ self.generation: str | None = None
156
+ self.pid: int | None = None
157
+ self.daemon_version: str | None = None
158
+ self._rejected_generation: str | None = None
159
+
160
+ @property
161
+ def state_path(self) -> Path:
162
+ return self.data_dir / _STATE_NAME
163
+
164
+ @property
165
+ def is_dead(self) -> bool:
166
+ return self.pid is None or not _pid_alive(self.pid)
167
+
168
+ def _read_state(self) -> dict[str, object] | None:
169
+ raw = _read_private(self.state_path)
170
+ if raw is None:
171
+ return None
172
+ try:
173
+ return validate_shared_state(json.loads(raw.decode("utf-8")))
174
+ except (UnicodeDecodeError, ValueError):
175
+ return None
176
+
177
+ def _set_state(self, state: dict[str, object]) -> str:
178
+ self.endpoint = str(state["endpoint"])
179
+ self.token = str(state["token"])
180
+ self.generation = str(state["generation"])
181
+ self.pid = cast(int, state["pid"])
182
+ self.daemon_version = str(state["version"])
183
+ return self.endpoint
184
+
185
+ def invalidate_current(self) -> None:
186
+ """Never re-adopt a generation whose authenticated connection failed."""
187
+ self._rejected_generation = self.generation
188
+ self.endpoint = None
189
+ self.pid = None
190
+ self.token = None
191
+
192
+ async def _prepare_directory(self) -> None:
193
+ """Have the Go binary enforce local file permissions before reading a token."""
194
+ binary, _ = _resolve_daemon(self._daemon_path)
195
+ try:
196
+ process = await asyncio.create_subprocess_exec(
197
+ str(binary), "shared-local", "--data-dir", str(self.data_dir),
198
+ "--prepare-dir", stdin=subprocess.DEVNULL,
199
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
200
+ )
201
+ try:
202
+ _, stderr = await asyncio.wait_for(
203
+ process.communicate(), timeout=max(5.0, self._startup_timeout)
204
+ )
205
+ except asyncio.TimeoutError:
206
+ process.kill() # targets this child only; never signals a console
207
+ await process.wait()
208
+ raise UnavailableError("shared-local directory preparation timed out") from None
209
+ except OSError as exc:
210
+ raise RingoError(f"could not verify shared-local directory: {exc}") from exc
211
+ if process.returncode != 0:
212
+ raise RingoError(
213
+ "shared-local directory permissions or discovery are unsafe: "
214
+ + stderr[:2048].decode("utf-8", errors="replace")
215
+ )
216
+
217
+ async def _launch(self) -> None:
218
+ binary, metadata = _resolve_daemon(self._daemon_path)
219
+ info = await _probe_binary_version(
220
+ binary, self._startup_timeout, allow_legacy=metadata is None
221
+ )
222
+ # Match LocalDaemon's release checks without relying on readiness stdout.
223
+ if info.get("name") != "ringo-task-queue":
224
+ raise IncompatibleVersionError(f"unexpected daemon name from {binary}")
225
+ if (
226
+ info.get("version") != daemon_version_for_sdk()
227
+ or info.get("protocolMajor") != PROTOCOL_MAJOR
228
+ or info.get("protocolMinor") != PROTOCOL_MINOR
229
+ or info.get("schemaVersion") != SCHEMA_VERSION
230
+ ):
231
+ raise IncompatibleVersionError(
232
+ f"daemon at {binary} is incompatible with protocol "
233
+ f"{PROTOCOL_MAJOR}.{PROTOCOL_MINOR}, schema {SCHEMA_VERSION}"
234
+ )
235
+ command = [
236
+ str(binary), "shared-local", "--data-dir", str(self.data_dir),
237
+ "--listen", "127.0.0.1:0", "--storage", "sqlite",
238
+ ]
239
+ try:
240
+ # DEVNULL is intentional: no lifecycle stdin and no inherited pipe
241
+ # can keep either process alive or block after this SDK exits.
242
+ detach: dict[str, Any] = (
243
+ {"creationflags": subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP} # type: ignore[attr-defined]
244
+ if os.name == "nt" else {"start_new_session": True}
245
+ )
246
+ self._launcher = await asyncio.create_subprocess_exec(
247
+ *command,
248
+ stdin=asyncio.subprocess.DEVNULL,
249
+ stdout=asyncio.subprocess.DEVNULL,
250
+ stderr=asyncio.subprocess.DEVNULL,
251
+ **detach,
252
+ )
253
+ except OSError as exc:
254
+ raise RingoError(f"failed to spawn shared daemon {binary}: {exc}") from exc
255
+
256
+ async def _endpoint_reachable(self, endpoint: str) -> bool:
257
+ try:
258
+ _host, raw_port = endpoint.rsplit(":", 1)
259
+ _reader, writer = await asyncio.wait_for(
260
+ asyncio.open_connection(_host, int(raw_port)), timeout=0.2
261
+ )
262
+ writer.close()
263
+ await writer.wait_closed()
264
+ return True
265
+ except (OSError, ValueError, asyncio.TimeoutError):
266
+ return False
267
+
268
+ async def start(self) -> str:
269
+ """Discover a private endpoint, or boundedly compete to start one writer."""
270
+ await self._prepare_directory()
271
+ deadline = time.monotonic() + self._startup_timeout
272
+ attempts = 0
273
+ next_attempt = 0.0
274
+ while time.monotonic() < deadline:
275
+ state = self._read_state()
276
+ if state is not None and state["generation"] != self._rejected_generation:
277
+ # Neither PID nor TCP reachability establishes identity; the
278
+ # caller must still authenticate AttachLocal and check its ack.
279
+ if _pid_alive(cast(int, state["pid"])):
280
+ endpoint = str(state["endpoint"])
281
+ if await self._endpoint_reachable(endpoint):
282
+ return self._set_state(state)
283
+ # Conflict losers wait for the winner instead of spawning ~20
284
+ # doomed daemon processes per second. A persistent startup fault
285
+ # has a finite three-attempt budget per call to start().
286
+ if attempts < 3 and time.monotonic() >= next_attempt and (
287
+ self._launcher is None or self._launcher.returncode is not None
288
+ ):
289
+ attempts += 1
290
+ await self._launch()
291
+ next_attempt = time.monotonic() + (0.3 * (2 ** (attempts - 1)))
292
+ await asyncio.sleep(0.1)
293
+ # A live/hung lock owner must never be killed or bypassed.
294
+ raise UnavailableError(
295
+ f"shared daemon did not publish a usable state within {self._startup_timeout:.1f}s"
296
+ )
297
+
298
+ async def refresh(self) -> str:
299
+ """Refresh state after generation rotation, or start a replacement."""
300
+ return await self.start()
301
+
302
+ async def stop(self) -> None:
303
+ """Shared clients never stop the daemon."""
304
+ return None
305
+
306
+
307
+ __all__ = ["SharedLocalDaemon", "validate_shared_state"]