Skip to content
Closed
Show file tree
Hide file tree
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
21 changes: 18 additions & 3 deletions src/jsc/bindings/node/http/JSHTTPParserPrototype.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,27 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPParser_execute, (JSGlobalObject * globalObject, C
throwOutOfMemoryError(globalObject, scope);
return {};
}
if (!backingBuffer->isShared())

std::span<const uint8_t> input = buffer->span();

// A pin stops transfer(), not resize() or a WebAssembly.Memory grow(), which a callback can call.
WTF::Vector<uint8_t> owned;
bool copied = !input.empty() && (backingBuffer->isResizableNonShared() || backingBuffer->isWasmMemory());
if (copied) {
if (!owned.tryAppend(input)) {
throwOutOfMemoryError(globalObject, scope);
return {};
}
input = owned.span();
}

bool pinned = !copied && !backingBuffer->isShared();
if (pinned)
backingBuffer->pin();

JSValue result = parser->impl()->execute(globalObject, reinterpret_cast<const char*>(buffer->vector()), buffer->byteLength());
JSValue result = parser->impl()->execute(globalObject, reinterpret_cast<const char*>(input.data()), input.size());

if (!backingBuffer->isShared())
if (pinned)
backingBuffer->unpin();
RETURN_IF_EXCEPTION(scope, {});

Expand Down
103 changes: 103 additions & 0 deletions test/js/node/http/node-http-parser.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
const { HTTPParser, ConnectionsList, methods, allMethods } = process.binding("http_parser");
const { parsers } = require("node:_http_common");

Expand Down Expand Up @@ -183,6 +184,108 @@ describe("HTTPParser.prototype.execute", () => {
expect(executed).toBe(inputLength);
});

// A callback can shrink a resizable ArrayBuffer, or grow a WebAssembly.Memory, while llhttp is
// still scanning the input. Both unmap the input bytes, which a pin does not stop, so each case
// runs in a child process: the parse must finish on bytes that stay mapped.
const head = "POST / HTTP/1.1\r\nHost: a\r\nTransfer-Encoding: chunked\r\n\r\n";
const tail = "0\r\n\r\n";
const payloadSize = 1024;
const chunkSize = `${payloadSize.toString(16)}\r\n`.length + payloadSize + "\r\n".length;
// A resizable ArrayBuffer of its own, or one WebAssembly.Memory page.
const inputSizes = { resizable: 1024 * 1024, wasm: 64 * 1024 };
// A signaling wasm memory reserves its maximum up front and commits pages in place as it grows,
// which leaves the input mapped. Only a bounds checked memory releases the block it grew out of,
// so the wasm case asks JSC for that mode instead of counting on a full signaling memory pool.
const modeEnv = { resizable: bunEnv, wasm: { ...bunEnv, BUN_JSC_useWasmFastMemory: "0" } };

const fixture = (mode: keyof typeof inputSizes) => `
const { HTTPParser } = process.binding("http_parser");

let bytes, mutate;
if (${JSON.stringify(mode)} === "resizable") {
const size = ${inputSizes.resizable};
const buffer = new ArrayBuffer(size, { maxByteLength: size });
bytes = new Uint8Array(buffer);
// resize() decommits the trimmed pages.
mutate = () => buffer.resize(0);
} else {
const memory = new WebAssembly.Memory({ initial: 1, maximum: 2 });
bytes = new Uint8Array(memory.buffer);
// grow() detaches the old buffer and releases the block it held.
mutate = () => memory.grow(1);
}

const payload = Buffer.alloc(${payloadSize}, 0x61);
const chunk = Buffer.concat([Buffer.from(${JSON.stringify(`${payloadSize.toString(16)}\r\n`)}), payload, Buffer.from("\\r\\n")]);
const head = Buffer.from(${JSON.stringify(head)});
const tail = Buffer.from(${JSON.stringify(tail)});

bytes.set(head, 0);
let end = head.byteLength;
while (end + chunk.byteLength + tail.byteLength <= bytes.byteLength) {
bytes.set(chunk, end);
end += chunk.byteLength;
}
bytes.set(tail, end);
end += tail.byteLength;

const parser = new HTTPParser();
parser.initialize(HTTPParser.REQUEST, {});

let mutated = false;
let bodyBytes = 0;
let bodyMatches = true;
let complete = false;
let currentBuffer = -1;
parser[HTTPParser.kOnHeadersComplete] = () => 0;
parser[HTTPParser.kOnBody] = received => {
bodyBytes += received.byteLength;
if (!received.equals(payload.subarray(0, received.byteLength))) bodyMatches = false;
if (!mutated) {
mutated = true;
mutate();
// getCurrentBuffer() copies out of the same bytes llhttp is reading.
currentBuffer = parser.getCurrentBuffer().byteLength;
}
};
parser[HTTPParser.kOnMessageComplete] = () => {
complete = true;
};

const executed = parser.execute(bytes.subarray(0, end));
console.log(JSON.stringify({ executed, bodyBytes, bodyMatches, complete, mutated, currentBuffer: currentBuffer === end }));
`;

test.each(Object.keys(inputSizes) as (keyof typeof inputSizes)[])(
"finishes the parse when a callback unmaps the input (%s)",
async mode => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", fixture(mode)],
env: modeEnv[mode],
stderr: "pipe",
});

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

const chunks = Math.floor((inputSizes[mode] - head.length - tail.length) / chunkSize);

// `executed` covers the whole request, so llhttp read every chunk that follows the callback
// which unmapped the input, and each one held the bytes the request was built with.
expect({ stdout: JSON.parse(stdout.trim() || "null"), stderr }).toEqual({
stdout: {
executed: head.length + chunks * chunkSize + tail.length,
bodyBytes: chunks * payloadSize,
bodyMatches: true,
complete: true,
mutated: true,
currentBuffer: true,
},
stderr: "",
});
expect(exitCode).toBe(0);
},
);

test("rejects re-entrant execute, even after a nested finish()", async () => {
const parser = new HTTPParser();
parser.initialize(HTTPParser.REQUEST, {});
Expand Down
Loading