python-dmon 0.4.0__py3-none-any.whl → 0.5.0__py3-none-any.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.
dmon/api.py CHANGED
@@ -17,6 +17,7 @@ from .control import (
17
17
  stop_single,
18
18
  task_snapshot,
19
19
  task_environment,
20
+ task_owns_listener,
20
21
  )
21
22
  from .results import (
22
23
  ActionResult,
@@ -39,10 +40,19 @@ class DmonConfigError(DmonError):
39
40
 
40
41
 
41
42
  class Dmon:
43
+ """Silent, structured Python interface to dmon task operations.
44
+
45
+ Args:
46
+ config: Optional dmon YAML/TOML file or directory. When omitted, dmon
47
+ uses the same configuration discovery rules as the CLI.
48
+ """
49
+
42
50
  def __init__(self, config: Optional[Union[str, os.PathLike]] = None) -> None:
43
51
  self.config = str(config) if config is not None else None
44
52
 
45
53
  def start(self, *tasks: str) -> BatchResult:
54
+ """Start one or more configured tasks in the background."""
55
+
46
56
  names, configs, _ = self._tasks(tasks)
47
57
  self._validate_environments(configs)
48
58
  results = []
@@ -59,12 +69,14 @@ class Dmon:
59
69
  ok=result.exit_code == 0,
60
70
  exit_code=result.exit_code,
61
71
  snapshot=snapshot,
62
- error="" if result.exit_code == 0 else "task did not start",
72
+ error=result.error,
63
73
  )
64
74
  )
65
75
  return BatchResult("start", tuple(results))
66
76
 
67
77
  def stop(self, *tasks: str) -> BatchResult:
78
+ """Stop one or more configured tasks using their recorded identities."""
79
+
68
80
  names, configs, _ = self._tasks(tasks)
69
81
  results = []
70
82
  with self._operation():
@@ -82,6 +94,8 @@ class Dmon:
82
94
  return BatchResult("stop", tuple(results))
83
95
 
84
96
  def restart(self, *tasks: str) -> BatchResult:
97
+ """Restart one or more configured tasks."""
98
+
85
99
  names, configs, _ = self._tasks(tasks)
86
100
  self._validate_environments(configs)
87
101
  results = []
@@ -103,6 +117,8 @@ class Dmon:
103
117
  return BatchResult("restart", tuple(results))
104
118
 
105
119
  def status(self, task: Optional[str] = None) -> TaskResult:
120
+ """Inspect one configured task without changing its lifecycle."""
121
+
106
122
  names, configs, _ = self._tasks(() if task is None else (task,))
107
123
  if len(names) != 1:
108
124
  raise DmonConfigError("status requires exactly one task")
@@ -110,6 +126,8 @@ class Dmon:
110
126
  return self._task_result(names[0], Path(configs[0].meta_path))
111
127
 
112
128
  def list_tasks(self) -> Tuple[TaskResult, ...]:
129
+ """Return recorded task results for the selected project."""
130
+
113
131
  project = self._project()
114
132
  results = []
115
133
  with self._operation():
@@ -126,6 +144,8 @@ class Dmon:
126
144
  return tuple(results)
127
145
 
128
146
  def stack_status(self, stack: str) -> StackResult:
147
+ """Inspect one recorded stack without changing its lifecycle."""
148
+
129
149
  if not isinstance(stack, str) or not stack:
130
150
  raise DmonConfigError("stack name must not be empty")
131
151
  project = self._project()
@@ -138,6 +158,8 @@ class Dmon:
138
158
  )
139
159
 
140
160
  def list_stacks(self) -> Tuple[StackResult, ...]:
161
+ """Return recorded stack results for the selected project."""
162
+
141
163
  project = self._project()
142
164
  results = []
143
165
  with self._operation():
@@ -154,6 +176,8 @@ class Dmon:
154
176
  timeout: Optional[float] = None,
155
177
  interval: Optional[float] = None,
156
178
  ) -> Tuple[WaitResult, ...]:
179
+ """Wait for configured readiness without starting or stopping tasks."""
180
+
157
181
  for name, value in (("timeout", timeout), ("interval", interval)):
158
182
  if value is not None and (
159
183
  not isinstance(value, (int, float))
@@ -208,6 +232,13 @@ class Dmon:
208
232
  process_running=lambda meta=meta: check_running(
209
233
  meta.pid, meta.create_time
210
234
  ),
235
+ listener_owned=(
236
+ lambda: task_owns_listener(
237
+ meta, spec.tcp_host, spec.tcp_port
238
+ )
239
+ )
240
+ if spec.require_owned
241
+ else None,
211
242
  )
212
243
  )
213
244
  return tuple(results)
dmon/cli.py CHANGED
@@ -43,6 +43,7 @@ from .constants import (
43
43
  from .logs import show_stack_logs
44
44
  from .inspection import inspect_stack, inspect_task
45
45
  from .readiness import ready_spec, wait_for_readiness
46
+ from .repair import request_repair
46
47
  from .results import WaitResult
47
48
  from .serialization import stack_result_data, task_result_data, wait_result_data
48
49
  from .supervisor import (
@@ -308,6 +309,17 @@ def main():
308
309
  nargs="?",
309
310
  )
310
311
 
312
+ sp_stack_repair = stack_subparsers.add_parser(
313
+ "repair", help="Repair one exited member without restarting peers"
314
+ )
315
+ sp_stack_repair.add_argument("stack")
316
+ sp_stack_repair.add_argument("task")
317
+ sp_stack_repair.add_argument("--timeout", type=float, default=30)
318
+ sp_stack_repair.add_argument(
319
+ "--operation-id", help="Recheck the same repair without starting another"
320
+ )
321
+ sp_stack_repair.add_argument("--format", choices=("human", "json"), default="human")
322
+
311
323
  sp_stack_status = stack_subparsers.add_parser(
312
324
  "status",
313
325
  help="Show stack and member task status",
@@ -370,6 +382,7 @@ def main():
370
382
  sp_stack_up,
371
383
  sp_stack_down,
372
384
  sp_stack_restart,
385
+ sp_stack_repair,
373
386
  sp_stack_status,
374
387
  sp_stack_logs,
375
388
  sp_stack_list,
@@ -393,6 +406,7 @@ def main():
393
406
  "up": sp_stack_up,
394
407
  "down": sp_stack_down,
395
408
  "restart": sp_stack_restart,
409
+ "repair": sp_stack_repair,
396
410
  "status": sp_stack_status,
397
411
  "logs": sp_stack_logs,
398
412
  "list": sp_stack_list,
@@ -477,6 +491,16 @@ def main():
477
491
  meta_path = Path(STACK_META_PATH_TEMPLATE.format(stack=stack))
478
492
  except Exception as error:
479
493
  sp.error(str(error))
494
+ if args.stack_command == "repair":
495
+ sp.exit(
496
+ request_repair(
497
+ meta_path,
498
+ args.task.lower(),
499
+ args.timeout,
500
+ args.operation_id,
501
+ args.format == "json",
502
+ )
503
+ )
480
504
  if args.stack_command == "down":
481
505
  sp.exit(stop_stack(meta_path))
482
506
  if args.stack_command == "status":
@@ -557,7 +581,9 @@ def main():
557
581
  meta_paths.extend([META_PATH_TEMPLATE.format(task=task) for task in tasks])
558
582
 
559
583
  # Remove duplicates
560
- unique_meta_paths = sorted(set(Path(p).resolve() for p in meta_paths))
584
+ unique_meta_paths = sorted(
585
+ set(Path(p).absolute().resolve() for p in meta_paths)
586
+ )
561
587
 
562
588
  if args.command == "stop":
563
589
  sp.exit(stop(unique_meta_paths))
dmon/config.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import math
2
+ import ipaddress
2
3
  import sys
3
4
  from pathlib import Path
4
5
  from typing import Dict, List, Optional, Sequence, Tuple, Union, cast
@@ -216,7 +217,7 @@ def validate_task(task, name: str) -> DmonTaskConfig:
216
217
  def validate_ready(ready, name: str) -> Dict[str, object]:
217
218
  if not isinstance(ready, dict):
218
219
  raise TypeError(f"Task '{name}' 'ready' field must be a table")
219
- allowed = {"http", "tcp", "command", "timeout", "interval"}
220
+ allowed = {"http", "tcp", "command", "timeout", "interval", "require_owned"}
220
221
  unknown = set(ready) - allowed
221
222
  if unknown:
222
223
  raise TypeError(
@@ -262,6 +263,26 @@ def validate_ready(ready, name: str) -> Dict[str, object]:
262
263
  raise TypeError(
263
264
  f"Task '{name}' readiness 'tcp' value must contain a host string and valid port"
264
265
  )
266
+ if "require_owned" in ready:
267
+ if not isinstance(ready["require_owned"], bool):
268
+ raise TypeError(
269
+ f"Task '{name}' readiness 'require_owned' must be a boolean"
270
+ )
271
+ if ready["require_owned"]:
272
+ host = (
273
+ urlsplit(ready["http"]).hostname
274
+ if "http" in ready
275
+ else ready.get("tcp", {}).get("host", "")
276
+ )
277
+ try:
278
+ local = ipaddress.ip_address(host).is_loopback
279
+ except ValueError:
280
+ local = False
281
+ if not local:
282
+ raise TypeError(
283
+ f"Task '{name}' readiness 'require_owned' needs an HTTP or TCP "
284
+ "probe with a literal loopback IP address"
285
+ )
265
286
  for key, default in (("timeout", 30.0), ("interval", 0.2)):
266
287
  value = ready.get(key, default)
267
288
  if (
dmon/control.py CHANGED
@@ -3,9 +3,9 @@ from contextvars import ContextVar
3
3
  from dataclasses import dataclass
4
4
  import os
5
5
  from pathlib import Path
6
- import shlex
7
6
  import shutil
8
7
  import signal
8
+ import socket
9
9
  import sys
10
10
  import subprocess
11
11
  import time
@@ -19,7 +19,7 @@ from termcolor import colored
19
19
 
20
20
  from .constants import DEFAULT_META_DIR, META_SUFFIX, ON_WINDOWS
21
21
  from .results import CommandSnapshot, TaskSnapshot
22
- from .types import DmonTaskConfig, DmonMeta, PathType
22
+ from .types import DmonTaskConfig, DmonMeta, PathType, ProcessIdentity
23
23
  from .utils import len_ansi, pad_ansi
24
24
 
25
25
 
@@ -49,6 +49,7 @@ def diagnostic_output(stream: TextIO) -> Iterator[None]:
49
49
  class StartResult:
50
50
  exit_code: int
51
51
  meta: Optional[DmonMeta] = None
52
+ error: str = ""
52
53
 
53
54
 
54
55
  def background_process_kwargs() -> dict:
@@ -102,8 +103,10 @@ def start_single(cfg: DmonTaskConfig) -> int:
102
103
 
103
104
 
104
105
  def start_single_result(cfg: DmonTaskConfig) -> StartResult:
105
- meta_path = Path(cfg.meta_path).resolve()
106
- log_path = Path(cfg.log_path).resolve()
106
+ # Python 3.8 on Windows may leave resolve() relative if a parent is missing.
107
+ # Anchor before resolving so newly created records have durable locations.
108
+ meta_path = Path(cfg.meta_path).absolute().resolve()
109
+ log_path = Path(cfg.log_path).absolute().resolve()
107
110
  cwd = Path(cfg.cwd).resolve()
108
111
 
109
112
  try:
@@ -117,7 +120,7 @@ def start_single_result(cfg: DmonTaskConfig) -> StartResult:
117
120
  ),
118
121
  file=sys.stderr,
119
122
  )
120
- return StartResult(1)
123
+ return StartResult(1, error=f"cannot read metadata {meta_path}: {error}")
121
124
  if existing_meta and existing_meta.state == "starting":
122
125
  print(
123
126
  colored(
@@ -131,7 +134,7 @@ def start_single_result(cfg: DmonTaskConfig) -> StartResult:
131
134
  "If the earlier start was interrupted, run 'dmon stop' to remove its reservation.",
132
135
  file=sys.stderr,
133
136
  )
134
- return StartResult(1)
137
+ return StartResult(1, error="task is already being started")
135
138
  if existing_meta and check_running(existing_meta.pid, existing_meta.create_time):
136
139
  print(
137
140
  f"{colored('Start failed: meta file already exists', color='red', attrs=['bold'])}",
@@ -142,8 +145,18 @@ def start_single_result(cfg: DmonTaskConfig) -> StartResult:
142
145
  "\nRun 'dmon status' / 'dmon list' to check, or 'dmon stop' to stop it.",
143
146
  file=sys.stderr,
144
147
  )
145
- return StartResult(1)
148
+ return StartResult(1, error="task is already running")
146
149
  if existing_meta:
150
+ if any(
151
+ check_running(child.pid, child.create_time)
152
+ for child in existing_meta.descendants
153
+ ) or unverified_group_remains(existing_meta):
154
+ print(
155
+ f"Start failed: task '{cfg.task}' still has residual processes; "
156
+ "stop its owning stack or inspect its preserved metadata first.",
157
+ file=sys.stderr,
158
+ )
159
+ return StartResult(1, error="task still has residual processes")
147
160
  print(
148
161
  colored(
149
162
  f"Removing stale metadata for exited task '{cfg.task}'.",
@@ -167,7 +180,8 @@ def start_single_result(cfg: DmonTaskConfig) -> StartResult:
167
180
  ),
168
181
  file=sys.stderr,
169
182
  )
170
- return StartResult(1)
183
+ # Detailed environment diagnostics belong in the log, not persisted state.
184
+ return StartResult(1, error="environment setup failed; see startup diagnostics")
171
185
 
172
186
  shell = isinstance(cfg.cmd, str)
173
187
 
@@ -203,11 +217,11 @@ def start_single_result(cfg: DmonTaskConfig) -> StartResult:
203
217
  ),
204
218
  file=sys.stderr,
205
219
  )
206
- return StartResult(1)
220
+ return StartResult(1, error="another dmon process is starting this task")
207
221
 
208
222
  try:
209
223
  if cfg.log_rotate:
210
- rotate_log_path = Path(cfg.rotate_log_path).resolve()
224
+ rotate_log_path = Path(cfg.rotate_log_path).absolute().resolve()
211
225
 
212
226
  meta.rotate_log_path = str(rotate_log_path)
213
227
  meta.log_max_size = cfg.log_max_size
@@ -219,8 +233,9 @@ def start_single_result(cfg: DmonTaskConfig) -> StartResult:
219
233
 
220
234
  # use runner to start user process and handle log rotation
221
235
  if isinstance(command, str):
222
- # when cmd is str, we need to split it into list for subprocess
223
- cmd = shlex.split(command)
236
+ # Transport shell text as one argument. Splitting and joining
237
+ # would discard quotes, escapes, and significant whitespace.
238
+ cmd = [command]
224
239
  else:
225
240
  cmd = command
226
241
  args = [
@@ -271,15 +286,31 @@ def start_single_result(cfg: DmonTaskConfig) -> StartResult:
271
286
  )
272
287
  except OSError as error:
273
288
  meta_path.unlink(missing_ok=True)
289
+ detail = str(error)
290
+ if not error.filename and isinstance(cfg.cmd, list):
291
+ # CreateProcess may omit the filename. Keep the executable useful
292
+ # for diagnostics without copying potentially sensitive arguments.
293
+ detail = f"command {cfg.cmd[0]!r}: {detail}"
294
+ if (
295
+ isinstance(error, FileNotFoundError)
296
+ and cfg.override_env
297
+ and env is not None
298
+ and "PATH" not in env
299
+ and cwd.is_dir()
300
+ ):
301
+ detail += (
302
+ "; override_env=true removes the inherited environment, including PATH. "
303
+ "Use env without override_env for variable overrides, or supply PATH explicitly."
304
+ )
274
305
  print(
275
306
  colored(
276
- f"Start failed for task '{cfg.task}': {error}",
307
+ f"Start failed for task '{cfg.task}': {detail}",
277
308
  color="red",
278
309
  attrs=["bold"],
279
310
  ),
280
311
  file=sys.stderr,
281
312
  )
282
- return StartResult(1)
313
+ return StartResult(1, error=detail)
283
314
 
284
315
  meta.pid = proc.pid
285
316
  meta.state = "running"
@@ -313,7 +344,7 @@ def start_single_result(cfg: DmonTaskConfig) -> StartResult:
313
344
  ),
