@@ -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
126134interface PendingStreamRequest {
@@ -138,6 +146,9 @@ const DEFAULT_STATUS_TIMEOUT_MS = Number(
138146const 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 ) ;
0 commit comments