davinci-resolve-mcp 2.67.1 → 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.
- package/CHANGELOG.md +99 -0
- package/README.md +27 -2
- package/docs/SKILL.md +20 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/bridge_differential.py +431 -0
- package/scripts/install_resolve_bridge.py +260 -0
- package/scripts/resolve_bridge_launcher.py +202 -0
- package/scripts/resolve_bridge_probe.py +162 -0
- package/scripts/resolve_capability_probe.py +270 -0
- package/src/granular/common.py +6 -1
- package/src/server.py +193 -6
- package/src/utils/bridge_differential.py +375 -0
- package/src/utils/captions.py +323 -0
- package/src/utils/colorimetry.py +196 -0
- package/src/utils/delivery_targets.py +225 -0
- package/src/utils/edit_engine.py +181 -7
- package/src/utils/image_qc.py +499 -0
- package/src/utils/page_lock.py +5 -1
- package/src/utils/resolve_bridge.py +557 -0
- package/src/utils/resolve_bridge_client.py +423 -0
- package/src/utils/resolve_bridge_ops.py +714 -0
- package/src/utils/resolve_connection.py +29 -1
- package/src/utils/silence_ripple.py +256 -8
- package/src/utils/strata_analyzers.py +7 -0
- package/src/utils/strata_faces.py +6 -0
- package/src/utils/transcript_edit.py +372 -0
|
@@ -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
|