diff --git a/crates/capabilities/src/http_transport.js b/crates/capabilities/src/http_transport.js index 962b6ba..0508041 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, head_ptr // 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-*); @@ -234,10 +239,10 @@ module.exports.httpRequest = function (host, port, isHttps, socketPath, head_ptr 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 5a97a58..a941880 100644 --- a/test/http_transport.js +++ b/test/http_transport.js @@ -128,6 +128,44 @@ describe('http_transport response header observer', () => { assert.strictEqual(body.length, Buffer.byteLength(RESPONSE_BODY)) assert.strictEqual(Buffer.from(body).toString('utf8'), RESPONSE_BODY) }) + + 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, '', 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)) + } + }) }) // libdatadog derives the request host from the agent URI, which keeps the