Skip to content

fix(handler): wait for context.succeed when handler returns undefined#786

Open
zarirhamza wants to merge 1 commit into
mainfrom
zarir/fix-handler-undefined-return-regression
Open

fix(handler): wait for context.succeed when handler returns undefined#786
zarirhamza wants to merge 1 commit into
mainfrom
zarir/fix-handler-undefined-return-regression

Conversation

@zarirhamza

@zarirhamza zarirhamza commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Restore the pre-v11.126.0 semantics for the (event, context) => { ... } handler shape (no callback parameter, no explicit return). When the handler returns undefined, the wrapper now waits for context.succeed / context.done / context.fail / callback instead of resolving the wrapping promise immediately with undefined.

This fixes a regression for handlers using aws-serverless-express@3's proxy(server, event, context) pattern (default CONTEXT_SUCCEED resolution mode) and any other "fire-and-forget then signal via context.*" handler shape.

Root cause

#661 (v11.126.0, "Fix Typescript Timeouts when Lambda handler returns undefined") added an eager-resolve branch:

} else if (asyncProm === undefined && handler.length < 3) {
  // Handler returned undefined and doesn't take a callback parameter, resolve immediately
  promise = Promise.resolve(undefined);
}

#665 (v12.127.0) refactored this and #683 (v12.130.0) added a looksLikeArtifact heuristic that keeps waiting when the handler returns a Server / EventEmitter. However, the artifact heuristic gates on asyncProm !== undefined, so when the handler returns nothing it still falls through to Promise.resolve(undefined) — i.e. #683 never covered the no-return case.

A handler that returns undefined synchronously is indistinguishable from a handler that's mid-flight and intends to complete via context.succeed / context.done / context.fail. Eager-resolving on undefined truncates that work: the Lambda runtime ships undefined back to the caller and freezes the worker before pending stdout writes flush to CloudWatch.

A representative handler shape that hits this:

exports.handler = (event, context) => {
  proxy(server, event, context);   // aws-serverless-express, fires context.succeed later
};

handler.length === 2, no return, so asyncProm === undefined → previously hit the eager-resolve branch and resolved before Express finished.

Fix

} else if (asyncProm === undefined) {
  // Handler returned nothing (implicit `undefined`) and doesn't take a callback parameter.
  // It must be relying on `context.succeed` / `context.done` / `context.fail` to signal
  // completion. Wait for callbackProm rather than resolving immediately.
  promise = callbackProm;
}

Why this is safe to revert

  • This was the behavior shipped from the beginning through v11.125.0. Reverting restores a long-stable contract.
  • The Lambda Node.js runtime contract is "the function ends when the callback is called, context.succeed/done/fail is called, or the returned promise settles". It is not "the function ends when the synchronous body returns". (fix): Fix Typescript Timeouts when Lambda handler returns undefined #661's branch silently changed that contract.
  • A handler that returns undefined and does nothing else is now left waiting on callbackProm, which is correct: it will hit the configured Lambda function timeout. That is the expected behavior — a function that never signals completion is a user bug, and conflating it with "the handler is done" was the wrong fix.

Notes for users still hitting timeouts with TS-bundled async handlers

The original problem #661 set out to solve (TS compilation losing the implicit return of an async handler) is better addressed at the user level: ensure your transpilation target supports native async (Node ≥ 12), or return the work explicitly. The wrapper should not silently truncate execution to compensate.

Tests

PASS src/utils/handler.spec.ts
  promisifiedHandler
    ✓ returns a promise when callback used by the handler
    ✓ returns a promise when the original handler returns a promise
    ✓ throws an error when the original lambda gives the callback an error
    ✓ throws an error when the original lambda throws an error
    ✓ returns the first result to complete between the callback and the handler promise
    ✓ doesn't complete using non-promise return values
    ✓ completes when calling context.done
    ✓ completes when calling context.succeed
    ✓ throws error when calling context.fail
    ✓ waits for context.succeed when handler returns undefined and length < 3     ← new
    ✓ waits for context.done    when handler returns undefined and length < 3     ← new
    ✓ waits for context.fail    when handler returns undefined and length < 3     ← new
    ✓ simulates aws-serverless-express proxy() pattern                            ← new
    ✓ completes when handler returns a value directly (sync handler)
    ✓ waits for callback when handler returns non-promise artifact with callback parameter
    ✓ detects http.Server-like artifact (has listen AND close)
    ✓ detects EventEmitter-like artifact (has on AND emit)
    ✓ detects EventEmitter instance
    ✓ detects artifact by constructor name (Server/Socket/Emitter)
    ✓ does NOT treat plain response objects as artifacts
Tests: 20 passed, 20 total

The four added tests would have caught #661 and #665. The existing it("completes when handler returns undefined") (added by #661, codifying the regressive behavior) is replaced; the new tests assert the actual Lambda runtime contract.

Test plan

  • jest src/utils/handler.spec.ts — 20/20 pass
  • tslint --project tsconfig.json — clean
  • prettier --check src/utils/handler.{ts,spec.ts} — clean
  • tsc --noEmit — clean
  • Full suite (jest) — only 2 pre-existing failures in src/index.spec.ts (nock-based, unrelated to handler logic; reproduce on main without this PR)

Related

@datadog-datadog-prod-us1

datadog-datadog-prod-us1 Bot commented Jun 11, 2026

Copy link
Copy Markdown

Pipelines

Fix all issues with BitsAI

⚠️ Warnings

🚦 1 Pipeline job failed

DataDog/datadog-lambda-js | e2e-test-status   View in Datadog   GitLab

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: eccce76 | Docs | Datadog PR Page | Give us feedback!

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Restores Lambda wrapper behavior for 2-arg handlers that implicitly return undefined by waiting for completion via context.succeed / context.done / context.fail (or callback), instead of resolving immediately with undefined. This aligns promisifiedHandler with the Node.js Lambda runtime contract and fixes regressions for “fire-and-forget then signal via context” handler shapes (e.g., aws-serverless-express proxy(server, event, context) in CONTEXT_SUCCEED mode).

Changes:

  • Update promisifiedHandler to wait on callbackProm when asyncProm === undefined and the handler does not declare a callback parameter.
  • Harden the “artifact” heuristic by excluding null before attempting instanceof EventEmitter / property checks.
  • Replace the prior “completes when handler returns undefined” test with new tests that assert waiting for context.* completion in the no-return case (including an aws-serverless-express-like proxy simulation).

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
src/utils/handler.ts Restores waiting semantics for implicit-undefined returns and tightens artifact detection against null.
src/utils/handler.spec.ts Adds/updates unit tests to ensure context.* completion is respected when the handler returns undefined with < 3 parameters.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/utils/handler.ts Outdated
const looksLikeArtifact =
asyncProm !== undefined &&
typeof asyncProm === "object" &&
asyncProm !== null &&

@lucaspimentel lucaspimentel Jun 11, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖

The added asyncProm !== null guard is both ineffective for its stated purpose and effectively dead code.

The PR body says this hardens against null so the code doesn't run instanceof EventEmitter / property reads on null. But a handler returning null never reaches this line — it throws earlier at line 60:

if (asyncProm !== undefined && typeof (asyncProm as any).then === "function") {

For null: null !== undefined is true, so it evaluates typeof (null).thenTypeError: Cannot read properties of null (reading 'then') (verified). So a null return crashes the invocation regardless of this guard.

And at this line, asyncProm can never actually be null: undefined is intercepted by the new branch above, and null already threw at line 60 — so the else only ever sees defined, non-null values. The asyncProm !== null check therefore never evaluates false (dead).

If guarding null is a real goal, fix it at line 60 (e.g. asyncProm != null && typeof (asyncProm as any).then === "function"); otherwise drop the line-85 check and the null claim from the PR description so it doesn't overstate the fix.

@zarirhamza zarirhamza Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — you're right that the guard at line 85 is unreachable: null already throws at the line 60 typeof (asyncProm as any).then access before reaching the artifact heuristic, and even if it didn't, the new else if (asyncProm === undefined) branch would still let null fall into the else with typeof null === 'object' triggering the artifact reads.

I've dropped the asyncProm !== null change from this PR and removed the corresponding paragraph from the commit message and PR description — this PR is now strictly the undefined-return regression fix and nothing else. Final diff vs main is just the new else if (asyncProm === undefined) { promise = callbackProm; } branch in handler.ts plus the four regression tests in handler.spec.ts.

I'll send the line-60 null crash as a separate small PR with its own test.

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.
@zarirhamza zarirhamza force-pushed the zarir/fix-handler-undefined-return-regression branch from 80e5f09 to eccce76 Compare June 11, 2026 19:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants