Skip to content

Commit b0ee684

Browse files
authored
feat(runtime): receive chunked worker blobs (#5688)
* feat(runtime): receive chunked worker blobs * fix(runtime): build blob transfer payload explicitly * test(runtime): update protocol v5 expectations
1 parent b030836 commit b0ee684

8 files changed

Lines changed: 269 additions & 17 deletions

packages/protocol/src/bridge-frames.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@
2121
* Python worker repo's test suite validates against, so the two sides of
2222
* the bridge can never silently drift apart.
2323
*
24-
* The dispatcher only ever switches on seven frame `type`s — `discover`,
25-
* `result`, `error`, `chunk`, `progress`, `comfy.event`, and `blender.event`
24+
* The dispatcher switches on the response frame `type`s modeled below,
25+
* including execution results, streamed output, blob transfer, progress, and
26+
* integration events.
2627
* — everything else is either a request the JS side sends (`execute`,
2728
* `worker.status`, `provider.*`, `models.*`, `comfy.execute`,
2829
* `blender.execute`, …) or silently ignored. Only the seven response types
@@ -229,6 +230,37 @@ export const chunkFrameSchema = z.object({
229230
data: resultOrChunkDataSchema
230231
});
231232

233+
const blobNameSchema = z.string().min(1);
234+
235+
export const blobStartFrameSchema = z.object({
236+
type: z.literal("blob.start"),
237+
request_id: requestIdSchema,
238+
data: z.object({
239+
name: blobNameSchema,
240+
size: z.number().int().nonnegative()
241+
})
242+
});
243+
244+
export const blobChunkFrameSchema = z.object({
245+
type: z.literal("blob.chunk"),
246+
request_id: requestIdSchema,
247+
data: z.object({
248+
name: blobNameSchema,
249+
offset: z.number().int().nonnegative(),
250+
bytes: z.instanceof(Uint8Array)
251+
})
252+
});
253+
254+
export const blobEndFrameSchema = z.object({
255+
type: z.literal("blob.end"),
256+
request_id: requestIdSchema,
257+
data: z.object({
258+
name: blobNameSchema,
259+
size: z.number().int().nonnegative(),
260+
sha256: z.string().regex(/^[a-f0-9]{64}$/)
261+
})
262+
});
263+
232264
// ---------------------------------------------------------------------------
233265
// progress
234266
// ---------------------------------------------------------------------------
@@ -375,6 +407,9 @@ export const bridgeFrameSchemas = {
375407
result: resultFrameSchema,
376408
error: errorFrameSchema,
377409
chunk: chunkFrameSchema,
410+
"blob.start": blobStartFrameSchema,
411+
"blob.chunk": blobChunkFrameSchema,
412+
"blob.end": blobEndFrameSchema,
378413
progress: progressFrameSchema,
379414
"comfy.event": comfyEventFrameSchema,
380415
"blender.event": blenderEventFrameSchema
@@ -388,6 +423,9 @@ export const bridgeFrameSchema = z.discriminatedUnion("type", [
388423
resultFrameSchema,
389424
errorFrameSchema,
390425
chunkFrameSchema,
426+
blobStartFrameSchema,
427+
blobChunkFrameSchema,
428+
blobEndFrameSchema,
391429
progressFrameSchema,
392430
comfyEventFrameSchema,
393431
blenderEventFrameSchema

packages/protocol/src/bridge-protocol.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
* 3. Update `MIN_NODETOOL_CORE_VERSION` to that new release.
4747
*/
4848

49-
export const BRIDGE_PROTOCOL_VERSION = 4;
49+
export const BRIDGE_PROTOCOL_VERSION = 5;
5050

5151
/**
5252
* Hard floor: the JS runtime rejects (at `discover`) any worker reporting a
@@ -63,6 +63,10 @@ export const BRIDGE_PROTOCOL_VERSION = 4;
6363
* JS side sends them unconditionally; only the new `job.start` / `job.end` /
6464
* `models.evict` message types are gated, because a pre-v4 worker answers
6565
* those with `Unknown message type`.
66+
*
67+
* v5 adds an optional `chunked-v1` execute-result blob transfer. The JS side
68+
* requests it only from workers reporting v5 or newer; older workers keep
69+
* returning the legacy inline `blobs` map.
6670
*/
6771
export const MIN_BRIDGE_PROTOCOL_VERSION = 1;
6872

packages/protocol/tests/error-and-protocol-coverage.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ describe("error-helpers.isTRPCErrorWithCode", () => {
7979

8080
describe("bridge-protocol constants", () => {
8181
it("BRIDGE_PROTOCOL_VERSION is the current speaking version", () => {
82-
expect(BRIDGE_PROTOCOL_VERSION).toBe(4);
82+
expect(BRIDGE_PROTOCOL_VERSION).toBe(5);
8383
});
8484

8585
it("MIN_BRIDGE_PROTOCOL_VERSION is the hard floor at 1", () => {

packages/runtime/src/python-bridge-base.ts

Lines changed: 139 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,14 @@ interface PendingRequest {
121121
resolve: (value: ExecuteResult) => void;
122122
reject: (error: Error) => void;
123123
onProgress?: (event: ProgressEvent) => void;
124+
blobTransfers?: Map<string, BlobTransfer>;
125+
completedBlobs?: Record<string, Uint8Array>;
126+
}
127+
128+
interface BlobTransfer {
129+
size: number;
130+
received: number;
131+
chunks: Uint8Array[];
124132
}
125133

126134
interface PendingStreamRequest {
@@ -138,6 +146,9 @@ const DEFAULT_STATUS_TIMEOUT_MS = Number(
138146
const DEFAULT_DOWNLOAD_IDLE_TIMEOUT_MS = Number(
139147
safeProcessEnv()["NODETOOL_PYTHON_DOWNLOAD_IDLE_TIMEOUT_MS"] ?? 5 * 60 * 1000
140148
);
149+
const MAX_RESULT_BLOB_BYTES = Number(
150+
safeProcessEnv()["NODETOOL_PYTHON_MAX_RESULT_BLOB_BYTES"] ?? 2 * 1024 * 1024 * 1024
151+
);
141152

142153
/**
143154
* Transport-agnostic Python bridge. Subclasses provide the transport via
@@ -312,11 +323,20 @@ export abstract class PythonBridgeBase
312323
const pending = this._pending.get(requestId);
313324
if (pending) {
314325
this._pending.delete(requestId);
326+
if (pending.blobTransfers?.size) {
327+
pending.reject(
328+
new Error("Python worker ended the result before all blob transfers completed")
329+
);
330+
return;
331+
}
315332
const data = msg.data as {
316333
outputs: Record<string, unknown>;
317334
blobs: Record<string, Uint8Array>;
318335
};
319-
pending.resolve({ outputs: data.outputs, blobs: data.blobs ?? {} });
336+
pending.resolve({
337+
outputs: data.outputs,
338+
blobs: { ...(data.blobs ?? {}), ...(pending.completedBlobs ?? {}) }
339+
});
320340
}
321341
} else if (type === "error" && requestId) {
322342
const streamReq = this._pendingStream.get(requestId);
@@ -341,6 +361,12 @@ export abstract class PythonBridgeBase
341361
if (streamReq) {
342362
streamReq.onChunk(msg.data as Record<string, unknown>);
343363
}
364+
} else if (type === "blob.start" && requestId) {
365+
this._startBlobTransfer(requestId, msg.data as Record<string, unknown>);
366+
} else if (type === "blob.chunk" && requestId) {
367+
this._appendBlobChunk(requestId, msg.data as Record<string, unknown>);
368+
} else if (type === "blob.end" && requestId) {
369+
this._finishBlobTransfer(requestId, msg.data as Record<string, unknown>);
344370
} else if (type === "progress" && requestId) {
345371
const pending = this._pending.get(requestId);
346372
if (pending?.onProgress) {
@@ -368,6 +394,103 @@ export abstract class PythonBridgeBase
368394
}
369395
}
370396

397+
private _rejectBlobTransfer(requestId: string, message: string): void {
398+
const pending = this._pending.get(requestId);
399+
if (!pending) return;
400+
this._pending.delete(requestId);
401+
pending.reject(new Error(message));
402+
try {
403+
this.cancel(requestId);
404+
} catch {
405+
// The worker may already have completed; cancellation is best-effort.
406+
}
407+
}
408+
409+
private _startBlobTransfer(
410+
requestId: string,
411+
data: Record<string, unknown>
412+
): void {
413+
const pending = this._pending.get(requestId);
414+
const name = data["name"];
415+
const size = data["size"];
416+
if (!pending || typeof name !== "string" || typeof size !== "number") return;
417+
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_RESULT_BLOB_BYTES) {
418+
this._rejectBlobTransfer(
419+
requestId,
420+
`Python worker declared invalid blob size ${String(size)} for "${name}"`
421+
);
422+
return;
423+
}
424+
pending.blobTransfers ??= new Map();
425+
pending.completedBlobs ??= Object.create(null) as Record<string, Uint8Array>;
426+
if (pending.blobTransfers.has(name) || Object.hasOwn(pending.completedBlobs, name)) {
427+
this._rejectBlobTransfer(requestId, `Python worker started duplicate blob "${name}"`);
428+
return;
429+
}
430+
pending.blobTransfers.set(name, { size, received: 0, chunks: [] });
431+
}
432+
433+
private _appendBlobChunk(
434+
requestId: string,
435+
data: Record<string, unknown>
436+
): void {
437+
const pending = this._pending.get(requestId);
438+
const name = data["name"];
439+
const offset = data["offset"];
440+
const bytes = data["bytes"];
441+
if (
442+
!pending ||
443+
typeof name !== "string" ||
444+
typeof offset !== "number" ||
445+
!(bytes instanceof Uint8Array)
446+
) return;
447+
const transfer = pending.blobTransfers?.get(name);
448+
if (!transfer || offset !== transfer.received || offset + bytes.length > transfer.size) {
449+
this._rejectBlobTransfer(
450+
requestId,
451+
`Python worker sent an out-of-order or oversized chunk for blob "${name}"`
452+
);
453+
return;
454+
}
455+
transfer.chunks.push(bytes);
456+
transfer.received += bytes.length;
457+
}
458+
459+
private _finishBlobTransfer(
460+
requestId: string,
461+
data: Record<string, unknown>
462+
): void {
463+
const pending = this._pending.get(requestId);
464+
const name = data["name"];
465+
const size = data["size"];
466+
const expectedDigest = data["sha256"];
467+
if (
468+
!pending ||
469+
typeof name !== "string" ||
470+
typeof size !== "number" ||
471+
typeof expectedDigest !== "string"
472+
) return;
473+
const transfer = pending.blobTransfers?.get(name);
474+
if (!transfer || size !== transfer.size || transfer.received !== transfer.size) {
475+
this._rejectBlobTransfer(requestId, `Python worker truncated blob "${name}"`);
476+
return;
477+
}
478+
const blob = new Uint8Array(transfer.size);
479+
let offset = 0;
480+
for (const chunk of transfer.chunks) {
481+
blob.set(chunk, offset);
482+
offset += chunk.length;
483+
}
484+
const digest = nodeCrypto?.createHash("sha256").update(blob).digest("hex");
485+
if (!digest || digest !== expectedDigest) {
486+
this._rejectBlobTransfer(requestId, `Python worker blob "${name}" failed SHA-256 verification`);
487+
return;
488+
}
489+
pending.blobTransfers?.delete(name);
490+
pending.completedBlobs ??= Object.create(null) as Record<string, Uint8Array>;
491+
pending.completedBlobs[name] = blob;
492+
}
493+
371494
/**
372495
* A frame decoded fine off the wire (valid msgpack) but failed its
373496
* `@nodetool-ai/protocol` schema — a worker-side protocol bug, not a
@@ -514,19 +637,27 @@ export abstract class PythonBridgeBase
514637

515638
log.debug("Python bridge execute dispatched", { nodeType, requestId });
516639

640+
const executeData: Record<string, unknown> = {
641+
node_type: nodeType,
642+
fields,
643+
secrets,
644+
blobs,
645+
...this._identityPayload(identity)
646+
};
647+
if (
648+
this._workerStatus?.protocol_version != null &&
649+
this._workerStatus.protocol_version >= 5
650+
) {
651+
executeData["blob_transfer"] = "chunked-v1";
652+
}
653+
517654
const executePromise = new Promise<ExecuteResult>((resolve, reject) => {
518655
this._pending.set(requestId, { resolve, reject, onProgress });
519656
try {
520657
this._send({
521658
type: "execute",
522659
request_id: requestId,
523-
data: {
524-
node_type: nodeType,
525-
fields,
526-
secrets,
527-
blobs,
528-
...this._identityPayload(identity)
529-
}
660+
data: executeData
530661
});
531662
} catch (err) {
532663
this._pending.delete(requestId);

packages/runtime/tests/python-bridge-base-coverage.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,8 @@ describe("PythonBridgeBase — execute", () => {
330330
node_type: "n.T",
331331
fields: { a: 1 },
332332
secrets: { KEY: "v" },
333-
blobs: {}
333+
blobs: {},
334+
blob_transfer: "chunked-v1"
334335
});
335336
bridge.handle({
336337
type: "result",

packages/runtime/tests/python-bridge-models.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ import {
1919
import type { ModelDownloadUpdate } from "../src/python-bridge-types.js";
2020

2121
describe("bridge protocol version", () => {
22-
it("is 4 (models.* + comfy.* + run identity/job.* support)", () => {
23-
expect(BRIDGE_PROTOCOL_VERSION).toBe(4);
22+
it("is 5 (chunked result blobs)", () => {
23+
expect(BRIDGE_PROTOCOL_VERSION).toBe(5);
2424
});
2525
});
2626

packages/runtime/tests/python-websocket-bridge.test-helpers.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { WebSocketServer, type WebSocket as WsServerSocket } from "ws";
1010
import { AddressInfo } from "node:net";
11+
import { createHash } from "node:crypto";
1112
import { pack, unpack } from "msgpackr";
1213

1314
import { BRIDGE_PROTOCOL_VERSION } from "@nodetool-ai/protocol/bridge-protocol";
@@ -35,6 +36,10 @@ interface FakeWorkerOptions {
3536
answerStatus?: boolean;
3637
/** When false, execute requests are silently ignored (no reply). */
3738
answerExecute?: boolean;
39+
/** Return this execute output through protocol-v5 chunked blob frames. */
40+
executeBlob?: Uint8Array;
41+
/** Override the chunked blob digest to exercise integrity failures. */
42+
executeBlobSha256?: string;
3843
/**
3944
* execute.stream behavior:
4045
* - "chunks": emit two chunks then the empty result terminator (default)
@@ -288,6 +293,8 @@ export function startFakeWorker(
288293
answerDiscover: initialOptions.answerDiscover ?? true,
289294
answerStatus: initialOptions.answerStatus ?? true,
290295
answerExecute: initialOptions.answerExecute ?? true,
296+
executeBlob: initialOptions.executeBlob ?? new Uint8Array(),
297+
executeBlobSha256: initialOptions.executeBlobSha256 ?? "",
291298
streamMode: initialOptions.streamMode ?? "chunks",
292299
protocolVersion: initialOptions.protocolVersion ?? BRIDGE_PROTOCOL_VERSION,
293300
downloadMode: initialOptions.downloadMode ?? "progress",
@@ -372,13 +379,45 @@ export function startFakeWorker(
372379
break;
373380
case "execute": {
374381
if (!opts.answerExecute) break;
375-
const data = msg.data as { fields?: Record<string, unknown> };
382+
const data = msg.data as {
383+
fields?: Record<string, unknown>;
384+
blob_transfer?: string;
385+
};
386+
if (opts.executeBlob.length > 0 && data.blob_transfer === "chunked-v1") {
387+
const blob = opts.executeBlob;
388+
send({
389+
type: "blob.start",
390+
request_id: requestId,
391+
data: { name: "out", size: blob.length }
392+
});
393+
for (let offset = 0; offset < blob.length; offset += 3) {
394+
send({
395+
type: "blob.chunk",
396+
request_id: requestId,
397+
data: { name: "out", offset, bytes: blob.subarray(offset, offset + 3) }
398+
});
399+
}
400+
send({
401+
type: "blob.end",
402+
request_id: requestId,
403+
data: {
404+
name: "out",
405+
size: blob.length,
406+
sha256:
407+
opts.executeBlobSha256 ||
408+
createHash("sha256").update(blob).digest("hex")
409+
}
410+
});
411+
}
376412
send({
377413
type: "result",
378414
request_id: requestId,
379415
data: {
380416
outputs: { out: (data.fields?.value as string) ?? "executed" },
381-
blobs: {}
417+
blobs:
418+
opts.executeBlob.length > 0 && data.blob_transfer !== "chunked-v1"
419+
? { out: opts.executeBlob }
420+
: {}
382421
}
383422
});
384423
break;

0 commit comments

Comments
 (0)