ringo-task-queue 0.1.0.dev7__py3-none-win_amd64.whl → 0.1.0.dev9__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.
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import os
7
7
  import secrets
8
+ import sys
8
9
  from datetime import timedelta
9
10
  from itertools import islice
10
11
  from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, Iterable, Literal
@@ -14,6 +15,7 @@ from grpc_health.v1 import health_pb2, health_pb2_grpc
14
15
 
15
16
  from . import _pb
16
17
  from ._proto.ringo.v1 import queue_pb2, queue_pb2_grpc
18
+ from ._supervisor_policy import TaskTimeouts
17
19
  from ._version import PROTOCOL_MAJOR, PROTOCOL_MINOR, SDK_VERSION
18
20
  from .concurrency import AdaptiveConcurrency, ResourceConcurrency
19
21
  from .errors import (
@@ -172,9 +174,7 @@ class _Connection:
172
174
  ):
173
175
  if key not in configured:
174
176
  self._channel_options.append((key, value))
175
- _check_compression_channel_option_conflict(
176
- self._channel_options, compression
177
- )
177
+ _check_compression_channel_option_conflict(self._channel_options, compression)
178
178
  # Channel-level default compression; survives every reconnect because
179
179
  # _open_channel() re-applies it to each new aio channel. ``None`` means
180
180
  # unspecified, so legacy raw channel options decide the encoding.
@@ -192,6 +192,8 @@ class _Connection:
192
192
  # -- lifecycle ---------------------------------------------------------
193
193
 
194
194
  def _open_channel(self) -> None:
195
+ if self._closed:
196
+ raise RingoError("connection is closed")
195
197
  if self._tls:
196
198
  credentials = grpc.ssl_channel_credentials()
197
199
  self._channel = grpc.aio.secure_channel(
@@ -226,10 +228,17 @@ class _Connection:
226
228
  call = stub.AttachLocal(request, metadata=self._metadata())
227
229
  response = await call.read()
228
230
  if response is grpc.aio.EOF or response is None:
229
- raise UnavailableError("shared daemon closed AttachLocal during handshake")
231
+ raise UnavailableError(
232
+ "shared daemon closed AttachLocal during handshake"
233
+ )
234
+ if self._closed:
235
+ call.cancel()
236
+ raise RingoError("connection is closed")
230
237
  if response.generation != self._local_generation:
231
238
  call.cancel()
232
- raise UnavailableError("shared daemon generation changed during AttachLocal")
239
+ raise UnavailableError(
240
+ "shared daemon generation changed during AttachLocal"
241
+ )
233
242
  except grpc.aio.AioRpcError as exc:
234
243
  raise map_rpc_error(exc) from exc
235
244
  self._attach_call = call
@@ -285,6 +294,8 @@ class _Connection:
285
294
  deadline = asyncio.get_running_loop().time() + timeout
286
295
  last: Exception | None = None
287
296
  while True:
297
+ if self._closed:
298
+ raise RingoError("connection is closed")
288
299
  try:
289
300
  response = await health_stub.Check(
290
301
  health_pb2.HealthCheckRequest(),
@@ -302,6 +313,8 @@ class _Connection:
302
313
 
303
314
  async def reset(self, endpoint: str) -> None:
304
315
  """Point the connection at a new endpoint (local daemon restart)."""
316
+ if self._closed:
317
+ raise RingoError("connection is closed")
305
318
  await self._close_attach()
306
319
  self._endpoint, self._tls = _parse_endpoint(endpoint)
307
320
  channel, self._channel = self._channel, None
@@ -325,6 +338,8 @@ class _Connection:
325
338
  )
326
339
  except grpc.aio.AioRpcError as exc:
327
340
  raise map_rpc_error(exc) from exc
341
+ if self._closed:
342
+ raise RingoError("connection is closed")
328
343
  if response.protocol_major != PROTOCOL_MAJOR:
329
344
  raise IncompatibleVersionError(
330
345
  f"server speaks protocol major {response.protocol_major},"
@@ -344,7 +359,7 @@ class _Connection:
344
359
  # -- helpers -----------------------------------------------------------
345
360
 
346
361
  def _require_stub(self) -> queue_pb2_grpc.QueueServiceStub:
347
- if self._stub is None:
362
+ if self._closed or self._stub is None:
348
363
  raise RingoError("connection is closed")
349
364
  return self._stub
350
365
 
@@ -379,7 +394,6 @@ class _Connection:
379
394
  *,
380
395
  timeout: float = DEFAULT_RPC_TIMEOUT,
381
396
  retry_safe: bool = False,
382
- context_attr: str | None = "context",
383
397
  ) -> Any:
384
398
  """Issue a single unary RPC.
385
399
 
@@ -391,9 +405,7 @@ class _Connection:
391
405
  pass ``retry_safe=True`` and may be re-sent, each time with a freshly
392
406
  negotiated ``RequestContext``.
393
407
  """
394
- rpc, sent_token = await self._begin_unary(
395
- name, request, timeout=timeout, context_attr=context_attr
396
- )
408
+ rpc, sent_token = await self._begin_unary(name, request, timeout=timeout)
397
409
  cause: BaseException | None = None
398
410
  err: RingoError | None = None
399
411
  try:
@@ -423,9 +435,7 @@ class _Connection:
423
435
  async with self._lock:
424
436
  rotated = self._session_token != sent_token
425
437
  if rotated:
426
- rpc, _ = await self._begin_unary(
427
- name, request, timeout=timeout, context_attr=context_attr
428
- )
438
+ rpc, _ = await self._begin_unary(name, request, timeout=timeout)
429
439
  try:
430
440
  return await rpc
431
441
  except grpc.aio.AioRpcError as retry_exc:
@@ -439,9 +449,7 @@ class _Connection:
439
449
  await self._recover_best_effort()
440
450
  if not retry_safe:
441
451
  raise err from cause
442
- rpc, _ = await self._begin_unary(
443
- name, request, timeout=timeout, context_attr=context_attr
444
- )
452
+ rpc, _ = await self._begin_unary(name, request, timeout=timeout)
445
453
  try:
446
454
  return await rpc
447
455
  except grpc.aio.AioRpcError as retry_exc:
@@ -453,7 +461,6 @@ class _Connection:
453
461
  request: Any,
454
462
  *,
455
463
  timeout: float,
456
- context_attr: str | None,
457
464
  ) -> tuple[Any, str]:
458
465
  """Snapshot a stub and fresh session context outside reconnect races."""
459
466
  async with self._lock:
@@ -466,8 +473,12 @@ class _Connection:
466
473
  "attempt history requires protocol minor >= 5 (GetTaskAttempts);"
467
474
  f" negotiated minor is {self._session_minor}"
468
475
  )
469
- if context_attr is not None:
470
- getattr(request, context_attr).CopyFrom(self.request_context())
476
+ if name == "ProbeQueue" and self._session_minor < 7:
477
+ raise IncompatibleVersionError(
478
+ "queue eligibility probe requires protocol minor >= 7 (ProbeQueue);"
479
+ f" negotiated minor is {self._session_minor}"
480
+ )
481
+ request.context.CopyFrom(self.request_context())
471
482
  stub = self._require_stub()
472
483
  token = self._session_token
473
484
  rpc = getattr(stub, name)(
@@ -545,7 +556,9 @@ class RingoClient:
545
556
  self._daemon = daemon
546
557
  self._shared_daemon = shared_daemon
547
558
  if daemon is not None and shared_daemon is not None:
548
- raise InvalidArgumentError("daemon and shared_daemon are mutually exclusive")
559
+ raise InvalidArgumentError(
560
+ "daemon and shared_daemon are mutually exclusive"
561
+ )
549
562
  # ``_conn`` is built in start(): a local client learns the daemon's
550
563
  # random loopback port only after the daemon announces readiness.
551
564
  self._conn: _Connection | None = None
@@ -553,6 +566,9 @@ class RingoClient:
553
566
  self._workers: set[Worker] = set()
554
567
  self._started = False
555
568
  self._closed = False
569
+ # Start, reconnect and final resource cleanup share one lifecycle lane.
570
+ # Reconnect enters from the connection's session lock; cleanup must not
571
+ # acquire that lock while holding this one.
556
572
  self._start_lock = asyncio.Lock()
557
573
  self._close_lock = asyncio.Lock()
558
574
  self._close_task: asyncio.Task[None] | None = None
@@ -560,10 +576,19 @@ class RingoClient:
560
576
 
561
577
  # -- lifecycle ---------------------------------------------------------
562
578
 
579
+ def _check_open(self) -> None:
580
+ if self._closed:
581
+ raise RingoError("client is closed")
582
+
563
583
  async def start(self) -> "RingoClient":
564
584
  async with self._start_lock:
565
- if self._closed:
566
- raise RingoError("client is closed")
585
+ self._check_open()
586
+ # Register before allocating resources: a runtime STOP fence rejects
587
+ # late starts, and an in-flight start is visible to unified drain.
588
+ module = sys.modules.get("ringo_task_queue._supervisor_runtime")
589
+ runtime = None if module is None else module.current_runtime()
590
+ if runtime is not None:
591
+ runtime.register_client(self)
567
592
  if not self._started:
568
593
  try:
569
594
  for attempt in range(2 if self._shared_daemon is not None else 1):
@@ -571,11 +596,20 @@ class RingoClient:
571
596
  self._endpoint = await self._daemon.start()
572
597
  elif self._shared_daemon is not None:
573
598
  self._endpoint = await self._shared_daemon.start()
599
+ self._check_open()
574
600
  conn = _Connection(
575
601
  self._endpoint or "",
576
602
  self._token,
577
- local_token=(self._shared_daemon.token if self._shared_daemon else None),
578
- local_generation=(self._shared_daemon.generation if self._shared_daemon else None),
603
+ local_token=(
604
+ self._shared_daemon.token
605
+ if self._shared_daemon
606
+ else None
607
+ ),
608
+ local_generation=(
609
+ self._shared_daemon.generation
610
+ if self._shared_daemon
611
+ else None
612
+ ),
579
613
  channel_options=self._channel_options,
580
614
  compression=self._compression,
581
615
  )
@@ -589,13 +623,19 @@ class RingoClient:
589
623
  )
590
624
  try:
591
625
  await conn.connect(
592
- check_health=self._daemon is not None or self._shared_daemon is not None
626
+ check_health=self._daemon is not None
627
+ or self._shared_daemon is not None
593
628
  )
629
+ self._check_open()
594
630
  break
595
631
  except Exception:
596
632
  await conn.close()
597
633
  self._conn = None
598
- if self._shared_daemon is None or attempt > 0:
634
+ if (
635
+ self._closed
636
+ or self._shared_daemon is None
637
+ or attempt > 0
638
+ ):
599
639
  raise
600
640
  # Stale PID/port, wrong token or generation is never
601
641
  # accepted as identity. Re-elect once, boundedly.
@@ -620,34 +660,47 @@ class RingoClient:
620
660
  return conn
621
661
 
622
662
  async def _reconnect(self) -> None:
623
- """Reconnect after a failed transport and negotiate a fresh session."""
624
- conn = self._require_conn()
625
- daemon = self._daemon
626
- shared = self._shared_daemon
627
- if daemon is not None and daemon.is_dead:
628
- if self._daemon_restart_count >= _MAX_DAEMON_RESTARTS:
629
- raise UnavailableError(
630
- f"local daemon restart budget ({_MAX_DAEMON_RESTARTS}) exhausted"
631
- )
632
- self._daemon_restart_count += 1
633
- delay = 0.3 * (2 ** (self._daemon_restart_count - 1))
634
- await asyncio.sleep(delay)
635
- endpoint = await daemon.start()
636
- await conn.reset(endpoint)
637
- elif shared is not None:
638
- try:
639
- endpoint = await shared.refresh()
663
+ """Rotate resources in the lifecycle lane; close wins at every boundary."""
664
+ async with self._start_lock:
665
+ self._check_open()
666
+ conn = self._require_conn()
667
+ daemon = self._daemon
668
+ shared = self._shared_daemon
669
+ if daemon is not None and daemon.is_dead:
670
+ if self._daemon_restart_count >= _MAX_DAEMON_RESTARTS:
671
+ raise UnavailableError(
672
+ f"local daemon restart budget ({_MAX_DAEMON_RESTARTS}) exhausted"
673
+ )
674
+ self._daemon_restart_count += 1
675
+ delay = 0.3 * (2 ** (self._daemon_restart_count - 1))
676
+ await asyncio.sleep(delay)
677
+ self._check_open()
678
+ endpoint = await daemon.start()
679
+ self._check_open()
640
680
  await conn.reset(endpoint)
641
- conn.set_shared_session(
642
- shared.token or "", shared.generation or "", self._shared_stream_lost
643
- )
644
- await conn.attach_local()
645
- await conn.negotiate()
646
- except Exception:
647
- shared.invalidate_current()
648
- raise
649
- return
650
- await conn.negotiate()
681
+ self._check_open()
682
+ elif shared is not None:
683
+ try:
684
+ endpoint = await shared.refresh()
685
+ self._check_open()
686
+ await conn.reset(endpoint)
687
+ self._check_open()
688
+ conn.set_shared_session(
689
+ shared.token or "",
690
+ shared.generation or "",
691
+ self._shared_stream_lost,
692
+ )
693
+ await conn.attach_local()
694
+ self._check_open()
695
+ await conn.negotiate()
696
+ self._check_open()
697
+ except Exception:
698
+ if not self._closed:
699
+ shared.invalidate_current()
700
+ raise
701
+ return
702
+ await conn.negotiate()
703
+ self._check_open()
651
704
 
652
705
  async def _shared_stream_lost(self) -> None:
653
706
  """Proactively reconnect an idle shared client after AttachLocal EOF."""
@@ -662,7 +715,9 @@ class RingoClient:
662
715
  if self._closed:
663
716
  return
664
717
  try:
665
- await asyncio.sleep(_BACKOFF_SECONDS[min(attempt, len(_BACKOFF_SECONDS) - 1)])
718
+ await asyncio.sleep(
719
+ _BACKOFF_SECONDS[min(attempt, len(_BACKOFF_SECONDS) - 1)]
720
+ )
666
721
  async with conn._lock:
667
722
  await self._reconnect()
668
723
  return
@@ -694,18 +749,30 @@ class RingoClient:
694
749
  except Exception as exc: # keep cleaning up the rest
695
750
  errors.append(exc)
696
751
  self._workers.clear()
697
- # 2. Close the gRPC channel.
698
- conn, self._conn = self._conn, None
752
+ # 2. Fence the current channel first: this interrupts a pending handshake
753
+ # without acquiring the session lock (reconnect holds it). Work remains
754
+ # usable through the worker grace period above.
755
+ conn = self._conn
699
756
  if conn is not None:
700
757
  try:
701
758
  await conn.close()
702
759
  except Exception as exc:
703
760
  errors.append(exc)
704
- # 3. Shut the local daemon down via the lifecycle pipe, then wait.
705
- try:
706
- await self._stop_daemon()
707
- except Exception as exc:
708
- errors.append(exc)
761
+ # 3. An in-progress daemon.start cannot be abandoned. Wait for its
762
+ # bounded completion, then reconcile resources under the lifecycle lane.
763
+ # Closed checks prevent it from publishing a new channel in the meantime.
764
+ async with self._start_lock:
765
+ conn, self._conn = self._conn, None
766
+ if conn is not None:
767
+ try:
768
+ await conn.close()
769
+ except Exception as exc:
770
+ errors.append(exc)
771
+ try:
772
+ await self._stop_daemon()
773
+ except Exception as exc:
774
+ errors.append(exc)
775
+ self._started = False
709
776
  if errors:
710
777
  raise ExceptionGroup("errors while closing Ringo client", errors)
711
778
 
@@ -857,7 +924,6 @@ class TaskQueue:
857
924
  if spec is None:
858
925
  spec = TaskSpec(**kwargs)
859
926
  request = queue_pb2.EnqueueRequest(queue=self.name, task=spec.to_proto())
860
- request.context.CopyFrom(self._client._require_conn().request_context())
861
927
  response = await self._client._require_conn().call("Enqueue", request)
862
928
  return EnqueueResult.from_proto(response.result)
863
929
 
@@ -882,7 +948,6 @@ class TaskQueue:
882
948
  request = queue_pb2.EnqueueManyRequest(
883
949
  queue=self.name, tasks=[s.to_proto() for s in chunk]
884
950
  )
885
- request.context.CopyFrom(self._client._require_conn().request_context())
886
951
  response = await self._client._require_conn().call("EnqueueMany", request)
887
952
  all_results.extend(EnqueueResult.from_proto(r) for r in response.results)
888
953
  total_created += response.created_count
@@ -900,7 +965,6 @@ class TaskQueue:
900
965
  if not task_id:
901
966
  raise InvalidArgumentError("task_id is required")
902
967
  request = queue_pb2.GetTaskRequest(queue=self.name, task_id=task_id)
903
- request.context.CopyFrom(self._client._require_conn().request_context())
904
968
  response = await self._client._require_conn().call(
905
969
  "GetTask", request, retry_safe=True
906
970
  )
@@ -910,7 +974,6 @@ class TaskQueue:
910
974
  if not unique_key:
911
975
  raise InvalidArgumentError("unique_key is required")
912
976
  request = queue_pb2.GetTaskRequest(queue=self.name, unique_key=unique_key)
913
- request.context.CopyFrom(self._client._require_conn().request_context())
914
977
  response = await self._client._require_conn().call(
915
978
  "GetTask", request, retry_safe=True
916
979
  )
@@ -1009,7 +1072,6 @@ class TaskQueue:
1009
1072
  request = queue_pb2.GetQueueStatsRequest(
1010
1073
  queue=self.name, task_types=selected or []
1011
1074
  )
1012
- request.context.CopyFrom(conn.request_context())
1013
1075
  response = await conn.call("GetQueueStats", request, retry_safe=True)
1014
1076
  return QueueStats.from_proto(response)
1015
1077
 
@@ -1023,7 +1085,6 @@ class TaskQueue:
1023
1085
  raise InvalidArgumentError("unique_key is required")
1024
1086
  request = queue_pb2.RetryTaskRequest(queue=self.name)
1025
1087
  request.unique_key = unique_key
1026
- request.context.CopyFrom(self._client._require_conn().request_context())
1027
1088
  response = await self._client._require_conn().call("RetryTask", request)
1028
1089
  return Task.from_proto(response.task)
1029
1090
 
@@ -1033,7 +1094,6 @@ class TaskQueue:
1033
1094
  raise InvalidArgumentError("task_id is required")
1034
1095
  request = queue_pb2.RetryTaskRequest(queue=self.name)
1035
1096
  request.task_id = task_id
1036
- request.context.CopyFrom(self._client._require_conn().request_context())
1037
1097
  response = await self._client._require_conn().call("RetryTask", request)
1038
1098
  return Task.from_proto(response.task)
1039
1099
 
@@ -1067,7 +1127,6 @@ class TaskQueue:
1067
1127
  request.wait_timeout.CopyFrom(_pb.td_to_dur(wait_timeout))
1068
1128
  if lease_duration is not None:
1069
1129
  request.lease_duration.CopyFrom(_pb.td_to_dur(lease_duration))
1070
- request.context.CopyFrom(self._client._require_conn().request_context())
1071
1130
  response = await self._client._require_conn().call("Claim", request)
1072
1131
  return [LeasedTask(self, t) for t in response.tasks]
1073
1132
 
@@ -1083,6 +1142,7 @@ class TaskQueue:
1083
1142
  lease_duration: timedelta = timedelta(seconds=60),
1084
1143
  grace_period: timedelta = timedelta(seconds=30),
1085
1144
  on_idle: Callable[[], Any] | None = None,
1145
+ task_timeouts: TaskTimeouts | None = None,
1086
1146
  ) -> "Worker":
