Skip to content

Request interrupted at CountDownLatch.await() returns 503; cause unknown #2

Description

@gary357

A request that completes without committing its response hangs the Lambda invocation until the runtime kills it, and the client receives a 503. Observed six times in a single 42-minute test run against a Lambda deployment.

Symptom

API Gateway returns 503. The Lambda log records:

FuseLess: StreamLambdaHandler: Request interrupted by Lambda runtime :: null
java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireSharedInterruptibly(Unknown Source)
java.base/java.util.concurrent.CountDownLatch.await(Unknown Source)
com.amazonaws.serverless.proxy.internal.LambdaContainerHandler.proxy(LambdaContainerHandler.java:217)
com.amazonaws.serverless.proxy.internal.LambdaContainerHandler.proxyStream(LambdaContainerHandler.java:258)
com.foundeo.fuseless.StreamLambdaHandler.handleRequest(StreamLambdaHandler.java:156)
com.amazonaws.services.lambda.runtime.api.client.EventHandlerLoader$2.call(EventHandlerLoader.java:604)

All six occurrences were byte-identical. Six different paths, both GET and POST, spread across 41 minutes.

Mechanism

LambdaContainerHandler.proxy() blocks on latch.await() until the servlet response is committed. In aws-serverless-java-container-core, countDown() has exactly one call site — inside AwsHttpServletResponse.flushBuffer():

public void flushBuffer() throws IOException {
    ...
    isCommitted = true;
    writersCountDownLatch.countDown();
}

If flushBuffer() is never reached, the latch is never released and the invocation blocks until the Lambda runtime interrupts it.

The library guards against this in AwsLambdaServletContainerHandler.doFilter():

protected void doFilter(HttpServletRequest request, HttpServletResponse response, Servlet servlet) {
    FilterChain chain = getFilterChain(request, servlet);
    chain.doFilter(request, response);
    if (requiresAsyncReDispatch(request)) { … }
    // if for some reason the response wasn't flushed yet, we force it here unless it's being processed asynchronously (WebFlux)
    if (!response.isCommitted() && request.getDispatcherType() != DispatcherType.ASYNC) {
        response.flushBuffer();
    }
}

FuseLess does not go through doFilter(). CFMLLambdaContainerHandler.handleRequest() calls the servlet directly:

StreamLambdaHandler.getCFMLServlet().service(req, httpServletResponse);

That is reasonable — FuseLess is a CFML container with no servlet filters to apply, and the commit guarantee is bundled inside filter machinery it has no reason to invoke. But it means nothing in the call path forces the flush when Lucee returns without committing.

Confirmed by grep: isCommitted, flushBuffer, startAsync and DispatcherType appear nowhere in the FuseLess source.

This is longstanding, not a regression — the service() call is present in the initial commit (2018-11-27) and unchanged since.

Related upstream fix

aws/serverless-java-container PR 1600, shipped in 3.0.1, fixes the same class of failure in their springboot4 module. Their description:

no message converter runs, no flush ever happens, countDown() is never called, and latch.await(15, TimeUnit.MINUTES) blocks indefinitely

Different trigger (an unconditional startAsync() skipping the same safety net), same mechanism. Their fix restored the path to the guard; FuseLess never had it.

Proposed change

Add the commit check after the servlet call in CFMLLambdaContainerHandler.handleRequest():

StreamLambdaHandler.getCFMLServlet().service(req, httpServletResponse);

if (!httpServletResponse.isCommitted()) {
    StreamLambdaHandler.log("Response not committed after service(); forcing flush. path=" + req.getRequestURI());
    httpServletResponse.flushBuffer();
}

Two parts, deliberately together:

  1. The flush converts a hung invocation into a served response.
  2. The log line records each occurrence. Without it the fault becomes silent rather than fixed, and the underlying condition — currently unidentified — would stop being observable.

Restoring doFilter() is not proposed: skipping the filter chain appears intentional and the guard can be replicated without it.

What this does not address

Why Lucee sometimes returns without committing the response is unknown. This makes the omission non-fatal; it does not explain it.

The failing requests were indistinguishable from successful ones in every logged respect — same path, same warm container, ~22ms duration against a 17–29ms neighbourhood, memory flat at 789 MB of 3008 MB, no exception logged, no application error. In one case the failure was roughly the 21st consecutive request to the same URL in the same container, after twenty successes.

Also worth noting: a forced flush may commit an empty or partial response, so the client could receive a blank 200 instead of a 503. Better for availability, and quieter — which is the reason for the log line.

Environment

  • aws-serverless-java-container-core 2.1.5
  • Java 21 Lambda runtime, SnapStart enabled
  • Failures ruled out as SnapStart-related: each occurred 5–19 minutes after the nearest RESTORE_REPORT or INIT_REPORT
  • Ruled out as idle-timeout-related: traffic was continuous, 1.6–2.3s between requests

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions