amplify-backend icon indicating copy to clipboard operation
amplify-backend copied to clipboard

Lambda Response Streaming never completes due to injected SSM refresh `setInterval`

Open eriktim opened this issue 2 months ago • 1 comments

Environment information

System:
  OS: macOS 15.6.1
  CPU: (10) arm64 Apple M2 Pro
  Memory: 225.67 MB / 16.00 GB
  Shell: /bin/zsh
Binaries:
  Node: 22.13.1 - ~/.nvm/versions/node/v22.13.1/bin/node
  Yarn: 1.22.22 - /opt/homebrew/bin/yarn
  npm: 10.9.2 - ~/.nvm/versions/node/v22.13.1/bin/npm
  pnpm: undefined - undefined
NPM Packages:
  @aws-amplify/auth-construct: 1.8.1
  @aws-amplify/backend: 1.16.1
  @aws-amplify/backend-ai: Not Found
  @aws-amplify/backend-auth: 1.7.1
  @aws-amplify/backend-cli: 1.7.2
  @aws-amplify/backend-data: 1.6.1
  @aws-amplify/backend-deployer: 2.1.1
  @aws-amplify/backend-function: 1.14.1
  @aws-amplify/backend-output-schemas: 1.6.0
  @aws-amplify/backend-output-storage: 1.3.1
  @aws-amplify/backend-secret: 1.4.0
  @aws-amplify/backend-storage: 1.4.1
  @aws-amplify/cli-core: 2.1.1
  @aws-amplify/client-config: 1.7.0
  @aws-amplify/data-construct: 1.16.1
  @aws-amplify/data-schema: 1.21.0
  @aws-amplify/deployed-backend-client: 1.7.0
  @aws-amplify/form-generator: 1.2.1
  @aws-amplify/model-generator: 1.2.0
  @aws-amplify/platform-core: 1.9.0
  @aws-amplify/plugin-types: 1.10.1
  @aws-amplify/sandbox: 2.1.2
  @aws-amplify/schema-generator: 1.4.0
  @aws-cdk/toolkit-lib: 0.3.2
  aws-amplify: 6.14.4
  aws-cdk-lib: 2.189.1
  typescript: 5.8.3
npm warn exec The following package was not found and will be installed: [email protected]
No AWS environment variables
No CDK environment variables

Describe the bug

When deploying a Lambda with Amplify Gen 2 that uses awslambda.streamifyResponse for response streaming, the function never terminates. Invocation times out even though responseStream.end() has been called.

Inspection of the transpiled bundle shows Amplify injecting SSM environment variable resolution logic that sets up a recurring setInterval to refresh SSM parameters every 60 seconds. This interval is not unref()’d and pins the Node.js event loop, preventing the Lambda runtime from completing.

Setting context.callbackWaitsForEmptyEventLoop = false allows the function to exit, but that only hides the root issue: it causes Lambda to freeze open handles across warm invocations, creating the risk of timers, sockets, or subscriptions leaking between requests.

Reproduction steps

  1. Create a new Amplify Gen 2 backend.
  2. Create a (simplified) Lambda function handler:
// handler.ts

export const handler = awslambda.streamifyResponse(async (_event, stream, _context) => {
  stream.write("Hello ");
  stream.write("world");
  stream.end();
});
  1. Define the Amplify function with a secret:
// resource.ts

import { defineFunction, secret } from '@aws-amplify/backend';

const streamFunction = defineFunction({
  name: 'StreamFunction',
  runtime: 22,
  entry: './handler.ts',
  timeoutSeconds: 60,
  environment: {
    SOME_SECRET: secret('SOME_SECRET'),
  },
});
  1. Attach a Function URL and enable Invoke mode RESPONSE_STREAM.
// backend.ts

import * as lambda from 'aws-cdk-lib/aws-lambda';

import { streamFunction } from './resource';

const backend = defineBackend({
  ...,
  streamFunction
});

backend.streamFunction.resources.lambda.addFunctionUrl({
      authType: lambda.FunctionUrlAuthType.NONE,
      invokeMode: lambda.InvokeMode.RESPONSE_STREAM,
    });
  1. Deploy.
  2. Invoke the Function URL, e.g. curl https://342dqyq5y5d3yq5gd35df3tg.lambda-url.eu-west-1.on.aws/.
  3. Observe the Lambda runs until timeout and never completes (in CloudWatch Logs), even though the client receives a full response.

Inspecting the built index.mjs shows Amplify injecting:

const SSM_PARAMETER_REFRESH_MS = 1000 * 60;
setInterval(async () => {
  try { await internalAmplifyFunctionResolveSsmParams(); }
  catch (error) { console.debug(error); }
}, SSM_PARAMETER_REFRESH_MS);

This timer keeps the event loop alive indefinitely.

Expected behavior A streaming Lambda should terminate after stream.end() once response transmission is complete, without requiring callbackWaitsForEmptyEventLoop = false.

eriktim avatar Sep 09 '25 07:09 eriktim