1087
1147
  """Create a credit-controlled worker over the frozen Work stream.
1088
1148
 
@@ -1114,7 +1174,9 @@ class TaskQueue:
1114
1174
  raise InvalidArgumentError(
1115
1175
  "concurrency must be an integer, ResourceConcurrency, or AdaptiveConcurrency"
1116
1176
  )
1117
- if isinstance(concurrency, int) and (concurrency < 1 or concurrency > 2**31 - 1):
1177
+ if isinstance(concurrency, int) and (
1178
+ concurrency < 1 or concurrency > 2**31 - 1
1179
+ ):
1118
1180
  raise InvalidArgumentError(
1119
1181
  "initial fixed concurrency must be between 1 and 2**31-1"
1120
1182
  )
@@ -1124,6 +1186,8 @@ class TaskQueue:
1124
1186
  raise InvalidArgumentError("lease_duration must not be negative")
1125
1187
  if grace_period < timedelta(0):
1126
1188
  raise InvalidArgumentError("grace_period must not be negative")
1189
+ if task_timeouts is not None and not isinstance(task_timeouts, TaskTimeouts):
1190
+ raise InvalidArgumentError("task_timeouts must be TaskTimeouts or None")
1127
1191
  worker = Worker(
1128
1192
  self,
1129
1193
  handler,
@@ -1133,6 +1197,7 @@ class TaskQueue:
1133
1197
  grace_period=grace_period,
1134
1198
  on_idle=on_idle,
1135
1199
  weight=weight,
1200
+ task_timeouts=task_timeouts,
1136
1201
  )
1137
1202
  self._client._register_worker(worker)
1138
1203
  return worker
@@ -1167,7 +1232,6 @@ class LeasedTask:
1167
1232
  async def _terminal(self, name: str, request: Any) -> None:
1168
1233
  request.lease.CopyFrom(self._ref())
1169
1234
  conn = self._queue._client._require_conn()
1170
- request.context.CopyFrom(conn.request_context())
1171
1235
  await conn.call(name, request)
1172
1236
 
1173
1237
  async def ack(self) -> None:
@@ -1193,6 +1257,5 @@ class LeasedTask:
1193
1257
  request.extension.CopyFrom(_pb.td_to_dur(extension))
1194
1258
  request.lease.CopyFrom(self._ref())
1195
1259
  conn = self._queue._client._require_conn()
1196
- request.context.CopyFrom(conn.request_context())
1197
1260
  response = await conn.call("ExtendLease", request)
1198
1261
  self._lease_until = _pb.ts_to_dt(response.lease.until)
@@ -8,6 +8,7 @@ import os
8
8
  import re
9
9
  import stat
10
10
  import subprocess
11
+ import threading
11
12
  import time
12
13
  from pathlib import Path
13
14
  from typing import Any, cast
@@ -131,6 +132,46 @@ def validate_shared_state(value: object) -> dict[str, object] | None:
131
132
  return value
132
133
 
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
+
134
175
  class SharedLocalDaemon:
135
176
  """Discover or start a shared-local daemon without owning its process.
136
177
 
@@ -149,7 +190,7 @@ class SharedLocalDaemon:
149
190
  self.data_dir = Path(data_dir).absolute()
150
191
  self._daemon_path = daemon_path
151
192
  self._startup_timeout = startup_timeout
152
- self._launcher: asyncio.subprocess.Process | None = None
193
+ self._launcher: _DetachedProcess | None = None
153
194
  self.endpoint: str | None = None
154
195
  self.token: str | None = None
155
196
  self.generation: str | None = None
@@ -240,18 +281,29 @@ class SharedLocalDaemon:
240
281
  # DEVNULL is intentional: no lifecycle stdin and no inherited pipe
241
282
  # can keep either process alive or block after this SDK exits.
242
283
  detach: dict[str, Any] = (
243
- {"creationflags": subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP} # type: ignore[attr-defined]
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
+ )}
244
289
  if os.name == "nt" else {"start_new_session": True}
245
290
  )
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,
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,
251
299
  **detach,
252
300
  )
253
301
  except OSError as exc:
254
- raise RingoError(f"failed to spawn shared daemon {binary}: {exc}") from 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
255
307
 
256
308
  async def _endpoint_reachable(self, endpoint: str) -> bool:
257
309
  try: