Skip to content

Commit 2fae807

Browse files
fix(serverless-init): align MicroVM lifecycle hooks with AWS contract
Update MicroVM lifecycle hook handling to match AWS's documented behavior for image-build and runtime hooks. For /ready and /validate, keep the platform-owned retry loop in control by returning fast 503s when the user app is not reachable or when forwarding times out. Set the default /ready and /validate forward budgets to 30s each so build-time smoke tests are not capped by the runtime hook default, and include the reachability dial budget in the lifecycle server WriteTimeout. For /run, fail the hook when the platform request body cannot be read instead of parsing or forwarding a partial runHookPayload. Validated with dda inv test --targets=./cmd/serverless-init/lifecycle/... and dda inv test --targets=./cmd/serverless-init. Amended to address Copilot review feedback on this PR: - lifecycle/forwarder.go: the Forwarder struct's readyTimeout/validateTimeout field comments still said "default 60s"/"default 10s" -- stale relative to the actual defaultReadyTimeout/defaultValidateTimeout constants (30s each, per the const block's own comment). Updated both to say "default 30s". (Copilot's TestNewForwarder_Defaults comment was based on this same stale 60s/10s reading -- no test change needed there, since the test already asserts the correct 30s/30s.) - lifecycle/forwarder_test.go: TestForwarder_PassThroughWaiting_UnboundPort_FailsFastWith503 passed a 150ms timeout and asserted elapsed < 100ms, conflating that parameter with dialCheckTimeout (the actual internal bound used by the reachability check) and leaving little margin under load. Now passes a 2s timeout -- large enough that a passing assertion can only be explained by the fast dial-check path -- and asserts elapsed < dialCheckTimeout+100ms. Verified stable across 5 repeated runs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 99a2b74 commit 2fae807

4 files changed

Lines changed: 189 additions & 78 deletions

File tree

cmd/serverless-init/lifecycle/forwarder.go

Lines changed: 48 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,34 @@ import (
2727
const defaultMaxResponseBodyBytes int64 = 1 << 20
2828

2929
const (
30+
// Per AWS, Run/Resume/Suspend/Terminate: 1 second
31+
// Ready/Validate: 30 seconds
3032
defaultForwardTimeout = 1 * time.Second
31-
defaultReadyTimeout = 60 * time.Second
32-
defaultValidateTimeout = 1 * time.Second
33+
defaultReadyTimeout = 30 * time.Second
34+
defaultValidateTimeout = 30 * time.Second
3335
)
3436

37+
// dialCheckTimeout bounds the single TCP dial attempt PassThroughWaiting uses
38+
// to check user-app reachability for /ready and /validate. Per the hook
39+
// contract, the platform — not the hook — owns the retry loop for these two
40+
// hooks, so the check must answer fast rather than block until the app is up.
41+
// A refused loopback connection fails in microseconds (the kernel sends RST
42+
// immediately), so this timeout only matters for a slow/hung connect (e.g.
43+
// host scheduling jitter on an oversubscribed MicroVM host). 200ms is
44+
// generous enough to absorb that jitter while staying well inside the "answer
45+
// fast" contract, and costs at most one extra platform retry (a 503) on the
46+
// rare call where it's hit.
47+
const dialCheckTimeout = 200 * time.Millisecond
48+
3549
// Forwarder POSTs lifecycle hooks to the user app. It is constructed only
3650
// when DD_AWS_MICROVM_USER_APP_PORT is set and we're in init-container
3751
// mode + MicroVM origin.
3852
type Forwarder struct {
3953
target string // e.g. "http://127.0.0.1:8080"
4054
client *http.Client // shared; no client-level Timeout (per-call deadlines via ctx)
4155
forwardTimeout time.Duration // default 1s, used for suspend/terminate/run/resume
42-
readyTimeout time.Duration // default 60s, used for /ready
43-
validateTimeout time.Duration // default 1s, used for /validate
56+
readyTimeout time.Duration // default 30s, used for /ready
57+
validateTimeout time.Duration // default 30s, used for /validate
4458
maxResponseBodyBytes int64 // default defaultMaxResponseBodyBytes; cap on user-app body surfaced to platform
4559
}
4660

@@ -83,63 +97,58 @@ func NewForwarder(port int, forwardTimeout, readyTimeout, validateTimeout time.D
8397
}
8498
}
8599

