From 50474a86ca3596a4b20866175125eca2c6da8668 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:54:55 +0000 Subject: [PATCH 1/5] fix(aws-lambda): clean up the body when preparing the response stream throws `awslambda.HttpResponseStream.from()` and the prelude-triggering `write('')` can throw synchronously (e.g. the runtime's "Cannot set content-type, too late." on an already-written stream, or a missing `awslambda` global). The promise rejected but the body was never destroyed, so event iterators never ran their `finally`, the keep-alive interval kept firing, and ReadableStream bodies were never cancelled. Mirror the node adapter: on a synchronous throw, destroy the body, destroy the response stream instead of leaving it half-open, and reject. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AVoginFKKdE3QrCKUARJ98 --- packages/aws-lambda/src/response.test.ts | 81 ++++++++++++++++++++++++ packages/aws-lambda/src/response.ts | 73 ++++++++++++--------- 2 files changed, 125 insertions(+), 29 deletions(-) diff --git a/packages/aws-lambda/src/response.test.ts b/packages/aws-lambda/src/response.test.ts index b374b2c3..5c2f3c23 100644 --- a/packages/aws-lambda/src/response.test.ts +++ b/packages/aws-lambda/src/response.test.ts @@ -274,6 +274,87 @@ describe('sendStandardResponse', () => { await sendPromise }) + describe('preparing the response stream throws', () => { + it('rejects, destroys the response stream and the body when `from` throws', async () => { + const responseStream = createResponseStream() + + const error = new Error('Cannot set content-type, too late.') + fromSpy.mockImplementationOnce(() => { + throw error + }) + + let clean = false + const standardResponse: StandardResponse = { + body: (async function* () { + try { + yield 1 + } + finally { + clean = true + } + })(), + headers: {}, + status: 200, + } + + await expect(sendStandardResponse(responseStream, standardResponse, { + eventStream: { keepAlive: { enabled: true, interval: 10 } }, + })).rejects.toBe(error) + + await vi.waitFor(() => { + expect(clean).toBe(true) + }) + + expect(responseStream.destroyed).toBe(true) + expect(responseStream.errored).toBe(error) + + const [resBody] = toNodeHttpBodySpy.mock.results[0]!.value + expect((resBody as any).destroyed).toBe(true) + }) + + it('rejects and cancels the body when the `awslambda` global is missing', async () => { + vi.unstubAllGlobals() + + const responseStream = createResponseStream() + + const cancel = vi.fn() + const stream = new ReadableStream({ cancel }) + + await expect(sendStandardResponse(responseStream, { + status: 200, + headers: {}, + body: stream, + })).rejects.toBeInstanceOf(ReferenceError) + + await vi.waitFor(() => { + expect(cancel).toHaveBeenCalledTimes(1) + }) + + expect(responseStream.destroyed).toBe(true) + }) + + it('rejects, destroys the response stream and the body when the first write throws', async () => { + const responseStream = createResponseStream() + + const error = new Error('write failed') + responseStream.write = () => { + throw error + } + + await expect(sendStandardResponse(responseStream, { + status: 200, + headers: {}, + body: new Blob(['foo']), + })).rejects.toBe(error) + + const [resBody] = toNodeHttpBodySpy.mock.results[0]!.value + expect((resBody as any).destroyed).toBe(true) + + expect(responseStream.destroyed).toBe(true) + expect(responseStream.errored).toBe(error) + }) + }) + describe('response stream closed before sending', () => { it('resolves and destroys the body', async () => { const responseStream = createResponseStream() diff --git a/packages/aws-lambda/src/response.ts b/packages/aws-lambda/src/response.ts index 828ca4aa..9d59f0b5 100644 --- a/packages/aws-lambda/src/response.ts +++ b/packages/aws-lambda/src/response.ts @@ -44,41 +44,56 @@ export async function sendStandardResponse( return } - const [headers, setCookies] = toLambdaHeaders(resHeaders) + try { + const [headers, setCookies] = toLambdaHeaders(resHeaders) - // arms the metadata prelude (status, headers, cookies) and - // returns the stream the body should be written to - const res = awslambda.HttpResponseStream.from(responseStream, { - statusCode: standardResponse.status, - headers, - cookies: setCookies, - }) + // arms the metadata prelude (status, headers, cookies) and + // returns the stream the body should be written to + const res = awslambda.HttpResponseStream.from(responseStream, { + statusCode: standardResponse.status, + headers, + cookies: setCookies, + }) - res.once('error', reject) - res.once('close', resolve) + res.once('error', reject) + res.once('close', resolve) - // The runtime only sends the armed prelude ahead of the first `write` call: - // `end(chunk)` bypasses it and an empty body never writes, so trigger it now - res.write('') + // The runtime only sends the armed prelude ahead of the first `write` call: + // `end(chunk)` bypasses it and an empty body never writes, so trigger it now + res.write('') - if (resBody === undefined) { - // NOTE: Lambda functions don't allow passing undefined to `res.end` - res.end() - } - else if (typeof resBody === 'string') { - res.end(resBody) - } - else { - res.once('close', () => { - if (!resBody.closed) { - resBody.destroy(getNodeResponseError(res) ?? undefined) - } - }) + if (resBody === undefined) { + // NOTE: Lambda functions don't allow passing undefined to `res.end` + res.end() + } + else if (typeof resBody === 'string') { + res.end(resBody) + } + else { + res.once('close', () => { + if (!resBody.closed) { + resBody.destroy(getNodeResponseError(res) ?? undefined) + } + }) - // WARNING: errors that occur here are silently ignored and not reported to the Promise - resBody.once('error', error => res.destroy(error)) + // WARNING: errors that occur here are silently ignored and not reported to the Promise + resBody.once('error', error => res.destroy(error)) + + resBody.pipe(res) + } + } + catch (error) { + if (typeof resBody === 'object' && !resBody.closed) { + resBody.on('error', reject) + resBody.destroy(error as any) + } - resBody.pipe(res) + // Destroy instead of leaving the response half-open: the metadata prelude may be + // partially applied. `from` may throw before the listeners above are attached, + // so listen here too to keep the `error` event from going unhandled. + responseStream.once('error', reject) + responseStream.destroy(error as any) + reject(error) } }) } From 81664440806e3f1302e27a343e1a79ca6a89ef7e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 06:18:50 +0000 Subject: [PATCH 2/5] refactor(aws-lambda): simplify response stream setup cleanup Attach the `error`/`close` listeners to the response stream before the try, like the node adapter, so the catch no longer needs its own `error` listener to cover a throwing `from`. Collapse the three throw-path tests into one `it.each`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AVoginFKKdE3QrCKUARJ98 --- packages/aws-lambda/src/response.test.ts | 94 ++++++------------------ packages/aws-lambda/src/response.ts | 12 ++- 2 files changed, 27 insertions(+), 79 deletions(-) diff --git a/packages/aws-lambda/src/response.test.ts b/packages/aws-lambda/src/response.test.ts index 5c2f3c23..23ab5e2c 100644 --- a/packages/aws-lambda/src/response.test.ts +++ b/packages/aws-lambda/src/response.test.ts @@ -274,85 +274,35 @@ describe('sendStandardResponse', () => { await sendPromise }) - describe('preparing the response stream throws', () => { - it('rejects, destroys the response stream and the body when `from` throws', async () => { - const responseStream = createResponseStream() - - const error = new Error('Cannot set content-type, too late.') + it.each([ + ['`from` throws', () => { fromSpy.mockImplementationOnce(() => { - throw error - }) - - let clean = false - const standardResponse: StandardResponse = { - body: (async function* () { - try { - yield 1 - } - finally { - clean = true - } - })(), - headers: {}, - status: 200, - } - - await expect(sendStandardResponse(responseStream, standardResponse, { - eventStream: { keepAlive: { enabled: true, interval: 10 } }, - })).rejects.toBe(error) - - await vi.waitFor(() => { - expect(clean).toBe(true) - }) - - expect(responseStream.destroyed).toBe(true) - expect(responseStream.errored).toBe(error) - - const [resBody] = toNodeHttpBodySpy.mock.results[0]!.value - expect((resBody as any).destroyed).toBe(true) - }) - - it('rejects and cancels the body when the `awslambda` global is missing', async () => { - vi.unstubAllGlobals() - - const responseStream = createResponseStream() - - const cancel = vi.fn() - const stream = new ReadableStream({ cancel }) - - await expect(sendStandardResponse(responseStream, { - status: 200, - headers: {}, - body: stream, - })).rejects.toBeInstanceOf(ReferenceError) - - await vi.waitFor(() => { - expect(cancel).toHaveBeenCalledTimes(1) + throw new Error('Cannot set content-type, too late.') }) - - expect(responseStream.destroyed).toBe(true) - }) - - it('rejects, destroys the response stream and the body when the first write throws', async () => { - const responseStream = createResponseStream() - - const error = new Error('write failed') + }], + ['the first write throws', (responseStream: HttpResponseStream) => { responseStream.write = () => { - throw error + throw new Error('write failed') } + }], + ['the `awslambda` global is missing', () => { + vi.unstubAllGlobals() + }], + ])('rejects, destroys the response stream and the body when %s', async (_, setup) => { + const responseStream = createResponseStream() + setup(responseStream) - await expect(sendStandardResponse(responseStream, { - status: 200, - headers: {}, - body: new Blob(['foo']), - })).rejects.toBe(error) + const error = await sendStandardResponse(responseStream, { + status: 200, + headers: {}, + body: new Blob(['foo']), + }).catch(error => error) - const [resBody] = toNodeHttpBodySpy.mock.results[0]!.value - expect((resBody as any).destroyed).toBe(true) + expect(error).toBeInstanceOf(Error) + expect(responseStream.errored).toBe(error) - expect(responseStream.destroyed).toBe(true) - expect(responseStream.errored).toBe(error) - }) + const [resBody] = toNodeHttpBodySpy.mock.results[0]!.value + expect((resBody as any).destroyed).toBe(true) }) describe('response stream closed before sending', () => { diff --git a/packages/aws-lambda/src/response.ts b/packages/aws-lambda/src/response.ts index 9d59f0b5..aa21f41c 100644 --- a/packages/aws-lambda/src/response.ts +++ b/packages/aws-lambda/src/response.ts @@ -44,6 +44,9 @@ export async function sendStandardResponse( return } + responseStream.once('error', reject) + responseStream.once('close', resolve) + try { const [headers, setCookies] = toLambdaHeaders(resHeaders) @@ -55,9 +58,6 @@ export async function sendStandardResponse( cookies: setCookies, }) - res.once('error', reject) - res.once('close', resolve) - // The runtime only sends the armed prelude ahead of the first `write` call: // `end(chunk)` bypasses it and an empty body never writes, so trigger it now res.write('') @@ -88,10 +88,8 @@ export async function sendStandardResponse( resBody.destroy(error as any) } - // Destroy instead of leaving the response half-open: the metadata prelude may be - // partially applied. `from` may throw before the listeners above are attached, - // so listen here too to keep the `error` event from going unhandled. - responseStream.once('error', reject) + // Destroy instead of leaving the response half-open: + // the metadata prelude may be partially applied responseStream.destroy(error as any) reject(error) } From 4076b7c38267a1905fa8a3f58ad2cc5aca06d9f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 06:43:05 +0000 Subject: [PATCH 3/5] refactor(aws-lambda): destroy the body from the single close listener Fold the stream branch's separate `close` listener into the one attached before the try, as the fastify adapter now does. The body is released in one place for every path, one fewer listener per streaming response, and settle/cleanup no longer listen on differently named aliases of the same stream. On the catch path the body is already destroyed, so the listener's destroy is a no-op. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AVoginFKKdE3QrCKUARJ98 --- packages/aws-lambda/src/response.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/aws-lambda/src/response.ts b/packages/aws-lambda/src/response.ts index aa21f41c..dad61130 100644 --- a/packages/aws-lambda/src/response.ts +++ b/packages/aws-lambda/src/response.ts @@ -45,7 +45,13 @@ export async function sendStandardResponse( } responseStream.once('error', reject) - responseStream.once('close', resolve) + responseStream.once('close', () => { + if (typeof resBody === 'object' && !resBody.closed) { + resBody.destroy(getNodeResponseError(responseStream) ?? undefined) + } + + resolve() + }) try { const [headers, setCookies] = toLambdaHeaders(resHeaders) @@ -70,12 +76,6 @@ export async function sendStandardResponse( res.end(resBody) } else { - res.once('close', () => { - if (!resBody.closed) { - resBody.destroy(getNodeResponseError(res) ?? undefined) - } - }) - // WARNING: errors that occur here are silently ignored and not reported to the Promise resBody.once('error', error => res.destroy(error)) From ed8585dfa296ab0d6bcd1416ff2d14725b7dcb0c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:14:11 +0000 Subject: [PATCH 4/5] refactor(aws-lambda): use destroyNodeHttpBody in the setup catch Replace the inline body teardown in the catch with the shared helper, as the node and fastify adapters now do. Drop the "errors are silently ignored" warning copied from the node adapter: here `res.destroy(error)` emits `error` on the response stream, which rejects the promise. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AVoginFKKdE3QrCKUARJ98 --- packages/aws-lambda/src/response.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/aws-lambda/src/response.ts b/packages/aws-lambda/src/response.ts index 142817e4..9323e9b5 100644 --- a/packages/aws-lambda/src/response.ts +++ b/packages/aws-lambda/src/response.ts @@ -73,17 +73,13 @@ export async function sendStandardResponse( res.end(resBody) } else { - // WARNING: errors that occur here are silently ignored and not reported to the Promise resBody.once('error', error => res.destroy(error)) resBody.pipe(res) } } catch (error) { - if (typeof resBody === 'object' && !resBody.closed) { - resBody.on('error', reject) - resBody.destroy(error as any) - } + destroyNodeHttpBody(resBody, error, reject) // Destroy instead of leaving the response half-open: // the metadata prelude may be partially applied From fa4baf59a76e5e2bc081fa4b9f30e0cffea8da9b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:24:36 +0000 Subject: [PATCH 5/5] refactor(aws-lambda): keep body teardown in the stream branch's close listener Restore the original stream-branch `close` listener for destroying the body and keep the pre-try `close` listener to `resolve` only, matching main and the node adapter. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AVoginFKKdE3QrCKUARJ98 --- packages/aws-lambda/src/response.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/aws-lambda/src/response.ts b/packages/aws-lambda/src/response.ts index 9323e9b5..ab457427 100644 --- a/packages/aws-lambda/src/response.ts +++ b/packages/aws-lambda/src/response.ts @@ -42,13 +42,7 @@ export async function sendStandardResponse( } responseStream.once('error', reject) - responseStream.once('close', () => { - if (typeof resBody === 'object' && !resBody.closed) { - resBody.destroy(getNodeResponseError(responseStream) ?? undefined) - } - - resolve() - }) + responseStream.once('close', resolve) try { const [headers, setCookies] = toLambdaHeaders(resHeaders) @@ -73,6 +67,12 @@ export async function sendStandardResponse( res.end(resBody) } else { + res.once('close', () => { + if (!resBody.closed) { + resBody.destroy(getNodeResponseError(res) ?? undefined) + } + }) + resBody.once('error', error => res.destroy(error)) resBody.pipe(res)