davinci-resolve-mcp 2.67.0 → 2.68.0

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,557 @@
1
+ """Authenticated loopback bridge that runs *inside* DaVinci Resolve.
2
+
3
+ External scripting is a Studio feature: `fusionscript` refuses a connection from
4
+ a foreign process on the free edition. The in-app path is not gated — Blackmagic's
5
+ own README documents the Workspace ▸ Scripts menu with no edition qualifier, and a
6
+ script launched from it is handed the same `resolve` object Studio exposes. This
7
+ module is that script: it re-exports a *named, allowlisted* operation surface over
8
+ 127.0.0.1 so the MCP server can drive Resolve on any edition.
9
+
10
+ This is the documented in-app path, not a circumvention of the licence check —
11
+ but Blackmagic could close it, so treat this tier as supported-until-it-isn't.
12
+
13
+ ## Runs on Resolve's embedded interpreter
14
+
15
+ Standard library only. No numpy, no MCP, no project imports — this file is copied
16
+ next to a `bridge.json` into Resolve's Scripts folder and must import cleanly
17
+ there.
18
+
19
+ ## Lifecycle: both host models are handled
20
+
21
+ Whether a Scripts-menu script runs in Resolve's own interpreter or as a child
22
+ `fuscript` process is version- and platform-dependent, and getting it wrong is
23
+ fatal in opposite directions:
24
+
25
+ - as a **child process**, a daemon thread dies the instant the script returns,
26
+ so the listener must block;
27
+ - **in-process**, blocking would wedge Resolve's script execution forever.
28
+
29
+ Rather than guess, `serve()` detects which it is (see `_host_model`) and does the
30
+ right thing for each. `probe_host_model()` reports the same detection without
31
+ starting a listener, which is how the question gets answered empirically.
32
+
33
+ ## Security
34
+
35
+ - Binds 127.0.0.1 only, never a routable interface.
36
+ - Every request is HMAC-SHA256 signed over its canonical form; the shared secret
37
+ never travels on the wire.
38
+ - Timestamp freshness **plus** one-use nonces, with the nonce cache retained for
39
+ twice the skew — see `_NONCE_RETENTION_FACTOR` for why one skew is not enough.
40
+ - Bounded before authentication: a read deadline, a byte cap enforced before any
41
+ allocation, and a concurrent-connection cap. An unauthenticated local process
42
+ must not be able to pin threads or memory inside Resolve.
43
+ - Operations are **named and allowlisted**. A method-name prefix match is a
44
+ spelling convention, not a security boundary: `startswith("Delete")` admits
45
+ `DeleteProject`, and arbitrary `Import`/`Export` admit arbitrary paths.
46
+
47
+ ## Stopping without restarting Resolve
48
+
49
+ Because the listener blocks, the script that started it cannot be re-run while it
50
+ is alive, and the copy of the runtime inside Resolve's Scripts folder is taken at
51
+ install time — so a repository change leaves a *stale* bridge with no way back
52
+ short of quitting Resolve. `request_stop()` gives a client the two ways out:
53
+
54
+ - ``exit`` — stop, so Workspace ▸ Scripts can start it again;
55
+ - ``reload`` — stop, re-import the runtime from disk, and start again in the same
56
+ process, needing no UI interaction at all.
57
+
58
+ `validate_runtime_sources()` is what makes the second one safe: the new sources
59
+ must compile *before* the running listener is asked to stop, so a syntax error
60
+ is refused by a bridge that is still serving rather than discovered by one that
61
+ has already gone.
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ import hashlib
67
+ import hmac
68
+ import json
69
+ import os
70
+ import re
71
+ import socket
72
+ import socketserver
73
+ import stat
74
+ import sys
75
+ import threading
76
+ import time
77
+ from collections import OrderedDict
78
+ from typing import Any, Callable, Dict, Optional, Tuple
79
+
80
+ PROTOCOL_VERSION = "1.0"
81
+
82
+ #: Nonces must outlive the window in which their request is still timestamp-fresh.
83
+ #: Accepting |now - ts| <= skew while pruning at now - skew leaves a replay hole
84
+ #: the width of the skew for a future-dated request: the nonce is forgotten while
85
+ #: the timestamp is still valid. Retaining for 2x the skew closes it.
86
+ _NONCE_RETENTION_FACTOR = 2
87
+
88
+ #: Pre-auth bounds. A local process that has not authenticated must not be able
89
+ #: to hold a thread or force an allocation inside Resolve's interpreter.
90
+ MAX_REQUEST_BYTES = 1_048_576
91
+ PREAUTH_READ_TIMEOUT_S = 5.0
92
+ MAX_CONCURRENT_CONNECTIONS = 16
93
+ #: Post-auth: a real Resolve call can be slow, but not unbounded.
94
+ OPERATION_TIMEOUT_S = 300.0
95
+
96
+ #: How a running bridge may be asked to stop. `exit` leaves nothing listening;
97
+ #: `reload` asks the launcher to re-import the runtime and start again.
98
+ STOP_MODES = ("exit", "reload")
99
+ #: The runtime files the launcher imports, and therefore what `reload` must be
100
+ #: able to compile before anyone is asked to stop.
101
+ RUNTIME_MODULES = ("resolve_bridge.py", "resolve_bridge_ops.py")
102
+ #: How long `serve()` waits for in-flight requests to finish before closing the
103
+ #: listener, so the reply to `shutdown` reaches the client that asked for it.
104
+ STOP_DRAIN_TIMEOUT_S = 5.0
105
+
106
+ #: Parent-process names that mean "this script is a child of Resolve".
107
+ #: `scripts/resolve_bridge_probe.py` duplicates this list because it must be
108
+ #: self-contained inside Resolve's Scripts folder; a test asserts they agree.
109
+ PARENT_MARKERS = ("resolve", "fuscript", "fusion")
110
+
111
+ _NONCE_RE = re.compile(r"^[A-Za-z0-9_-]{16,128}$")
112
+ _SIGNATURE_RE = re.compile(r"^[0-9a-f]{64}$")
113
+
114
+
115
+ class BridgeConfigError(RuntimeError):
116
+ pass
117
+
118
+
119
+ # ── protocol ─────────────────────────────────────────────────────────────────
120
+
121
+
122
+ def canonical_request(request: Dict[str, Any]) -> bytes:
123
+ """The exact signed representation — everything except the signature."""
124
+ unsigned = {k: v for k, v in request.items() if k != "signature"}
125
+ return json.dumps(unsigned, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
126
+
127
+
128
+ def sign_request(token: str, request: Dict[str, Any]) -> str:
129
+ return hmac.new(token.encode("utf-8"), canonical_request(request), hashlib.sha256).hexdigest()
130
+
131
+
132
+ def signature_is_valid(token: str, request: Dict[str, Any], signature: str) -> bool:
133
+ return hmac.compare_digest(sign_request(token, request), signature)
134
+
135
+
136
+ # ── host model detection ─────────────────────────────────────────────────────
137
+
138
+
139
+ def _host_model(
140
+ *,
141
+ getppid: Callable[[], int] = os.getppid,
142
+ process_name_of: Optional[Callable[[int], str]] = None,
143
+ self_executable: Optional[str] = None,
144
+ ) -> Dict[str, Any]:
145
+ """Is this script running inside Resolve's own interpreter, or as a child?
146
+
147
+ **Asks about this process, not the parent.** The parent-name approach fails
148
+ on the sandboxed App Store build: `ps` on another process is blocked, the
149
+ name comes back empty, and the script looks like it has no Resolve ancestor
150
+ when in fact it is a child of one (measured: script pid 97667, parent pid
151
+ 97258, name unreadable). Defaulting that to "in-process" starts a listener
152
+ that dies the moment the script returns — on the exact edition the bridge
153
+ exists for.
154
+
155
+ Reading `sys.executable` needs no permission at all. If *this* interpreter is
156
+ Resolve's own binary we are in-process; anything else (fuscript, a python
157
+ binary) is a child. The parent name is kept as corroboration when readable,
158
+ never as the decision.
159
+
160
+ Returns a dict rather than a bool so the reasoning is inspectable: this
161
+ decision changes whether `serve()` blocks, and a silent wrong answer either
162
+ wedges Resolve or kills the bridge instantly.
163
+ """
164
+ executable = self_executable if self_executable is not None else (sys.executable or "")
165
+ parent_pid = getppid()
166
+ reader = process_name_of or _process_name
167
+ parent_name = reader(parent_pid) or ""
168
+
169
+ # In-process means THIS interpreter is Resolve itself. A Resolve-launched
170
+ # child runs fuscript or a python binary, neither of which is the app.
171
+ self_is_host = bool(executable) and executable.rstrip("/").endswith("/Resolve")
172
+ parent_looks_like_resolve = any(m in parent_name.lower() for m in PARENT_MARKERS)
173
+ is_child = not self_is_host
174
+
175
+ if self_is_host:
176
+ reason = (
177
+ f"this interpreter is Resolve itself ({executable!r}) — the module stays alive after "
178
+ "the script returns, so blocking would wedge Resolve's script execution"
179
+ )
180
+ elif parent_looks_like_resolve:
181
+ reason = (
182
+ f"this interpreter is {executable!r} with parent {parent_name!r} — a Resolve-launched "
183
+ "child, so a daemon thread would die when the script returns and the listener must block"
184
+ )
185
+ else:
186
+ reason = (
187
+ f"this interpreter is {executable!r}, which is not Resolve"
188
+ + (f" (parent {parent_name!r})" if parent_name else " (parent name unreadable — normal "
189
+ "under the App Store sandbox, where ps on another process is blocked)")
190
+ + " — treated as a child process, so the listener blocks. A bridge that exits early is "
191
+ "recoverable by re-running the script; a listener that never starts is not."
192
+ )
193
+ return {
194
+ "model": "child_process" if is_child else "in_process",
195
+ "parent_pid": parent_pid,
196
+ "parent_name": parent_name,
197
+ "self_executable": executable,
198
+ "self_is_resolve": self_is_host,
199
+ "parent_corroborates": parent_looks_like_resolve,
200
+ "blocking_required": is_child,
201
+ "reason": reason,
202
+ }
203
+
204
+
205
+ def _process_name(pid: int) -> str:
206
+ """Best-effort process name; empty string when it cannot be read."""
207
+ try: # Linux
208
+ with open(f"/proc/{pid}/comm", "r", encoding="utf-8") as handle:
209
+ return handle.read().strip()
210
+ except OSError:
211
+ pass
212
+ try: # macOS
213
+ import subprocess
214
+
215
+ out = subprocess.run(
216
+ ["ps", "-p", str(pid), "-o", "comm="],
217
+ capture_output=True, text=True, timeout=5, check=False,
218
+ )
219
+ return (out.stdout or "").strip()
220
+ except Exception: # pragma: no cover - defensive
221
+ return ""
222
+
223
+
224
+ def probe_host_model() -> Dict[str, Any]:
225
+ """Report the host model without starting a listener.
226
+
227
+ This is the empirical answer to "does a Scripts-menu script run in-process
228
+ or as a child?" — run it from Workspace ▸ Scripts and read the output.
229
+ """
230
+ model = _host_model()
231
+ model["pid"] = os.getpid()
232
+ model["protocol_version"] = PROTOCOL_VERSION
233
+ return model
234
+
235
+
236
+ # ── configuration ────────────────────────────────────────────────────────────
237
+
238
+
239
+ def load_config(path: str) -> Dict[str, Any]:
240
+ """Read bridge.json, refusing a world/group-readable secret."""
241
+ try:
242
+ mode = stat.S_IMODE(os.stat(path).st_mode)
243
+ if os.name != "nt" and mode & 0o077:
244
+ raise BridgeConfigError(f"{path} is group/world accessible; chmod 600 it")
245
+ with open(path, "r", encoding="utf-8") as handle:
246
+ raw = json.load(handle)
247
+ except BridgeConfigError:
248
+ raise
249
+ except (OSError, ValueError) as exc:
250
+ raise BridgeConfigError(f"cannot read bridge config {path}: {exc}") from exc
251
+ if not isinstance(raw, dict):
252
+ raise BridgeConfigError("bridge config must be an object")
253
+ if raw.get("host", "127.0.0.1") != "127.0.0.1":
254
+ raise BridgeConfigError("bridge host must be 127.0.0.1 — never a routable interface")
255
+ token = raw.get("token")
256
+ if not isinstance(token, str) or len(token) < 43:
257
+ raise BridgeConfigError("bridge token is missing or too short (need >= 43 chars)")
258
+ port = raw.get("port", 49632)
259
+ if not isinstance(port, int) or not 1024 <= port <= 65535:
260
+ raise BridgeConfigError("bridge port must be 1024..65535")
261
+ skew = raw.get("auth_clock_skew_seconds", 60)
262
+ if not isinstance(skew, int) or isinstance(skew, bool) or not 10 <= skew <= 300:
263
+ raise BridgeConfigError("auth_clock_skew_seconds must be 10..300")
264
+ return {"host": "127.0.0.1", "port": port, "token": token, "auth_clock_skew_seconds": skew}
265
+
266
+
267
+ def validate_runtime_sources(runtime_root: str) -> list:
268
+ """Problems that would make re-importing the runtime fail. Empty means safe.
269
+
270
+ Checked *before* the running listener is asked to stop. A reload that stops
271
+ first and discovers the breakage afterwards leaves no bridge at all — and no
272
+ way to start one without the Scripts menu, which is the situation reload
273
+ exists to avoid. Compiling is not proof the module imports cleanly, so the
274
+ launcher still loads into a throwaway module object and keeps the old one on
275
+ failure; this catches the overwhelmingly common case (a typo) at the point
276
+ where refusing is free.
277
+ """
278
+ problems = []
279
+ for name in RUNTIME_MODULES:
280
+ path = os.path.join(runtime_root, name)
281
+ try:
282
+ with open(path, "r", encoding="utf-8") as handle:
283
+ source = handle.read()
284
+ except OSError as exc:
285
+ problems.append(f"{name}: cannot be read ({exc})")
286
+ continue
287
+ try:
288
+ compile(source, path, "exec")
289
+ except SyntaxError as exc:
290
+ problems.append(f"{name}: does not compile (line {exc.lineno}: {exc.msg})")
291
+ return problems
292
+
293
+
294
+ # ── authentication ───────────────────────────────────────────────────────────
295
+
296
+
297
+ class NonceCache:
298
+ """One-use nonces, retained past the timestamp window they protect."""
299
+
300
+ def __init__(self, skew_seconds: int, *, capacity: int = 8192) -> None:
301
+ self._retention = skew_seconds * _NONCE_RETENTION_FACTOR
302
+ self._capacity = capacity
303
+ self._seen: "OrderedDict[str, float]" = OrderedDict()
304
+ self._lock = threading.Lock()
305
+
306
+ def check_and_add(self, nonce: str, now: float) -> bool:
307
+ """True when the nonce is fresh; False when it has been used."""
308
+ with self._lock:
309
+ cutoff = now - self._retention
310
+ while self._seen:
311
+ oldest, seen_at = next(iter(self._seen.items()))
312
+ if seen_at >= cutoff:
313
+ break
314
+ self._seen.pop(oldest)
315
+ if nonce in self._seen:
316
+ return False
317
+ self._seen[nonce] = now
318
+ # Capacity eviction is a memory backstop. It can in principle drop a
319
+ # nonce that is still within retention under a flood, so it is set
320
+ # far above any legitimate rate.
321
+ while len(self._seen) > self._capacity:
322
+ self._seen.popitem(last=False)
323
+ return True
324
+
325
+
326
+ def authenticate(
327
+ request: Any, *, token: str, skew_seconds: int, nonces: NonceCache, now: Optional[float] = None
328
+ ) -> Optional[str]:
329
+ """None when the request is authentic; otherwise a stable error code."""
330
+ now = time.time() if now is None else now
331
+ if not isinstance(request, dict):
332
+ return "invalid_request"
333
+ if request.get("protocol") != PROTOCOL_VERSION:
334
+ return "protocol_mismatch"
335
+ if "token" in request:
336
+ return "unauthorized" # the secret must never be on the wire
337
+ timestamp = request.get("timestamp")
338
+ nonce = request.get("nonce")
339
+ signature = request.get("signature")
340
+ if (
341
+ not isinstance(timestamp, int)
342
+ or isinstance(timestamp, bool)
343
+ or not isinstance(nonce, str)
344
+ or not _NONCE_RE.match(nonce)
345
+ or not isinstance(signature, str)
346
+ or not _SIGNATURE_RE.match(signature)
347
+ ):
348
+ return "unauthorized"
349
+ if not signature_is_valid(token, request, signature):
350
+ return "unauthorized"
351
+ if abs(now - timestamp) > skew_seconds:
352
+ return "stale_request"
353
+ if not nonces.check_and_add(nonce, now):
354
+ return "replayed_request"
355
+ return None
356
+
357
+
358
+ # ── server ───────────────────────────────────────────────────────────────────
359
+
360
+
361
+ class _Handler(socketserver.StreamRequestHandler):
362
+ def handle(self) -> None: # pragma: no cover - exercised via integration
363
+ bridge: "Bridge" = self.server.bridge # type: ignore[attr-defined]
364
+ if not bridge.acquire_slot():
365
+ self._write({"ok": False, "error": {"code": "too_many_connections"}})
366
+ return
367
+ try:
368
+ self.request.settimeout(PREAUTH_READ_TIMEOUT_S)
369
+ line = self.rfile.readline(MAX_REQUEST_BYTES + 1)
370
+ if len(line) > MAX_REQUEST_BYTES:
371
+ self._write({"ok": False, "error": {"code": "request_too_large"}})
372
+ return
373
+ if not line:
374
+ return
375
+ try:
376
+ request = json.loads(line.decode("utf-8"))
377
+ except (UnicodeDecodeError, ValueError):
378
+ self._write({"ok": False, "error": {"code": "invalid_json"}})
379
+ return
380
+ self.request.settimeout(OPERATION_TIMEOUT_S)
381
+ self._write(bridge.process(request))
382
+ finally:
383
+ bridge.release_slot()
384
+
385
+ def _write(self, payload: Dict[str, Any]) -> None:
386
+ self.wfile.write((json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8"))
387
+
388
+
389
+ class _Server(socketserver.ThreadingTCPServer):
390
+ allow_reuse_address = True
391
+ daemon_threads = True
392
+ address_family = socket.AF_INET
393
+
394
+ def __init__(self, address: Tuple[str, int], bridge: "Bridge") -> None:
395
+ self.bridge = bridge
396
+ super().__init__(address, _Handler)
397
+
398
+
399
+ class Bridge:
400
+ """Owns the listener, the nonce cache, and the serialised operation lock."""
401
+
402
+ def __init__(self, resolve: Any, config: Dict[str, Any], dispatch: Callable[[str, Dict[str, Any]], Any]) -> None:
403
+ self.resolve = resolve
404
+ self.config = config
405
+ self._dispatch = dispatch
406
+ self._nonces = NonceCache(config["auth_clock_skew_seconds"])
407
+ # Resolve's API is not thread-safe; the listener is threaded. Every
408
+ # native call is serialised behind this, matching the server-side
409
+ # bridge-lock discipline.
410
+ self._operation_lock = threading.RLock()
411
+ self._slots = threading.Semaphore(MAX_CONCURRENT_CONNECTIONS)
412
+ self._server: Optional[_Server] = None
413
+ self._thread: Optional[threading.Thread] = None
414
+ self._stop_event = threading.Event()
415
+ self._stop_mode: Optional[str] = None
416
+
417
+ def acquire_slot(self) -> bool:
418
+ return self._slots.acquire(blocking=False)
419
+
420
+ def release_slot(self) -> None:
421
+ try:
422
+ self._slots.release()
423
+ except ValueError: # pragma: no cover - defensive
424
+ pass
425
+
426
+ def process(self, request: Any) -> Dict[str, Any]:
427
+ request_id = request.get("id") if isinstance(request, dict) else None
428
+ error = authenticate(
429
+ request,
430
+ token=self.config["token"],
431
+ skew_seconds=self.config["auth_clock_skew_seconds"],
432
+ nonces=self._nonces,
433
+ )
434
+ if error:
435
+ return {"id": request_id, "ok": False, "error": {"code": error}}
436
+ operation = request.get("operation")
437
+ arguments = request.get("arguments") or {}
438
+ if not isinstance(operation, str) or not isinstance(arguments, dict):
439
+ return {"id": request_id, "ok": False, "error": {"code": "invalid_request"}}
440
+ try:
441
+ with self._operation_lock:
442
+ result = self._dispatch(operation, arguments)
443
+ except Exception as exc:
444
+ # The surface chooses stable codes — `stale_handle` tells a client to
445
+ # re-fetch, `ambiguous_locator` tells it to disambiguate, and so on.
446
+ # Collapsing them all to `operation_failed` here would strip exactly
447
+ # the information they exist to carry, leaving the client one
448
+ # undifferentiated failure and a string to pattern-match. Read by
449
+ # attribute rather than by import: the transport must not depend on
450
+ # the surface module, and anything raising a str `code` qualifies.
451
+ code = getattr(exc, "code", None)
452
+ details = getattr(exc, "details", None)
453
+ error = {
454
+ "code": code if isinstance(code, str) and code else "operation_failed",
455
+ "message": str(exc)[:300],
456
+ }
457
+ if isinstance(details, dict) and details:
458
+ try:
459
+ json.dumps(details)
460
+ except (TypeError, ValueError):
461
+ pass # a detail we cannot send must not cost the whole reply
462
+ else:
463
+ error["details"] = details
464
+ return {"id": request_id, "ok": False, "error": error}
465
+ return {"id": request_id, "ok": True, "result": result}
466
+
467
+ def request_stop(self, mode: str = "exit") -> str:
468
+ """Ask `serve()` to come back. Returns without waiting, on purpose.
469
+
470
+ The caller is inside a request handler: stopping the listener here would
471
+ tear down the socket the reply still has to travel over. Setting a flag
472
+ lets `process()` finish normally and `serve()` do the teardown after it
473
+ has drained.
474
+ """
475
+ if mode not in STOP_MODES:
476
+ raise ValueError(f"stop mode must be one of {list(STOP_MODES)}")
477
+ self._stop_mode = mode
478
+ self._stop_event.set()
479
+ return mode
480
+
481
+ @property
482
+ def stop_mode(self) -> Optional[str]:
483
+ return self._stop_mode
484
+
485
+ def _drain(self, timeout: float) -> bool:
486
+ """Wait until no handler is in flight, so replies are already on the wire.
487
+
488
+ Every handler holds a slot for the whole of `handle()`, and the reply is
489
+ written inside it (`wbufsize = 0`, so the write is a `sendall`, not a
490
+ buffer). Reclaiming every slot therefore means every reply has been sent
491
+ — deterministic, where a fixed grace period would only be probable.
492
+ """
493
+ deadline = time.monotonic() + timeout
494
+ held = 0
495
+ while held < MAX_CONCURRENT_CONNECTIONS and time.monotonic() < deadline:
496
+ if self._slots.acquire(blocking=False):
497
+ held += 1
498
+ else:
499
+ time.sleep(0.01)
500
+ for _ in range(held):
501
+ self._slots.release()
502
+ return held == MAX_CONCURRENT_CONNECTIONS
503
+
504
+ def start(self) -> "Bridge":
505
+ self._server = _Server((self.config["host"], self.config["port"]), self)
506
+ self._thread = threading.Thread(target=self._server.serve_forever, name="ResolveBridge", daemon=True)
507
+ self._thread.start()
508
+ return self
509
+
510
+ def stop(self) -> None:
511
+ if self._server is not None:
512
+ self._server.shutdown()
513
+ self._server.server_close()
514
+ self._server = None
515
+ self._thread = None
516
+
517
+ @property
518
+ def port(self) -> int:
519
+ return self._server.server_address[1] if self._server else self.config["port"]
520
+
521
+ def serve(self, *, poll_seconds: float = 1.0, host_model: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
522
+ """Start, then block or return according to the detected host model.
523
+
524
+ Returns immediately in-process (the caller keeps a reference alive);
525
+ blocks until Resolve exits, or until a client asks it to stop, as a child
526
+ process. Either choice made wrongly is fatal, which is why the detection
527
+ is explicit and reported.
528
+
529
+ The returned dict carries `stop_reason`, which is how the launcher knows
530
+ whether to exit or to re-import the runtime and serve again.
531
+ """
532
+ model = dict(host_model or _host_model())
533
+ self.start()
534
+ if not model["blocking_required"]:
535
+ model["stop_reason"] = None
536
+ return model
537
+ expected_parent = model["parent_pid"]
538
+ reason = "resolve_exited"
539
+ try:
540
+ while True:
541
+ if self._stop_event.is_set():
542
+ reason = self._stop_mode or "exit"
543
+ break
544
+ if self._thread is None or not self._thread.is_alive():
545
+ reason = "listener_died"
546
+ break
547
+ if os.getppid() != expected_parent:
548
+ break
549
+ # Waiting on the event rather than joining the thread makes a
550
+ # requested stop immediate instead of up to `poll_seconds` late.
551
+ self._stop_event.wait(poll_seconds)
552
+ if self._stop_event.is_set():
553
+ self._drain(STOP_DRAIN_TIMEOUT_S)
554
+ finally:
555
+ self.stop()
556
+ model["stop_reason"] = reason
557
+ return model