From f76f8932c971997166373cde3b0d221a1bbcd9b2 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Mon, 24 Aug 2026 14:46:24 -0400 Subject: [PATCH 1/5] MLE-32051 Fix multipart body termination for Node.js v26 compatibility --- lib/requester.js | 77 ++++++++++++++++++++++++++++++------------------ 1 file changed, 49 insertions(+), 28 deletions(-) diff --git a/lib/requester.js b/lib/requester.js index 07b777f3..bd011e57 100644 --- a/lib/requester.js +++ b/lib/requester.js @@ -420,11 +420,13 @@ function multipartRequester(request) { const operation = this; const operationBoundary = operation.multipartBoundary; - const multipartStream = new Multipart((operationBoundary == null) ? - mlutil.multipartBoundary : operationBoundary); + const boundary = (operationBoundary == null) ? mlutil.multipartBoundary : operationBoundary; + const CRNL = '\r\n'; const requestPartsProvider = operation.requestPartsProvider; - if(operation.bindingParam) { + if (operation.bindingParam) { + // bindingParam path: use multipart-stream (form-data body is a stream) + const multipartStream = new Multipart(boundary); const form = new formData(); const bindingParam = operation.bindingParam; const query = bindingParam.query; @@ -475,25 +477,34 @@ function multipartRequester(request) { }, body: form, }); + multipartStream.on('data', chunk => request.write(chunk)); + multipartStream.on('end', () => request.end()); } else if (typeof requestPartsProvider === 'function') { + // requestPartsProvider path: use multipart-stream (provider controls parts) + const multipartStream = new Multipart(boundary); requestPartsProvider.call(operation, multipartStream); + multipartStream.on('data', chunk => request.write(chunk)); + multipartStream.on('end', () => request.end()); } else { + // requestPartList path: all parts have synchronous content — build body directly + // as a Buffer to avoid sandwich-stream's 'readable' event regression on Node.js v26+. const parts = operation.requestPartList; + const bufs = [Buffer.from('--' + boundary + CRNL)]; + let written = 0; if (Array.isArray(parts)) { - const partsLen = parts.length; - operation.logger.debug('writing %s parts', partsLen); - for (let i=0; i < partsLen; i++) { + operation.logger.debug('writing %s parts', parts.length); + for (let i = 0; i < parts.length; i++) { const part = parts[i]; const headers = part.headers; const content = part.content; - if ((headers != null) && - (content != null)) { + if ((headers != null) && (content != null)) { + if (written > 0) bufs.push(Buffer.from(CRNL + '--' + boundary + CRNL)); operation.logger.debug('starting part %s', i); - multipartStream.addPart({ - headers: headers, - body: content - }); + Object.entries(headers).forEach(([k, v]) => bufs.push(Buffer.from(k + ': ' + v + CRNL))); + bufs.push(Buffer.from(CRNL)); + bufs.push(Buffer.isBuffer(content) ? content : Buffer.from(content)); operation.logger.debug('finished part %s', i); + written++; } else { operation.logger.debug('nothing to write for part %d', i); } @@ -501,8 +512,9 @@ function multipartRequester(request) { } else { operation.logger.debug('no part list to write'); } + bufs.push(Buffer.from(CRNL + '--' + boundary + '--')); + request.end(Buffer.concat(bufs)); } - multipartStream.pipe(request); } function chunkedRequester(request) { /*jshint validthis:true */ @@ -530,38 +542,47 @@ function chunkedMultipartRequester(request) { request.end(); } else { const operationBoundary = operation.multipartBoundary; + const boundary = (operationBoundary == null) ? mlutil.multipartBoundary : operationBoundary; + const CRNL = '\r\n'; - const multipartStream = new Multipart((operationBoundary == null) ? - mlutil.multipartBoundary : operationBoundary); - + // Write all non-stream (metadata) parts directly as Buffers, then pipe the + // streaming content part into the request. This avoids the sandwich-stream + // 'readable' event regression on Node.js v26+ where multi-chunk PassThrough + // streams are not fully drained, leaving the closing boundary unemitted. const partLast = requestDocument.length - 1; + request.write(Buffer.from('--' + boundary + CRNL)); + let written = 0; for (let i=0; i <= partLast; i++) { const part = requestDocument[i]; const headers = part.headers; if (i < partLast) { const content = part.content; - if ((headers != null) && - (content != null)) { - multipartStream.addPart({ - headers: headers, - body: mlutil.marshal(content, operation) - }); + if ((headers != null) && (content != null)) { + if (written > 0) request.write(Buffer.from(CRNL + '--' + boundary + CRNL)); + Object.entries(headers).forEach(([k, v]) => request.write(Buffer.from(k + ': ' + v + CRNL))); + request.write(Buffer.from(CRNL)); + const marshaledContent = mlutil.marshal(content, operation); + request.write(Buffer.isBuffer(marshaledContent) ? marshaledContent : Buffer.from(marshaledContent)); + written++; } else { operation.logger.debug('could not write metadata part'); } } else { if (headers != null) { - multipartStream.addPart({ - headers: headers, - body: requestWriter - }); + if (written > 0) request.write(Buffer.from(CRNL + '--' + boundary + CRNL)); + Object.entries(headers).forEach(([k, v]) => request.write(Buffer.from(k + ': ' + v + CRNL))); + request.write(Buffer.from(CRNL)); + requestWriter.on('data', chunk => request.write(chunk)); + requestWriter.on('end', () => { + request.write(Buffer.from(CRNL + '--' + boundary + '--')); + request.end(); + }); } else { operation.logger.debug('could not write content part'); + request.end(); } } } - - multipartStream.pipe(request); } } From 4b3c60481660e4dcfc6cae5059eebf064280f59f Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Mon, 24 Aug 2026 15:18:58 -0400 Subject: [PATCH 2/5] MLE-32051 lint fixes --- lib/requester.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/requester.js b/lib/requester.js index bd011e57..ac35bda1 100644 --- a/lib/requester.js +++ b/lib/requester.js @@ -498,7 +498,7 @@ function multipartRequester(request) { const headers = part.headers; const content = part.content; if ((headers != null) && (content != null)) { - if (written > 0) bufs.push(Buffer.from(CRNL + '--' + boundary + CRNL)); + if (written > 0) { bufs.push(Buffer.from(CRNL + '--' + boundary + CRNL)); } operation.logger.debug('starting part %s', i); Object.entries(headers).forEach(([k, v]) => bufs.push(Buffer.from(k + ': ' + v + CRNL))); bufs.push(Buffer.from(CRNL)); @@ -558,7 +558,7 @@ function chunkedMultipartRequester(request) { if (i < partLast) { const content = part.content; if ((headers != null) && (content != null)) { - if (written > 0) request.write(Buffer.from(CRNL + '--' + boundary + CRNL)); + if (written > 0) { request.write(Buffer.from(CRNL + '--' + boundary + CRNL)); } Object.entries(headers).forEach(([k, v]) => request.write(Buffer.from(k + ': ' + v + CRNL))); request.write(Buffer.from(CRNL)); const marshaledContent = mlutil.marshal(content, operation); @@ -569,7 +569,7 @@ function chunkedMultipartRequester(request) { } } else { if (headers != null) { - if (written > 0) request.write(Buffer.from(CRNL + '--' + boundary + CRNL)); + if (written > 0) { request.write(Buffer.from(CRNL + '--' + boundary + CRNL)); } Object.entries(headers).forEach(([k, v]) => request.write(Buffer.from(k + ': ' + v + CRNL))); request.write(Buffer.from(CRNL)); requestWriter.on('data', chunk => request.write(chunk)); From 78a86789de648b324c6dc6e088bd65b7b7b096e9 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Mon, 24 Aug 2026 15:37:50 -0400 Subject: [PATCH 3/5] MLE-32051 Copilot Suggestion Fixes --- lib/requester.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/requester.js b/lib/requester.js index ac35bda1..e2d9534f 100644 --- a/lib/requester.js +++ b/lib/requester.js @@ -477,14 +477,14 @@ function multipartRequester(request) { }, body: form, }); - multipartStream.on('data', chunk => request.write(chunk)); - multipartStream.on('end', () => request.end()); + multipartStream.on('error', err => request.destroy(err)); + multipartStream.pipe(request); } else if (typeof requestPartsProvider === 'function') { // requestPartsProvider path: use multipart-stream (provider controls parts) const multipartStream = new Multipart(boundary); requestPartsProvider.call(operation, multipartStream); - multipartStream.on('data', chunk => request.write(chunk)); - multipartStream.on('end', () => request.end()); + multipartStream.on('error', err => request.destroy(err)); + multipartStream.pipe(request); } else { // requestPartList path: all parts have synchronous content — build body directly // as a Buffer to avoid sandwich-stream's 'readable' event regression on Node.js v26+. @@ -513,7 +513,8 @@ function multipartRequester(request) { operation.logger.debug('no part list to write'); } bufs.push(Buffer.from(CRNL + '--' + boundary + '--')); - request.end(Buffer.concat(bufs)); + for (let i = 0; i < bufs.length - 1; i++) { request.write(bufs[i]); } + request.end(bufs[bufs.length - 1]); } } function chunkedRequester(request) { @@ -572,7 +573,8 @@ function chunkedMultipartRequester(request) { if (written > 0) { request.write(Buffer.from(CRNL + '--' + boundary + CRNL)); } Object.entries(headers).forEach(([k, v]) => request.write(Buffer.from(k + ': ' + v + CRNL))); request.write(Buffer.from(CRNL)); - requestWriter.on('data', chunk => request.write(chunk)); + requestWriter.on('error', err => request.destroy(err)); + requestWriter.pipe(request, { end: false }); requestWriter.on('end', () => { request.write(Buffer.from(CRNL + '--' + boundary + '--')); request.end(); From d3c2b682c793331ca811ada4e70f9df7fc77b6ab Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 25 Aug 2026 08:44:27 -0400 Subject: [PATCH 4/5] MLE-32051 Jenkins test fixes --- lib/requester.js | 55 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/lib/requester.js b/lib/requester.js index e2d9534f..7f736e75 100644 --- a/lib/requester.js +++ b/lib/requester.js @@ -486,35 +486,60 @@ function multipartRequester(request) { multipartStream.on('error', err => request.destroy(err)); multipartStream.pipe(request); } else { - // requestPartList path: all parts have synchronous content — build body directly - // as a Buffer to avoid sandwich-stream's 'readable' event regression on Node.js v26+. const parts = operation.requestPartList; - const bufs = [Buffer.from('--' + boundary + CRNL)]; - let written = 0; - if (Array.isArray(parts)) { + // Stream-driven PassThroughs receive writes asynchronously (after listeners are + // attached), so multipart-stream works correctly for them on all Node.js versions. + // Only the all-synchronous case triggers the Node.js v26+ 'readable' regression. + const hasStreamContent = Array.isArray(parts) && + parts.some(p => p.content !== null && typeof p.content.pipe === 'function'); + + if (hasStreamContent) { + const multipartStream = new Multipart(boundary); operation.logger.debug('writing %s parts', parts.length); for (let i = 0; i < parts.length; i++) { const part = parts[i]; const headers = part.headers; const content = part.content; - if ((headers != null) && (content != null)) { - if (written > 0) { bufs.push(Buffer.from(CRNL + '--' + boundary + CRNL)); } + if ((headers !== null) && (content !== null)) { operation.logger.debug('starting part %s', i); - Object.entries(headers).forEach(([k, v]) => bufs.push(Buffer.from(k + ': ' + v + CRNL))); - bufs.push(Buffer.from(CRNL)); - bufs.push(Buffer.isBuffer(content) ? content : Buffer.from(content)); + multipartStream.addPart({ headers: headers, body: content }); operation.logger.debug('finished part %s', i); - written++; } else { operation.logger.debug('nothing to write for part %d', i); } } + multipartStream.on('error', err => request.destroy(err)); + multipartStream.pipe(request); } else { - operation.logger.debug('no part list to write'); + // All synchronous content: build body directly as Buffers to avoid + // sandwich-stream's 'readable' event regression on Node.js v26+. + const bufs = [Buffer.from('--' + boundary + CRNL)]; + let written = 0; + if (Array.isArray(parts)) { + operation.logger.debug('writing %s parts', parts.length); + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const headers = part.headers; + const content = part.content; + if ((headers != null) && (content != null)) { + if (written > 0) { bufs.push(Buffer.from(CRNL + '--' + boundary + CRNL)); } + operation.logger.debug('starting part %s', i); + Object.entries(headers).forEach(([k, v]) => bufs.push(Buffer.from(k + ': ' + v + CRNL))); + bufs.push(Buffer.from(CRNL)); + bufs.push(Buffer.isBuffer(content) ? content : Buffer.from(content)); + operation.logger.debug('finished part %s', i); + written++; + } else { + operation.logger.debug('nothing to write for part %d', i); + } + } + } else { + operation.logger.debug('no part list to write'); + } + bufs.push(Buffer.from(CRNL + '--' + boundary + '--')); + for (let i = 0; i < bufs.length - 1; i++) { request.write(bufs[i]); } + request.end(bufs[bufs.length - 1]); } - bufs.push(Buffer.from(CRNL + '--' + boundary + '--')); - for (let i = 0; i < bufs.length - 1; i++) { request.write(bufs[i]); } - request.end(bufs[bufs.length - 1]); } } function chunkedRequester(request) { From 4d5d724e93fa20825265f12f1574df841e3a5f98 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Wed, 26 Aug 2026 09:04:02 -0400 Subject: [PATCH 5/5] MLE-32051 Test fixes for test-basic/bindingFromParam.js --- lib/requester.js | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/lib/requester.js b/lib/requester.js index 7f736e75..efb13400 100644 --- a/lib/requester.js +++ b/lib/requester.js @@ -425,8 +425,9 @@ function multipartRequester(request) { const requestPartsProvider = operation.requestPartsProvider; if (operation.bindingParam) { - // bindingParam path: use multipart-stream (form-data body is a stream) - const multipartStream = new Multipart(boundary); + // bindingParam path: write boundary+headers as Buffers, pipe form-data stream directly. + // multipart-stream's addPart() writes headers synchronously to a PassThrough before piping, + // triggering the same Node.js v26+ 'readable' regression as the requestPartList path. const form = new formData(); const bindingParam = operation.bindingParam; const query = bindingParam.query; @@ -470,15 +471,17 @@ function multipartRequester(request) { form.append('metadata', JSON.stringify(metadata), {contentType: 'application/json', filename: 'metadata.json'}); } - multipartStream.add({ - headers: { - 'Content-Type': 'multipart/form-data; boundary=' + mlutil.multipartBoundary, - Accept: 'application/json', - }, - body: form, - }); - multipartStream.on('error', err => request.destroy(err)); - multipartStream.pipe(request); + // All bindingParam content (query AST, binding, attachments, metadata) is synchronous + // JSON so getBuffer() collects it without streaming, avoiding the Node.js v26+ + // sandwich-stream 'readable' regression entirely. + const formBuffer = form.getBuffer(); + request.write(Buffer.from('--' + boundary + CRNL)); + request.write(Buffer.from('Content-Type: multipart/form-data; boundary=' + mlutil.multipartBoundary + CRNL)); + request.write(Buffer.from('Accept: application/json' + CRNL)); + request.write(Buffer.from(CRNL)); + request.write(formBuffer); + request.write(Buffer.from(CRNL + '--' + boundary + '--')); + request.end(); } else if (typeof requestPartsProvider === 'function') { // requestPartsProvider path: use multipart-stream (provider controls parts) const multipartStream = new Multipart(boundary);