davinci-resolve-mcp 2.70.1 → 2.70.3

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,130 @@
2
2
 
3
3
  Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
4
4
 
5
+ ## What's New in v2.70.3
6
+
7
+ The free-edition bridge could never notice Resolve exiting on Windows, so it
8
+ orphaned itself and blocked the next session. Reported in issue #112 by
9
+ @ZontarLives, with a self-contained repro.
10
+
11
+ ### The bug
12
+
13
+ `serve()` detected host exit with a single test:
14
+
15
+ ```python
16
+ if os.getppid() != expected_parent:
17
+ break
18
+ ```
19
+
20
+ That is a POSIX signal. When a parent dies, POSIX reparents the orphan to init
21
+ and the value changes. **Windows does not reparent** — the parent pid is a
22
+ static field in the process record, so `os.getppid()` returns the dead parent's
23
+ pid forever and the check can never fire.
24
+
25
+ The consequence is not a cosmetic leak. The orphaned `fuscript.exe` keeps port
26
+ 49632 and keeps accepting connections while holding a dead `resolve` handle, so
27
+ the next Resolve session's bridge cannot bind, and the client sees:
28
+
29
+ ```
30
+ bridge_timeout: Resolve did not answer in time - check for an open modal dialog,
31
+ which blocks its scripting API entirely
32
+ ```
33
+
34
+ against a socket that `Get-NetTCPConnection` reports as healthily `LISTENING`.
35
+ Every surface-level check passes and the suggested cause is a red herring, which
36
+ is what made it expensive to diagnose.
37
+
38
+ ### The fix
39
+
40
+ Liveness is now asked directly instead of inferred from a pid changing:
41
+
42
+ - `parent_has_exited()` keeps the reparent test as the fast path where it works,
43
+ then checks whether the parent pid still resolves to a live process, then
44
+ whether that process still name-matches `PARENT_MARKERS`. The last step also
45
+ catches pid reuse, which the old check would have misread as "Resolve exited".
46
+ - On Windows liveness comes from `OpenProcess` + `WaitForSingleObject` via
47
+ `ctypes`. Only "no such process" counts as death: access-denied and every
48
+ other error are *unknown*, and unknown never ends the session — the module's
49
+ standing rule is that a bridge which exits early is worse than one that
50
+ lingers.
51
+ - `_process_name()` gained a Windows branch (`QueryFullProcessImageNameW`). It
52
+ previously tried `/proc` then `ps`, so it returned an empty string on every
53
+ Windows machine. `scripts/resolve_bridge_probe.py` gets the same treatment.
54
+
55
+ Binding failures now name the likely cause and the way out, rather than
56
+ surfacing a bare "address already in use" that sends people to check firewalls.
57
+
58
+ The Windows branch is injectable so it is tested off Windows — a constant
59
+ `getppid` plus a simulated liveness answer. The platform difference is precisely
60
+ what kept this invisible to everyone developing on macOS or Linux.
61
+
62
+ ### Also confirmed
63
+
64
+ `%APPDATA%` now joins `%PROGRAMDATA%` as a verified Windows bridge location; the
65
+ #112 report served reads from it against free 21.0.3.7. README and `docs/SKILL.md`
66
+ updated, along with guidance that a bridge which stops answering while
67
+ `LISTENING` is a stale process rather than a modal dialog.
68
+
69
+ ### Also in this release
70
+
71
+ Two offline guard tests built their "not a temp path" target from `os.getcwd()`,
72
+ which failed — and wrote a real `look.cube` into the working directory — whenever
73
+ the suite ran from a directory under `/tmp`. `tests/_paths.py` makes them
74
+ independent of where the suite runs.
75
+
76
+ ## What's New in v2.70.2
77
+
78
+ The control panel could never reach the free edition, even with a perfectly
79
+ healthy in-app bridge. Reported in issue #109 by @alpaolo.
80
+
81
+ ### The bug
82
+
83
+ The panel runs as a separate process from the MCP server and has its own Resolve
84
+ connector. That connector returned as soon as `import DaVinciResolveScript`
85
+ failed:
86
+
87
+ ```python
88
+ try:
89
+ import DaVinciResolveScript as dvr_script
90
+ except Exception as exc:
91
+ return None, f"Resolve scripting API unavailable: {exc}"
92
+ ```
93
+
94
+ On the free edition that import is exactly what fails — Blackmagic's module ships
95
+ with the installer, not the App Store build, so there is no
96
+ `Developer/Scripting/Modules` tree to import from. The panel returned there,
97
+ before ever calling `connect_resolve`, whose entire purpose is that it accepts
98
+ `None` in bridge mode:
99
+
100
+ > `dvr_script` may be None in bridge mode: the bridge does not need Blackmagic's
101
+ > module at all, which is precisely why it reaches editions the module cannot.
102
+
103
+ So the reporter saw the bridge listening, the MCP server connected, and the panel
104
+ insisting "Resolve unavailable" — all at the same time, all correct.
105
+
106
+ `_try_connect` in `src/server.py` already carried this guard, with a comment
107
+ recording the same diagnosis from when it bit the server. The panel's connector
108
+ was missed. That makes it the third connector overlooked when a transport was
109
+ added, after the network-scripting one in v2.64.0.
110
+
111
+ ### The fix
112
+
113
+ The panel now consults the bridge first and treats both the environment setup and
114
+ the module import as optional when it is enabled — the same ordering the MCP
115
+ server uses.
116
+
117
+ The not-connected message no longer assumes Studio. It used to send every reader
118
+ to "open Resolve Studio with a project loaded", which is poor advice for a
119
+ free-edition user, since free is the one edition external scripting refuses by
120
+ design. It now names the fix that fits the situation, and says something
121
+ different depending on whether the bridge is enabled.
122
+
123
+ ### Windows bridge: `%PROGRAMDATA%` now confirmed
124
+
125
+ v2.70.1 shipped Windows script paths unverified. The #109 report was made on free
126
+ 21.0.1.11 with the bridge installed, listed and serving from `%PROGRAMDATA%`, so
127
+ that half is now confirmed rather than assumed. `%APPDATA%` remains untested.
128
+
5
129
  ## What's New in v2.70.1
6
130
 
7
131
  Windows support for the free-edition in-app bridge, and the end of doctor.py's
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.70.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.70.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-blue.svg)](#server-modes)
@@ -57,11 +57,16 @@ interpreters are not detected, and the script silently never appears in the
57
57
  menu. A Lua canary is installed alongside so you can tell that apart from a
58
58
  wrong folder.
59
59
 
60
- Validated on free 21.0.3.7 and Studio 19.1.3.7, both macOS. **Windows paths were
61
- added in v2.70.1 (issue #106) and have not yet been confirmed on Windows
62
- hardware** the installer targets the `%APPDATA%` and `%PROGRAMDATA%` script
63
- folders Blackmagic documents, but whether Resolve lists the bridge from them is
64
- unverified. Reports welcome.
60
+ Validated on free 21.0.3.7 and Studio 19.1.3.7, both macOS. The Windows paths
61
+ added in v2.70.1 (issue #106) shipped unverified; reports on free 21.0.1.11
62
+ (issue #109) and free 21.0.3.7 (issue #112) have since shown the bridge
63
+ installing, listing and serving from **both** `%PROGRAMDATA%` and `%APPDATA%` on
64
+ Windows 11, so those paths are now confirmed rather than assumed.
65
+
66
+ Note that the bridge holds its port for as long as it serves. Before v2.70.3 a
67
+ Windows bridge could outlive Resolve and block the next session's listener; if
68
+ you are on an older build and a bridge stops answering, check for a stale
69
+ `fuscript.exe` still holding the port.
65
70
 
66
71
  This is the documented in-app path, not a licence circumvention, but Blackmagic
67
72
  could close it — treat it as a supported-until-it-is-not tier. Loopback only,
package/docs/SKILL.md CHANGED
@@ -33,11 +33,20 @@ work unchanged. Two things to know when diagnosing it:
33
33
  Lua canary, which always lists, so "Python not detected" is distinguishable
34
34
  from "wrong folder". The preflight is macOS-only — off macOS Resolve finds
35
35
  Python by other means, and running the check there was a false alarm (#106).
36
- - **Windows is unconfirmed.** The `%APPDATA%`/`%PROGRAMDATA%` script folders are
37
- targeted as of v2.70.1, but no Windows machine has verified that Resolve lists
38
- the bridge from them. If a user reports the menu entry missing on Windows, ask
39
- whether the Lua canary lists — that separates "wrong folder" from "Python not
40
- detected" there too.
36
+ - **Windows: both script folders confirmed.** `%PROGRAMDATA%` (#109) and
37
+ `%APPDATA%` (#112) have each been shown serving the bridge on Windows 11 free
38
+ builds. If a user reports the menu entry missing on Windows, ask whether the
39
+ Lua canary lists — that separates "wrong folder" from "Python not detected".
40
+ - **A bridge that stops answering while its socket is `LISTENING` is a stale
41
+ process, not a modal dialog.** Before v2.70.3 the Windows bridge could never
42
+ detect Resolve exiting (`os.getppid()` does not change there), so it outlived
43
+ Resolve holding the port and answering with a dead handle — and the
44
+ `bridge_timeout` message blamed a modal dialog. On any build, the way out is
45
+ the `shutdown` operation (`BridgeClient.bridge_shutdown()`); killing the
46
+ process is the fallback, not the first move.
47
+ - The **control panel connects over the bridge too** (fixed in v2.70.2). It runs
48
+ as a separate process with its own connector, so a panel that reports "Resolve
49
+ unavailable" while tool calls work is a panel-side bug, not a broken bridge.
41
50
  - The in-Resolve runtime is a **copy taken at install time**. After changing the
42
51
  repository, re-run the installer and then ask the running bridge to reload —
43
52
  it re-imports from disk in place, so Resolve does not need restarting.
package/install.py CHANGED
@@ -36,7 +36,7 @@ from src.utils.update_check import (
36
36
 
37
37
  # ─── Version ──────────────────────────────────────────────────────────────────
38
38
 
39
- VERSION = "2.70.1"
39
+ VERSION = "2.70.3"
40
40
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
41
41
  # Resolve's scripting bridge loads into newer interpreters on recent builds
42
42
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.70.1",
3
+ "version": "2.70.3",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -35,6 +35,26 @@ PARENT_MARKERS = ("resolve", "fuscript", "fusion")
35
35
 
36
36
  def process_name(pid):
37
37
  """Best-effort process name; empty string when it cannot be read."""
38
+ if os.name == "nt":
39
+ # Windows has neither /proc nor ps, so the probe reported an empty
40
+ # parent name on every Windows machine (issue #112).
41
+ try:
42
+ import ctypes
43
+
44
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
45
+ handle = kernel32.OpenProcess(0x1000, False, int(pid))
46
+ if not handle:
47
+ return ""
48
+ try:
49
+ size = ctypes.c_ulong(260)
50
+ buf = ctypes.create_unicode_buffer(size.value)
51
+ if not kernel32.QueryFullProcessImageNameW(handle, 0, buf, ctypes.byref(size)):
52
+ return ""
53
+ return os.path.basename(buf.value)
54
+ finally:
55
+ kernel32.CloseHandle(handle)
56
+ except Exception:
57
+ return ""
38
58
  try: # Linux
39
59
  with open("/proc/%d/comm" % pid) as handle:
40
60
  return handle.read().strip()
@@ -11953,9 +11953,33 @@ def _serialize_resolve(func):
11953
11953
  return wrapper
11954
11954
 
11955
11955
 
11956
+ def _bridge_requested() -> bool:
11957
+ """Has the operator asked for the in-app bridge?
11958
+
11959
+ Read at call time rather than at import, so a panel started before the
11960
+ variable was set still honours it.
11961
+ """
11962
+ try:
11963
+ from src.utils import resolve_bridge_client
11964
+
11965
+ return resolve_bridge_client.bridge_enabled()
11966
+ except Exception: # pragma: no cover - the client is optional
11967
+ return False
11968
+
11969
+
11956
11970
  def _connect_resolve_read_only() -> Tuple[Any, Optional[str]]:
11957
11971
  global _RESOLVE_ENV_READY
11958
11972
  with _RESOLVE_API_LOCK:
11973
+ # Blackmagic's module ships with the *installer*, not the App Store
11974
+ # build, so a free-edition machine has no Developer/Scripting/Modules
11975
+ # tree and both the environment setup and the import fail. Returning
11976
+ # here made the control panel unreachable on exactly the configuration
11977
+ # the bridge exists for: the MCP server connects fine over the bridge
11978
+ # while the panel — a separate process with its own connector — reports
11979
+ # "Resolve unavailable". `connect_resolve` accepts None in bridge mode,
11980
+ # and this returned before ever calling it (server.py:_try_connect
11981
+ # carries the same guard for the same reason).
11982
+ bridge_on = _bridge_requested()
11959
11983
  # Environment + sys.path setup is pure overhead and never goes stale, so
11960
11984
  # run it once per process rather than on every connection.
11961
11985
  if not _RESOLVE_ENV_READY:
@@ -11968,20 +11992,41 @@ def _connect_resolve_read_only() -> Tuple[Any, Optional[str]]:
11968
11992
  sys.path.append(candidate)
11969
11993
  _RESOLVE_ENV_READY = True
11970
11994
  except Exception as exc:
11971
- return None, f"Resolve scripting API unavailable: {exc}"
11995
+ if not bridge_on:
11996
+ return None, f"Resolve scripting API unavailable: {exc}"
11997
+ dvr_script = None
11972
11998
  try:
11973
- import DaVinciResolveScript as dvr_script # type: ignore
11999
+ import DaVinciResolveScript as _dvr_script # type: ignore
12000
+
12001
+ dvr_script = _dvr_script
11974
12002
  except Exception as exc:
11975
- return None, f"Resolve scripting API unavailable: {exc}"
12003
+ if not bridge_on:
12004
+ return None, f"Resolve scripting API unavailable: {exc}"
11976
12005
  try:
11977
12006
  resolve = connect_resolve(dvr_script)
11978
12007
  except Exception as exc:
11979
12008
  return None, f"Resolve connection failed: {exc}"
11980
12009
  if resolve is None:
11981
- return None, "DaVinci Resolve is not connected. Open Resolve Studio with a project loaded."
12010
+ return None, _not_connected_message(bridge_on)
11982
12011
  return resolve, None
11983
12012
 
11984
12013
 
12014
+ def _not_connected_message(bridge_on: bool) -> str:
12015
+ """Name the fix that applies, rather than assuming Studio.
12016
+
12017
+ The old text sent every reader to "open Resolve Studio", which is wrong
12018
+ advice for a free-edition user — the edition external scripting refuses by
12019
+ design, and the one the bridge exists to reach.
12020
+ """
12021
+ if bridge_on:
12022
+ return ("The in-app bridge is enabled but not answering. In Resolve, run "
12023
+ "Workspace > Scripts > resolve_bridge; launching Resolve cannot start it.")
12024
+ return ("DaVinci Resolve is not connected. On Studio, enable Preferences > General > "
12025
+ "'External scripting using' = Local. On the free edition, install the in-app "
12026
+ "bridge, run Workspace > Scripts > resolve_bridge, and set "
12027
+ "DAVINCI_RESOLVE_BRIDGE=1.")
12028
+
12029
+
11985
12030
  @_serialize_resolve
11986
12031
  def _current_resolve_project_id() -> Tuple[Optional[str], Optional[str]]:
11987
12032
  """(project_id, error) for the currently-open Resolve project.
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
85
85
  handlers=[logging.StreamHandler()],
86
86
  )
87
87
 
88
- VERSION = "2.70.1"
88
+ VERSION = "2.70.3"
89
89
  logger = logging.getLogger("davinci-resolve-mcp")
90
90
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
91
91
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.70.1"
14
+ VERSION = "2.70.3"
15
15
 
16
16
  import base64
17
17
  import os
@@ -204,6 +204,8 @@ def _host_model(
204
204
 
205
205
  def _process_name(pid: int) -> str:
206
206
  """Best-effort process name; empty string when it cannot be read."""
207
+ if os.name == "nt":
208
+ return _windows_process_name(pid)
207
209
  try: # Linux
208
210
  with open(f"/proc/{pid}/comm", "r", encoding="utf-8") as handle:
209
211
  return handle.read().strip()
@@ -221,6 +223,126 @@ def _process_name(pid: int) -> str:
221
223
  return ""
222
224
 
223
225
 
226
+ def _windows_process_name(pid: int) -> str: # pragma: no cover - exercised on Windows
227
+ """Image name for a pid via Win32, or "" when it cannot be read.
228
+
229
+ ctypes rather than `tasklist`: the bridge polls this, and spawning a console
230
+ process every second inside Resolve is both slow and visible.
231
+ """
232
+ try:
233
+ import ctypes
234
+ from ctypes import wintypes
235
+
236
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
237
+ handle = kernel32.OpenProcess(_WIN_PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid))
238
+ if not handle:
239
+ return ""
240
+ try:
241
+ size = wintypes.DWORD(260)
242
+ buf = ctypes.create_unicode_buffer(size.value)
243
+ if not kernel32.QueryFullProcessImageNameW(handle, 0, buf, ctypes.byref(size)):
244
+ return ""
245
+ return os.path.basename(buf.value)
246
+ finally:
247
+ kernel32.CloseHandle(handle)
248
+ except Exception:
249
+ return ""
250
+
251
+
252
+ # Win32 constants used for parent-liveness detection.
253
+ _WIN_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
254
+ _WIN_SYNCHRONIZE = 0x00100000
255
+ _WIN_WAIT_OBJECT_0 = 0x0
256
+ _WIN_WAIT_TIMEOUT = 0x102
257
+ _WIN_ERROR_INVALID_PARAMETER = 87
258
+
259
+
260
+ def _process_is_alive(pid: int) -> Optional[bool]:
261
+ """True / False / None when it genuinely cannot be determined.
262
+
263
+ `None` matters: this module's standing rule is that a bridge which exits
264
+ early is worse than one that lingers, so an undeterminable answer must not
265
+ be allowed to read as "the parent died".
266
+ """
267
+ if not pid or pid <= 0:
268
+ return None
269
+ if os.name == "nt": # pragma: no cover - exercised on Windows
270
+ try:
271
+ import ctypes
272
+
273
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
274
+ access = _WIN_SYNCHRONIZE | _WIN_PROCESS_QUERY_LIMITED_INFORMATION
275
+ handle = kernel32.OpenProcess(access, False, int(pid))
276
+ if not handle:
277
+ # Only "no such process" is proof of death. Access-denied and
278
+ # everything else are unknown — a bridge must not quit because
279
+ # it could not open a handle.
280
+ return False if ctypes.get_last_error() == _WIN_ERROR_INVALID_PARAMETER else None
281
+ try:
282
+ status = kernel32.WaitForSingleObject(handle, 0)
283
+ if status == _WIN_WAIT_OBJECT_0:
284
+ return False # signalled == exited
285
+ if status == _WIN_WAIT_TIMEOUT:
286
+ return True
287
+ return None
288
+ finally:
289
+ kernel32.CloseHandle(handle)
290
+ except Exception:
291
+ return None
292
+ try:
293
+ os.kill(pid, 0)
294
+ return True
295
+ except ProcessLookupError:
296
+ return False
297
+ except PermissionError:
298
+ return True # exists, owned by someone else
299
+ except OSError:
300
+ return None
301
+
302
+
303
+ def parent_has_exited(expected_pid: int, expected_name: str = "",
304
+ *, getppid: Callable[[], int] = os.getppid,
305
+ is_alive: Callable[[int], Optional[bool]] = _process_is_alive,
306
+ name_of: Callable[[int], str] = _process_name) -> bool:
307
+ """Has the Resolve that launched this script gone away?
308
+
309
+ `os.getppid() != expected_pid` is the POSIX signal — an orphan is reparented
310
+ to init, so the value changes. **Windows does not reparent.** The parent pid
311
+ is a static field in the process record, so `getppid()` returns the dead
312
+ parent's pid forever and a check written that way can never fire: the bridge
313
+ outlives Resolve, keeps its port, and answers with a dead handle. The next
314
+ session's bridge then cannot bind, and the client sees a timeout against a
315
+ socket that is `LISTENING` — every surface-level check passes (issue #112).
316
+
317
+ So liveness is asked directly, and reparenting is kept as the fast path
318
+ where it works. The name check additionally catches pid reuse, which the
319
+ reparent test would misread as "Resolve exited".
320
+
321
+ Unknown liveness is never treated as death, per the module's standing rule.
322
+ """
323
+ try:
324
+ if getppid() != expected_pid:
325
+ return True
326
+ except Exception: # pragma: no cover - defensive
327
+ pass
328
+ alive = is_alive(expected_pid)
329
+ if alive is False:
330
+ return True
331
+ if alive is None:
332
+ return False
333
+ # Pid reuse: the number is alive but now belongs to something else. Only
334
+ # usable when the ORIGINAL name looked like Resolve — otherwise there is no
335
+ # baseline to have drifted from. `_host_model` documents that the parent
336
+ # name is routinely empty or non-matching under the App Store sandbox, where
337
+ # `ps` on another process is blocked; treating that as death would kill the
338
+ # bridge on its first poll, on the exact edition it exists for.
339
+ if expected_name and any(m in expected_name.lower() for m in PARENT_MARKERS):
340
+ current = (name_of(expected_pid) or "").lower()
341
+ if current and not any(marker in current for marker in PARENT_MARKERS):
342
+ return True
343
+ return False
344
+
345
+
224
346
  def probe_host_model() -> Dict[str, Any]:
225
347
  """Report the host model without starting a listener.
226
348
 
@@ -502,7 +624,23 @@ class Bridge:
502
624
  return held == MAX_CONCURRENT_CONNECTIONS
503
625
 
504
626
  def start(self) -> "Bridge":
505
- self._server = _Server((self.config["host"], self.config["port"]), self)
627
+ host, port = self.config["host"], self.config["port"]
628
+ try:
629
+ self._server = _Server((host, port), self)
630
+ except OSError as exc:
631
+ # An orphaned bridge from a previous session is the likely cause,
632
+ # and a bare "address already in use" sends people looking at
633
+ # firewalls. Say what is actually holding the port and how to clear
634
+ # it — the alternative is a LISTENING socket answering with a dead
635
+ # Resolve handle, which reads as a broken install (issue #112).
636
+ raise BridgeConfigError(
637
+ f"cannot listen on {host}:{port} — {exc}. Another bridge is probably still "
638
+ "running from an earlier Resolve session. Ask it to stop (send the `shutdown` "
639
+ "operation, or `resolve_bridge_client.BridgeClient(...).bridge_shutdown()`), or "
640
+ "end the stale process: Windows `Get-NetTCPConnection -LocalPort "
641
+ f"{port} | Select-Object OwningProcess` then `Stop-Process -Id <pid>`; "
642
+ f"macOS/Linux `lsof -ti tcp:{port} | xargs kill`."
643
+ ) from exc
506
644
  self._thread = threading.Thread(target=self._server.serve_forever, name="ResolveBridge", daemon=True)
507
645
  self._thread.start()
508
646
  return self
@@ -518,7 +656,8 @@ class Bridge:
518
656
  def port(self) -> int:
519
657
  return self._server.server_address[1] if self._server else self.config["port"]
520
658
 
521
- def serve(self, *, poll_seconds: float = 1.0, host_model: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
659
+ def serve(self, *, poll_seconds: float = 1.0, host_model: Optional[Dict[str, Any]] = None,
660
+ parent_exited: Optional[Callable[[int, str], bool]] = None) -> Dict[str, Any]:
522
661
  """Start, then block or return according to the detected host model.
523
662
 
524
663
  Returns immediately in-process (the caller keeps a reference alive);
@@ -535,6 +674,10 @@ class Bridge:
535
674
  model["stop_reason"] = None
536
675
  return model
537
676
  expected_parent = model["parent_pid"]
677
+ expected_name = model.get("parent_name") or ""
678
+ # Injectable so the Windows branch is testable off Windows — the bug
679
+ # this replaced was invisible on the maintainer's platform.
680
+ host_exited = parent_exited or parent_has_exited
538
681
  reason = "resolve_exited"
539
682
  try:
540
683
  while True:
@@ -544,7 +687,7 @@ class Bridge:
544
687
  if self._thread is None or not self._thread.is_alive():
545
688
  reason = "listener_died"
546
689
  break
547
- if os.getppid() != expected_parent:
690
+ if host_exited(expected_parent, expected_name):
548
691
  break
549
692
  # Waiting on the event rather than joining the thread makes a
550
693
  # requested stop immediate instead of up to `poll_seconds` late.