From b9394b17dc3f9d005db1f3835367f86fb2489f16 Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Wed, 3 Jun 2026 18:14:08 +0200 Subject: [PATCH 1/5] feat(init): add runtime exit error reporting via supervisor and events API Ports the supervisor and events API from PR #41 to enable proper error reporting when a Lambda runtime process exits unexpectedly (e.g. sys.exit() or missing wrapper script), instead of LocalStack timing out with a generic error. - Add LocalStackSupervisor: wraps ProcessSupervisor, detects unexpected runtime-* process exits and emits SendFault(RuntimeExit) events - Add LocalStackEventsAPI: wraps StandaloneEventsAPI, overrides SendFault to forward errors to LocalStack via SendStatus(error, ...) - Wire both into SandboxBuilder via SetEventsAPI / SetSupervisor - Refactor NewCustomInteropServer to accept a pre-created *LocalStackAdapter shared with the events API - Improve SendInitErrorResponse: properly deserialises the payload, includes RequestId, and sends asynchronously (non-blocking) Enables test_lambda_runtime_exit and test_lambda_runtime_wrapper_not_found. Co-Authored-By: Claude Sonnet 4.6 --- cmd/localstack/custom_interop.go | 51 ++++++++++--- cmd/localstack/events.go | 55 ++++++++++++++ cmd/localstack/main.go | 22 +++++- cmd/localstack/supervisor.go | 124 +++++++++++++++++++++++++++++++ 4 files changed, 239 insertions(+), 13 deletions(-) create mode 100644 cmd/localstack/events.go create mode 100644 cmd/localstack/supervisor.go diff --git a/cmd/localstack/custom_interop.go b/cmd/localstack/custom_interop.go index 6b89d65..ca02d25 100644 --- a/cmd/localstack/custom_interop.go +++ b/cmd/localstack/custom_interop.go @@ -97,15 +97,12 @@ type ErrorResponse struct { StackTrace []string `json:"stackTrace,omitempty"` } -func NewCustomInteropServer(lsOpts *LsOpts, delegate interop.Server, logCollector *LogCollector) (server *CustomInteropServer) { +func NewCustomInteropServer(lsOpts *LsOpts, adapter *LocalStackAdapter, delegate interop.Server, logCollector *LogCollector) (server *CustomInteropServer) { server = &CustomInteropServer{ - delegate: delegate.(*rapidcore.Server), - port: lsOpts.InteropPort, - upstreamEndpoint: lsOpts.RuntimeEndpoint, - localStackAdapter: &LocalStackAdapter{ - UpstreamEndpoint: lsOpts.RuntimeEndpoint, - RuntimeId: lsOpts.RuntimeId, - }, + delegate: delegate.(*rapidcore.Server), + port: lsOpts.InteropPort, + upstreamEndpoint: lsOpts.RuntimeEndpoint, + localStackAdapter: adapter, } // TODO: extract this @@ -219,12 +216,44 @@ func (c *CustomInteropServer) SendErrorResponse(invokeID string, resp *interop.E return c.delegate.SendErrorResponse(invokeID, resp) } -// SendInitErrorResponse writes error response during init to a shared memory and sends GIRD FAULT. +// SendInitErrorResponse forwards the init error to LocalStack and then propagates it to the delegate. func (c *CustomInteropServer) SendInitErrorResponse(resp *interop.ErrorInvokeResponse) error { log.Traceln("SendInitErrorResponse called") - if err := c.localStackAdapter.SendStatus(Error, resp.Payload); err != nil { - log.Fatalln("Failed to send init error to LocalStack " + err.Error() + ". Exiting.") + + // Deserialize the raw payload so we can include the requestId and structured fields. + var parsed struct { + ErrorMessage string `json:"errorMessage"` + ErrorType string `json:"errorType"` + StackTrace []string `json:"stackTrace,omitempty"` + } + if err := json.Unmarshal(resp.Payload, &parsed); err != nil { + log.WithError(err).Warn("Failed to parse init error payload; forwarding raw payload") + if err := c.localStackAdapter.SendStatus(Error, resp.Payload); err != nil { + log.WithError(err).WithField("runtime-id", c.localStackAdapter.RuntimeId). + Error("Failed to send init error to LocalStack") + } + return c.delegate.SendInitErrorResponse(resp) + } + + adaptedResp := ErrorResponse{ + ErrorMessage: parsed.ErrorMessage, + ErrorType: parsed.ErrorType, + RequestId: c.delegate.GetCurrentInvokeID(), + StackTrace: parsed.StackTrace, + } + body, err := json.Marshal(adaptedResp) + if err != nil { + log.WithError(err).Error("Failed to marshal adapted init error response") + body = resp.Payload } + + go func() { + if err := c.localStackAdapter.SendStatus(Error, body); err != nil { + log.WithError(err).WithField("runtime-id", c.localStackAdapter.RuntimeId). + Error("Failed to send init error to LocalStack") + } + }() + return c.delegate.SendInitErrorResponse(resp) } diff --git a/cmd/localstack/events.go b/cmd/localstack/events.go new file mode 100644 index 0000000..bf6fa00 --- /dev/null +++ b/cmd/localstack/events.go @@ -0,0 +1,55 @@ +package main + +import ( + "encoding/json" + "fmt" + "sync" + + "github.com/aws/aws-lambda-runtime-interface-emulator/internal/lambda/interop" + "github.com/aws/aws-lambda-runtime-interface-emulator/internal/lambda/rapidcore/standalone/telemetry" +) + +// LocalStackEventsAPI intercepts fault events and forwards them to LocalStack as error status callbacks. +type LocalStackEventsAPI struct { + *telemetry.StandaloneEventsAPI + adapter *LocalStackAdapter + requestID string + mu sync.RWMutex +} + +func NewLocalStackEventsAPI(adapter *LocalStackAdapter) *LocalStackEventsAPI { + return &LocalStackEventsAPI{ + adapter: adapter, + StandaloneEventsAPI: new(telemetry.StandaloneEventsAPI), + } +} + +func (ev *LocalStackEventsAPI) SendFault(data interop.FaultData) error { + _ = ev.StandaloneEventsAPI.SendFault(data) + + requestID := string(data.RequestID) + if data.RequestID == "" { + ev.mu.RLock() + requestID = ev.requestID + ev.mu.RUnlock() + } + + resp := ErrorResponse{ + ErrorMessage: fmt.Sprintf("RequestId: %s Error: %s", requestID, data.ErrorMessage), + ErrorType: string(data.ErrorType), + } + + payload, err := json.Marshal(resp) + if err != nil { + return err + } + + return ev.adapter.SendStatus(Error, payload) +} + +func (ev *LocalStackEventsAPI) SetCurrentRequestID(id interop.RequestID) { + ev.mu.Lock() + defer ev.mu.Unlock() + ev.requestID = string(id) + ev.StandaloneEventsAPI.SetCurrentRequestID(id) +} diff --git a/cmd/localstack/main.go b/cmd/localstack/main.go index b03d877..23abadc 100644 --- a/cmd/localstack/main.go +++ b/cmd/localstack/main.go @@ -179,6 +179,20 @@ func main() { localStackLogsEgressApi := NewLocalStackLogsEgressAPI(logCollector) tracer := NewLocalStackTracer() + // Create LocalStack adapter upfront so it can be shared with the events API and interop server + lsAdapter := &LocalStackAdapter{ + UpstreamEndpoint: lsOpts.RuntimeEndpoint, + RuntimeId: lsOpts.RuntimeId, + } + + // Events API forwards runtime fault events (unexpected exits) to LocalStack as error callbacks + lsEventsAPI := NewLocalStackEventsAPI(lsAdapter) + + // Supervisor intercepts runtime process terminations and emits fault events via the events API + supervisorCtx, cancelSupervisor := context.WithCancel(context.Background()) + + localStackSupv := NewLocalStackSupervisor(supervisorCtx, lsEventsAPI) + // build sandbox sandbox := rapidcore. NewSandboxBuilder(). @@ -186,11 +200,15 @@ func main() { AddShutdownFunc(func() { log.Debugln("Stopping file watcher") cancelFileWatcher() + log.Debugln("Stopping supervisor") + cancelSupervisor() }). SetExtensionsFlag(true). SetInitCachingFlag(true). SetLogsEgressAPI(localStackLogsEgressApi). - SetTracer(tracer) + SetTracer(tracer). + SetEventsAPI(lsEventsAPI). + SetSupervisor(localStackSupv) // Corresponds to the 'AWS_LAMBDA_RUNTIME_API' environment variable. // We need to ensure the runtime server is up before the INIT phase, @@ -211,7 +229,7 @@ func main() { runDaemon(d) // async defaultInterop := sandbox.DefaultInteropServer() - interopServer := NewCustomInteropServer(lsOpts, defaultInterop, logCollector) + interopServer := NewCustomInteropServer(lsOpts, lsAdapter, defaultInterop, logCollector) sandbox.SetInteropServer(interopServer) if len(handler) > 0 { sandbox.SetHandler(handler) diff --git a/cmd/localstack/supervisor.go b/cmd/localstack/supervisor.go new file mode 100644 index 0000000..161a940 --- /dev/null +++ b/cmd/localstack/supervisor.go @@ -0,0 +1,124 @@ +package main + +import ( + "context" + "fmt" + "strings" + "sync/atomic" + + "github.com/aws/aws-lambda-runtime-interface-emulator/internal/lambda/fatalerror" + "github.com/aws/aws-lambda-runtime-interface-emulator/internal/lambda/interop" + "github.com/aws/aws-lambda-runtime-interface-emulator/internal/lambda/supervisor" + "github.com/aws/aws-lambda-runtime-interface-emulator/internal/lambda/supervisor/model" + "github.com/google/uuid" + log "github.com/sirupsen/logrus" +) + +// LocalStackSupervisor wraps a ProcessSupervisor and intercepts runtime process termination events. +// When a runtime process exits unexpectedly it sends a fault event via the EventsAPI so LocalStack +// receives a proper error instead of timing out. +type LocalStackSupervisor struct { + model.ProcessSupervisor + eventsChan chan model.Event + eventsAPI interop.EventsAPI + + isShuttingDown *atomic.Bool +} + +func NewLocalStackSupervisor(ctx context.Context, evs interop.EventsAPI) *LocalStackSupervisor { + var isShuttingDown atomic.Bool + ls := &LocalStackSupervisor{ + ProcessSupervisor: supervisor.NewLocalSupervisor(), + eventsAPI: evs, + eventsChan: make(chan model.Event), + isShuttingDown: &isShuttingDown, + } + + go ls.loop(ctx) + + return ls +} + +func (ls *LocalStackSupervisor) loop(ctx context.Context) { + inCh, err := ls.ProcessSupervisor.Events(ctx, nil) + if err != nil { + panic(err) + } + defer close(ls.eventsChan) + for { + select { + case event, ok := <-inCh: + if !ok { + return + } + + select { + case ls.eventsChan <- event: + case <-ctx.Done(): + return + } + + if ls.isShuttingDown.Load() { + continue + } + + termination := event.Event.ProcessTerminated() + if termination == nil { + continue + } + + if !strings.Contains(*termination.Name, "runtime-") { + log.Debugf("Ignoring non-runtime process termination: %s", *termination.Name) + continue + } + + if termination.Signaled() != nil { + log.Debugf("Runtime process signalled: %d", *termination.Signo) + } + + faultData := interop.FaultData{ + RequestID: interop.RequestID(uuid.NewString()), + ErrorMessage: fmt.Errorf("Runtime exited without providing a reason"), + ErrorType: fatalerror.RuntimeExit, + } + if !termination.Success() { + faultData.ErrorMessage = fmt.Errorf("Runtime exited with error: %s", termination.String()) + } + + if err := ls.eventsAPI.SendFault(faultData); err != nil { + log.WithError(err).Error("Failed to send runtime fault event") + } + case <-ctx.Done(): + return + } + } +} + +func (ls *LocalStackSupervisor) Exec(ctx context.Context, request *model.ExecRequest) error { + if request.Domain == "runtime" { + ls.isShuttingDown.Store(false) + } + return ls.ProcessSupervisor.Exec(ctx, request) +} + +func (ls *LocalStackSupervisor) Terminate(ctx context.Context, request *model.TerminateRequest) error { + defer func() { + if request.Domain == "runtime" && strings.HasPrefix(request.Name, "runtime-") { + ls.isShuttingDown.Store(true) + } + }() + return ls.ProcessSupervisor.Terminate(ctx, request) +} + +func (ls *LocalStackSupervisor) Kill(ctx context.Context, request *model.KillRequest) error { + defer func() { + if request.Domain == "runtime" && strings.HasPrefix(request.Name, "runtime-") { + ls.isShuttingDown.Store(true) + } + }() + return ls.ProcessSupervisor.Kill(ctx, request) +} + +func (ls *LocalStackSupervisor) Events(ctx context.Context, _ *model.EventsRequest) (<-chan model.Event, error) { + return ls.eventsChan, nil +} From a08c667d935129771c795e39282961f33440eec2 Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Wed, 3 Jun 2026 19:45:50 +0200 Subject: [PATCH 2/5] fix(init): include requestId in init error response even when empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use *string for the RequestId field in ErrorResponse so that an empty string is serialized (not omitted by omitempty), while nil — used for fault events — stays omitted. Fixes test_lambda_runtime_error snapshot mismatch where requestId: "" was expected but absent. Co-Authored-By: Claude Sonnet 4.6 --- cmd/localstack/custom_interop.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cmd/localstack/custom_interop.go b/cmd/localstack/custom_interop.go index ca02d25..d46ff91 100644 --- a/cmd/localstack/custom_interop.go +++ b/cmd/localstack/custom_interop.go @@ -93,7 +93,9 @@ type InvokeRequest struct { type ErrorResponse struct { ErrorMessage string `json:"errorMessage"` ErrorType string `json:"errorType,omitempty"` - RequestId string `json:"requestId,omitempty"` + // RequestId uses *string so that an empty string "" is serialized (not omitted), + // while nil is omitted — init errors always set this field, fault events leave it nil. + RequestId *string `json:"requestId,omitempty"` StackTrace []string `json:"stackTrace,omitempty"` } @@ -235,10 +237,11 @@ func (c *CustomInteropServer) SendInitErrorResponse(resp *interop.ErrorInvokeRes return c.delegate.SendInitErrorResponse(resp) } + requestId := c.delegate.GetCurrentInvokeID() adaptedResp := ErrorResponse{ ErrorMessage: parsed.ErrorMessage, ErrorType: parsed.ErrorType, - RequestId: c.delegate.GetCurrentInvokeID(), + RequestId: &requestId, StackTrace: parsed.StackTrace, } body, err := json.Marshal(adaptedResp) From 59e6a2f519cc2a0e223a8d9064b8533840d18290 Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Fri, 5 Jun 2026 09:27:53 +0200 Subject: [PATCH 3/5] fix(init): fix REPORT log format and add init timeout retry support - Move Init Duration after Max Memory Used in REPORT line (matches AWS) - Add Status: timeout to REPORT line on invoke timeout - Fix timeout error message format to "RequestId: Error: Task timed out after N.00 seconds" - Add ErrorType: "Sandbox.Timedout" to timeout error response - Track init start time and emit Init Duration on first non-retry invocation - Add is-init-retry field to InvokeRequest to suppress Init Duration on retry invokes Co-Authored-By: Claude Sonnet 4.6 --- cmd/localstack/awsutil.go | 7 ++++--- cmd/localstack/custom_interop.go | 19 ++++++++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/cmd/localstack/awsutil.go b/cmd/localstack/awsutil.go index 31235e4..d7cdf5c 100644 --- a/cmd/localstack/awsutil.go +++ b/cmd/localstack/awsutil.go @@ -92,7 +92,7 @@ func getBootstrap(args []string) (interop.Bootstrap, string) { return NewSimpleBootstrap(bootstrapLookupCmd, currentWorkingDir), handler } -func PrintEndReports(invokeId string, initDuration string, memorySize string, invokeStart time.Time, timeoutDuration time.Duration, w io.Writer) { +func PrintEndReports(invokeId string, initDuration string, status string, memorySize string, invokeStart time.Time, timeoutDuration time.Duration, w io.Writer) { // Calculate invoke duration invokeDuration := math.Min(float64(time.Now().Sub(invokeStart).Nanoseconds()), float64(timeoutDuration.Nanoseconds())) / float64(time.Millisecond) @@ -102,11 +102,12 @@ func PrintEndReports(invokeId string, initDuration string, memorySize string, in // not a clean way to get this information from rapidcore _, _ = fmt.Fprintf(w, "REPORT RequestId: %s\t"+ - initDuration+ "Duration: %.2f ms\t"+ "Billed Duration: %.f ms\t"+ "Memory Size: %s MB\t"+ - "Max Memory Used: %s MB\t\n", + "Max Memory Used: %s MB\t"+ + initDuration+ + status+"\n", invokeId, invokeDuration, math.Ceil(invokeDuration), memorySize, memorySize) } diff --git a/cmd/localstack/custom_interop.go b/cmd/localstack/custom_interop.go index d46ff91..34d3192 100644 --- a/cmd/localstack/custom_interop.go +++ b/cmd/localstack/custom_interop.go @@ -27,6 +27,8 @@ type CustomInteropServer struct { localStackAdapter *LocalStackAdapter port string upstreamEndpoint string + initStart time.Time + warmStart bool } type LocalStackAdapter struct { @@ -87,6 +89,7 @@ type InvokeRequest struct { InvokedFunctionArn string `json:"invoked-function-arn"` Payload string `json:"payload"` TraceId string `json:"trace-id"` + IsInitRetry bool `json:"is-init-retry,omitempty"` } // The ErrorResponse is sent TO LocalStack when encountering an error @@ -127,6 +130,13 @@ func NewCustomInteropServer(lsOpts *LsOpts, adapter *LocalStackAdapter, delegate functionVersion := GetEnvOrDie("AWS_LAMBDA_FUNCTION_VERSION") // default $LATEST _, _ = fmt.Fprintf(logCollector, "START RequestId: %s Version: %s\n", invokeR.InvokeId, functionVersion) + initDuration := "" + if !server.warmStart && !invokeR.IsInitRetry { + initTimeMS := float64(time.Since(server.initStart).Nanoseconds()) / float64(time.Millisecond) + initDuration = fmt.Sprintf("Init Duration: %.2f ms\t", initTimeMS) + } + server.warmStart = true + invokeStart := time.Now() err = server.Invoke(invokeResp, &interop.Invoke{ ID: invokeR.InvokeId, @@ -148,15 +158,17 @@ func NewCustomInteropServer(lsOpts *LsOpts, adapter *LocalStackAdapter, delegate }) timeout := int(server.delegate.GetInvokeTimeout().Seconds()) isErr := false + status := "" if err != nil { switch { case errors.Is(err, rapidcore.ErrInvokeTimeout): log.Debugf("Got invoke timeout") isErr = true + status = "Status: timeout" errorResponse := ErrorResponse{ + ErrorType: "Sandbox.Timedout", ErrorMessage: fmt.Sprintf( - "%s %s Task timed out after %d.00 seconds", - time.Now().Format("2006-01-02T15:04:05Z"), + "RequestId: %s Error: Task timed out after %d.00 seconds", invokeR.InvokeId, timeout, ), @@ -185,7 +197,7 @@ func NewCustomInteropServer(lsOpts *LsOpts, adapter *LocalStackAdapter, delegate } timeoutDuration := time.Duration(timeout) * time.Second memorySize := GetEnvOrDie("AWS_LAMBDA_FUNCTION_MEMORY_SIZE") - PrintEndReports(invokeR.InvokeId, "", memorySize, invokeStart, timeoutDuration, logCollector) + PrintEndReports(invokeR.InvokeId, initDuration, status, memorySize, invokeStart, timeoutDuration, logCollector) if err2 := server.localStackAdapter.SendLogs(invokeR.InvokeId, logCollector.getLogs()); err2 != nil { log.Error("failed to send logs to LocalStack: ", err2) @@ -272,6 +284,7 @@ func (c *CustomInteropServer) SendRuntimeReady() error { func (c *CustomInteropServer) Init(i *interop.Init, invokeTimeoutMs int64) error { log.Traceln("Init called") + c.initStart = time.Now() return c.delegate.Init(i, invokeTimeoutMs) } From 1c2b2a7169ae8294aa713636ba438e3a35891c38 Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Fri, 5 Jun 2026 16:26:55 +0200 Subject: [PATCH 4/5] chore(init): close HTTP response bodies and minor cleanups - Close response bodies in SendStatus/SendLogs/SendResult so idle connections are released instead of leaked. - Use errors.New instead of fmt.Errorf with no format arguments. - Document the single-invoke assumption behind the unsynchronized initStart/warmStart fields. Co-Authored-By: Claude Opus 4.7 --- cmd/localstack/custom_interop.go | 34 +++++++++++++++++++++----------- cmd/localstack/supervisor.go | 3 ++- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/cmd/localstack/custom_interop.go b/cmd/localstack/custom_interop.go index 34d3192..a5fa71c 100644 --- a/cmd/localstack/custom_interop.go +++ b/cmd/localstack/custom_interop.go @@ -27,8 +27,11 @@ type CustomInteropServer struct { localStackAdapter *LocalStackAdapter port string upstreamEndpoint string - initStart time.Time - warmStart bool + // initStart is set once in Init() and warmStart is flipped on the first invoke. + // Both are accessed only from the single sequential init -> invoke flow (the RIE + // processes one invocation at a time), so they need no additional synchronization. + initStart time.Time + warmStart bool } type LocalStackAdapter struct { @@ -45,10 +48,11 @@ const ( func (l *LocalStackAdapter) SendStatus(status LocalStackStatus, payload []byte) error { statusUrl := fmt.Sprintf("%s/status/%s/%s", l.UpstreamEndpoint, l.RuntimeId, status) - _, err := http.Post(statusUrl, "application/json", bytes.NewReader(payload)) + resp, err := http.Post(statusUrl, "application/json", bytes.NewReader(payload)) if err != nil { return err } + defer resp.Body.Close() return nil } @@ -58,8 +62,12 @@ func (l *LocalStackAdapter) SendLogs(invokeId string, logs LogResponse) error { if err != nil { return err } - _, err = http.Post(l.UpstreamEndpoint+"/invocations/"+invokeId+"/logs", "application/json", bytes.NewReader(serialized)) - return err + resp, err := http.Post(l.UpstreamEndpoint+"/invocations/"+invokeId+"/logs", "application/json", bytes.NewReader(serialized)) + if err != nil { + return err + } + defer resp.Body.Close() + return nil } // SendResult posts the invocation result body to LocalStack. @@ -79,8 +87,12 @@ func (l *LocalStackAdapter) SendResult(invokeId string, body []byte, isError boo } else { log.Infoln("Sending to /response") } - _, err := http.Post(l.UpstreamEndpoint+endpoint, "application/json", bytes.NewReader(body)) - return err + resp, err := http.Post(l.UpstreamEndpoint+endpoint, "application/json", bytes.NewReader(body)) + if err != nil { + return err + } + defer resp.Body.Close() + return nil } // The InvokeRequest is sent by LocalStack to trigger an invocation @@ -94,12 +106,12 @@ type InvokeRequest struct { // The ErrorResponse is sent TO LocalStack when encountering an error type ErrorResponse struct { - ErrorMessage string `json:"errorMessage"` - ErrorType string `json:"errorType,omitempty"` + ErrorMessage string `json:"errorMessage"` + ErrorType string `json:"errorType,omitempty"` // RequestId uses *string so that an empty string "" is serialized (not omitted), // while nil is omitted — init errors always set this field, fault events leave it nil. - RequestId *string `json:"requestId,omitempty"` - StackTrace []string `json:"stackTrace,omitempty"` + RequestId *string `json:"requestId,omitempty"` + StackTrace []string `json:"stackTrace,omitempty"` } func NewCustomInteropServer(lsOpts *LsOpts, adapter *LocalStackAdapter, delegate interop.Server, logCollector *LogCollector) (server *CustomInteropServer) { diff --git a/cmd/localstack/supervisor.go b/cmd/localstack/supervisor.go index 161a940..79de691 100644 --- a/cmd/localstack/supervisor.go +++ b/cmd/localstack/supervisor.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "strings" "sync/atomic" @@ -78,7 +79,7 @@ func (ls *LocalStackSupervisor) loop(ctx context.Context) { faultData := interop.FaultData{ RequestID: interop.RequestID(uuid.NewString()), - ErrorMessage: fmt.Errorf("Runtime exited without providing a reason"), + ErrorMessage: errors.New("Runtime exited without providing a reason"), ErrorType: fatalerror.RuntimeExit, } if !termination.Success() { From 348ee93a5e643cce6a9134eb60ed90863b655b12 Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Fri, 5 Jun 2026 16:35:04 +0200 Subject: [PATCH 5/5] fix(init): report the real invoke ID for runtime fault events Resolve the fault request ID in the events API: prefer an explicit ID, then the current invoke ID so a mid-invocation runtime crash reports the actual request, and only synthesize a UUID as a fallback for init-phase faults where no invocation has been dispatched yet. Previously the supervisor always passed a random UUID, masking the real invoke ID. Co-Authored-By: Claude Opus 4.7 --- cmd/localstack/events.go | 9 ++++++++- cmd/localstack/supervisor.go | 5 +++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/cmd/localstack/events.go b/cmd/localstack/events.go index bf6fa00..4718b93 100644 --- a/cmd/localstack/events.go +++ b/cmd/localstack/events.go @@ -7,6 +7,7 @@ import ( "github.com/aws/aws-lambda-runtime-interface-emulator/internal/lambda/interop" "github.com/aws/aws-lambda-runtime-interface-emulator/internal/lambda/rapidcore/standalone/telemetry" + "github.com/google/uuid" ) // LocalStackEventsAPI intercepts fault events and forwards them to LocalStack as error status callbacks. @@ -28,11 +29,17 @@ func (ev *LocalStackEventsAPI) SendFault(data interop.FaultData) error { _ = ev.StandaloneEventsAPI.SendFault(data) requestID := string(data.RequestID) - if data.RequestID == "" { + if requestID == "" { ev.mu.RLock() requestID = ev.requestID ev.mu.RUnlock() } + if requestID == "" { + // No invocation is active during the init phase (LocalStack only dispatches an invoke + // after the runtime reports ready), so synthesize an ID to preserve AWS's + // "RequestId: Error: ..." message format. + requestID = uuid.NewString() + } resp := ErrorResponse{ ErrorMessage: fmt.Sprintf("RequestId: %s Error: %s", requestID, data.ErrorMessage), diff --git a/cmd/localstack/supervisor.go b/cmd/localstack/supervisor.go index 79de691..249dda3 100644 --- a/cmd/localstack/supervisor.go +++ b/cmd/localstack/supervisor.go @@ -11,7 +11,6 @@ import ( "github.com/aws/aws-lambda-runtime-interface-emulator/internal/lambda/interop" "github.com/aws/aws-lambda-runtime-interface-emulator/internal/lambda/supervisor" "github.com/aws/aws-lambda-runtime-interface-emulator/internal/lambda/supervisor/model" - "github.com/google/uuid" log "github.com/sirupsen/logrus" ) @@ -77,8 +76,10 @@ func (ls *LocalStackSupervisor) loop(ctx context.Context) { log.Debugf("Runtime process signalled: %d", *termination.Signo) } + // RequestID is left empty so the events API can resolve it: it uses the current + // invoke ID for a mid-invocation crash and synthesizes a placeholder for init-phase + // faults, where no invocation has been dispatched yet. faultData := interop.FaultData{ - RequestID: interop.RequestID(uuid.NewString()), ErrorMessage: errors.New("Runtime exited without providing a reason"), ErrorType: fatalerror.RuntimeExit, }