86-
// PassThroughWaiting waits for the user app to accept TCP connections bounded
87-
// by timeout, then forwards the request and mirrors the response. Used for:
100+
// PassThroughWaiting checks user-app reachability with a single fast TCP dial
101+
// (bounded by dialCheckTimeout), then forwards the request bounded by timeout
102+
// and mirrors the response. Used for:
88103
// - /ready ("I am booted and ready to be snapshotted"): the platform
89-
// retries on non-200, so the TCP wait absorbs the startup race.
104+
// retries on non-200 until its own configured timeout, so the hook
105+
// answers fast rather than blocking on the startup race.
90106
// - /validate ("I was resumed from a snapshot and everything is good"): the
91-
// TCP wait handles a crash-then-restart between resume and this
92-
// call.
107+
// same fast-answer contract applies to the crash-then-restart window
108+
// between resume and this call.
93109
//
94-
// Body is buffered before the TCP wait so the bytes survive waitForUserApp.
95-
// Deadline exceeded maps to 504. Body Close contract documented on PassThrough.
110+
// Per the /ready and /validate hook contract, only 200 and 503 are meaningful
111+
// responses — the platform retries on 503 until its own configured timeout,
112+
// while any other non-200 (including 504) fails the build. So unlike
113+
// PassThrough, an unreachable app or a deadline exceeded here always maps to
114+
// 503, never 504. Body Close contract documented on PassThrough.
96115
func (f *Forwarder) PassThroughWaiting(timeout time.Duration, path string, headers http.Header, body io.Reader) *http.Response {
97116
var bodyBytes []byte
98117
if body != nil {
99118
var err error
100-
// Read the full inbound body before the TCP wait. A read error is a
101-
// server-side failure (network, OS, memory) — not a client mistake —
102-
// so return 500 rather than 400. We still forward nothing to the user
103-
// app to avoid passing a partial body (which could make it answer
104-
// /validate "healthy" off incomplete data).
119+
// Read the full inbound body before the reachability check. A read
120+
// error is a server-side failure (network, OS, memory) — not a client
121+
// mistake — so return 500 rather than 400. We still forward nothing to
122+
// the user app to avoid passing a partial body (which could make it
123+
// answer /validate "healthy" off incomplete data).
105124
if bodyBytes, err = io.ReadAll(body); err != nil {
106125
return statusOnlyResponse(http.StatusInternalServerError)
107126
}
108127
}
109-
ctx, cancel := context.WithTimeout(context.Background(), timeout)
110-
if err := f.waitForUserApp(ctx); err != nil {
111-
cancel()
112-
return statusOnlyResponse(mapErrToStatus(err))
128+
if !f.reachable(dialCheckTimeout) {
129+
return statusOnlyResponse(http.StatusServiceUnavailable)
113130
}
131+
ctx, cancel := context.WithTimeout(context.Background(), timeout)
114132
resp, err := f.do(ctx, path, headers, bytes.NewReader(bodyBytes))
115133
if err != nil {
116134
cancel()
117-
return statusOnlyResponse(mapErrToStatus(err))
135+
return statusOnlyResponse(http.StatusServiceUnavailable)
118136
}
119137
resp.Body = wrapResponseBody(resp.Body, f.maxResponseBodyBytes, cancel)
120138
return resp
121139
}
122140

123-
// waitForUserApp polls the user app's TCP port until a connection succeeds or
124-
// ctx is cancelled. Returns nil when the port is reachable, ctx.Err() when
125-
// the deadline is exceeded. Polls every 50ms.
126-
func (f *Forwarder) waitForUserApp(ctx context.Context) error {
141+
// reachable performs a single TCP dial attempt to the user app, bounded by
142+
// timeout. No polling/retrying: per the hook contract, the platform (not the
143+
// hook) owns the retry loop for /ready and /validate.
144+
func (f *Forwarder) reachable(timeout time.Duration) bool {
127145
addr := strings.TrimPrefix(f.target, "http://")
128-
dialer := &net.Dialer{}
129-
ticker := time.NewTicker(50 * time.Millisecond)
130-
defer ticker.Stop()
131-
for {
132-
conn, err := dialer.DialContext(ctx, "tcp", addr)
133-
if err == nil {
134-
_ = conn.Close()
135-
return nil
136-
}
137-
select {
138-
case <-ctx.Done():
139-
return ctx.Err()
140-
case <-ticker.C:
141-
}
146+
conn, err := net.DialTimeout("tcp", addr, timeout)
147+
if err != nil {
148+
return false
142149
}
150+
_ = conn.Close()
151+
return true
143152
}
144153

145154
// PassThrough forwards a per-MicroVM lifecycle hook (/run, /resume,

cmd/serverless-init/lifecycle/forwarder_test.go

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -134,15 +134,15 @@ func TestNewForwarder_DisableKeepAlives_OpensNewConnectionPerRequest(t *testing.
134134
"DisableKeepAlives must open a fresh TCP connection per request; with keep-alives enabled only 1 connection would be accepted")
135135
}
136136

137-
// NewForwarder defaults reflect the configured wire.go constants. /ready keeps
138-
// a 60s budget (matches the platform hook timeout); the remaining hooks default
139-
// to 1s. Changing these defaults is a deliberate behavior change — the test
140-
// failure is intentional.
137+
// NewForwarder defaults reflect the configured wire.go constants. /ready and
138+
// /validate each keep a 30s budget, and runtime hooks default to 1s. Changing
139+
// these defaults is a deliberate behavior change — the test failure is
140+
// intentional.
141141
func TestNewForwarder_Defaults(t *testing.T) {
142142
f := NewForwarder(8080, defaultForwardTimeout, defaultReadyTimeout, defaultValidateTimeout)
143143
assert.Equal(t, 1*time.Second, f.forwardTimeout, "forwardTimeout default must be 1s")
144-
assert.Equal(t, 60*time.Second, f.readyTimeout, "readyTimeout default must be 60s (matches platform /ready hook timeout)")
145-
assert.Equal(t, 1*time.Second, f.validateTimeout, "validateTimeout default must be 1s")
144+
assert.Equal(t, 30*time.Second, f.readyTimeout, "readyTimeout default must be 30s (matches platform /ready hook timeout)")
145+
assert.Equal(t, 30*time.Second, f.validateTimeout, "validateTimeout default must be 30s")
146146
assert.Equal(t, defaultMaxResponseBodyBytes, f.maxResponseBodyBytes, "maxResponseBodyBytes default must be 1 MiB")
147147
tr, ok := f.client.Transport.(*http.Transport)
148148
require.True(t, ok, "transport must be *http.Transport")
@@ -267,7 +267,8 @@ func TestForwarder_PassThroughWaiting_MirrorsStatusAndBody(t *testing.T) {
267267
// PassThroughWaiting must honor the timeout it receives. The caller (server.go)
268268
// passes readyTimeout or validateTimeout — PassThroughWaiting must not apply
269269
// any other field. This test uses a 50ms timeout against a 200ms upstream: the
270-
// context expires and the call returns 504.
270+
// context expires and the call returns 503 (per the /ready and /validate hook
271+
// contract, only 200 and 503 are meaningful — never 504).
271272
func TestForwarder_PassThroughWaiting_HonorsProvidedTimeout(t *testing.T) {
272273
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
273274
time.Sleep(200 * time.Millisecond)
@@ -279,8 +280,8 @@ func TestForwarder_PassThroughWaiting_HonorsProvidedTimeout(t *testing.T) {
279280
resp := f.PassThroughWaiting(50*time.Millisecond, "/ready", nil, nil)
280281
require.NotNil(t, resp)
281282
defer resp.Body.Close()
282-
assert.Equal(t, http.StatusGatewayTimeout, resp.StatusCode,
283-
"PassThroughWaiting must time out on the provided timeout")
283+
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode,
284+
"PassThroughWaiting must time out on the provided timeout and return 503, never 504")
284285
}
285286

286287
// On the happy path the response body is wrapped by cancelOnCloseReader.
@@ -329,29 +330,36 @@ func TestWrapResponseBody_CapZero_DisablesCap(t *testing.T) {
329330
"cap=0 must read the full body — LimitReader must NOT be applied")
330331
}
331332

332-
// PassThroughWaiting retries TCP dials until the context expires when the port
333-
// is unbound, so the response is always 504 (deadline exceeded) — never 503.
334-
// This is distinct from PassThrough which returns 503 on the very first
335-
// connect-refused error. The behavioral difference is intentional: /ready and
336-
// /validate must wait for the user app to start, not fail-fast on a transient
337-
// dial error.
338-
func TestForwarder_PassThroughWaiting_UnboundPort_RetriesUntilTimeout504(t *testing.T) {
333+
// PassThroughWaiting makes a single reachability dial attempt, bounded by
334+
// dialCheckTimeout — it must not retry/poll, and must not wait out the
335+
// timeout parameter, before answering. Per the hook contract the platform
336+
// (not the hook) owns the retry loop for /ready and /validate, so an
337+
// unreachable app must return 503 fast, the same fail-fast contract
338+
// PassThrough already applies to connect-refused errors.
339+
func TestForwarder_PassThroughWaiting_UnboundPort_FailsFastWith503(t *testing.T) {
339340
f := &Forwarder{
340-
target: "http://127.0.0.1:1", // unbound — every dial attempt is refused
341+
target: "http://127.0.0.1:1", // unbound — dial attempt is refused
341342
client: &http.Client{},
342343
}
343-
resp := f.PassThroughWaiting(150*time.Millisecond, "/ready", nil, nil)
344+
start := time.Now()
345+
// timeout is deliberately much larger than dialCheckTimeout so a passing
346+
// assertion below can only be explained by the fast dial-check path, not
347+
// by this parameter.
348+
resp := f.PassThroughWaiting(2*time.Second, "/ready", nil, nil)
349+
elapsed := time.Since(start)
344350
require.NotNil(t, resp)
345351
defer resp.Body.Close()
346-
assert.Equal(t, http.StatusGatewayTimeout, resp.StatusCode,
347-
"unbound port must retry until timeout (504), not immediately 503 like PassThrough")
352+
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode,
353+
"unreachable app must return 503, not 504")
354+
assert.Less(t, elapsed, dialCheckTimeout+100*time.Millisecond,
355+
"an unreachable app must fail within dialCheckTimeout, not retry until the much larger timeout parameter (2s) elapses")
348356
}
349357

350-
// passThroughWaiting buffers the request body before the TCP wait so it is
351-
// still available after waitForUserApp returns. Without buffering, the body
352-
// reader would be exhausted during the wait loop and f.do would send an empty
353-
// body to the user app. This pins the buffer-then-forward contract.
354-
func TestForwarder_PassThroughWaiting_ForwardsBodyAfterTCPWait(t *testing.T) {
358+
// passThroughWaiting buffers the request body before the reachability check
359+
// so it is still available once that check returns. Without buffering, the
360+
// body reader would be exhausted during the dial attempt and f.do would send
361+
// an empty body to the user app. This pins the buffer-then-forward contract.
362+
func TestForwarder_PassThroughWaiting_ForwardsBodyAfterReachabilityCheck(t *testing.T) {
355363
received := make(chan []byte, 1)
356364
srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
357365
b, _ := io.ReadAll(r.Body)
@@ -381,7 +389,8 @@ func (errReader) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF }
381389

382390
// A failed read of the inbound body must NOT forward a partial body to the
383391
// user app. PassThroughWaiting returns 500 (server-side read failure) and
384-
// short-circuits before the TCP wait so no partial body reaches the user app.
392+
// short-circuits before the reachability check so no partial body reaches the
393+
// user app.
385394
func TestForwarder_PassThroughWaiting_BodyReadError_Returns500(t *testing.T) {
386395
var reached atomic.Int32
387396
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {

cmd/serverless-init/lifecycle/server.go

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,12 @@
3232
// - When the env var is set: the agent forwards each hook to
3333
// 127.0.0.1:<user-app-port> on the same path and mirrors the user app's
3434
// response (status, body, Content-Type) back to the platform. /ready and
35-
// /validate wait for TCP reachability before forwarding. For /run,
36-
// /resume, /suspend, and /terminate the agent's own work — metric
37-
// emission, /suspend and /terminate telemetry flush — runs in a goroutine
38-
// in parallel with the pass-through.
35+
// /validate check TCP reachability with a single fast dial before
36+
// forwarding, answering 503 immediately if the app isn't up yet rather
37+
// than blocking — the platform owns the retry loop for those two hooks.
38+
// For /run, /resume, /suspend, and /terminate the agent's own work —
39+
// metric emission, /suspend and /terminate telemetry flush — runs in a
40+
// goroutine in parallel with the pass-through.
3941
//
4042
// /terminate does NOT synthesize SIGTERM. The platform owns process
4143
// termination via OS signals delivered independently of this HTTP event.
@@ -238,16 +240,18 @@ func NewServer(
238240
// WriteTimeout must cover the full handler wall-clock for every path:
239241
// - No forwarder: flushTimeout (flush budget + write headroom)
240242
// - /run, /resume, /suspend, /terminate: forwardTimeout (default 1s)
241-
// - /ready: readyTimeout (default 60s, matching platform /ready timeout)
242-
// - /validate: validateTimeout (default 1s)
243+
// - /ready: dialCheckTimeout + readyTimeout (default 30s)
244+
// - /validate: dialCheckTimeout + validateTimeout (default 30s)
243245
// Use the largest of all applicable budgets so the HTTP server does not
244246
// close the platform-facing connection before the handler writes the
245247
// mirrored response.
246248
maxTimeout := s.flushTimeout
247249
if s.fwd != nil {
248250
// /terminate uses flushSequential: flush runs after the forward, so its
249251
// wall-clock is forwardTimeout+flushTimeout, not max of the two.
250-
maxTimeout = max(maxTimeout, s.fwd.forwardTimeout+s.flushTimeout, s.fwd.readyTimeout, s.fwd.validateTimeout)
252+
readyBudget := dialCheckTimeout + s.fwd.readyTimeout
253+
validateBudget := dialCheckTimeout + s.fwd.validateTimeout
254+
maxTimeout = max(maxTimeout, s.fwd.forwardTimeout+s.flushTimeout, readyBudget, validateBudget)
251255
}
252256
writeTimeout := maxTimeout + writeTimeoutHeadroom
253257
s.httpServer = &http.Server{
@@ -347,7 +351,8 @@ func (s *Server) handler() http.Handler {
347351
//
348352
// Dispatcher:
349353
// - If a Forwarder is configured (env-var opt-in), pass-through to the user
350-
// app with TCP-wait: dial errors map to 503, deadline to 504.
354+
// app with a fast reachability check: dial errors and deadline exceeded
355+
// both map to 503.
351356
// - Otherwise, alive-check via ChildHandle: child alive → 200, anything
352357
// else (not yet started, already exited, or nil handle) → 503. The
353358
// pre-spawn race is absorbed by the platform's /ready retry behavior;
@@ -375,9 +380,10 @@ func (s *Server) passThroughReady(w http.ResponseWriter, r *http.Request) {
375380
// lifecycle of a production MicroVM.
376381
//
377382
// When a Forwarder is configured (DD_AWS_MICROVM_USER_APP_PORT set):
378-
// pass-through to the user app with TCP-wait, mirroring the response, so the
379-
// user app's own smoke test drives the build's validity decision. The TCP-wait
380-
// absorbs the window before the app is reachable on the test run. Without a
383+
// pass-through to the user app with a fast reachability check, mirroring the
384+
// response, so the user app's own smoke test drives the build's validity
385+
// decision. A 503 while the app isn't yet reachable on the test run relies on
386+
// the platform's own /validate retry to absorb the window. Without a
381387
// forwarder the agent returns 200 directly; the user app is not required to
382388
// implement /validate in that mode.
383389
func (s *Server) handleValidate(w http.ResponseWriter, r *http.Request) {
@@ -467,7 +473,8 @@ func (s *Server) flushAll(flushCtx context.Context) {
467473
//
468474
// Used for /run and /resume (noFlush), /suspend (flushParallel), and
469475
// /terminate (flushSequential). /ready and /validate use passThroughReady
470-
// and passThroughValidate directly (TCP-wait path, not this function).
476+
// and passThroughValidate directly (fast-reachability-check path, not this
477+
// function).
471478
//
472479
// flushParallel: flush runs concurrently with the forward; wall-clock is
473480
// max(forwardTimeout, flushTimeout)+ε. Known limitation: telemetry produced
@@ -570,7 +577,7 @@ func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) {
570577
bodyBytes, err := io.ReadAll(r.Body)
571578
_ = r.Body.Close()
572579
if err != nil {
573-
log.Debugf("MicroVM lifecycle: could not read run body: %v", err)
580+
log.Warnf("MicroVM lifecycle: could not read run body: %v", err)
574581
w.WriteHeader(http.StatusInternalServerError)
575582
return
576583
}

0 commit comments

Comments
 (0)