Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 88 additions & 37 deletions lib/requester.js

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add repro tests to the test suite in the repo?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need specific repro tests for this because the existing tests (test-basic/bindingFromParam.js, test-basic/optic-cts-param-test.js, etc) reproduce the same bug.

Original file line number Diff line number Diff line change
Expand Up @@ -420,11 +420,14 @@ 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: 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;
Expand Down Expand Up @@ -468,41 +471,79 @@ 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,
});
// 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);
requestPartsProvider.call(operation, multipartStream);
multipartStream.on('error', err => request.destroy(err));
multipartStream.pipe(request);
} else {
const parts = operation.requestPartList;
if (Array.isArray(parts)) {
const partsLen = parts.length;
operation.logger.debug('writing %s parts', partsLen);
for (let i=0; i < partsLen; i++) {
// 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 ((headers !== null) && (content !== null)) {
operation.logger.debug('starting part %s', i);
multipartStream.addPart({
headers: headers,
body: content
});
multipartStream.addPart({ headers: headers, body: content });
operation.logger.debug('finished part %s', i);
} 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]);
}
}
multipartStream.pipe(request);
}
function chunkedRequester(request) {
/*jshint validthis:true */
Expand Down Expand Up @@ -530,38 +571,48 @@ 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('error', err => request.destroy(err));
requestWriter.pipe(request, { end: false });
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);
}
}

Expand Down
Loading