314
345
  file=sys.stderr,
315
346
  )
316
- return StartResult(1)
347
+ return StartResult(1, error=f"cannot save metadata: {error}")
317
348
 
318
349
  print_status(meta)
319
350
  with warnings.catch_warnings():
@@ -397,6 +428,8 @@ def stop_single(
397
428
  try:
398
429
  proc = psutil.Process(pid)
399
430
  except (psutil.NoSuchProcess, ValueError):
431
+ if stop_owned_processes(meta, timeout):
432
+ return 1
400
433
  print(
401
434
  colored(
402
435
  f"Process {pid} not found (already exited); removing stale meta file",
@@ -411,6 +444,8 @@ def stop_single(
411
444
 
412
445
  # check if it's the same process by comparing create_time
413
446
  if not check_same_process(proc, meta.create_time):
447
+ if stop_owned_processes(meta, timeout):
448
+ return 1
414
449
  print(
415
450
  colored(
416
451
  f"Warning: PID {pid} exists but create_time does not match (maybe reused by another process); removing stale meta file",
@@ -423,6 +458,8 @@ def stop_single(
423
458
  meta_path.unlink(missing_ok=True)
424
459
  return 0
425
460
  if not process_is_alive(proc):
461
+ if stop_owned_processes(meta, timeout):
462
+ return 1
426
463
  print(
427
464
  colored(
428
465
  f"Process {pid} already exited; removing stale meta file",
@@ -435,13 +472,119 @@ def stop_single(
435
472
  meta_path.unlink(missing_ok=True)
436
473
  return 0
437
474
 
438
- ret = terminate_process(proc, timeout)
475
+ refresh_descendants(meta)
476
+ ret = stop_owned_processes(meta, timeout)
439
477
  print_status(meta)
440
478
  if ret == 0:
441
479
  meta_path.unlink(missing_ok=True)
442
480
  return ret
443
481
 
444
482
 
483
+ def owned_identities(meta: DmonMeta) -> List[ProcessIdentity]:
484
+ identities = list(meta.descendants)
485
+ if meta.pid > 0 and meta.create_time >= 0:
486
+ identities.insert(0, ProcessIdentity(meta.pid, meta.create_time))
487
+ return identities
488
+
489
+
490
+ def refresh_descendants(meta: DmonMeta) -> bool:
491
+ """Retain observed live identities, even after their parent has exited.
492
+
493
+ This is ancestry evidence within the user's trust domain, not a security
494
+ boundary. Children that escape before observation cannot be recovered by
495
+ guessing from names, ports or process group numbers.
496
+ """
497
+ observed = set()
498
+ for identity in owned_identities(meta):
499
+ process = get_unique_process(identity.pid, identity.create_time)
500
+ if process is None:
501
+ continue
502
+ if identity.pid != meta.pid:
503
+ observed.add(identity)
504
+ try:
505
+ children = process.children(recursive=True)
506
+ if not check_same_process(process, identity.create_time):
507
+ continue
508
+ for child in children:
509
+ if process_is_alive(child):
510
+ observed.add(ProcessIdentity(child.pid, child.create_time()))
511
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
512
+ continue
513
+ descendants = sorted(observed, key=lambda item: (item.pid, item.create_time))
514
+ changed = descendants != meta.descendants
515
+ meta.descendants = descendants
516
+ return changed
517
+
518
+
519
+ def task_owns_listener(meta: DmonMeta, host: str, port: int) -> bool:
520
+ """Positive evidence only; unavailable process sockets never imply ownership.
521
+
522
+ Callers validate a literal loopback endpoint. Never inspect or signal a
523
+ foreign listener, and never treat a successful network probe as identity.
524
+ """
525
+ root = get_unique_process(meta.pid, meta.create_time)
526
+ if root is None:
527
+ return False
528
+ try:
529
+ processes = [root, *root.children(recursive=True)]
530
+ for process in processes:
531
+ try:
532
+ birth = process.create_time()
533
+ for connection in process.net_connections(kind="tcp"):
534
+ if (
535
+ connection.status == psutil.CONN_LISTEN
536
+ and connection.laddr.port == port
537
+ and (
538
+ connection.laddr.ip == host
539
+ or connection.laddr.ip
540
+ == (
541
+ "::"
542
+ if connection.family == socket.AF_INET6
543
+ else "0.0.0.0"
544
+ )
545
+ and (
546
+ (":" in host) == (connection.family == socket.AF_INET6)
547
+ )
548
+ )
549
+ and check_running(process.pid, birth)
550
+ and check_running(meta.pid, meta.create_time)
551
+ ):
552
+ return True
553
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
554
+ continue
555
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
556
+ pass
557
+ return False
558
+
559
+
560
+ def stop_owned_processes(meta: DmonMeta, timeout: float) -> int:
561
+ """Stop verified identities; preserve evidence when cleanup is uncertain."""
562
+ failed = False
563
+ for identity in owned_identities(meta):
564
+ process = get_unique_process(identity.pid, identity.create_time)
565
+ if process is not None and terminate_process(process, timeout):
566
+ failed = True
567
+ if unverified_group_remains(meta):
568
+ print(
569
+ f"Task '{meta.task}' exited but its former process group still has "
570
+ "unverified members; refusing to signal them. Metadata was preserved.",
571
+ file=sys.stderr,
572
+ )
573
+ failed = True
574
+ return int(failed)
575
+
576
+
577
+ def unverified_group_remains(meta: DmonMeta) -> bool:
578
+ if ON_WINDOWS or meta.pid <= 0 or not meta.popen_kwargs.get("start_new_session"):
579
+ return False
580
+ try:
581
+ if not check_same_process(psutil.Process(meta.pid), meta.create_time):
582
+ return False # A recycled group leader is not ours.
583
+ except (psutil.NoSuchProcess, ValueError):
584
+ pass
585
+ return process_group_exists(meta.pid)
586
+
587
+
445
588
  def terminate_process(proc: psutil.Process, timeout: float) -> int:
446
589
  if not ON_WINDOWS:
447
590
  try:
@@ -666,6 +809,11 @@ def task_snapshot(meta: DmonMeta) -> TaskSnapshot:
666
809
  log_backup_count=meta.log_backup_count,
667
810
  rotate_log_max_size=meta.rotate_log_max_size,
668
811
  rotate_log_backup_count=meta.rotate_log_backup_count,
812
+ live_descendant_pids=tuple(
813
+ identity.pid
814
+ for identity in meta.descendants
815
+ if check_running(identity.pid, identity.create_time)
816
+ ),
669
817
  )
670
818
 
671
819
 
dmon/readiness.py CHANGED
@@ -6,6 +6,7 @@ import subprocess
6
6
  import time
7
7
  from typing import Callable, Mapping, Optional
8
8
  from urllib.request import urlopen
9
+ from urllib.parse import urlsplit
9
10
 
10
11
  from .results import CommandSnapshot, WaitResult
11
12
 
@@ -22,6 +23,7 @@ class ReadySpec:
22
23
  tcp_host: str = ""
23
24
  tcp_port: int = 0
24
25
  command: CommandSnapshot = ""
26
+ require_owned: bool = False
25
27
 
26
28
 
27
29
  def ready_spec(
@@ -37,11 +39,15 @@ def ready_spec(
37
39
  interval if interval is not None else ready.get("interval", 0.2)
38
40
  )
39
41
  if "http" in ready:
42
+ url = urlsplit(str(ready["http"]))
40
43
  return ReadySpec(
41
44
  "http",
42
45
  resolved_timeout,
43
46
  resolved_interval,
44
47
  http_url=str(ready["http"]),
48
+ tcp_host=url.hostname or "",
49
+ tcp_port=url.port or (443 if url.scheme == "https" else 80),
50
+ require_owned=bool(ready.get("require_owned")),
45
51
  )
46
52
  if "tcp" in ready:
47
53
  tcp = ready["tcp"]
@@ -52,6 +58,7 @@ def ready_spec(
52
58
  resolved_interval,
53
59
  tcp_host=str(tcp["host"]),
54
60
  tcp_port=int(tcp["port"]),
61
+ require_owned=bool(ready.get("require_owned")),
55
62
  )
56
63
  command = ready.get("command", "")
57
64
  if isinstance(command, list):
@@ -76,10 +83,12 @@ def wait_for_readiness(
76
83
  env: Optional[Mapping[str, str]],
77
84
  process_running: Optional[Check] = None,
78
85
  stop_requested: Optional[Check] = None,
86
+ listener_owned: Optional[Check] = None,
79
87
  ) -> WaitResult:
80
88
  started = time.monotonic()
81
89
  deadline = started + spec.timeout
82
90
  attempts = 0
91
+ ownership_verified = not spec.require_owned
83
92
  while True:
84
93
  if stop_requested is not None and stop_requested():
85
94
  return WaitResult(
@@ -99,17 +108,43 @@ def wait_for_readiness(
99
108
  return WaitResult(
100
109
  target,
101
110
  ready,
102
- "ready" if ready else "timeout",
111
+ "ready"
112
+ if ready
113
+ else "timeout"
114
+ if ownership_verified
115
+ else "listener-unverified",
103
116
  time.monotonic() - started,
104
117
  attempts,
105
118
  )
106
119
  if spec.kind != "process":
107
120
  attempts += 1
108
121
  attempt_timeout = min(max(1.0, spec.interval), remaining)
109
- if probe(spec, cwd=cwd, env=env, timeout=attempt_timeout):
110
- return WaitResult(
111
- target, True, "ready", time.monotonic() - started, attempts
112
- )
122
+ ownership_verified = not spec.require_owned or (
123
+ listener_owned is not None and listener_owned()
124
+ )
125
+ if ownership_verified and probe(
126
+ spec, cwd=cwd, env=env, timeout=attempt_timeout
127
+ ):
128
+ if process_running is not None and not process_running():
129
+ return WaitResult(
130
+ target,
131
+ False,
132
+ "process-exited",
133
+ time.monotonic() - started,
134
+ attempts,
135
+ )
136
+ if stop_requested is not None and stop_requested():
137
+ return WaitResult(
138
+ target, False, "stopped", time.monotonic() - started, attempts
139
+ )
140
+ if spec.require_owned and (
141
+ listener_owned is None or not listener_owned()
142
+ ):
143
+ ownership_verified = False
144
+ else:
145
+ return WaitResult(
146
+ target, True, "ready", time.monotonic() - started, attempts
147
+ )
113
148
  time.sleep(min(spec.interval, max(0.0, deadline - time.monotonic())))
114
149
 
115
150