Skip to content

Commit 72a8b9f

Browse files
joe4devclaude
andcommitted
feat(init): surface on-demand init failures via the first invocation
AWS folds a failed cold-start init into the first invocation (suppressed init), reporting it as a failed invoke with the full INIT_REPORT(phase=invoke)/START/END/ REPORT envelope rather than a separate init error. Match this for on-demand: - main.go: on init failure for on-demand, signal ready and keep the process alive instead of SendInitError+exit, so the first invoke surfaces the cached init error. - custom_interop: skip /status/error forwarding for on-demand (cache only, so the invoke carries the error); emit INIT_REPORT Phase:invoke Status:error Error Type before START and add Status/Error Type to the REPORT, using rapidcore's scrubbed fatal error type. PC/SnapStart/Managed Instances keep the provisioning-time model. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a6469a1 commit 72a8b9f

2 files changed

Lines changed: 59 additions & 15 deletions

File tree

cmd/localstack/custom_interop.go

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ type CustomInteropServer struct {
4848
// LocalStack via SendInitErrorResponse, so the crash-path fallback (SendInitError) does
4949
// not send a duplicate error status for the same failed initialization.
5050
initErrorForwarded atomic.Bool
51+
// initErrorType holds rapidcore's scrubbed fatal error type (e.g. Runtime.Unknown) when init
52+
// failed, used to render the INIT_REPORT(phase=invoke) and REPORT Status/Error Type lines for
53+
// the on-demand folded-into-invoke path. Stores a string; empty/unset means init did not fail.
54+
initErrorType atomic.Value
55+
// onDemand is true for on-demand functions, where AWS folds a failed cold-start init into
56+
// the first invocation (suppressed init). For these we do NOT report init failures via
57+
// /status/error; instead we signal ready and let the first invoke surface the error with
58+
// the full INIT_REPORT/START/END/REPORT envelope. Provisioned concurrency, SnapStart, and
59+
// Managed Instances keep the provisioning-time /status/error model.
60+
onDemand bool
5161
}
5262

