Skip to content

Commit 867829a

Browse files
joe4devclaude
andcommitted
chore(init): review cleanups — reporting hardening and fork-change documentation
- REPORT line: pass the pre-formatted Init Duration/Status fragments as %s arguments instead of concatenating them into the Fprintf format string, where a runtime-supplied error type containing a formatting verb would corrupt the line (the fatal-error scrub regex is unanchored). - SendInitErrorResponse returns the delegate's error again (pre-branch behavior): the /runtime/init/error handler renders an interop error to the runtime based on it (e.g. ErrResponseSent during a suppressed init). - LocalStack adapter POSTs now fail on non-2xx responses (e.g. LocalStack rejects a duplicate /status/error with 400, previously treated as success) and share one post() helper. - LOCALSTACK_INIT_PHASE_TIMEOUT is validated (> 0; a 0 previously forced every init down the timeout path) and parsed once instead of double-defaulting. - SendInitError fault messages use a blank requestId, matching the /init/error path which forwards AWS's blank init-phase requestId; the unused uuid dependency is dropped and lsapi.ErrorResponse.RequestId reverts to a plain string (never set anywhere), restoring internal/lsapi/types.go to its pre-branch state. - Dedupe the INIT_REPORT line format and the ns->ms conversion. - Document fork changes per README-LOCALSTACK.md convention: LOCALSTACK CHANGES prefix in server_localstack.go and new entries for it and internal/lsapi in the custom-changes list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e7516c4 commit 867829a

6 files changed

Lines changed: 70 additions & 54 deletions

File tree

