davinci-resolve-mcp 2.70.2 → 2.70.4
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 +129 -0
- package/README.md +10 -5
- package/docs/SKILL.md +16 -6
- package/docs/reference/api-limitations.md +9 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/resolve_bridge_probe.py +20 -0
- package/src/granular/common.py +1 -1
- package/src/granular/timeline.py +17 -3
- package/src/server.py +86 -29
- package/src/utils/api_truth.py +20 -0
- package/src/utils/page_lock.py +36 -0
- package/src/utils/resolve_bridge.py +191 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,135 @@
|
|
|
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.4
|
|
6
|
+
|
|
7
|
+
Three silent-failure fixes from community reports, plus the Windows bridge
|
|
8
|
+
follow-ups from #112's live confirmation.
|
|
9
|
+
|
|
10
|
+
### Thumbnails silently failed off the Color page (#110, @billcarroll)
|
|
11
|
+
|
|
12
|
+
`Timeline.GetCurrentClipThumbnailImage` returns data only while Resolve is on
|
|
13
|
+
the Color page — Blackmagic's own reference documents it as returning data "for
|
|
14
|
+
current media in the Color Page". `thumbnail_contact_sheet` (and
|
|
15
|
+
`marker_thumbnail_review`, which routes through it) reported "No thumbnail
|
|
16
|
+
available at frame" for every sample from any other page, which reads as an
|
|
17
|
+
empty timeline rather than a page requirement.
|
|
18
|
+
|
|
19
|
+
A shared `color_page_for_thumbnails` context manager in `page_lock.py` switches
|
|
20
|
+
under the existing page lock and restores the previous page after. The granular
|
|
21
|
+
server's thumbnail tool shares it and gained a real error message in place of a
|
|
22
|
+
bare `{"success": false}`. Where the current page cannot be read, no switch is
|
|
23
|
+
attempted at all, so a skipped restore can never strand the user on Color.
|
|
24
|
+
|
|
25
|
+
### probe_media_pool truncated silently, and good script runs reported failure (#108, @billcarroll)
|
|
26
|
+
|
|
27
|
+
`_folder_probe` reported `subfolder_count` from the list it had *expanded*, so
|
|
28
|
+
at the default depth every unexpanded folder looked like an empty leaf. In the
|
|
29
|
+
field, a drive-rename relink sweep trusted `subfolder_count: 0` and skipped
|
|
30
|
+
about 40 populated bins and 573 clips. The count is now the real
|
|
31
|
+
`GetSubFolderList()` length at every level, and folders carry `truncated: true`
|
|
32
|
+
at the cutoff so a walker can descend.
|
|
33
|
+
|
|
34
|
+
Separately, `fusionscript`'s RemoteApp thread can SIGSEGV during interpreter
|
|
35
|
+
teardown *after* a spawned script has finished its work, turning exit 0 into
|
|
36
|
+
-11 and a successful run into `success: false`. Scripts now run via `runpy` and
|
|
37
|
+
hard-exit before teardown. The guard catches `SystemExit`, so a script ending in
|
|
38
|
+
`sys.exit(0)` cannot reopen the race, and repoints `sys.path[0]` at the script's
|
|
39
|
+
directory — under `-c` it points at the server's cwd, which would break sibling
|
|
40
|
+
imports and let stray files shadow real modules. The cost of `os._exit` (atexit
|
|
41
|
+
handlers skipped, non-daemon threads not joined) is documented on the action.
|
|
42
|
+
|
|
43
|
+
### Bridge: a dead listener no longer reads as a live one (#112)
|
|
44
|
+
|
|
45
|
+
`serve()` treated "the serve thread is alive" as "the bridge is serving".
|
|
46
|
+
`serve_forever()` keeps its thread alive, so that is a narrower question than it
|
|
47
|
+
appears, and a bridge was reported lingering with nothing on its port while the
|
|
48
|
+
serve loop kept polling. That divergence is not explained here, but a process
|
|
49
|
+
running with no listener is useless either way, so the exit condition now asks
|
|
50
|
+
the socket directly.
|
|
51
|
+
|
|
52
|
+
The Win32 prototypes are declared rather than relying on ctypes' default
|
|
53
|
+
int-sized handle. The default is safe by documented contract, but the reporter
|
|
54
|
+
had to derive that from Microsoft's interop documentation to rule out
|
|
55
|
+
truncation; declaring the signatures spares the next reader the exercise.
|
|
56
|
+
|
|
57
|
+
**The v2.70.3 Windows fix is now confirmed on real hardware.** ZontarLives ran a
|
|
58
|
+
same-machine control: on v2.70.2 the bridge outlived Resolve indefinitely; on
|
|
59
|
+
v2.70.3 it exits 0.23s after. `_process_is_alive` returned `None` for pid 4
|
|
60
|
+
(System), confirming that access-denied reads as unknown rather than death
|
|
61
|
+
against a genuinely protected process.
|
|
62
|
+
|
|
63
|
+
## What's New in v2.70.3
|
|
64
|
+
|
|
65
|
+
The free-edition bridge could never notice Resolve exiting on Windows, so it
|
|
66
|
+
orphaned itself and blocked the next session. Reported in issue #112 by
|
|
67
|
+
@ZontarLives, with a self-contained repro.
|
|
68
|
+
|
|
69
|
+
### The bug
|
|
70
|
+
|
|
71
|
+
`serve()` detected host exit with a single test:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
if os.getppid() != expected_parent:
|
|
75
|
+
break
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
That is a POSIX signal. When a parent dies, POSIX reparents the orphan to init
|
|
79
|
+
and the value changes. **Windows does not reparent** — the parent pid is a
|
|
80
|
+
static field in the process record, so `os.getppid()` returns the dead parent's
|
|
81
|
+
pid forever and the check can never fire.
|
|
82
|
+
|
|
83
|
+
The consequence is not a cosmetic leak. The orphaned `fuscript.exe` keeps port
|
|
84
|
+
49632 and keeps accepting connections while holding a dead `resolve` handle, so
|
|
85
|
+
the next Resolve session's bridge cannot bind, and the client sees:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
bridge_timeout: Resolve did not answer in time - check for an open modal dialog,
|
|
89
|
+
which blocks its scripting API entirely
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
against a socket that `Get-NetTCPConnection` reports as healthily `LISTENING`.
|
|
93
|
+
Every surface-level check passes and the suggested cause is a red herring, which
|
|
94
|
+
is what made it expensive to diagnose.
|
|
95
|
+
|
|
96
|
+
### The fix
|
|
97
|
+
|
|
98
|
+
Liveness is now asked directly instead of inferred from a pid changing:
|
|
99
|
+
|
|
100
|
+
- `parent_has_exited()` keeps the reparent test as the fast path where it works,
|
|
101
|
+
then checks whether the parent pid still resolves to a live process, then
|
|
102
|
+
whether that process still name-matches `PARENT_MARKERS`. The last step also
|
|
103
|
+
catches pid reuse, which the old check would have misread as "Resolve exited".
|
|
104
|
+
- On Windows liveness comes from `OpenProcess` + `WaitForSingleObject` via
|
|
105
|
+
`ctypes`. Only "no such process" counts as death: access-denied and every
|
|
106
|
+
other error are *unknown*, and unknown never ends the session — the module's
|
|
107
|
+
standing rule is that a bridge which exits early is worse than one that
|
|
108
|
+
lingers.
|
|
109
|
+
- `_process_name()` gained a Windows branch (`QueryFullProcessImageNameW`). It
|
|
110
|
+
previously tried `/proc` then `ps`, so it returned an empty string on every
|
|
111
|
+
Windows machine. `scripts/resolve_bridge_probe.py` gets the same treatment.
|
|
112
|
+
|
|
113
|
+
Binding failures now name the likely cause and the way out, rather than
|
|
114
|
+
surfacing a bare "address already in use" that sends people to check firewalls.
|
|
115
|
+
|
|
116
|
+
The Windows branch is injectable so it is tested off Windows — a constant
|
|
117
|
+
`getppid` plus a simulated liveness answer. The platform difference is precisely
|
|
118
|
+
what kept this invisible to everyone developing on macOS or Linux.
|
|
119
|
+
|
|
120
|
+
### Also confirmed
|
|
121
|
+
|
|
122
|
+
`%APPDATA%` now joins `%PROGRAMDATA%` as a verified Windows bridge location; the
|
|
123
|
+
#112 report served reads from it against free 21.0.3.7. README and `docs/SKILL.md`
|
|
124
|
+
updated, along with guidance that a bridge which stops answering while
|
|
125
|
+
`LISTENING` is a stale process rather than a modal dialog.
|
|
126
|
+
|
|
127
|
+
### Also in this release
|
|
128
|
+
|
|
129
|
+
Two offline guard tests built their "not a temp path" target from `os.getcwd()`,
|
|
130
|
+
which failed — and wrote a real `look.cube` into the working directory — whenever
|
|
131
|
+
the suite ran from a directory under `/tmp`. `tests/_paths.py` makes them
|
|
132
|
+
independent of where the suite runs.
|
|
133
|
+
|
|
5
134
|
## What's New in v2.70.2
|
|
6
135
|
|
|
7
136
|
The control panel could never reach the free edition, even with a perfectly
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DaVinci Resolve MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-blue.svg)](#server-modes)
|
|
@@ -58,10 +58,15 @@ menu. A Lua canary is installed alongside so you can tell that apart from a
|
|
|
58
58
|
wrong folder.
|
|
59
59
|
|
|
60
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;
|
|
62
|
-
(issue #109)
|
|
63
|
-
|
|
64
|
-
|
|
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,17 @@ 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:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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.
|
|
41
47
|
- The **control panel connects over the bridge too** (fixed in v2.70.2). It runs
|
|
42
48
|
as a separate process with its own connector, so a panel that reports "Resolve
|
|
43
49
|
unavailable" while tool calls work is a panel-side bug, not a broken bridge.
|
|
@@ -1333,7 +1339,11 @@ Key actions:
|
|
|
1333
1339
|
- `apply_look_to_items(target_ids, cdl?|copy_from_item_id?, dry_run?)` — apply a
|
|
1334
1340
|
normalized CDL and/or copy a source grade to multiple video items
|
|
1335
1341
|
- `thumbnail_contact_sheet` / `marker_thumbnail_review` — sample Resolve-rendered
|
|
1336
|
-
thumbnails under the project analysis root for visual verification
|
|
1342
|
+
thumbnails under the project analysis root for visual verification. Resolve
|
|
1343
|
+
only serves these thumbnails on the Color page; the tool switches there
|
|
1344
|
+
automatically and restores the previous page. Expect a page flash in the GUI,
|
|
1345
|
+
and note that landing on Color can kick off cache/render work for the current
|
|
1346
|
+
clip — on a large timeline the switch is not free
|
|
1337
1347
|
- `edit_kernel_capabilities` — report supported, partially supported, and
|
|
1338
1348
|
unsupported timeline edit kernel behavior
|
|
1339
1349
|
- `probe_edit_kernel_item(clip_ids? selected? timeline_item?)` — read-only
|
|
@@ -12,7 +12,7 @@ that none exists).
|
|
|
12
12
|
|
|
13
13
|
**Verified on:** DaVinci Resolve Studio 21.0.0
|
|
14
14
|
|
|
15
|
-
**Totals:**
|
|
15
|
+
**Totals:** 22 missing capabilities, 20 bugs / unreliable behaviors.
|
|
16
16
|
|
|
17
17
|
The authoritative source is the runtime-queryable `api_truth` ledger
|
|
18
18
|
(`resolve_control api_truth "<query>"`); this document is generated from
|
|
@@ -48,6 +48,14 @@ equivalent, blocking full automation.
|
|
|
48
48
|
- **Workaround / current handling:** Ask the user to set it in Project Settings > Master Settings > Playback frame rate as a SETUP step, before any timeline exists. Read it back to confirm; do not report it as set on the strength of the call alone.
|
|
49
49
|
- **Tags:** project-settings, silent-failure, timeline
|
|
50
50
|
|
|
51
|
+
### Timeline.GetCurrentClipThumbnailImage (Color page only)
|
|
52
|
+
|
|
53
|
+
- **Object:** `Timeline`
|
|
54
|
+
- **Signature:** `() -> {width, height, format, data} | None`
|
|
55
|
+
- **Behavior:** Returns thumbnail data only while Resolve is on the Color page — the reference documents it as returning data 'for current media in the Color Page'. On every other page it silently returns None for every frame, indistinguishable from 'no thumbnail exists', with no error naming the page requirement.
|
|
56
|
+
- **Workaround / current handling:** Switch to the Color page under the page lock before reading and restore the user's page after (src/utils/page_lock.py:color_page_for_thumbnails does exactly this); when the switch fails (headless), name the Color-page requirement in the error instead of reporting a missing thumbnail.
|
|
57
|
+
- **Tags:** timeline, thumbnail, silent-failure, page-dependent
|
|
58
|
+
|
|
51
59
|
### Timeline.GetTimelineByName
|
|
52
60
|
|
|
53
61
|
- **Object:** `Project`
|
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.
|
|
39
|
+
VERSION = "2.70.4"
|
|
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
|
@@ -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()
|
package/src/granular/common.py
CHANGED
|
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
|
|
|
85
85
|
handlers=[logging.StreamHandler()],
|
|
86
86
|
)
|
|
87
87
|
|
|
88
|
-
VERSION = "2.70.
|
|
88
|
+
VERSION = "2.70.4"
|
|
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/granular/timeline.py
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from src.granular.common import * # noqa: F401,F403
|
|
4
4
|
|
|
5
|
+
from src.utils.page_lock import color_page_for_thumbnails
|
|
6
|
+
|
|
5
7
|
resolve = ResolveProxy()
|
|
6
8
|
|
|
7
9
|
@mcp.resource("resolve://timelines")
|
|
@@ -930,6 +932,9 @@ def timeline_get_current_video_item() -> Dict[str, Any]:
|
|
|
930
932
|
def timeline_get_current_clip_thumbnail(width: int = 320, height: int = 180) -> Dict[str, Any]:
|
|
931
933
|
"""Get thumbnail image data for the current clip.
|
|
932
934
|
|
|
935
|
+
Switches to the Color page for the read (GetCurrentClipThumbnailImage only
|
|
936
|
+
returns data there) and restores the previous page after.
|
|
937
|
+
|
|
933
938
|
Args:
|
|
934
939
|
width: Thumbnail width. Default: 320.
|
|
935
940
|
height: Thumbnail height. Default: 180.
|
|
@@ -937,10 +942,19 @@ def timeline_get_current_clip_thumbnail(width: int = 320, height: int = 180) ->
|
|
|
937
942
|
_, tl, err = _get_timeline()
|
|
938
943
|
if err:
|
|
939
944
|
return err
|
|
940
|
-
|
|
945
|
+
with color_page_for_thumbnails(resolve) as on_color:
|
|
946
|
+
result = tl.GetCurrentClipThumbnailImage()
|
|
941
947
|
if result:
|
|
942
|
-
return {"success": True, "has_data":
|
|
943
|
-
return {
|
|
948
|
+
return {"success": True, "has_data": True}
|
|
949
|
+
return {
|
|
950
|
+
"success": False,
|
|
951
|
+
"error": (
|
|
952
|
+
"No thumbnail available for the current clip"
|
|
953
|
+
if on_color
|
|
954
|
+
else "No thumbnail: GetCurrentClipThumbnailImage requires the "
|
|
955
|
+
"Color page and automatic switching failed (headless or page locked)"
|
|
956
|
+
),
|
|
957
|
+
}
|
|
944
958
|
|
|
945
959
|
|
|
946
960
|
@mcp.tool()
|
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.
|
|
14
|
+
VERSION = "2.70.4"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -48,7 +48,11 @@ from src.utils.mcp_stdio import run_fastmcp_stdio
|
|
|
48
48
|
from src.utils.api_truth import lookup_api_truth, VERIFIED_ON as _API_TRUTH_VERIFIED_ON
|
|
49
49
|
from src.utils.contracts import validate as _validate_params
|
|
50
50
|
from src.utils.cut_ir import build_cut_list as _build_cut_list
|
|
51
|
-
from src.utils.page_lock import
|
|
51
|
+
from src.utils.page_lock import (
|
|
52
|
+
color_page_for_thumbnails as _color_page_for_thumbnails,
|
|
53
|
+
open_page_serialized as _open_page_serialized,
|
|
54
|
+
page_lock as _page_lock,
|
|
55
|
+
)
|
|
52
56
|
from src.utils.proc import safe_run
|
|
53
57
|
from src.utils.readback import verify_by_readback, verification_stats as _verification_stats
|
|
54
58
|
from src.utils.render_ids import (
|
|
@@ -5513,32 +5517,42 @@ def _timeline_thumbnail_contact_sheet(proj, tl, p: Dict[str, Any]) -> Dict[str,
|
|
|
5513
5517
|
original_timecode = tl.GetCurrentTimecode()
|
|
5514
5518
|
except Exception:
|
|
5515
5519
|
pass
|
|
5520
|
+
# GetCurrentClipThumbnailImage only returns data "for current media in the
|
|
5521
|
+
# Color Page" (docs/reference/resolve_scripting_api.txt); on any other page
|
|
5522
|
+
# every frame silently yields None. Switch there for the sampling loop and
|
|
5523
|
+
# restore the user's page afterwards.
|
|
5516
5524
|
sampled = []
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5525
|
+
with _color_page_for_thumbnails(get_resolve()) as on_color:
|
|
5526
|
+
try:
|
|
5527
|
+
for sample in samples:
|
|
5528
|
+
timecode, tc_err = _timeline_frame_id_to_timecode(tl, _marker_display_frame(tl, sample["frame"]))
|
|
5529
|
+
if tc_err:
|
|
5530
|
+
sample["error"] = tc_err.get("error")
|
|
5531
|
+
sampled.append(sample)
|
|
5532
|
+
continue
|
|
5533
|
+
try:
|
|
5534
|
+
tl.SetCurrentTimecode(timecode)
|
|
5535
|
+
thumbnail = tl.GetCurrentClipThumbnailImage()
|
|
5536
|
+
if not thumbnail:
|
|
5537
|
+
sample["error"] = (
|
|
5538
|
+
"No thumbnail available at frame"
|
|
5539
|
+
if on_color
|
|
5540
|
+
else "No thumbnail: GetCurrentClipThumbnailImage requires the "
|
|
5541
|
+
"Color page and automatic switching failed (headless or page locked)"
|
|
5542
|
+
)
|
|
5543
|
+
else:
|
|
5544
|
+
sample["timecode"] = timecode
|
|
5545
|
+
sample["thumbnail_rgb"] = _thumbnail_raw_rgb(thumbnail)
|
|
5546
|
+
sample["thumbnail_available"] = True
|
|
5547
|
+
except Exception as exc:
|
|
5548
|
+
sample["error"] = str(exc)
|
|
5522
5549
|
sampled.append(sample)
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
|
|
5526
|
-
|
|
5527
|
-
|
|
5528
|
-
|
|
5529
|
-
else:
|
|
5530
|
-
sample["timecode"] = timecode
|
|
5531
|
-
sample["thumbnail_rgb"] = _thumbnail_raw_rgb(thumbnail)
|
|
5532
|
-
sample["thumbnail_available"] = True
|
|
5533
|
-
except Exception as exc:
|
|
5534
|
-
sample["error"] = str(exc)
|
|
5535
|
-
sampled.append(sample)
|
|
5536
|
-
finally:
|
|
5537
|
-
if original_timecode:
|
|
5538
|
-
try:
|
|
5539
|
-
tl.SetCurrentTimecode(original_timecode)
|
|
5540
|
-
except Exception:
|
|
5541
|
-
pass
|
|
5550
|
+
finally:
|
|
5551
|
+
if original_timecode:
|
|
5552
|
+
try:
|
|
5553
|
+
tl.SetCurrentTimecode(original_timecode)
|
|
5554
|
+
except Exception:
|
|
5555
|
+
pass
|
|
5542
5556
|
sheet_samples = [sample for sample in sampled if sample.get("thumbnail_rgb")]
|
|
5543
5557
|
if not sheet_samples:
|
|
5544
5558
|
return {"success": False, "samples": sampled, "error": "No thumbnails could be sampled"}
|
|
@@ -10877,9 +10891,10 @@ def _folder_probe(folder, depth: int = 1):
|
|
|
10877
10891
|
clips = []
|
|
10878
10892
|
for clip in (folder.GetClipList() or []):
|
|
10879
10893
|
clips.append(_media_pool_item_summary(clip))
|
|
10894
|
+
subs = folder.GetSubFolderList() or []
|
|
10880
10895
|
subfolders = []
|
|
10881
10896
|
if depth > 0:
|
|
10882
|
-
for sub in
|
|
10897
|
+
for sub in subs:
|
|
10883
10898
|
subfolders.append(_folder_probe(sub, depth - 1))
|
|
10884
10899
|
stale = None
|
|
10885
10900
|
try:
|
|
@@ -10892,8 +10907,11 @@ def _folder_probe(folder, depth: int = 1):
|
|
|
10892
10907
|
"stale": stale,
|
|
10893
10908
|
"clip_count": len(clips),
|
|
10894
10909
|
"clips": clips,
|
|
10895
|
-
|
|
10910
|
+
# True count even below the depth cutoff — reporting len(subfolders)
|
|
10911
|
+
# here made unexpanded folders look like empty leaves.
|
|
10912
|
+
"subfolder_count": len(subs),
|
|
10896
10913
|
"subfolders": subfolders,
|
|
10914
|
+
"truncated": len(subs) > len(subfolders),
|
|
10897
10915
|
}
|
|
10898
10916
|
|
|
10899
10917
|
|
|
@@ -16449,6 +16467,8 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
16449
16467
|
import_folder(path, source_clips_path?) -> {success}
|
|
16450
16468
|
ingest_capabilities() -> {supported, partially_supported, unsupported}
|
|
16451
16469
|
probe_media_pool(depth?) -> {media_pool_id, methods, root, current_folder, selected_clips}
|
|
16470
|
+
depth defaults to 1, max 4. Folders below the cutoff have truncated:true
|
|
16471
|
+
and a real subfolder_count; re-probe deeper or use folder.get_subfolders.
|
|
16452
16472
|
probe_ingest_item(clip_ids? selected?) -> {items, count}
|
|
16453
16473
|
safe_import_media(paths, target_folder?, dry_run?) -> {success, imported, clips}
|
|
16454
16474
|
safe_import_sequence(FilePath|file_path|pattern, StartIndex?, EndIndex?, target_folder?, dry_run?) -> {success, imported, clips}
|
|
@@ -25558,11 +25578,44 @@ def _python_env_for_resolve() -> Dict[str, str]:
|
|
|
25558
25578
|
return env
|
|
25559
25579
|
|
|
25560
25580
|
|
|
25581
|
+
# fusionscript's RemoteApp thread keeps dispatching packets from Resolve while
|
|
25582
|
+
# the interpreter tears down at exit, and can SIGSEGV *after* the script has
|
|
25583
|
+
# finished — turning a successful run into exit code -11 / success:false.
|
|
25584
|
+
# Run the script via runpy and hard-exit before teardown so the exit code is
|
|
25585
|
+
# truthful. SystemExit must be caught here: uncaught, a plain sys.exit(0) at
|
|
25586
|
+
# the end of a script would take the normal teardown path and reopen the
|
|
25587
|
+
# segfault window. sys.path[0] is pointed at the script's directory to mimic
|
|
25588
|
+
# `python script.py` (under -c it points at the server's cwd, which both
|
|
25589
|
+
# breaks sibling imports and lets stray files there shadow real modules).
|
|
25590
|
+
# Cost of os._exit: atexit handlers never run and non-daemon threads are not
|
|
25591
|
+
# joined — documented in script_plugin's execute action.
|
|
25592
|
+
_PY_SCRIPT_EXIT_GUARD = (
|
|
25593
|
+
"import os, runpy, sys, traceback\n"
|
|
25594
|
+
"sys.argv = sys.argv[1:]\n"
|
|
25595
|
+
"sys.path[0] = os.path.dirname(os.path.abspath(sys.argv[0]))\n"
|
|
25596
|
+
"code = 0\n"
|
|
25597
|
+
"try:\n"
|
|
25598
|
+
" runpy.run_path(sys.argv[0], run_name='__main__')\n"
|
|
25599
|
+
"except SystemExit as e:\n"
|
|
25600
|
+
" if isinstance(e.code, int):\n"
|
|
25601
|
+
" code = e.code\n"
|
|
25602
|
+
" elif e.code is not None:\n"
|
|
25603
|
+
" print(e.code, file=sys.stderr)\n"
|
|
25604
|
+
" code = 1\n"
|
|
25605
|
+
"except BaseException:\n"
|
|
25606
|
+
" traceback.print_exc()\n"
|
|
25607
|
+
" code = 1\n"
|
|
25608
|
+
"sys.stdout.flush()\n"
|
|
25609
|
+
"sys.stderr.flush()\n"
|
|
25610
|
+
"os._exit(code)\n"
|
|
25611
|
+
)
|
|
25612
|
+
|
|
25613
|
+
|
|
25561
25614
|
def _execute_python_script(path: str, args: List[str],
|
|
25562
25615
|
timeout: int) -> Dict[str, Any]:
|
|
25563
25616
|
# Ensure Resolve is running so the script can connect.
|
|
25564
25617
|
get_resolve()
|
|
25565
|
-
cmd = [sys.executable, path] + [str(a) for a in args]
|
|
25618
|
+
cmd = [sys.executable, "-c", _PY_SCRIPT_EXIT_GUARD, path] + [str(a) for a in args]
|
|
25566
25619
|
try:
|
|
25567
25620
|
result = safe_run(cmd, env=_python_env_for_resolve(),
|
|
25568
25621
|
capture_output=True, text=True, timeout=timeout)
|
|
@@ -26240,6 +26293,10 @@ def script_plugin(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
|
|
|
26240
26293
|
— args: list of CLI args for the Python subprocess (Python only).
|
|
26241
26294
|
— timeout: seconds (default 120 for execute, 60 for run_inline).
|
|
26242
26295
|
— Auto-launches Resolve if not running.
|
|
26296
|
+
— Python scripts hard-exit after the script body (guards against
|
|
26297
|
+
fusionscript's segfault-at-exit race), so atexit handlers do not
|
|
26298
|
+
run and non-daemon threads are not joined. Do cleanup inline or
|
|
26299
|
+
in try/finally, not in atexit.
|
|
26243
26300
|
run_inline(source, language, timeout?) -> {success, stdout?, stderr?, result?}
|
|
26244
26301
|
— Python: writes to temp file with `resolve`/`project`/`mp`/`timeline`
|
|
26245
26302
|
pre-bound, runs as subprocess, captures stdout/stderr.
|
package/src/utils/api_truth.py
CHANGED
|
@@ -354,6 +354,26 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
354
354
|
"tags": ["fusion", "unreliable-return"],
|
|
355
355
|
"submit": "bug",
|
|
356
356
|
},
|
|
357
|
+
{
|
|
358
|
+
"symbol": "Timeline.GetCurrentClipThumbnailImage (Color page only)",
|
|
359
|
+
"object": "Timeline",
|
|
360
|
+
"signature": "() -> {width, height, format, data} | None",
|
|
361
|
+
"reality": "Returns thumbnail data only while Resolve is on the Color "
|
|
362
|
+
"page — the reference documents it as returning data 'for "
|
|
363
|
+
"current media in the Color Page'. On every other page it "
|
|
364
|
+
"silently returns None for every frame, indistinguishable "
|
|
365
|
+
"from 'no thumbnail exists', with no error naming the page "
|
|
366
|
+
"requirement.",
|
|
367
|
+
"recommended": "Switch to the Color page under the page lock before "
|
|
368
|
+
"reading and restore the user's page after "
|
|
369
|
+
"(src/utils/page_lock.py:color_page_for_thumbnails does "
|
|
370
|
+
"exactly this); when the switch fails (headless), name "
|
|
371
|
+
"the Color-page requirement in the error instead of "
|
|
372
|
+
"reporting a missing thumbnail.",
|
|
373
|
+
"tags": ["timeline", "thumbnail", "silent-failure", "page-dependent"],
|
|
374
|
+
"submit": "missing",
|
|
375
|
+
"mitigation": ["color_page_for_thumbnails", "_timeline_thumbnail_contact_sheet"],
|
|
376
|
+
},
|
|
357
377
|
{
|
|
358
378
|
"symbol": "Timeline.GetTimelineByName",
|
|
359
379
|
"object": "Project",
|
package/src/utils/page_lock.py
CHANGED
|
@@ -77,3 +77,39 @@ def open_page_serialized(resolve, page):
|
|
|
77
77
|
"""Switch Resolve to `page` under the page lock. Returns OpenPage's result."""
|
|
78
78
|
with page_lock():
|
|
79
79
|
return resolve.OpenPage(page)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@contextmanager
|
|
83
|
+
def color_page_for_thumbnails(resolve):
|
|
84
|
+
"""Hold the Color page for the block, restoring the user's page after.
|
|
85
|
+
|
|
86
|
+
GetCurrentClipThumbnailImage returns data only "for current media in the
|
|
87
|
+
Color Page" (docs/reference/resolve_scripting_api.txt) — on every other
|
|
88
|
+
page it silently returns None. Yields True when Resolve is on the Color
|
|
89
|
+
page for the block. If the current page can't be captured (GetCurrentPage
|
|
90
|
+
returned None or raised), no switch is attempted, so a skipped restore can
|
|
91
|
+
never strand the user on the Color page.
|
|
92
|
+
|
|
93
|
+
Switching to Color is not free in the GUI: besides the visible page flash,
|
|
94
|
+
Resolve may start cache/render work for the current clip.
|
|
95
|
+
"""
|
|
96
|
+
original = None
|
|
97
|
+
try:
|
|
98
|
+
original = resolve.GetCurrentPage() if resolve else None
|
|
99
|
+
except Exception:
|
|
100
|
+
original = None
|
|
101
|
+
with page_lock():
|
|
102
|
+
on_color = original == "color"
|
|
103
|
+
if original and not on_color:
|
|
104
|
+
try:
|
|
105
|
+
on_color = bool(resolve.OpenPage("color"))
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
try:
|
|
109
|
+
yield on_color
|
|
110
|
+
finally:
|
|
111
|
+
if original and original != "color":
|
|
112
|
+
try:
|
|
113
|
+
resolve.OpenPage(original)
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
@@ -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,152 @@ 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 = _win_kernel32()
|
|
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 _win_kernel32(): # pragma: no cover - exercised on Windows
|
|
261
|
+
"""kernel32 with prototypes declared.
|
|
262
|
+
|
|
263
|
+
Undeclared, ctypes treats the returned HANDLE as a 32-bit `c_int` while a
|
|
264
|
+
Win64 HANDLE is 64 bits. That is safe here by documented contract — Windows
|
|
265
|
+
keeps handle values within 32 bits for 32/64-bit interop, and observed
|
|
266
|
+
values are in the low thousands — but relying on it silently means the next
|
|
267
|
+
reader has to re-derive that argument, so declare the signatures instead.
|
|
268
|
+
"""
|
|
269
|
+
import ctypes
|
|
270
|
+
from ctypes import wintypes
|
|
271
|
+
|
|
272
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
273
|
+
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
274
|
+
kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
275
|
+
kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
|
276
|
+
kernel32.WaitForSingleObject.restype = wintypes.DWORD
|
|
277
|
+
kernel32.QueryFullProcessImageNameW.argtypes = [
|
|
278
|
+
wintypes.HANDLE, wintypes.DWORD, wintypes.LPWSTR, ctypes.POINTER(wintypes.DWORD)
|
|
279
|
+
]
|
|
280
|
+
kernel32.QueryFullProcessImageNameW.restype = wintypes.BOOL
|
|
281
|
+
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
282
|
+
kernel32.CloseHandle.restype = wintypes.BOOL
|
|
283
|
+
return kernel32
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _process_is_alive(pid: int) -> Optional[bool]:
|
|
287
|
+
"""True / False / None when it genuinely cannot be determined.
|
|
288
|
+
|
|
289
|
+
`None` matters: this module's standing rule is that a bridge which exits
|
|
290
|
+
early is worse than one that lingers, so an undeterminable answer must not
|
|
291
|
+
be allowed to read as "the parent died".
|
|
292
|
+
"""
|
|
293
|
+
if not pid or pid <= 0:
|
|
294
|
+
return None
|
|
295
|
+
if os.name == "nt": # pragma: no cover - exercised on Windows
|
|
296
|
+
try:
|
|
297
|
+
import ctypes
|
|
298
|
+
|
|
299
|
+
kernel32 = _win_kernel32()
|
|
300
|
+
access = _WIN_SYNCHRONIZE | _WIN_PROCESS_QUERY_LIMITED_INFORMATION
|
|
301
|
+
handle = kernel32.OpenProcess(access, False, int(pid))
|
|
302
|
+
if not handle:
|
|
303
|
+
# Only "no such process" is proof of death. Access-denied and
|
|
304
|
+
# everything else are unknown — a bridge must not quit because
|
|
305
|
+
# it could not open a handle.
|
|
306
|
+
return False if ctypes.get_last_error() == _WIN_ERROR_INVALID_PARAMETER else None
|
|
307
|
+
try:
|
|
308
|
+
status = kernel32.WaitForSingleObject(handle, 0)
|
|
309
|
+
if status == _WIN_WAIT_OBJECT_0:
|
|
310
|
+
return False # signalled == exited
|
|
311
|
+
if status == _WIN_WAIT_TIMEOUT:
|
|
312
|
+
return True
|
|
313
|
+
return None
|
|
314
|
+
finally:
|
|
315
|
+
kernel32.CloseHandle(handle)
|
|
316
|
+
except Exception:
|
|
317
|
+
return None
|
|
318
|
+
try:
|
|
319
|
+
os.kill(pid, 0)
|
|
320
|
+
return True
|
|
321
|
+
except ProcessLookupError:
|
|
322
|
+
return False
|
|
323
|
+
except PermissionError:
|
|
324
|
+
return True # exists, owned by someone else
|
|
325
|
+
except OSError:
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def parent_has_exited(expected_pid: int, expected_name: str = "",
|
|
330
|
+
*, getppid: Callable[[], int] = os.getppid,
|
|
331
|
+
is_alive: Callable[[int], Optional[bool]] = _process_is_alive,
|
|
332
|
+
name_of: Callable[[int], str] = _process_name) -> bool:
|
|
333
|
+
"""Has the Resolve that launched this script gone away?
|
|
334
|
+
|
|
335
|
+
`os.getppid() != expected_pid` is the POSIX signal — an orphan is reparented
|
|
336
|
+
to init, so the value changes. **Windows does not reparent.** The parent pid
|
|
337
|
+
is a static field in the process record, so `getppid()` returns the dead
|
|
338
|
+
parent's pid forever and a check written that way can never fire: the bridge
|
|
339
|
+
outlives Resolve, keeps its port, and answers with a dead handle. The next
|
|
340
|
+
session's bridge then cannot bind, and the client sees a timeout against a
|
|
341
|
+
socket that is `LISTENING` — every surface-level check passes (issue #112).
|
|
342
|
+
|
|
343
|
+
So liveness is asked directly, and reparenting is kept as the fast path
|
|
344
|
+
where it works. The name check additionally catches pid reuse, which the
|
|
345
|
+
reparent test would misread as "Resolve exited".
|
|
346
|
+
|
|
347
|
+
Unknown liveness is never treated as death, per the module's standing rule.
|
|
348
|
+
"""
|
|
349
|
+
try:
|
|
350
|
+
if getppid() != expected_pid:
|
|
351
|
+
return True
|
|
352
|
+
except Exception: # pragma: no cover - defensive
|
|
353
|
+
pass
|
|
354
|
+
alive = is_alive(expected_pid)
|
|
355
|
+
if alive is False:
|
|
356
|
+
return True
|
|
357
|
+
if alive is None:
|
|
358
|
+
return False
|
|
359
|
+
# Pid reuse: the number is alive but now belongs to something else. Only
|
|
360
|
+
# usable when the ORIGINAL name looked like Resolve — otherwise there is no
|
|
361
|
+
# baseline to have drifted from. `_host_model` documents that the parent
|
|
362
|
+
# name is routinely empty or non-matching under the App Store sandbox, where
|
|
363
|
+
# `ps` on another process is blocked; treating that as death would kill the
|
|
364
|
+
# bridge on its first poll, on the exact edition it exists for.
|
|
365
|
+
if expected_name and any(m in expected_name.lower() for m in PARENT_MARKERS):
|
|
366
|
+
current = (name_of(expected_pid) or "").lower()
|
|
367
|
+
if current and not any(marker in current for marker in PARENT_MARKERS):
|
|
368
|
+
return True
|
|
369
|
+
return False
|
|
370
|
+
|
|
371
|
+
|
|
224
372
|
def probe_host_model() -> Dict[str, Any]:
|
|
225
373
|
"""Report the host model without starting a listener.
|
|
226
374
|
|
|
@@ -502,7 +650,23 @@ class Bridge:
|
|
|
502
650
|
return held == MAX_CONCURRENT_CONNECTIONS
|
|
503
651
|
|
|
504
652
|
def start(self) -> "Bridge":
|
|
505
|
-
|
|
653
|
+
host, port = self.config["host"], self.config["port"]
|
|
654
|
+
try:
|
|
655
|
+
self._server = _Server((host, port), self)
|
|
656
|
+
except OSError as exc:
|
|
657
|
+
# An orphaned bridge from a previous session is the likely cause,
|
|
658
|
+
# and a bare "address already in use" sends people looking at
|
|
659
|
+
# firewalls. Say what is actually holding the port and how to clear
|
|
660
|
+
# it — the alternative is a LISTENING socket answering with a dead
|
|
661
|
+
# Resolve handle, which reads as a broken install (issue #112).
|
|
662
|
+
raise BridgeConfigError(
|
|
663
|
+
f"cannot listen on {host}:{port} — {exc}. Another bridge is probably still "
|
|
664
|
+
"running from an earlier Resolve session. Ask it to stop (send the `shutdown` "
|
|
665
|
+
"operation, or `resolve_bridge_client.BridgeClient(...).bridge_shutdown()`), or "
|
|
666
|
+
"end the stale process: Windows `Get-NetTCPConnection -LocalPort "
|
|
667
|
+
f"{port} | Select-Object OwningProcess` then `Stop-Process -Id <pid>`; "
|
|
668
|
+
f"macOS/Linux `lsof -ti tcp:{port} | xargs kill`."
|
|
669
|
+
) from exc
|
|
506
670
|
self._thread = threading.Thread(target=self._server.serve_forever, name="ResolveBridge", daemon=True)
|
|
507
671
|
self._thread.start()
|
|
508
672
|
return self
|
|
@@ -518,7 +682,26 @@ class Bridge:
|
|
|
518
682
|
def port(self) -> int:
|
|
519
683
|
return self._server.server_address[1] if self._server else self.config["port"]
|
|
520
684
|
|
|
521
|
-
def
|
|
685
|
+
def _listener_is_live(self) -> bool:
|
|
686
|
+
"""Is the socket still open, not merely: is the thread still running?
|
|
687
|
+
|
|
688
|
+
`serve_forever()` keeps a thread alive, so `is_alive()` answers a
|
|
689
|
+
narrower question than it appears to. A bridge was reported lingering
|
|
690
|
+
with nothing listening on its port while its serve loop kept polling
|
|
691
|
+
(issue #112) — that combination is unexplained, but a process still
|
|
692
|
+
running with no listener is useless either way, so the exit condition
|
|
693
|
+
asks about the socket directly rather than inferring it from the thread.
|
|
694
|
+
"""
|
|
695
|
+
server = self._server
|
|
696
|
+
if server is None:
|
|
697
|
+
return False
|
|
698
|
+
try:
|
|
699
|
+
return server.fileno() >= 0
|
|
700
|
+
except Exception:
|
|
701
|
+
return False
|
|
702
|
+
|
|
703
|
+
def serve(self, *, poll_seconds: float = 1.0, host_model: Optional[Dict[str, Any]] = None,
|
|
704
|
+
parent_exited: Optional[Callable[[int, str], bool]] = None) -> Dict[str, Any]:
|
|
522
705
|
"""Start, then block or return according to the detected host model.
|
|
523
706
|
|
|
524
707
|
Returns immediately in-process (the caller keeps a reference alive);
|
|
@@ -535,16 +718,20 @@ class Bridge:
|
|
|
535
718
|
model["stop_reason"] = None
|
|
536
719
|
return model
|
|
537
720
|
expected_parent = model["parent_pid"]
|
|
721
|
+
expected_name = model.get("parent_name") or ""
|
|
722
|
+
# Injectable so the Windows branch is testable off Windows — the bug
|
|
723
|
+
# this replaced was invisible on the maintainer's platform.
|
|
724
|
+
host_exited = parent_exited or parent_has_exited
|
|
538
725
|
reason = "resolve_exited"
|
|
539
726
|
try:
|
|
540
727
|
while True:
|
|
541
728
|
if self._stop_event.is_set():
|
|
542
729
|
reason = self._stop_mode or "exit"
|
|
543
730
|
break
|
|
544
|
-
if self._thread is None or not self._thread.is_alive():
|
|
731
|
+
if self._thread is None or not self._thread.is_alive() or not self._listener_is_live():
|
|
545
732
|
reason = "listener_died"
|
|
546
733
|
break
|
|
547
|
-
if
|
|
734
|
+
if host_exited(expected_parent, expected_name):
|
|
548
735
|
break
|
|
549
736
|
# Waiting on the event rather than joining the thread makes a
|
|
550
737
|
# requested stop immediate instead of up to `poll_seconds` late.
|