5363
type LocalStackAdapter struct {
@@ -118,6 +128,7 @@ func NewCustomInteropServer(lsOpts *LsOpts, adapter *LocalStackAdapter, delegate
118128
upstreamEndpoint: lsOpts.RuntimeEndpoint,
119129
localStackAdapter: adapter,
120130
logCollector: logCollector,
131+
onDemand: GetenvWithDefault("AWS_LAMBDA_INITIALIZATION_TYPE", "on-demand") == "on-demand",
121132
}
122133

123134
// TODO: extract this
@@ -140,13 +151,24 @@ func NewCustomInteropServer(lsOpts *LsOpts, adapter *LocalStackAdapter, delegate
140151
// The synthetic START line is emitted via LocalStackEventsAPI.SendInvokeStart so it
141152
// lands after any inline (suppressed) init, matching AWS — see events.go.
142153

154+
initErrType, _ := server.initErrorType.Load().(string)
155+
143156
initDuration := ""
144-
if !server.warmStart && !server.initTimedOut.Load() {
157+
if !server.warmStart && !server.initTimedOut.Load() && initErrType == "" {
145158
initTimeMS := float64(time.Since(server.initStart).Nanoseconds()) / float64(time.Millisecond)
146159
initDuration = fmt.Sprintf("Init Duration: %.2f ms\t", initTimeMS)
147160
}
148161
server.warmStart = true
149162

163+
// On-demand init failure folded into this invocation (AWS suppressed init): emit
164+
// the INIT_REPORT(phase=invoke) line before START (emitted during Invoke below).
165+
if initErrType != "" {
166+
initTimeMS := float64(time.Since(server.initStart).Nanoseconds()) / float64(time.Millisecond)
167+
_, _ = fmt.Fprintf(logCollector,
168+
"INIT_REPORT Init Duration: %.2f ms\tPhase: invoke\tStatus: error\tError Type: %s\n",
169+
initTimeMS, initErrType)
170+
}
171+
150172
invokeStart := time.Now()
151173
err = server.Invoke(invokeResp, &interop.Invoke{
152174
ID: invokeR.InvokeId,
@@ -197,6 +219,12 @@ func NewCustomInteropServer(lsOpts *LsOpts, adapter *LocalStackAdapter, delegate
197219
log.Fatalln(err)
198220
}
199221
}
222+
// On-demand init failure folded into this invocation: the REPORT carries the
223+
// failure status and rapidcore's scrubbed fatal error type (e.g. Runtime.Unknown).
224+
if initErrType != "" {
225+
isErr = true
226+
status = "Status: error\tError Type: " + initErrType
227+
}
200228
// optional sleep. can be used for debugging purposes
201229
if lsOpts.PostInvokeWaitMS != "" {
202230
waitMS, err := strconv.Atoi(lsOpts.PostInvokeWaitMS)
@@ -249,6 +277,19 @@ func (c *CustomInteropServer) SendInitErrorResponse(resp *interop.ErrorInvokeRes
249277
// Mark synchronously, before sending: this runs in the init flow before
250278
// AwaitInitializedWithDetails unblocks in main.go, so the fallback observes the flag.
251279
c.initErrorForwarded.Store(true)
280+
// Record rapidcore's scrubbed fatal error type so the folded-into-invoke path can render the
281+
// INIT_REPORT(phase=invoke) and REPORT Status/Error Type lines (on-demand).
282+
c.initErrorType.Store(string(resp.FunctionError.Type))
283+
284+
// Always cache the structured error in the delegate so the first invoke can surface it.
285+
defer c.delegate.SendInitErrorResponse(resp)
286+
287+
// On-demand folds the failed init into the first invocation, which carries the error and
288+
// logs; reporting it here via /status/error too would race the invoke and fail the env
289+
// startup before the invoke runs. PC/SnapStart/MI report at provisioning time below.
290+
if c.onDemand {
291+
return nil
292+
}
252293

253294
// Forward the runtime's structured payload as-is and only inject the requestId. Decoding
254295
// into a map rather than a typed struct preserves fields exactly as the runtime emitted
@@ -261,7 +302,7 @@ func (c *CustomInteropServer) SendInitErrorResponse(resp *interop.ErrorInvokeRes
261302
log.WithError(err).WithField("runtime-id", c.localStackAdapter.RuntimeId).
262303
Error("Failed to send init error to LocalStack")
263304
}
264-
return c.delegate.SendInitErrorResponse(resp)
305+
return nil
265306
}
266307

267308
// No invocation is active during the init phase, so this is typically blank; AWS still
@@ -274,14 +315,11 @@ func (c *CustomInteropServer) SendInitErrorResponse(resp *interop.ErrorInvokeRes
274315
body = resp.Payload
275316
}
276317

277-
go func() {
278-
if err := c.localStackAdapter.SendStatus(Error, body); err != nil {
279-
log.WithError(err).WithField("runtime-id", c.localStackAdapter.RuntimeId).
280-
Error("Failed to send init error to LocalStack")
281-
}
282-
}()
283-
284-
return c.delegate.SendInitErrorResponse(resp)
318+
if err := c.localStackAdapter.SendStatus(Error, body); err != nil {
319+
log.WithError(err).WithField("runtime-id", c.localStackAdapter.RuntimeId).
320+
Error("Failed to send init error to LocalStack")
321+
}
322+
return nil
285323
}
286324

287325
// SendInitError reports a structured init failure to LocalStack when the runtime failed to

cmd/localstack/main.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -299,12 +299,18 @@ func main() {
299299
log.Debugf("Reset after init timeout returned: %s", resetErr)
300300
}
301301
}()
302+
case interopServer.onDemand && errors.Is(err, rapidcore.ErrInitDoneFailed):
303+
// On-demand: AWS folds a failed cold-start init into the first invocation (suppressed
304+
// init). Signal ready and keep the process alive so LocalStack dispatches the first
305+
// invoke, which surfaces the cached init error (or a runtime-exit error) together with
306+
// the full INIT_REPORT/START/END/REPORT log envelope. SendInitErrorResponse has already
307+
// cached the structured error (without reporting via /status/error for on-demand).
308+
log.Debugln("Init failed; deferring to first invocation (on-demand suppressed init).")
302309
case err != nil:
303-
// Error cases: ErrInitDoneFailed (runtime crashed/exited or called /init/error) or
304-
// ErrInitResetReceived (init-phase reset). When the runtime reported its own error via
305-
// /init/error, SendInitErrorResponse already forwarded it and SendInitError is a no-op.
306-
// When the runtime instead crashed/exited without reporting, this is the only callback
307-
// that notifies LocalStack (otherwise it waits until the environment timeout).
310+
// PC/SnapStart/MI, or an init-phase reset: report the failure now and exit. When the
311+
// runtime reported its own error via /init/error, SendInitErrorResponse already
312+
// forwarded it and SendInitError is a no-op. When the runtime crashed/exited without
313+
// reporting, this is the only callback that notifies LocalStack.
308314
log.Errorln("Runtime init failed to initialize: " + err.Error() + ". Exiting.")
309315
if !errors.Is(err, rapidcore.ErrInitResetReceived) {
310316
interopServer.SendInitError(initResp.InitErrorType, initResp.InitErrorMessage)

0 commit comments

Comments
 (0)