README-LOCALSTACK.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,6 @@ Document all custom changes with the following comment prefix `# LOCALSTACK CHAN
4545

4646
* Everything in `cmd/localstack`, `cmd/ls-mock`, and `.github`
4747
* `Makefile` for debugging and building with Docker
48+
* `internal/lsapi` LocalStack-only package with the request/response types of the LocalStack <-> RIE HTTP API
4849
* 2023-10-17: `lambda/rapidcore/server.go` pass request metadata into .Reserve(invoke.ID, invoke.TraceID, invoke.LambdaSegmentID)
50+
* 2026-06-11: `lambda/rapidcore/server_localstack.go` new LocalStack-only file with additions to the rapidcore Server (timeout-aware init await, init-failure drain, structured init-failure interpretation)

cmd/localstack/awsutil.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,15 +100,17 @@ func PrintEndReports(invokeId string, initDuration string, status string, memory
100100
_, _ = fmt.Fprintln(w, "END RequestId: "+invokeId)
101101
// We set the Max Memory Used and Memory Size to be the same (whatever it is set to) since there is
102102
// not a clean way to get this information from rapidcore
103+
// initDuration and status are pre-formatted fragments: pass them as %s arguments, not as
104+
// part of the format string — status may embed a runtime-supplied error type, and a stray
105+
// formatting verb in it would corrupt the REPORT line.
103106
_, _ = fmt.Fprintf(w,
104107
"REPORT RequestId: %s\t"+
105108
"Duration: %.2f ms\t"+
106109
"Billed Duration: %.f ms\t"+
107110
"Memory Size: %s MB\t"+
108111
"Max Memory Used: %s MB\t"+
109-
initDuration+
110-
status+"\n",
111-
invokeId, invokeDuration, math.Ceil(invokeDuration), memorySize, memorySize)
112+
"%s%s\n",
113+
invokeId, invokeDuration, math.Ceil(invokeDuration), memorySize, memorySize, initDuration, status)
112114
}
113115

114116
type Sandbox interface {

cmd/localstack/custom_interop.go

Lines changed: 42 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import (
2222
"github.com/aws/aws-lambda-runtime-interface-emulator/internal/lambda/rapidcore/standalone"
2323
"github.com/aws/aws-lambda-runtime-interface-emulator/internal/lsapi"
2424
"github.com/go-chi/chi/v5"
25-
"github.com/google/uuid"
2625
log "github.com/sirupsen/logrus"
2726
)
2827

@@ -49,7 +48,8 @@ type CustomInteropServer struct {
4948
initTimedOut atomic.Bool
5049
// initErrorForwarded is set once the runtime's own /init/error has been forwarded to
5150
// LocalStack via SendInitErrorResponse, so the crash-path fallback (SendInitError) does
52-
// not send a duplicate error status for the same failed initialization.
51+
// not send a duplicate error status for the same failed initialization. Unlike
52+
// initErrorType below it is never cleared: it only guards the one-shot init-phase report.
5353
initErrorForwarded atomic.Bool
5454
// initErrorType holds rapidcore's scrubbed fatal error type (e.g. Runtime.Unknown) when init
5555
// failed, used to render the INIT_REPORT(phase=invoke) and REPORT Status/Error Type lines for
@@ -83,28 +83,31 @@ const (
8383
Error LocalStackStatus = "error"
8484
)
8585

86-
func (l *LocalStackAdapter) SendStatus(status LocalStackStatus, payload []byte) error {
87-
statusUrl := fmt.Sprintf("%s/status/%s/%s", l.UpstreamEndpoint, l.RuntimeId, status)
88-
resp, err := http.Post(statusUrl, "application/json", bytes.NewReader(payload))
86+
// post sends a JSON payload to the given LocalStack endpoint path and fails on non-2xx
87+
// responses (e.g. LocalStack rejects a duplicate /status/error with 400).
88+
func (l *LocalStackAdapter) post(path string, payload []byte) error {
89+
resp, err := http.Post(l.UpstreamEndpoint+path, "application/json", bytes.NewReader(payload))
8990
if err != nil {
9091
return err
9192
}
9293
defer resp.Body.Close()
94+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
95+
return fmt.Errorf("POST %s returned status %d", path, resp.StatusCode)
96+
}
9397
return nil
9498
}
9599

100+
func (l *LocalStackAdapter) SendStatus(status LocalStackStatus, payload []byte) error {
101+
return l.post(fmt.Sprintf("/status/%s/%s", l.RuntimeId, status), payload)
102+
}
103+
96104
// SendLogs posts the captured invocation logs to LocalStack.
97105
func (l *LocalStackAdapter) SendLogs(invokeId string, logs lsapi.LogResponse) error {
98106
serialized, err := json.Marshal(logs)
99107
if err != nil {
100108
return err
101109
}
102-
resp, err := http.Post(l.UpstreamEndpoint+"/invocations/"+invokeId+"/logs", "application/json", bytes.NewReader(serialized))
103-
if err != nil {
104-
return err
105-
}
106-
defer resp.Body.Close()
107-
return nil
110+
return l.post("/invocations/"+invokeId+"/logs", serialized)
108111
}
109112

110113
// SendResult posts the invocation result body to LocalStack.
@@ -124,12 +127,7 @@ func (l *LocalStackAdapter) SendResult(invokeId string, body []byte, isError boo
124127
} else {
125128
log.Infoln("Sending to /response")
126129
}
127-
resp, err := http.Post(l.UpstreamEndpoint+endpoint, "application/json", bytes.NewReader(body))
128-
if err != nil {
129-
return err
130-
}
131-
defer resp.Body.Close()
132-
return nil
130+
return l.post(endpoint, body)
133131
}
134132

135133
func NewCustomInteropServer(lsOpts *LsOpts, adapter *LocalStackAdapter, delegate interop.Server, logCollector *LogCollector, eventsAPI *LocalStackEventsAPI) (server *CustomInteropServer) {
@@ -184,11 +182,9 @@ func NewCustomInteropServer(lsOpts *LsOpts, adapter *LocalStackAdapter, delegate
184182
if initErrType != "" {
185183
initTimeMS, ok := server.eventsAPI.InitDurationMS()
186184
if !ok {
187-
initTimeMS = float64(time.Since(server.initStart).Nanoseconds()) / float64(time.Millisecond)
185+
initTimeMS = millisSince(server.initStart)
188186
}
189-
_, _ = fmt.Fprintf(logCollector,
190-
"INIT_REPORT Init Duration: %.2f ms\tPhase: invoke\tStatus: error\tError Type: %s\n",
191-
initTimeMS, initErrType)
187+
fprintInitReport(logCollector, initTimeMS, "invoke", "error", initErrType)
192188
}
193189

194190
invokeStart := time.Now()
@@ -302,7 +298,7 @@ func (c *CustomInteropServer) SendErrorResponse(invokeID string, resp *interop.E
302298
// LocalStack and then propagates it to the delegate. It marks initErrorForwarded so the
303299
// crash-path fallback in main.go (SendInitError) does not send a duplicate error status for
304300
// the same failed initialization.
305-
func (c *CustomInteropServer) SendInitErrorResponse(resp *interop.ErrorInvokeResponse) error {
301+
func (c *CustomInteropServer) SendInitErrorResponse(resp *interop.ErrorInvokeResponse) (err error) {
306302
log.Traceln("SendInitErrorResponse called")
307303
// Mark synchronously, before sending: this runs in the init flow before
308304
// AwaitInitializedWithDetails unblocks in main.go, so the fallback observes the flag.
@@ -311,8 +307,10 @@ func (c *CustomInteropServer) SendInitErrorResponse(resp *interop.ErrorInvokeRes
311307
// INIT_REPORT(phase=invoke) and REPORT Status/Error Type lines (on-demand).
312308
c.initErrorType.Store(string(resp.FunctionError.Type))
313309

314-
// Always cache the structured error in the delegate so the first invoke can surface it.
315-
defer c.delegate.SendInitErrorResponse(resp)
310+
// Always cache the structured error in the delegate so the first invoke can surface it, and
311+
// return its error: the /runtime/init/error handler renders an interop error to the runtime
312+
// based on it (e.g. ErrResponseSent during a suppressed init).
313+
defer func() { err = c.delegate.SendInitErrorResponse(resp) }()
316314

317315
// On-demand folds the failed init into the first invocation, which carries the error and
318316
// logs; reporting it here via /status/error too would race the invoke and fail the env
@@ -374,15 +372,11 @@ func (c *CustomInteropServer) SendInitError(errType fatalerror.ErrorType, errMsg
374372

375373
// Match AWS's fault message format "RequestId: <id> Error: <msg>". No invocation is active
376374
// during the init phase (LocalStack only dispatches an invoke after the runtime reports
377-
// ready), so synthesize a request ID, preferring the current invoke ID if one exists.
378-
requestID := c.delegate.GetCurrentInvokeID()
379-
if requestID == "" {
380-
requestID = uuid.NewString()
381-
}
382-
375+
// ready), so the request id is blank — matching the /init/error path, which forwards AWS's
376+
// blank init-phase requestId (see SendInitErrorResponse).
383377
payload, err := json.Marshal(lsapi.ErrorResponse{
384378
ErrorType: string(errType),
385-
ErrorMessage: fmt.Sprintf("RequestId: %s Error: %s", requestID, message),
379+
ErrorMessage: fmt.Sprintf("RequestId: %s Error: %s", c.delegate.GetCurrentInvokeID(), message),
386380
})
387381
if err != nil {
388382
log.WithError(err).Error("Failed to marshal init error response")
@@ -431,9 +425,23 @@ func (c *CustomInteropServer) Init(i *interop.Init, invokeTimeoutMs int64) error
431425
// invocation (under the function timeout), and that invocation's REPORT omits Init Duration.
432426
func (c *CustomInteropServer) ReportInitTimeout() {
433427
c.initTimedOut.Store(true)
434-
initTimeMS := float64(time.Since(c.initStart).Nanoseconds()) / float64(time.Millisecond)
435-
_, _ = fmt.Fprintf(c.logCollector,
436-
"INIT_REPORT Init Duration: %.2f ms\tPhase: init\tStatus: timeout\n", initTimeMS)
428+
fprintInitReport(c.logCollector, millisSince(c.initStart), "init", "timeout", "")
429+
}
430+
431+
// millisSince returns the wall-clock milliseconds elapsed since start.
432+
func millisSince(start time.Time) float64 {
433+
return float64(time.Since(start).Nanoseconds()) / float64(time.Millisecond)
434+
}
435+
436+
// fprintInitReport emits an AWS-style INIT_REPORT log line, e.g.
437+
// "INIT_REPORT Init Duration: 9999.27 ms\tPhase: init\tStatus: timeout" or
438+
// "INIT_REPORT Init Duration: 0.91 ms\tPhase: invoke\tStatus: error\tError Type: Runtime.ExitError".
439+
func fprintInitReport(w io.Writer, durationMS float64, phase string, status string, errorType string) {
440+
_, _ = fmt.Fprintf(w, "INIT_REPORT Init Duration: %.2f ms\tPhase: %s\tStatus: %s", durationMS, phase, status)
441+
if errorType != "" {
442+
_, _ = fmt.Fprintf(w, "\tError Type: %s", errorType)
443+
}
444+
_, _ = fmt.Fprintln(w)
437445
}
438446

439447
func (c *CustomInteropServer) Invoke(responseWriter http.ResponseWriter, invoke *interop.Invoke) error {

cmd/localstack/main.go

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,14 @@ func InitLsOpts() *LsOpts {
6363
RuntimeId: GetEnvOrDie("LOCALSTACK_RUNTIME_ID"),
6464
AccountId: GetenvWithDefault("LOCALSTACK_FUNCTION_ACCOUNT_ID", "000000000000"),
6565
// optional with default
66-
InteropPort: GetenvWithDefault("LOCALSTACK_INTEROP_PORT", "9563"),
67-
InitTracingPort: GetenvWithDefault("LOCALSTACK_RUNTIME_TRACING_PORT", "9564"),
68-
User: GetenvWithDefault("LOCALSTACK_USER", "sbx_user1051"),
69-
InitLogLevel: GetenvWithDefault("LOCALSTACK_INIT_LOG_LEVEL", "warn"),
70-
EdgePort: GetenvWithDefault("EDGE_PORT", "4566"),
71-
MaxPayloadSize: GetenvWithDefault("LOCALSTACK_MAX_PAYLOAD_SIZE", "6291556"),
72-
InitPhaseTimeout: GetenvWithDefault("LOCALSTACK_INIT_PHASE_TIMEOUT", strconv.Itoa(defaultInitPhaseTimeoutSeconds)),
66+
InteropPort: GetenvWithDefault("LOCALSTACK_INTEROP_PORT", "9563"),
67+
InitTracingPort: GetenvWithDefault("LOCALSTACK_RUNTIME_TRACING_PORT", "9564"),
68+
User: GetenvWithDefault("LOCALSTACK_USER", "sbx_user1051"),
69+
InitLogLevel: GetenvWithDefault("LOCALSTACK_INIT_LOG_LEVEL", "warn"),
70+
EdgePort: GetenvWithDefault("EDGE_PORT", "4566"),
71+
MaxPayloadSize: GetenvWithDefault("LOCALSTACK_MAX_PAYLOAD_SIZE", "6291556"),
7372
// optional or empty
73+
InitPhaseTimeout: os.Getenv("LOCALSTACK_INIT_PHASE_TIMEOUT"),
7474
CodeArchives: os.Getenv("LOCALSTACK_CODE_ARCHIVES"),
7575
HotReloadingPaths: strings.Split(GetenvWithDefault("LOCALSTACK_HOT_RELOADING_PATHS", ""), ","),
7676
FileWatcherStrategy: os.Getenv("LOCALSTACK_FILE_WATCHER_STRATEGY"),
@@ -276,10 +276,13 @@ func main() {
276276
InitHandler(sandbox.LambdaInvokeAPI(), GetEnvOrDie("AWS_LAMBDA_FUNCTION_VERSION"), int64(invokeTimeoutSeconds), bootstrap, lsOpts.AccountId) // TODO: replace this with a custom init
277277

278278
initPhaseTimeoutSeconds := defaultInitPhaseTimeoutSeconds
279-
if parsed, perr := strconv.Atoi(lsOpts.InitPhaseTimeout); perr == nil {
280-
initPhaseTimeoutSeconds = parsed
281-
} else {
282-
log.Warnln("Invalid LOCALSTACK_INIT_PHASE_TIMEOUT, using default:", perr)
279+
if lsOpts.InitPhaseTimeout != "" {
280+
if parsed, perr := strconv.Atoi(lsOpts.InitPhaseTimeout); perr == nil && parsed > 0 {
281+
initPhaseTimeoutSeconds = parsed
282+
} else {
283+
log.Warnf("Invalid LOCALSTACK_INIT_PHASE_TIMEOUT %q (must be a positive integer); using default %ds",
284+
lsOpts.InitPhaseTimeout, defaultInitPhaseTimeoutSeconds)
285+
}
283286
}
284287

285288
log.Debugln("Awaiting initialization of runtime init.")

internal/lambda/rapidcore/server_localstack.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package rapidcore
22

3+
// LOCALSTACK CHANGES 2026-06-11: new LocalStack-only file with additions to the rapidcore
4+
// Server (timeout-aware init await, init-failure drain, structured init-failure interpretation)
5+
36
// This file contains LocalStack-specific additions to the rapidcore Server. It is kept
47
// separate from server.go (which is vendored upstream from
58
// aws-lambda-runtime-interface-emulator) so that upstream stays byte-identical and rebases

internal/lsapi/types.go

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,8 @@ type LogResponse struct {
1515

1616
// ErrorResponse is sent to LocalStack when encountering an error.
1717
type ErrorResponse struct {
18-
ErrorMessage string `json:"errorMessage"`
19-
ErrorType string `json:"errorType,omitempty"`
20-
// RequestId uses *string so that an empty string "" is serialized (not omitted),
21-
// while nil is omitted — init errors always set this field, fault events leave it nil.
22-
RequestId *string `json:"requestId,omitempty"`
23-
StackTrace []string `json:"stackTrace,omitempty"`
18+
ErrorMessage string `json:"errorMessage"`
19+
ErrorType string `json:"errorType,omitempty"`
20+
RequestId string `json:"requestId,omitempty"`
21+
StackTrace []string `json:"stackTrace,omitempty"`
2422
}

0 commit comments

Comments
 (0)