Skip to content

Commit 58c6e76

Browse files
authored
fix(handler): wait for context.succeed when handler returns undefined (#786)
Restore the pre-v11.126.0 semantics for the `(event, context) => { ... }` / `length < 3` / no-return handler shape: when `asyncProm === undefined`, fall back to `callbackProm` instead of resolving immediately with `undefined`. PR #661 (v11.126.0) introduced an "eager-resolve on undefined" branch intended to fix TypeScript-transpiled async handlers whose `return` was lost during bundling. PR #665 (v12.127.0) refactored that branch and PR #683 (v12.130.0) added a `looksLikeArtifact` heuristic for handlers that *return* a Server / EventEmitter, but the `asyncProm === undefined` path was never re-guarded. As a result every layer from v11.126.0 onwards silently truncates handlers that return nothing and finish via `context.succeed` / `context.done` / `context.fail`, most notably `aws-serverless-express@3`'s `proxy(server, event, context)` with the default `CONTEXT_SUCCEED` resolution mode. Symptom: handler returns `undefined` synchronously, wrapper resolves immediately with `undefined`, the Lambda runtime ships `undefined` back to the caller and freezes the worker before any deferred work writes to stdout, so nothing is flushed to CloudWatch. Tests: - Replace the test added by PR #661 that codified the regressive "return undefined -> resolve immediately" behavior. - Add four regression tests covering the actual contract: a handler with `length < 3` and no return must wait for `context.succeed` / `context.done` / `context.fail`, including a reproducer mirroring the aws-serverless-express `proxy()` shape.
1 parent 99a1f4f commit 58c6e76

2 files changed

Lines changed: 75 additions & 3 deletions

File tree

src/utils/handler.spec.ts

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,16 +122,78 @@ describe("promisifiedHandler", () => {
122122
await expect(result).rejects.toEqual(new Error("Some error"));
123123
});
124124

125-
it("completes when handler returns undefined", async () => {
125+
it("waits for context.succeed when handler returns undefined and length < 3", async () => {
126+
// A handler with no callback parameter that returns undefined must NOT cause the wrapper
127+
// to resolve immediately with undefined. Real-world handlers (notably the ones using
128+
// aws-serverless-express's `proxy(server, event, context)` with the default
129+
// CONTEXT_SUCCEED resolution mode) finish via context.succeed long after the synchronous
130+
// body returns. Eager-resolving on undefined truncates that work, makes Lambda return
131+
// an empty response, and freezes the worker before stdout flushes (no CloudWatch output).
126132
const handler: Handler = (event, context) => {
127-
// No return statement, implicitly returns undefined
133+
setTimeout(() => {
134+
context.succeed({ statusCode: 200, body: "deferred via context.succeed" });
135+
}, 10);
136+
// Implicitly returns undefined.
137+
};
138+
139+
const promHandler = promisifiedHandler(handler) as any;
140+
const result = await promHandler({}, mockContext);
141+
142+
expect(result).toEqual({ statusCode: 200, body: "deferred via context.succeed" });
143+
});
144+
145+
it("waits for context.done when handler returns undefined and length < 3", async () => {
146+
const handler: Handler = (event, context) => {
147+
setTimeout(() => {
148+
context.done(undefined, { statusCode: 200, body: "deferred via context.done" });
149+
}, 10);
128150
};
129151

130152
const promHandler = promisifiedHandler(handler) as any;
153+
const result = await promHandler({}, mockContext);
154+
155+
expect(result).toEqual({ statusCode: 200, body: "deferred via context.done" });
156+
});
131157

158+
it("waits for context.fail when handler returns undefined and length < 3", async () => {
159+
const handler: Handler = (event, context) => {
160+
setTimeout(() => {
161+
context.fail(new Error("deferred failure"));
162+
}, 10);
163+
};
164+
165+
const promHandler = promisifiedHandler(handler) as any;
166+
const result = promHandler({}, mockContext);
167+
168+
await expect(result).rejects.toEqual(new Error("deferred failure"));
169+
});
170+
171+
it("simulates aws-serverless-express proxy() pattern", async () => {
172+
// Closely mirrors a real-world handler shape:
173+
//
174+
// exports.handler = (event, context) => {
175+
// proxy(server, event, context); // called, NOT returned
176+
// };
177+
//
178+
// `proxy` here stands in for aws-serverless-express@3 in default CONTEXT_SUCCEED mode,
179+
// which calls `context.succeed(response)` asynchronously once Express finishes handling
180+
// the request. The handler itself returns undefined.
181+
const proxy = (_server: unknown, _event: any, context: Context) => {
182+
setImmediate(() => {
183+
context.succeed({ statusCode: 200, body: "Express response" });
184+
});
185+
};
186+
const server = { listen: () => {}, close: () => {} };
187+
188+
const handler = (event: any, context: Context) => {
189+
proxy(server, event, context);
190+
// No return — exactly the customer's handler shape.
191+
};
192+
193+
const promHandler = promisifiedHandler(handler as any) as any;
132194
const result = await promHandler({}, mockContext);
133195

134-
expect(result).toEqual(undefined);
196+
expect(result).toEqual({ statusCode: 200, body: "Express response" });
135197
});
136198

137199
it("completes when handler returns a value directly (sync handler)", async () => {

src/utils/handler.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,16 @@ export function promisifiedHandler<TEvent, TResult>(handler: Handler<TEvent, TRe
6363
} else if (handler.length >= 3) {
6464
// Handler takes a callback, wait for the callback to be called
6565
promise = callbackProm;
66+
} else if (asyncProm === undefined) {
67+
// Handler returned nothing (implicit `undefined`) and doesn't take a callback parameter.
68+
// It must be relying on `context.succeed` / `context.done` / `context.fail` to signal
69+
// completion (e.g. `aws-serverless-express`'s `proxy()` with the default
70+
// `CONTEXT_SUCCEED` resolution mode, or any other fire-and-forget pattern that finishes
71+
// asynchronously). Wait for callbackProm rather than resolving immediately, otherwise
72+
// the wrapper would shortcut the function before its work finishes, which causes the
73+
// Lambda runtime to return an empty response and freeze the worker before any pending
74+
// stdout writes are flushed to CloudWatch.
75+
promise = callbackProm;
6676
} else {
6777
// Handler returned a value directly
6878
// Distinguish between:

0 commit comments

Comments
 (0)