From 2e99ff163cdb0ab2d503d828f3901bb3dbba72f1 Mon Sep 17 00:00:00 2001 From: Bryan English Date: Mon, 31 Aug 2026 13:29:06 -0400 Subject: [PATCH] fix(capabilities): copy wasm request bodies before send Node can queue ClientRequest writes before a socket is assigned. Passing a Uint8Array view over wasm memory lets a later memory.grow detach the queued buffer, which can throw from _flushOutput outside the transport retry path. Copy the request body into Node-owned memory for each attempt before writing it. The head is still parsed from the current wasm memory view and response handling is unchanged. This landed on the v0.x release branch in v0.18.1 but was never brought back to main, so main still writes the live wasm view. Refs: https://github.com/DataDog/libdatadog-nodejs/pull/184 --- crates/capabilities/src/http_transport.js | 13 +++++--- test/http_transport.js | 40 +++++++++++++++++++++++ yarn.lock | 2 +- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/crates/capabilities/src/http_transport.js b/crates/capabilities/src/http_transport.js index dd6f450f..0fd60257 100644 --- a/crates/capabilities/src/http_transport.js +++ b/crates/capabilities/src/http_transport.js @@ -193,7 +193,12 @@ module.exports.httpRequest = function (host, port, isHttps, socketPath, connecti // wasm_memory.buffer is replaced each time WebAssembly.Memory grows, so // the views must be recreated on every attempt against the current buffer. const headView = new Uint8Array(wasm_memory.buffer, head_ptr, head_len) - const bodyView = new Uint8Array(wasm_memory.buffer, body_ptr, body_len) + // Copy the body into Node-owned memory before giving it to http.request. + // `body_ptr/body_len` points into wasm memory; if that memory grows while + // Node still has the write queued, the original ArrayBuffer detaches and + // ClientRequest can throw asynchronously from `_flushOutput`, outside the + // `req.write()` try/catch/retry path. + const body = Buffer.from(new Uint8Array(wasm_memory.buffer, body_ptr, body_len)) // The Rust side already rendered the full HTTP/1.1 request head (real // method, `/v0.4/traces` path, Content-Type/Length, datadog-meta-*); @@ -237,10 +242,10 @@ module.exports.httpRequest = function (host, port, isHttps, socketPath, connecti req.on('error', reject) // The request head (method/path/headers) was supplied via requestOptions - // above; just write the body. (No `req._header` injection — that Node - // internal is not honored by Bun.) + // above; just write the stable Node-owned body. (No `req._header` + // injection — that Node internal is not honored by Bun.) try { - req.write(bodyView) + req.write(body) req.end() } catch (error) { reject(error) diff --git a/test/http_transport.js b/test/http_transport.js index 29b7d389..7333d8e8 100644 --- a/test/http_transport.js +++ b/test/http_transport.js @@ -304,6 +304,46 @@ describe('http_transport connection pooling', () => { }) }) +describe('http_transport request body lifetime', () => { + it('copies the request body before queued writes can observe detached wasm memory', async () => { + const received = [] + const body = Buffer.from('wasm-backed request body') + const server = http.createServer((req, res) => { + req.on('data', chunk => received.push(chunk)) + req.on('end', () => res.end(RESPONSE_BODY)) + }) + + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + try { + const port = server.address().port + const head = Buffer.from( + `POST /v0.4/traces HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\n` + + `Content-Length: ${body.length}\r\nConnection: close\r\n\r\n`, + 'utf8', + ) + const memory = new WebAssembly.Memory({ initial: 1 }) + const bytes = new Uint8Array(memory.buffer) + bytes.set(head, 0) + bytes.set(body, head.length) + + const result = transport.httpRequest( + '127.0.0.1', port, false, '', true, 0, head.length, head.length, body.length, memory, + ) + // Old behavior passed a live wasm-memory view to req.write(). If the memory + // grew before Node assigned a socket and flushed its queued output, Node + // threw `Cannot perform Construct on a detached ArrayBuffer` from + // ClientRequest._flushOutput, outside httpRequest's try/catch retry path. + memory.grow(1) + + const [status] = await result + assert.strictEqual(status, 200) + assert.strictEqual(Buffer.concat(received).toString('utf8'), body.toString('utf8')) + } finally { + await new Promise(resolve => server.close(resolve)) + } + }) +}) + // Entity-header injection: container-id / entity-id / external-env detection // (Node reads /proc + env; libdatadog's own detection is inert on wasm) and the // rewrite of the Rust-rendered request head that carries them. diff --git a/yarn.lock b/yarn.lock index a8a63d10..d8f55992 100644 --- a/yarn.lock +++ b/yarn.lock @@ -280,7 +280,7 @@ baseline-browser-mapping@^2.9.0: resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz#5b09935025bf8a80e29130251e337c6a7fc8cbb9" integrity sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA== -brace-expansion@^5.0.2, brace-expansion@^5.0.9: +brace-expansion@^5.0.2: version "5.0.9" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf" integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==