ringo-task-queue 0.1.0.dev5__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.
- ringo_task_queue/__init__.py +75 -71
- ringo_task_queue/_pb.py +154 -154
- ringo_task_queue/_proto/ringo/v1/queue_pb2.py +93 -85
- ringo_task_queue/_proto/ringo/v1/queue_pb2_grpc.py +93 -5
- ringo_task_queue/_version.py +17 -17
- ringo_task_queue/bin/manifest.json +7 -7
- ringo_task_queue/bin/ringo-task-queue-windows-amd64.exe +0 -0
- ringo_task_queue/binary.py +253 -253
- ringo_task_queue/client.py +1198 -1071
- ringo_task_queue/concurrency.py +480 -480
- ringo_task_queue/daemon.py +507 -507
- ringo_task_queue/errors.py +159 -159
- ringo_task_queue/models.py +449 -364
- ringo_task_queue/shared.py +307 -305
- ringo_task_queue/worker.py +1531 -1297
- {ringo_task_queue-0.1.0.dev5.dist-info → ringo_task_queue-0.1.0.dev7.dist-info}/METADATA +24 -6
- ringo_task_queue-0.1.0.dev7.dist-info/RECORD +20 -0
- {ringo_task_queue-0.1.0.dev5.dist-info → ringo_task_queue-0.1.0.dev7.dist-info}/licenses/LICENSE +21 -21
- ringo_task_queue-0.1.0.dev5.dist-info/RECORD +0 -20
- {ringo_task_queue-0.1.0.dev5.dist-info → ringo_task_queue-0.1.0.dev7.dist-info}/WHEEL +0 -0
ringo_task_queue/shared.py
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
47
|
-
kernel32.
|
|
48
|
-
kernel32.
|
|
49
|
-
kernel32.
|
|
50
|
-
kernel32.
|
|
51
|
-
kernel32.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
except
|
|
69
|
-
return
|
|
70
|
-
except
|
|
71
|
-
return
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if
|
|
87
|
-
return None
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
os.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if value
|
|
109
|
-
return None
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if not isinstance(
|
|
122
|
-
return None
|
|
123
|
-
if
|
|
124
|
-
return None
|
|
125
|
-
if value["
|
|
126
|
-
return None
|
|
127
|
-
if value["
|
|
128
|
-
return None
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
self.
|
|
150
|
-
self.
|
|
151
|
-
self.
|
|
152
|
-
self.
|
|
153
|
-
self.
|
|
154
|
-
self.
|
|
155
|
-
self.
|
|
156
|
-
self.
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return
|
|
172
|
-
|
|
173
|
-
return
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
self.
|
|
179
|
-
self.
|
|
180
|
-
self.
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
self.
|
|
188
|
-
self.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
raise RingoError(
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
or info.get("
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
return
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
#
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
)
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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"]
|