Skip to content

Commit e7516c4

Browse files
joe4devclaude
andcommitted
fix(init): drain the reset-interrupted init failure so it cannot mask the suppressed init's real error
After the timeout path resets the in-progress init, awaitInitCompletion is parked sending a ResetReceived failure on the unbuffered initFailures channel. The first invoke's Reserve()/awaitInitialized() consumed it and cached a generic placeholder error (Sandbox.Failure with an EMPTY payload, see the ErrInitResetReceived handling in Invoke), which took precedence over the real failure when the suppressed init re-run crashed without calling /init/error: the invocation returned an empty error payload instead of e.g. Runtime.ExitError "Runtime exited with error: exit status 1" (AWS-validated by test_lambda_init_timeout_then_crash in localstack-pro). Drain the notification right after the synchronous reset, so the invoke's awaitInitialized() observes the closed channel, treats the init outcome as pending, and the suppressed init's own result stays authoritative. This also unparks the awaitInitCompletion goroutine for environments that time out their init but never receive an invoke. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent cc2d7e2 commit e7516c4

2 files changed

Lines changed: 20 additions & 0 deletions

File tree

cmd/localstack/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,11 @@ func main() {
317317
// itself has completed and the suppressed-init retry stays valid.
318318
log.Debugf("Reset after init timeout returned: %s", resetErr)
319319
}
320+
// Consume the reset-interrupted init's failure notification: if the first invoke's
321+
// awaitInitialized() consumed it instead, rapidcore would cache a generic placeholder
322+
// error (Sandbox.Failure with an empty payload) that masks the real error when the
323+
// suppressed init re-run fails (e.g. a runtime crash without /init/error).
324+
interopServer.delegate.DrainInitFailure()
320325
case interopServer.onDemand && errors.Is(err, rapidcore.ErrInitDoneFailed):
321326
// On-demand: AWS folds a failed cold-start init into the first invocation (suppressed
322327
// init). Signal ready and keep the process alive so LocalStack dispatches the first

internal/lambda/rapidcore/server_localstack.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,21 @@ func (s *Server) interpretInitFailure(initFailure interop.InitFailure, awaitingI
5151
return resp, nil
5252
}
5353

54+
// DrainInitFailure consumes (without any side effects) the init-failure notification left
55+
// behind after resetting a timed-out init. The reset aborts the in-progress init, leaving
56+
// awaitInitCompletion parked on the unbuffered initFailures channel with a ResetReceived
57+
// failure; if the first invoke's Reserve()/awaitInitialized() consumed that instead, it would
58+
// cache a generic placeholder error (Sandbox.Failure with an empty payload, see the
59+
// ErrInitResetReceived handling in Invoke) that later masks the real outcome of the suppressed
60+
// init re-run. Draining it here lets awaitInitialized() observe the closed channel and treat
61+
// the init outcome as pending, so the suppressed init's own result is authoritative.
62+
// The receive cannot block indefinitely: once the reset has completed, awaitInitCompletion is
63+
// committed to either sending the failure or closing the channel (init succeeded just before
64+
// the reset took effect).
65+
func (s *Server) DrainInitFailure() {
66+
<-s.getInitFailuresChan()
67+
}
68+
5469
// AwaitInitializedWithTimeout behaves like the upstream AwaitInitialized but (1) returns the
5570
// structured init error on failure and (2) returns early if init does not complete within the
5671
// timeout. On timeout it returns timedOut=true WITHOUT consuming the init-failures channel and

0 commit comments

Comments
 (0)