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