From 165cfc23c95c8cbb5f756ba5257cf62b9f8534bd Mon Sep 17 00:00:00 2001 From: muralidhar-reddy Date: Sat, 23 May 2026 11:20:40 +0530 Subject: [PATCH 1/3] fix: buffer multi-segment HTTP POST/PUT bodies in fetch network adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a POST or PUT request body spans multiple TCP segments (~1460 bytes each), only the first segment's body bytes were used — subsequent segments were silently dropped because this.read was cleared after the first \r\n\r\n delimiter match, and no more headers trigger re-processing. This caused "Invalid JSON" errors for any request body larger than approximately one MTU. Fix: - On the first segment: if Content-Length is present and the body is incomplete, store the partial body in this._xb with a deferred callback instead of firing fetch() immediately. - On subsequent segments: accumulate incoming data into this._xb.buf. Once Content-Length is satisfied, decode and invoke the deferred fetch. - Extract the fetch+response handling into _dispatch_fetch() to avoid duplicating it in both the normal and deferred code paths. --- src/browser/fetch_network.js | 130 +++++++++++++++++++++++------------ 1 file changed, 87 insertions(+), 43 deletions(-) diff --git a/src/browser/fetch_network.js b/src/browser/fetch_network.js index f40fc9c36f..718777ac54 100644 --- a/src/browser/fetch_network.js +++ b/src/browser/fetch_network.js @@ -74,6 +74,23 @@ FetchNetworkAdapter.prototype.tcp_probe = function(port) */ async function on_data_http(data) { + // If we're buffering a partial request body, accumulate chunks until + // Content-Length is satisfied, then fire the deferred fetch. + if(this._xb) { + const chunk = data instanceof Uint8Array ? data : new Uint8Array(data); + const combined = new Uint8Array(this._xb.buf.length + chunk.length); + combined.set(this._xb.buf); + combined.set(chunk, this._xb.buf.length); + this._xb.buf = combined; + if(this._xb.buf.length >= this._xb.cl) { + const body = new TextDecoder().decode(this._xb.buf); + const done = this._xb.done; + this._xb = null; + done(body); + } + return; + } + this.read = this.read || ""; this.read += new TextDecoder().decode(data); if(this.read && this.read.indexOf("\r\n\r\n") !== -1) { @@ -129,57 +146,84 @@ async function on_data_http(data) headers: req_headers, }; if(["put", "post"].indexOf(opts.method.toLowerCase()) !== -1) { + // Check if the body might be split across multiple TCP segments. + // If Content-Length is present and larger than what we have, + // buffer and wait for the remaining chunks. + const content_length = parseInt(req_headers.get("content-length") || "0", 10); + const body_bytes = (data instanceof Uint8Array) ? data : new TextEncoder().encode(data); + if(content_length > 0 && body_bytes.length < content_length) { + const fetch_url = this.net.cors_proxy ? this.net.cors_proxy + encodeURIComponent(target.href) : target.href; + this._xb = { + buf: body_bytes, + cl: content_length, + done: (body) => { + opts.body = body; + this._dispatch_fetch(fetch_url, opts); + }, + }; + return; + } opts.body = data; } const fetch_url = this.net.cors_proxy ? this.net.cors_proxy + encodeURIComponent(target.href) : target.href; - const encoder = new TextEncoder(); - let response_started = false; - let handler = (resp) => { - let resp_headers = new Headers(resp.headers); - resp_headers.delete("content-encoding"); - resp_headers.delete("keep-alive"); - resp_headers.delete("content-length"); - resp_headers.delete("transfer-encoding"); - resp_headers.set("x-was-fetch-redirected", `${!!resp.redirected}`); - resp_headers.set("x-fetch-resp-url", resp.url); - resp_headers.set("connection", "close"); + this._dispatch_fetch(fetch_url, opts); + } +} - this.write(this.net.form_response_head(resp.status, resp.statusText, resp_headers)); - response_started = true; +/** + * @this {TCPConnection} + * @param {string} fetch_url + * @param {!Object} opts + */ +FetchNetworkAdapter.prototype._dispatch_fetch = function(fetch_url, opts) +{ + const encoder = new TextEncoder(); + let response_started = false; + let handler = (resp) => { + let resp_headers = new Headers(resp.headers); + resp_headers.delete("content-encoding"); + resp_headers.delete("keep-alive"); + resp_headers.delete("content-length"); + resp_headers.delete("transfer-encoding"); + resp_headers.set("x-was-fetch-redirected", `${!!resp.redirected}`); + resp_headers.set("x-fetch-resp-url", resp.url); + resp_headers.set("connection", "close"); - if(resp.body && resp.body.getReader) { - const resp_reader = resp.body.getReader(); - const pump = ({ value, done }) => { - if(value) { - this.write(value); - } - if(done) { - this.close(); - } - else { - return resp_reader.read().then(pump); - } - }; - resp_reader.read().then(pump); - } else { - resp.arrayBuffer().then(buffer => { - this.write(new Uint8Array(buffer)); + this.write(this.net.form_response_head(resp.status, resp.statusText, resp_headers)); + response_started = true; + + if(resp.body && resp.body.getReader) { + const resp_reader = resp.body.getReader(); + const pump = ({ value, done }) => { + if(value) { + this.write(value); + } + if(done) { this.close(); - }); - } - }; + } + else { + return resp_reader.read().then(pump); + } + }; + resp_reader.read().then(pump); + } else { + resp.arrayBuffer().then(buffer => { + this.write(new Uint8Array(buffer)); + this.close(); + }); + } + }; - this.net.fetch(fetch_url, opts).then(handler) - .catch((e) => { - console.warn("Fetch Failed: " + fetch_url + "\n" + e); - if(!response_started) { - this.net.respond_text_and_close(this, 502, "Fetch Error", `Fetch ${fetch_url} failed:\n\n${e.stack || e.message}`); - } - this.close(); - }); - } -} + this.net.fetch(fetch_url, opts).then(handler) + .catch((e) => { + console.warn("Fetch Failed: " + fetch_url + "\n" + e); + if(!response_started) { + this.net.respond_text_and_close(this, 502, "Fetch Error", `Fetch ${fetch_url} failed:\n\n${e.stack || e.message}`); + } + this.close(); + }); +}; FetchNetworkAdapter.prototype.fetch = async function(url, options) { From 2e022cd6a4edb9d859e3570533043dc1c336be99 Mon Sep 17 00:00:00 2001 From: muralidhar-reddy Date: Mon, 25 May 2026 12:33:30 +0530 Subject: [PATCH 2/3] fix: binary-safe HTTP body buffering for multi-segment POST/PUT Addresses reviewer feedback on PR #1567: - Buffer incoming TCP data as raw bytes (Uint8Array) instead of text-decoding everything. Search for \r\n\r\n in binary, text-decode only the header portion. This prevents corruption of binary POST bodies and ensures the first \r\n\r\n in headers (not in body) is used as the separator. - Replace ad-hoc new TextEncoder()/new TextDecoder() with module-scoped singletons (textEncoder, textDecoder). - Refactor _dispatch_fetch into a standalone dispatch_fetch(conn, ...) helper that takes the connection explicitly, avoiding incorrect binding between the connection and adapter objects. - Add comment noting that Transfer-Encoding: chunked is unsupported. - Add unit tests (tests/devices/fetch_network_post.js): * Small POST body in one segment * Large POST body split across 3 segments * Binary body containing embedded CRLFCRLF bytes * GET request (no body) * Headers split across 2 segments --- src/browser/fetch_network.js | 271 ++++++++++++++++++---------- tests/devices/fetch_network_post.js | 189 +++++++++++++++++++ 2 files changed, 364 insertions(+), 96 deletions(-) create mode 100644 tests/devices/fetch_network_post.js diff --git a/src/browser/fetch_network.js b/src/browser/fetch_network.js index 718777ac54..cf3c7a8e20 100644 --- a/src/browser/fetch_network.js +++ b/src/browser/fetch_network.js @@ -13,6 +13,28 @@ import { // For Types Only import { BusConnector } from "../bus.js"; +// Module-scoped encoder/decoder singletons (avoids repeated allocations). +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +/** Find the first occurrence of `needle` bytes in `haystack`. Returns -1 if not found. */ +function indexOfBytes(haystack, needle) +{ + outer: + for(let i = 0; i <= haystack.length - needle.length; i++) + { + for(let j = 0; j < needle.length; j++) + { + if(haystack[i + j] !== needle[j]) continue outer; + } + return i; + } + return -1; +} + +// Pre-encoded \r\n\r\n separator for binary header/body split. +const CRLFCRLF = textEncoder.encode("\r\n\r\n"); + /** * @constructor * @@ -69,6 +91,19 @@ FetchNetworkAdapter.prototype.tcp_probe = function(port) }; /** + * HTTP data handler for port-80 TCP connections. + * + * Incoming TCP segments are buffered as raw bytes and searched for the + * \r\n\r\n header/body separator. Once found the header portion alone is + * text-decoded and parsed; the body stays as a binary Uint8Array. + * + * When a POST/PUT body spans multiple TCP segments the partial body is + * stored in this._xb and subsequent segments are accumulated there until + * Content-Length is satisfied, at which point the deferred fetch is fired. + * + * NOTE: Transfer-Encoding: chunked is not supported. Requests using it + * will not be dispatched. + * * @this {TCPConnection} * @param {!ArrayBuffer} data */ @@ -76,14 +111,16 @@ async function on_data_http(data) { // If we're buffering a partial request body, accumulate chunks until // Content-Length is satisfied, then fire the deferred fetch. - if(this._xb) { + if(this._xb) + { const chunk = data instanceof Uint8Array ? data : new Uint8Array(data); const combined = new Uint8Array(this._xb.buf.length + chunk.length); combined.set(this._xb.buf); combined.set(chunk, this._xb.buf.length); this._xb.buf = combined; - if(this._xb.buf.length >= this._xb.cl) { - const body = new TextDecoder().decode(this._xb.buf); + if(this._xb.buf.length >= this._xb.cl) + { + const body = this._xb.buf; const done = this._xb.done; this._xb = null; done(body); @@ -91,94 +128,127 @@ async function on_data_http(data) return; } - this.read = this.read || ""; - this.read += new TextDecoder().decode(data); - if(this.read && this.read.indexOf("\r\n\r\n") !== -1) { - let offset = this.read.indexOf("\r\n\r\n"); - let headers = this.read.substring(0, offset).split(/\r\n/); - let data = this.read.substring(offset + 4); - this.read = ""; - - let first_line = headers[0].split(" "); - let target; - if(/^https?:/.test(first_line[1])) { - // HTTP proxy - target = new URL(first_line[1]); + // Accumulate raw bytes (not text) so binary body data is preserved. + const chunk = data instanceof Uint8Array ? data : new Uint8Array(data); + if(this._raw) + { + const combined = new Uint8Array(this._raw.length + chunk.length); + combined.set(this._raw); + combined.set(chunk, this._raw.length); + this._raw = combined; + } + else + { + this._raw = chunk; + } + + const sep_index = indexOfBytes(this._raw, CRLFCRLF); + if(sep_index === -1) return; + + // Split into header (text) and body (binary). + const headerBytes = this._raw.slice(0, sep_index); + const bodyBytes = this._raw.slice(sep_index + CRLFCRLF.length); + this._raw = null; + + const headerText = textDecoder.decode(headerBytes); + const headerLines = headerText.split(/\r\n/); + + const first_line = headerLines[0].split(" "); + let target; + if(/^https?:/.test(first_line[1])) + { + // HTTP proxy + target = new URL(first_line[1]); + } + else + { + target = new URL("http://host" + first_line[1]); + } + if(typeof window !== "undefined" && target.protocol === "http:" && window.location.protocol === "https:") + { + // fix "Mixed Content" errors + target.protocol = "https:"; + } + + const req_headers = new Headers(); + for(let i = 1; i < headerLines.length; ++i) + { + const header = this.net.parse_http_header(headerLines[i]); + if(!header) + { + console.warn('The request contains an invalid header: "%s"', headerLines[i]); + this.net.respond_text_and_close(this, 400, "Bad Request", `Invalid header in request: ${headerLines[i]}`); + return; } - else { - target = new URL("http://host" + first_line[1]); + if(header.key.toLowerCase() === "host") target.host = header.value; + else req_headers.append(header.key, header.value); + } + + if(!this.net.cors_proxy && /^\d+\.external$/.test(target.hostname)) + { + dbg_log("Request to localhost: " + target.href, LOG_FETCH); + const localport = parseInt(target.hostname.split(".")[0], 10); + if(!isNaN(localport) && localport > 0 && localport < 65536) + { + target.protocol = "http:"; + target.hostname = "localhost"; + target.port = localport.toString(10); } - if(typeof window !== "undefined" && target.protocol === "http:" && window.location.protocol === "https:") { - // fix "Mixed Content" errors - target.protocol = "https:"; + else + { + console.warn('Unknown port for localhost: "%s"', target.href); + this.net.respond_text_and_close(this, 400, "Bad Request", `Unknown port for localhost: ${target.href}`); + return; } + } - let req_headers = new Headers(); - for(let i = 1; i < headers.length; ++i) { - const header = this.net.parse_http_header(headers[i]); - if(!header) { - console.warn('The request contains an invalid header: "%s"', headers[i]); - this.net.respond_text_and_close(this, 400, "Bad Request", `Invalid header in request: ${headers[i]}`); - return; - } - if( header.key.toLowerCase() === "host" ) target.host = header.value; - else req_headers.append(header.key, header.value); - } + dbg_log("HTTP Dispatch: " + target.href, LOG_FETCH); + this.name = target.href; - if(!this.net.cors_proxy && /^\d+\.external$/.test(target.hostname)) { - dbg_log("Request to localhost: " + target.href, LOG_FETCH); - const localport = parseInt(target.hostname.split(".")[0], 10); - if(!isNaN(localport) && localport > 0 && localport < 65536) { - target.protocol = "http:"; - target.hostname = "localhost"; - target.port = localport.toString(10); - } else { - console.warn('Unknown port for localhost: "%s"', target.href); - this.net.respond_text_and_close(this, 400, "Bad Request", `Unknown port for localhost: ${target.href}`); - return; - } - } + const opts = { + method: first_line[0], + headers: req_headers, + }; - dbg_log("HTTP Dispatch: " + target.href, LOG_FETCH); - this.name = target.href; - let opts = { - method: first_line[0], - headers: req_headers, - }; - if(["put", "post"].indexOf(opts.method.toLowerCase()) !== -1) { - // Check if the body might be split across multiple TCP segments. - // If Content-Length is present and larger than what we have, - // buffer and wait for the remaining chunks. - const content_length = parseInt(req_headers.get("content-length") || "0", 10); - const body_bytes = (data instanceof Uint8Array) ? data : new TextEncoder().encode(data); - if(content_length > 0 && body_bytes.length < content_length) { - const fetch_url = this.net.cors_proxy ? this.net.cors_proxy + encodeURIComponent(target.href) : target.href; - this._xb = { - buf: body_bytes, - cl: content_length, - done: (body) => { - opts.body = body; - this._dispatch_fetch(fetch_url, opts); - }, - }; - return; - } - opts.body = data; + if(["put", "post"].indexOf(opts.method.toLowerCase()) !== -1) + { + // The body may span multiple TCP segments. + // If Content-Length is present and larger than what we have so far, + // buffer the partial body and wait for remaining chunks. + const content_length = parseInt(req_headers.get("content-length") || "0", 10); + if(content_length > 0 && bodyBytes.length < content_length) + { + const fetch_url = this.net.cors_proxy + ? this.net.cors_proxy + encodeURIComponent(target.href) + : target.href; + this._xb = { + buf: bodyBytes, + cl: content_length, + done: (body) => { + opts.body = body; + dispatch_fetch(this, fetch_url, opts); + }, + }; + return; } - - const fetch_url = this.net.cors_proxy ? this.net.cors_proxy + encodeURIComponent(target.href) : target.href; - this._dispatch_fetch(fetch_url, opts); + opts.body = bodyBytes; } + + const fetch_url = this.net.cors_proxy + ? this.net.cors_proxy + encodeURIComponent(target.href) + : target.href; + dispatch_fetch(this, fetch_url, opts); } /** - * @this {TCPConnection} + * Execute the HTTP fetch and pipe the response back to the guest. + * + * @param {TCPConnection} conn * @param {string} fetch_url * @param {!Object} opts */ -FetchNetworkAdapter.prototype._dispatch_fetch = function(fetch_url, opts) +function dispatch_fetch(conn, fetch_url, opts) { - const encoder = new TextEncoder(); let response_started = false; let handler = (resp) => { let resp_headers = new Headers(resp.headers); @@ -190,40 +260,47 @@ FetchNetworkAdapter.prototype._dispatch_fetch = function(fetch_url, opts) resp_headers.set("x-fetch-resp-url", resp.url); resp_headers.set("connection", "close"); - this.write(this.net.form_response_head(resp.status, resp.statusText, resp_headers)); + conn.write(conn.net.form_response_head(resp.status, resp.statusText, resp_headers)); response_started = true; - if(resp.body && resp.body.getReader) { + if(resp.body && resp.body.getReader) + { const resp_reader = resp.body.getReader(); const pump = ({ value, done }) => { - if(value) { - this.write(value); + if(value) + { + conn.write(value); } - if(done) { - this.close(); + if(done) + { + conn.close(); } - else { + else + { return resp_reader.read().then(pump); } }; resp_reader.read().then(pump); - } else { + } + else + { resp.arrayBuffer().then(buffer => { - this.write(new Uint8Array(buffer)); - this.close(); + conn.write(new Uint8Array(buffer)); + conn.close(); }); } }; - this.net.fetch(fetch_url, opts).then(handler) + conn.net.fetch(fetch_url, opts).then(handler) .catch((e) => { console.warn("Fetch Failed: " + fetch_url + "\n" + e); - if(!response_started) { - this.net.respond_text_and_close(this, 502, "Fetch Error", `Fetch ${fetch_url} failed:\n\n${e.stack || e.message}`); + if(!response_started) + { + conn.net.respond_text_and_close(conn, 502, "Fetch Error", `Fetch ${fetch_url} failed:\n\n${e.stack || e.message}`); } - this.close(); + conn.close(); }); -}; +} FetchNetworkAdapter.prototype.fetch = async function(url, options) { @@ -244,7 +321,7 @@ FetchNetworkAdapter.prototype.fetch = async function(url, options) statusText: "Fetch Error", headers: new Headers({ "Content-Type": "text/plain" }), }, - new TextEncoder().encode(`Fetch ${url} failed:\n\n${e.stack}`).buffer + textEncoder.encode(`Fetch ${url} failed:\n\n${e.stack}`).buffer ]; } }; @@ -255,11 +332,12 @@ FetchNetworkAdapter.prototype.form_response_head = function(status_code, status_ `HTTP/1.1 ${status_code} ${status_text}` ]; - for(const [key, value] of headers.entries()) { + for(const [key, value] of headers.entries()) + { lines.push(`${key}: ${value}`); } - return new TextEncoder().encode(lines.join("\r\n") + "\r\n\r\n"); + return textEncoder.encode(lines.join("\r\n") + "\r\n\r\n"); }; FetchNetworkAdapter.prototype.respond_text_and_close = function(conn, status_code, status_text, body) @@ -269,14 +347,15 @@ FetchNetworkAdapter.prototype.respond_text_and_close = function(conn, status_cod "content-length": body.length.toString(10), "connection": "close" }); - conn.writev([this.form_response_head(status_code, status_text, headers), new TextEncoder().encode(body)]); + conn.writev([this.form_response_head(status_code, status_text, headers), textEncoder.encode(body)]); conn.close(); }; FetchNetworkAdapter.prototype.parse_http_header = function(header) { const parts = header.match(/^([^:]*):(.*)$/); - if(!parts) { + if(!parts) + { dbg_log("Unable to parse HTTP header", LOG_FETCH); return; } diff --git a/tests/devices/fetch_network_post.js b/tests/devices/fetch_network_post.js new file mode 100644 index 0000000000..fb72de2ea5 --- /dev/null +++ b/tests/devices/fetch_network_post.js @@ -0,0 +1,189 @@ +#!/usr/bin/env node +/** + * Test: multi-segment HTTP POST/PUT body buffering in fetch_network adapter. + * + * Verifies that a POST body split across multiple TCP segments is fully + * buffered and dispatched, rather than truncated at the first segment. + * + * Run: node tests/devices/fetch_network_post.js + */ + +import assert from "node:assert/strict"; +import { FetchNetworkAdapter } from "../../src/browser/fetch_network.js"; + +const textEncoder = new TextEncoder(); + +let tests_passed = 0; +let tests_failed = 0; + +function test(name, fn) { + process.stdout.write(name + " ... "); + try { + fn(); + console.log("PASS"); + tests_passed++; + } catch(e) { + console.log("FAIL"); + console.log(" " + e.message); + tests_failed++; + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a minimal mock BusConnector and FetchNetworkAdapter, returning + * the captured `on_data_http` handler and the adapter itself. */ +function setup() { + // Capture fetch() calls so we can inspect them. + let fetch_calls = []; + let bus_handler = null; + + const bus = { + register(event, handler, ctx) { + if(event === "tcp-connection") bus_handler = handler.bind(ctx); + }, + send(event, data) { /* noop */ }, + }; + + const adapter = new FetchNetworkAdapter(bus, {}); + // Override adapter.fetch to capture rather than actually fetching. + adapter.fetch = (url, opts) => { + fetch_calls.push({ url, opts }); + // Return a promise that resolves with a mock response. + return Promise.resolve([ + { status: 200, statusText: "OK", headers: new Headers() }, + new ArrayBuffer(0), + ]); + }; + + // The connection's write/close methods need to exist; make them no-ops. + const connection = { + sport: 80, + handlers: {}, + on(event, handler) { + this.handlers[event] = handler; + }, + accept() {}, + write() {}, + writev() {}, + close() {}, + net: adapter, + }; + + // Trigger the bus handler to register on_data_http. + bus_handler(connection); + + const on_data = connection.handlers["data"]; + if(!on_data) throw new Error("on_data_http not registered"); + + // Wrap so `this` inside on_data_http is bound to the mock connection. + const dispatch = (buf) => on_data.call(connection, buf); + + return { dispatch, adapter, fetch_calls }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test("Small POST body fits in one segment", () => { + const { dispatch, fetch_calls } = setup(); + + const body = "hello world"; + const request = "POST /api HTTP/1.1\r\nHost: example.com\r\nContent-Length: 11\r\n\r\n" + body; + dispatch(textEncoder.encode(request).buffer); + + assert.equal(fetch_calls.length, 1, "fetch should be called once"); + const { opts } = fetch_calls[0]; + assert.ok(opts.body instanceof Uint8Array, "body should be Uint8Array"); + assert.equal(opts.body.length, 11, "body length should be 11"); +}); + +test("Large POST body split across three segments", () => { + const { dispatch, fetch_calls } = setup(); + + const bigBody = new Uint8Array(4000); + bigBody.fill(0x42); // 'B' + + const headers = "POST /api HTTP/1.1\r\nHost: example.com\r\nContent-Length: 4000\r\n\r\n"; + const hdrBytes = textEncoder.encode(headers); + + // Segment 1: headers + first 1000 bytes of body + const seg1 = new Uint8Array(hdrBytes.length + 1000); + seg1.set(hdrBytes); + seg1.set(bigBody.slice(0, 1000), hdrBytes.length); + dispatch(seg1.buffer); + assert.equal(fetch_calls.length, 0, "no fetch yet — body incomplete"); + + // Segment 2: next 1500 bytes + dispatch(bigBody.slice(1000, 2500).buffer); + assert.equal(fetch_calls.length, 0, "still no fetch"); + + // Segment 3: final 1500 bytes + dispatch(bigBody.slice(2500, 4000).buffer); + assert.equal(fetch_calls.length, 1, "fetch called now"); + + const body = fetch_calls[0].opts.body; + assert.ok(body instanceof Uint8Array, "body should be Uint8Array"); + assert.equal(body.length, 4000, "all 4000 bytes present"); + assert.equal(body[0], 0x42, "first byte preserved"); + assert.equal(body[3999], 0x42, "last byte preserved"); +}); + +test("Binary body with embedded CRLFCRLF is not corrupted", () => { + const { dispatch, fetch_calls } = setup(); + + // Body contains the byte sequence 0x0D 0x0A 0x0D 0x0A (\r\n\r\n) inside it + const binBody = new Uint8Array([0xAA, 0x0D, 0x0A, 0x0D, 0x0A, 0xBB]); + const headers = "POST /api HTTP/1.1\r\nHost: example.com\r\nContent-Length: 6\r\n\r\n"; + const hdrBytes = textEncoder.encode(headers); + + const full = new Uint8Array(hdrBytes.length + binBody.length); + full.set(hdrBytes); + full.set(binBody, hdrBytes.length); + dispatch(full.buffer); + + assert.equal(fetch_calls.length, 1); + const body = fetch_calls[0].opts.body; + assert.equal(body.length, 6, "body length should be 6"); + assert.equal(body[0], 0xAA); + assert.equal(body[1], 0x0D); + assert.equal(body[5], 0xBB); +}); + +test("GET request (no body) is dispatched immediately", () => { + const { dispatch, fetch_calls } = setup(); + + const request = "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"; + dispatch(textEncoder.encode(request).buffer); + + assert.equal(fetch_calls.length, 1, "fetch called once"); + assert.equal(fetch_calls[0].opts.method, "GET"); +}); + +test("Headers split across two segments", () => { + const { dispatch, fetch_calls } = setup(); + + const body = "hi"; + const request = "POST /api HTTP/1.1\r\nHost: example.com\r\nContent-Length: 2\r\n\r\n" + body; + + // Split in the middle of a header line + const bytes = textEncoder.encode(request); + const mid = Math.floor(bytes.length * 0.4); + + dispatch(bytes.slice(0, mid).buffer); + assert.equal(fetch_calls.length, 0, "no fetch — headers incomplete"); + + dispatch(bytes.slice(mid).buffer); + assert.equal(fetch_calls.length, 1, "fetch called after second segment"); + assert.equal(fetch_calls[0].opts.body.length, 2); +}); + +// --------------------------------------------------------------------------- +// Summary +// --------------------------------------------------------------------------- + +console.log(`\n${tests_passed} passed, ${tests_failed} failed`); +process.exit(tests_failed > 0 ? 1 : 0); From d4a8dd4d62ecaad226e00cc16b7cf3fbce3a7ccf Mon Sep 17 00:00:00 2001 From: muralidhar-reddy Date: Tue, 26 May 2026 11:46:44 +0530 Subject: [PATCH 3/3] test: add integration test for multi-segment POST body buffering Add a POST handler to the test server that echoes the received body length, and a test case that POSTs 3000 bytes (> ~1460 byte MTU) from the buildroot guest via curl + dd. Verifies the full body reaches the server after the binary-safe buffering fix. --- tests/devices/fetch_network.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/devices/fetch_network.js b/tests/devices/fetch_network.js index 5222f4678c..969a11295d 100755 --- a/tests/devices/fetch_network.js +++ b/tests/devices/fetch_network.js @@ -249,6 +249,23 @@ if(isMainThread) assert(/400 Bad Request/.test(capture), "got error 400"); }, }, + { + name: "POST large body to local server", + allow_failure: true, + timeout: 30, + start: () => + { + // POST a 3000-byte body — larger than one TCP segment (~1460 bytes). + // This exercises the multi-segment body buffering fix. + emulator.serial0_send(`dd if=/dev/zero bs=3000 count=1 2>/dev/null | curl -m 10 -s -X POST --data-binary @- ${SERVER_PORT}.external/post\n`); + emulator.serial0_send("echo -e done\\\\tpost large body\n"); + }, + end_trigger: "done\tpost large body", + end: (capture) => + { + assert(/Received 3000 bytes/.test(capture), "server received full 3000 byte body"); + }, + }, ]; @@ -395,6 +412,19 @@ else response.end("Unknown endpoint"); } break; + case "POST": + if(request.url === "/post") { + let chunks = []; + request.on("data", chunk => chunks.push(chunk)); + request.on("end", () => { + const full = Buffer.concat(chunks); + response.end(`Received ${full.length} bytes`); + }); + } else { + response.writeHead(404); + response.end("Unknown POST endpoint"); + } + break; default: response.writeHead(405); response.end("Unknown method");