Skip to content

feat: add Lambda SnapStart support - #831

Open
bnusunny wants to merge 71 commits into
mainfrom
snapstart-support
Open

feat: add Lambda SnapStart support#831
bnusunny wants to merge 71 commits into
mainfrom
snapstart-support

Conversation

@bnusunny

@bnusunny bnusunny commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds Lambda SnapStart support to the Lambda Web Adapter.

What it does

  • New src/snapstart.rs: registers a SnapStart resource with the Lambda runtime and
    bridges the before-checkpoint / after-restore lifecycle to the inner web app over HTTP.
  • New env vars to opt in and configure hook paths:
    AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH and AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH.
  • Hook paths are rejected with 403 when requested from outside the runtime, so they are
    not reachable by external callers.
  • Inner-app hooks get a 60s timeout so a hung hook cannot stall a checkpoint or restore.
  • The readiness check re-runs after restore, and the HTTP client is swapped to a fresh
    restored_client (write-once OnceLock) so no pre-snapshot connections are reused.
  • Refactors along the way: build_client() extraction, register_and_run() dedup of the
    run() arms, fetch_response returns BoxBody<Bytes, Error>.

Docs & examples

  • Guide: new SnapStart feature page plus environment-variable reference entries.
  • examples/fastapi-snapstart (container image) and examples/fastapi-snapstart-zip (zip),
    both wiring the hook env vars through the SAM template.
  • README features list and examples overview updated.

Testing

  • cargo build — clean
  • cargo test — 78 tests pass (4 e2e tests ignored, as they require deployed infrastructure)
  • cargo clippy --all-targets — clean

Note: nextest was unavailable in this environment, so tests ran via cargo test -- --test-threads=1
to preserve the env-var isolation the SnapStart config tests rely on.

Switch from the git-branch dependency to the published lambda_http/lambda_runtime
1.3.0 from crates.io, removing the release blocker. Also fix the integration test
body-reader helpers to accept the BoxBody response type, and resolve a clippy
ok().expect() lint.
@bnusunny
bnusunny requested a review from a team as a code owner August 31, 2026 17:46
@bnusunny
bnusunny requested a review from vicheey August 31, 2026 17:48

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 34d3a29..592bb5a
Files: 26 (focused on src/lib.rs, src/snapstart.rs, src/readiness.rs, example templates/Dockerfiles; skipped Cargo.lock, .gitignore, docs prose)
Comments: 4

Comment thread src/lib.rs Outdated
Comment thread examples/fastapi-snapstart/template.yaml
Comment thread examples/fastapi-snapstart/app/Dockerfile
Comment thread src/lib.rs Outdated
…er%0A)

canonicalize_hook_path treated a control byte as undecidable and returned None,
which matches_hook_path turned into pass-through — but a router like Starlette
still resolves /snapstart/after%0A (decoded /snapstart/after\n) to the hook route
(Python $ matches before a trailing newline), leaving the state-mutating hook
externally reachable. Strip control bytes during canonicalization so the path
collapses onto the hook and is blocked; malformed percent-escapes still pass
through (no /reports/100% false 403). Adds %0A/%0a/%0d%0a/%00 regression cases.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 986113f..2098a21
Files: 27
Comments: 2

Comment thread src/lib.rs Outdated
Comment thread src/lib.rs
Remove Bug Fixes entries for issues introduced and fixed within this PR (missing
TracingLayer on the new concurrent-runtime path, the control-byte hook-guard
bypass, and the configured-hook-path set_path normalization / matrix-param gaps —
all in code this PR adds), and the pool_max_idle_per_host(0) 'restore' which nets
to no change versus the last release. Keep only the genuinely pre-existing
AWS_LWA_REMOVE_BASE_PATH fix, plus the SnapStart features.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 986113f..9434eaa
Files: 27
Comments: 3

Comment thread src/lib.rs Outdated
Comment thread README.md
Comment thread src/lib.rs

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 986113f..fd885e7
Files: 27
Comments: 1

Comment thread src/lib.rs Outdated
…e guard

The hook-path guard had two fail-open holes, both reachable on the FastAPI
examples this PR ships.

1. A configured path that could not be canonicalized (a malformed % escape)
   fell back to HookTarget::Raw, which compared raw strings on both sides.
   With AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH=/snapstart/after%, a request for
   /snapstart/after%25 did not match the raw configured string and passed
   through, while uvicorn/Starlette unquoted it onto the very same route — so
   the one case the Raw branch existed to protect is the case it failed to
   protect.

2. Configuring that route the correct way (/snapstart/after%25, canonical
   `after%`) left the bare-% spelling reachable: an undecidable request path
   passes through the guard, but Starlette still resolves it onto the route.
   Verified end-to-end: POST /snapstart/after% -> 200, handler ran.

hook_target now returns Result and rejects both classes, so Adapter::new fails
initialization with an actionable error rather than starting with a
state-mutating route reachable. HookTarget goes away entirely, and with it
matches_hook_path's raw-compare arm.

Rejecting a literal % is what makes the request-side pass-through provably
safe rather than incidentally safe, on any framework and without modelling
per-framework decoding: an undecidable request path is either rejected by the
router outright (Node throws URIError, so Express answers 400; Go and Spring
likewise) or decoded leniently into a path containing a literal % or U+FFFD
(Python's unquote) — and neither can equal a %-free hook route. The
pass-through itself is unchanged, so /reports/100% still takes no false 403.
hook_target returned Ok(None) for a configured path that canonicalizes to the
root ("/", "//", "/..", "/.", "/foo/..", "/%2f"), silently disabling the guard.
But after_restore POSTs the RAW configured path — it reads after_restore_path,
not the guard target — so the hook still fired at "/". The two diverged with no
diagnostic: with AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH=/.. the adapter POSTs to
"/" on every restore, which is a 405 on both FastAPI examples (they declare only
`@app.get("/")`), and post_hook treats any non-2xx as fatal — so every restore
failed and nothing explained why.

Reject it instead. The guard cannot cover the root without returning 403 for all
normal traffic, and the docs require a hook path "your normal application traffic
does not use", which the root never is. Same rule as the % cases: if the adapter
cannot guard the route, it refuses to run with it rather than starting up with a
state-mutating route reachable or a hook that fails every restore.

Unset and empty still mean "no hook" and are unaffected.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 986113f..d6f802c
Files: 27 (focused on src/lib.rs, src/snapstart.rs, src/readiness.rs, Cargo.toml, the two new examples, and the docs)
Comments: 1

Comment thread docs/guide/src/configuration/environment-variables.md
…r SnapStart

build_client applied pool_max_idle_per_host(0) whenever
AWS_LAMBDA_INITIALIZATION_TYPE=snap-start, and that variable stays set for the
whole lifetime of a restored environment. Both call sites went through it, so the
client rebuilt in after_restore -- the one Adapter::client() returns for every
invocation after a restore -- also never retained a connection. The configured
idle keep-alive was therefore a no-op on exactly the functions this feature
targets, and every invocation opened a fresh TCP connection to the inner app for
the life of the environment, consuming a file descriptor each time against
Lambda's limit.

The snapshot hazard only applies to the client built BEFORE the snapshot. A
client built inside after_restore starts with an empty pool and cannot hold a
snapshotted connection, so it is safe for it to pool normally.

build_client no longer reads the environment; the caller decides, so the
post-restore rebuild cannot silently inherit the pre-snapshot restriction.
Adapter::new passes Duration::ZERO under SnapStart via the new
base_client_idle_timeout, which disables idle keep-alive for the pre-snapshot
client -- measured equivalent to pool_max_idle_per_host(0), including for
back-to-back requests. The configured value is retained on
Adapter::pool_idle_timeout and used for the after-restore rebuild.

This keeps the pre-snapshot client safe by construction, so a consumer driving
the Service impl directly (who never triggers the after-restore hook) is still
protected against hyper#3810, and it removes the post-restore path's dependence
on AWS_LAMBDA_INITIALIZATION_TYPE.

Also makes the SnapStartHooks::pool_idle_timeout field comment true: the
post-restore client now really does honor the configured value.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 986113f..2033c84
Files: 30
Comments: 2

Comment thread src/lib.rs Outdated
Comment thread docs/superpowers/plans/2026-06-22-snapstart-support.md Outdated
…ness branch

Findings from a final systematic pass over the branch.

1. hook_target short-circuits on configured.is_empty() and returns Ok(None) ("no
   hook"), but Adapter::new stored the raw Some(""), which run() hands to
   SnapStartHooks. before_snapshot/after_restore then took their `if let
   Some(path)` branch and called post_hook(.., ""), and Url::set_path("") yields
   "/" -- so the adapter POSTed to the unguarded application root on every
   lifecycle event (405 on both FastAPI examples, which post_hook treats as
   fatal). This is the same guard-versus-POST divergence the root-collapse
   rejection closed; "" slipped past by returning before canonicalization.
   Adapter::new now normalizes an empty hook path to None before anything reads
   it, so both sides agree by construction and the documented "empty means unset"
   semantics are preserved. Only reachable via a directly constructed
   AdapterOptions -- env-derived options already drop empties.

2. readiness::wait_until_ready drives Retry::spawn over an unbounded
   FixedInterval, so it can only return ready or never return. Its bool, and the
   `if !ready` branches plus "readiness check failed" errors in
   check_readiness_with_timeout / check_readiness_unbounded, were unreachable.
   Removed; wait_until_ready now returns (). check_init_health's ready_at_init
   comes from whether the wait COMPLETED within its bound, which is what the
   value always meant. Documented that an unbounded post-restore wait holds the
   restore open until Lambda's own timeout, with the escalating "app is not ready
   after {}ms" log as the adapter-side signal, and that
   AWS_LWA_READINESS_CHECK_TIMEOUT_SECONDS converts that into /restore/error.

Doc corrections, no behavior change:

- canonicalize_hook_path's rustdoc listed a control/null byte as a None case, but
  control bytes are stripped and canonicalization continues -- they WIDEN the
  blocked class. Stale in the fail-open direction. The same paragraph still
  described the HookTarget::Raw fallback deleted in ca56fd9, and the guard
  comment in fetch_response repeated the control-byte claim.
- build_client / base_client_idle_timeout claimed Duration::ZERO means no
  connection is "retained". It does not: hyper's pool stays enabled
  (Config::is_enabled is max_idle_per_host > 0), so the socket is still parked and
  captured in the snapshot; ZERO guarantees it is evicted on checkout rather than
  reused. Documented the real guarantee, and the init-time reconnect cost it
  carries (27 connections per 300ms of readiness polling versus 1) -- confined to
  init, and unchanged from the pool_max_idle_per_host(0) behavior it replaced.
- SnapStartHooks::client is used for the before-checkpoint hook only;
  after_restore deliberately uses the fresh client.
- The guide's rejection rule said "a path containing a percent sign", stricter
  than the code, which rejects only when the DECODED form contains one --
  /snapstart/%61fter is accepted and guarded as /snapstart/after.
- Two public-docs-link-to-private-item rustdoc warnings; cargo doc --no-deps is
  now clean.
- Unused `import os` in the zip example.
…g it

be50614 replaced pool_max_idle_per_host(0) with pool_idle_timeout(Duration::ZERO)
for the pre-snapshot client, and claimed the two were "identical on reuse". They
are not, in exactly the scenario the original workaround was written for.

A zero idle timeout leaves hyper's pool ENABLED (Config::is_enabled() is
max_idle_per_host > 0), so the connection is parked in the idle map and reuse is
decided at checkout by `now.saturating_duration_since(idle_at) > timeout`. That
saturates to ZERO when the recorded instant is ahead of `now`, and ZERO > ZERO is
false -- so the entry counts as fresh and is handed out. A monotonic clock that
has not advanced across a restore is precisely the condition hyper#3810 /
rust-lang/rust#79462 describe, so the guarantee rested on the very clock the
workaround exists to distrust. is_closed() does not catch it either: the app
process was restored from the same snapshot and never sent a FIN.

Under run() this is masked, because after_restore publishes a fresh client before
any invocation. The only exposed path is a consumer driving the Service impl
directly -- which is the sole reason the pre-snapshot restriction exists, so the
protection was vacuous for its only beneficiary.

build_client now takes an explicit Pooling parameter: Disabled sets
pool_max_idle_per_host(0) (pool off, no clock consulted) and Adapter::new uses it
under SnapStart via base_client_pooling; the after-restore rebuild passes Enabled
and keeps the configured AWS_LWA_POOL_IDLE_TIMEOUT_SECONDS, so everything gained
in be50614 on the invocation path is retained.

test_adapter_new_client_never_pools_under_snapstart could not catch this: it
sleeps 40ms between requests, so elapsed() is non-zero and it passes either way.
The new test_pre_snapshot_client_pool_is_disabled_not_merely_expiring observes the
connection's lifetime instead -- whether the socket is dropped or parked after one
request -- which no clock reading can satisfy.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 986113f..81fadbd
Files: 27
Comments: 3

Most items from the prior review rounds are now resolved in the code I read: check_init_health returns Err on sync-init timeout (src/lib.rs, and src/main.rs propagates it), Duration::try_from_secs_f64 replaces the panicking constructor, hook_target rejects root-collapsing and %-bearing configured paths at init, both sides of the guard now normalize through Url::set_path, control bytes are stripped-and-blocked rather than passed through, pool_max_idle_per_host(0) is restored for the pre-snapshot client via base_client_pooling(), and the duplicated guard comment block is gone. The remaining findings are narrow.


Comments on lines outside the diff:

[src/lib.rs:1446] [GENERAL] hook_target fails initialization for hook paths it cannot guard (root-collapsing, literal %), but it does not detect a hook path that collides with pass_through_path. Because the pass-through rewrite runs before the guard:

if matches!(request_context, RequestContext::PassThrough) && parts.method == Method::POST {
   path = self.pass_through_path.as_str();
}
// ... guard runs on this rewritten path

setting AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH=/events (the default pass-through path) makes every non-HTTP trigger event get rewritten onto the guarded route and answered with 403 instead of being delivered to the app — silently, with only a per-invocation warn!. Given the init-time validation already exists for the other unguardable cases, rejecting a hook path equal to pass_through_path there would be consistent and cheap.

Comment thread src/lib.rs Outdated
Comment thread src/lib.rs Outdated
…h; warn on bad env values

Three findings from the latest bot pass.

1. A hook path equal to AWS_LWA_PASS_THROUGH_PATH was accepted, but the
   pass-through rewrite in fetch_response replaces `path` with pass_through_path
   for a PassThrough POST BEFORE the guard runs. So configuring the hook at
   /events -- the default pass-through path -- made every non-HTTP trigger event
   canonicalize onto the guarded route and get a 403 instead of reaching the app,
   silently, with only a per-invocation warn!. Adapter::new now rejects a hook path
   that resolves to the same route as the pass-through path, alongside the existing
   unguardable cases, and compares canonical routes so /Events, /events/, /./events
   and /%65vents are caught too.

2. duration_secs_from_env silently fell back to the default on any unparseable
   value -- the exact failure mode readiness_check_timeout_from_env was written to
   avoid. The two sibling variables also accepted different numeric formats:
   AWS_LWA_READINESS_CHECK_TIMEOUT_SECONDS took fractional seconds while
   AWS_LWA_POOL_IDLE_TIMEOUT_SECONDS took only whole ones, so `0.5` and `4.5`
   parse-failed into 4s with no diagnostic. It now accepts fractional seconds like
   its sibling and warns on any set-but-unusable value.

3. The request path was canonicalized twice per invocation whenever both hook paths
   are configured -- the configuration both examples and the guide use -- because
   each matches_hook_path call canonicalized independently. New matches_any_hook_path
   canonicalizes once and compares against both targets; semantics are identical, and
   the no-hooks case still short-circuits before canonicalizing. matches_hook_path
   becomes the #[cfg(test)] single-target form.

The guide's rejection list gains the pass-through case, and Adapter::new's # Errors
section now names all four unguardable hook-path conditions.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 986113f..de0ea31
Files: 27 (focused on src/lib.rs, src/snapstart.rs, src/readiness.rs, src/main.rs, plus docs/examples)
Comments: 1

Comment thread src/lib.rs Outdated
Regression from de0ea31. The pass-through collision check ran

    let pass_through_target = hook_target(&domain, &Some(pass_through_path))?;

unconditionally, so a pass_through_path that hook_target rejects aborted
Adapter::new -- with a SnapStart-flavored error -- even when no hook was
configured and there was therefore no guard and nothing to collide with.
AWS_LWA_PASS_THROUGH_PATH is read straight from the environment with no prior
validation, and "/" is a plausible setting for an app that handles non-HTTP event
payloads at its root; it collapses to the root and so was rejected. The same
applied to a pass-through path whose decoded form contains a literal % or a
malformed escape. Existing tests missed it because they all use the default
/events, which canonicalizes cleanly.

The check now runs only when a hook target exists, and treats an unguardable
pass-through path as "no collision" rather than an error. That is sound, not just
lenient: hook targets are canonicalizable and non-empty by construction, so a
request rewritten onto a root-collapsing, %-bearing, or non-canonicalizable
pass-through path can never canonicalize onto one -- the guard would pass it
through anyway.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 986113f..f6679db
Files: 27 (see note on src/lib.rs)
Comments: 4

│ Note: .lambda-tooling-pr-reviewer/diff/file_src_lib.rs.json contains "(no patch available; status=modified)", and the workspace copy of src/lib.rs is the pre-PR state (no snapstart, build_client, canonicalize_hook_path, or restored_client symbols in it). The largest change in this PR (+1689/-94) therefore could not be read. Findings 1–3 below are prior-round issues on that file that the author never explicitly dismissed; they are re-raised unverified, and each notes what would resolve it.

Comment thread src/snapstart.rs Outdated
`let _ = self.restored_client.set(fresh.clone())` discarded the "already set"
case and then used `fresh` for steps 2 and 3. If the cell were already populated,
the hook POST and the readiness check would run over a client no request can
reach, so the restore would report healthy on the basis of something the request
path never touches -- with no signal anywhere.

New publish_or_adopt returns whichever client invocations will actually use:
`fresh` when it wins the race, otherwise the already-published one. That removes
the divergence rather than merely reporting it, and warns so an unexpected second
lifecycle run is visible in logs.

Latent today, since lambda_runtime drives the restore lifecycle once. The test
pins the property directly via Arc::ptr_eq -- a second call must return the first
client, not its own -- so it cannot become real.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 986113f..928bc8c
Files: 26 of 27 (see note)
Comments: 4

Note on coverage: .lambda-tooling-pr-reviewer/diff/file_src_lib.rs.json contains "patch": "(no patch available; status=modified)", so the largest change in this PR (src/lib.rs, +1689/-94 — the hook guard, env parsing, build_client, register_and_run, fetch_response) could not be read. The workspace copy of src/lib.rs is the pre-PR state (no snapstart, Pooling, build_client, or canonicalize_hook_path symbols present), so it is not a substitute. Comments 1–3 are therefore re-raises of prior findings on that file that carry no author response; they are based on the code quoted in the earlier threads, not on the current head, and should be re-verified against it. Everything else (src/snapstart.rs, src/readiness.rs, src/main.rs, tests/integ_tests/main.rs, both examples, docs, Cargo.toml/Cargo.lock, CHANGELOG.md) was reviewed in full and I found no further issues.

Comment thread README.md
register_extension_internal built its RAPID client with default pooling, so a
connection to the Runtime API could be parked in the idle pool and captured in a
SnapStart snapshot. That is the same hazard lambda_runtime handles by calling
reset_pool() on its own RAPID client during the restore lifecycle -- but nothing
resets or re-establishes this one, and register_default_extension terminates the
process with exit(1) when its request fails, so handing out a dead connection
after restore would kill the restored environment before it serves anything.

Pooling costs nothing to give up here: the client issues exactly two requests,
`register` and then the long poll for the first extension event, and the long
poll's in-flight connection is unaffected by the idle-pool setting. Disabled
unconditionally rather than gated on AWS_LAMBDA_INITIALIZATION_TYPE, since there
is no case where reuse helps.

Extracted as runtime_api_client so the property is testable; the test observes
connection lifetime (dropped vs parked), and I confirmed it fails against a
default-pooled client before the change.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 986113f..db37e17
Files: 27 (16 reviewed in detail; lock file, .gitignores, and event fixtures skipped)
Comments: 1

A caveat before the findings: .lambda-tooling-pr-reviewer/diff/file_src_lib.rs.json contains "patch": "(no patch available; status=modified)", so the diff for src/lib.rs (+1729/−95) was not available, and the working-tree copy is the pre-PR state. That file holds the hook guard, canonicalize_hook_path, the env-var parsing for the two new timeouts, and the reworked Adapter::new/fetch_response. I could not verify or refute the unresolved prior findings scoped to it (the Duration::from_secs_f64 overflow path, the configured-vs-request canonicalization asymmetry, the root/pass-through collapse guards, AdapterOptions not being #[non_exhaustive]). Several of those look addressed judging by src/main.rs, the integ-test changes, and the new docs — the cold-start timeout now propagates (check_init_health().await?) and the guide documents startup rejection of root-collapsing, percent-ambiguous, and pass-through-colliding hook paths — but that is inference from adjacent files, not verification. Re-run the review with the src/lib.rs patch present before treating it as reviewed.

Comment thread src/snapstart.rs
The pre-PR comment explaining why pooling was disabled under SnapStart was the
only record of the reason, and it was deleted. Restore it with the measurement
that settles it, taken from a SnapStart container function deployed from this
branch:

  across the restore:  monotonic +0.54s   while wall +161s
  after the restore:   monotonic +6.079s / +6.059s  vs wall +6.1s / +6.0s

CLOCK_MONOTONIC does not advance across the snapshot gap, but never goes
backwards, and after the restore it tracks wall time exactly. So the anomaly is
confined to the boundary, which is what makes the two sites correct in opposite
directions:

- Adapter::new (pre-snapshot) must have the pool OFF. hyper decides reuse with
  `now.saturating_duration_since(idle_at) > idle_timeout`, so an entry pooled
  before the snapshot reads as ~0.5s idle after restore however long the snapshot
  sat -- fresh, and dead. No idle timeout fixes that, including Duration::ZERO,
  since ZERO > ZERO is false. This is hyper#3810 / rust-lang/rust#79462.

- after_restore may have the pool ON. Every entry it holds is post-boundary, where
  accounting is reliable; verified live with idle gaps longer than the configured
  4s keep-alive all succeeding. This is also the only way
  AWS_LWA_POOL_IDLE_TIMEOUT_SECONDS affects the invocations that serve traffic.

Comment-only; no behavior change.
The guide and both example READMEs warn that the 403 hook guard only exists while
the adapter is in the request path; the top-level README presented the guard
without it, and it is the most widely read of the four. Its own opening advertises
that the same image runs on EC2, Fargate and local machines -- exactly the
deployments where the hook routes are reachable and unauthenticated.

All four docs now carry the caveat.
Findings from the final systematic pass.

1. before_snapshot was the only path into the application that was not
   readiness-gated. With AWS_LWA_ASYNC_INIT=true, check_init_health gives up at
   9.8s and returns Ok(()) with ready_at_init=false so the app can keep booting;
   run() then drives snapstart_lifecycle straight into before_snapshot, which
   POSTed immediately. For an app that has not bound its port the POST fails at
   once with ECONNREFUSED -- the 60s HOOK_TIMEOUT never applies to a refusal -- and
   lambda_runtime reports it to /init/error, so publishing the SnapStart version
   fails with what looks like an application bug. That is exactly the slow-booting
   app async_init exists for.

   Both hooks now go through ensure_ready, bounded by
   AWS_LWA_READINESS_CHECK_TIMEOUT_SECONDS when set. The timeout error names the
   phase, so an initialization failure is distinguishable from a restore failure.
   check_readiness_with_timeout / check_readiness_unbounded collapse into
   ensure_ready / ensure_ready_with_timeout.

2. The five pooling tests mutated the process-global AWS_LAMBDA_INITIALIZATION_TYPE
   with directly conflicting expectations, so under plain `cargo test` -- the runner
   a contributor following the standard Rust workflow uses, where nextest's
   per-test process isolation does not apply -- they could interleave and invert
   each other's assertions. Those are the assertions guarding the
   snapshot-connection hazard, so a silent inversion is the worst case.

   Four of them now pass Pooling explicitly to build_client and touch no
   environment at all. The env-var side collapses into one test,
   test_base_client_pooling_from_env, per the convention the neighbouring
   env-mutating tests already follow; verified it fails when the decision is
   inverted.

3. AdapterOptions::pool_idle_timeout's rustdoc and the CHANGELOG said
   AWS_LWA_POOL_IDLE_TIMEOUT_SECONDS takes "whole seconds", left stale by the
   commit that made it accept fractional seconds and pinned 0.5 -> 500ms in a test.

4. CHANGELOG now records the lambda_http 1.1.1 -> 1.3.0 bump and that it changes
   the Cookie header on every deployment: a multi-entry API Gateway v2 cookies
   array is joined with "; " instead of ";", so apps see `a=1; b=2`. RFC 6265 form
   and universally accepted, but it is a plain-request-path change that was
   undocumented.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 986113f..85ad8cb
Files: 27
Comments: 3


Comments on lines outside the diff:

[src/lib.rs:0] [SECURITY] Re-raising (previously flagged, no author response): the hook-path guard's equivalence class does not include path parameters (matrix variables). The guard canonicalizes percent-encoding, collapses // and ./.., and compares case-insensitively — the guide enumerates exactly these — but a ;-suffixed segment is kept verbatim by the url crate, so:

request:  POST /snapstart/after;x=1
set_path: /snapstart/after;x=1     (';' left in the path)
guard:    != /snapstart/after      -> forwarded

Jakarta Servlet containers and Spring strip ;-parameters from each segment before routing, so a request the guard forwards can still land on the hook handler. That is a reachable, unauthenticated, state-mutating route on the frameworks this repo supports (examples/springboot, examples/springboot-zip, examples/javalin-zip). Suggest truncating each segment at the first ; inside canonicalize_hook_path so both sides land in the same equivalence class.

[src/lib.rs:0] [SECURITY] Re-raising (previously flagged, no author response): the guard fails open on the request side, which contradicts its own documented contract. canonicalize_hook_path's rustdoc states:

/// Returns None only for genuinely undecidable inputs (a malformed % escape,
/// non-UTF-8 after decoding, or a control/null byte). The caller treats None
/// as "reject" (fail closed).

but on the request side None is turned into "not the hook" and the request is forwarded. Configured-side None is rejected at startup (that part is fail-closed and correct); the request side is the half that matters for the guard, and there the undecidable branch admits the request instead of returning 403. Even if no concrete bypass spelling exists for a given framework today, a security guard whose comment says "fail closed" while the code fails open is very likely to be broken by a future edit. Either return 403 on None for request paths, or correct the doc comment to state that the request side deliberately fails open and why.

[src/lib.rs:0] [BUG] Re-raising (previously flagged, no author response): AWS_LWA_READINESS_CHECK_TIMEOUT_SECONDS (and the same pattern for AWS_LWA_POOL_IDLE_TIMEOUT_SECONDS) can panic at startup on an out-of-range value. As quoted in the earlier thread:

.filter(|secs| secs.is_finite() && secs >= 0.0)
.map(Duration::from_secs_f64)

Duration::from_secs_f64 panics on overflow, not just on NaN/infinity/negatives. AWS_LWA_READINESS_CHECK_TIMEOUT_SECONDS=1e20 parses as a finite positive f64 above Duration's max (~1.8e19 s), so it passes the filter and panics — the adapter aborts during init on a mistyped env var rather than warning and falling back, which is the behavior the README and guide both promise ("a set-but-<= 0 or malformed value is ignored with a warn!"). Use Duration::try_from_secs_f64 (or bound the value) and route the error into the existing fallback-with-warn! path. Note: I could not read the current src/lib.rs to confirm this line survives at HEAD — if the parser was already switched to try_from_secs_f64, this is resolved and can be dismissed.

…cation

The guard's summary line and a test-section banner called it "strict, fail-closed"
while the detailed doc a dozen lines below correctly explains that the REQUEST side
forwards an undecidable path rather than 403-ing it. Only the configured side fails
closed, and the review bot has now twice read the summary, concluded the request
side returns 403 on doubt, and re-raised it as a contradiction -- quoting doc text
that was already replaced.

The risk is not the confusion itself but the obvious "fix" it invites: turning the
request-side pass-through into a 403 would reinstate the /reports/100%25 false-403
regression that pass-through exists to prevent.

Both labels now state which half does which, the summary points at the reasoning
rather than summarizing it away, and the term is gone from the codebase entirely
(the remaining historical use in a test comment is reworded to "undecidable
branch").

Comment-only; no behavior change.
The extension registers with `{"events": []}` and then parks on
GET /2020-01-01/extension/event/next forever. Nothing said why, so the standing
question "the long poll is not re-established after a SnapStart restore -- is that
a leak or a crash risk?" kept coming back.

It is neither, and the empty events list is the reason. Being registered at all is
the entire point: Lambda only delivers SIGTERM at environment shutdown when an
extension is registered, which is what the graceful-shutdown feature relies on.
Subscribing to nothing means RAPID has no event to deliver, so the long poll is
never going to resolve -- parking on it IS what keeps the extension alive.

Under SnapStart that request is captured mid-flight in the snapshot and never
re-established, which is harmless for the same reason, and RAPID's registration
state is part of the snapshotted microVM so the effect survives even though the
connection does not.

Verified on a deployed SnapStart container function: the restored environment
received SIGTERM 457s after restore, in the same log stream that logged
after_restore, and the failure path that would exit(1) never fired across ~8
minutes and five restores. A non-SnapStart control on $LATEST behaved identically
apart from the clocks (monotonic_delta == wall_delta on a normal cold start, versus
0.38s against 184.3s across a restore).

Re-registering after restore would be robustness, not a fix.

Comment-only; no behavior change.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 986113f..db2eaba
Files: 27
Comments: 3


Comments on lines outside the diff:

[.lambda-tooling-pr-reviewer/diff/file_src_lib.rs.json:1] [GENERAL] The diff payload for src/lib.rs — by far the largest change in this PR (+1787/-95, including the SnapStart hook guard, the env-var parsing for the two new timeout variables, build_client/register_and_run, and the AWS_LWA_REMOVE_BASE_PATH semantics change) — is empty:

{"filename": "src/lib.rs", "status": "modified", "additions": 1787, "deletions": 95,
"patch": "(no patch available; status=modified)"}

The workspace checkout is the pre-PR state (no src/snapstart.rs, check_init_health still returns ()), so the post-change content of that file is not available anywhere in the workspace. The two findings below are therefore based on code quoted verbatim in the earlier review threads rather than on the current file, and coverage of src/lib.rs in this pass is incomplete — please re-run the diff generation for it before treating this review as complete.

2. [BUG] src/lib.rs — readiness_check_timeout_from_env

Re-raising (previously flagged, no author response): the parser rejects <= 0 and unparseable values but not finite values outside Duration's range, and Duration::from_secs_f64 panics on overflow, not just on NaN/infinity/negatives.

match trimmed.parse::<f64>() {
   Ok(secs) if secs <= 0.0 => None, // intentional unbounded, no warning
   ...

AWS_LWA_READINESS_CHECK_TIMEOUT_SECONDS=1e20 parses as a finite positive f64 above Duration::MAX (~1.8e19 s), so the conversion panics during AdapterOptions construction and the function fails to initialize with a panic rather than the documented warn!-and-ignore path. The new docs in this PR only promise handling for <= 0 and malformed input:

a set-but-<= 0 or malformed value is ignored with a warn!

Use Duration::try_from_secs_f64 (or bound the value before converting) and route the out-of-range case into the same warn!-and-fall-back branch as a malformed value. The same applies to AWS_LWA_POOL_IDLE_TIMEOUT_SECONDS if it shares the conversion.

3. [SECURITY] src/lib.rs — canonicalize_hook_path / matches_hook_path

Re-raising (previously flagged, no author response on this specific point — the author's replies addressed the separate normalization-divergence thread, not the fail-open direction): the guard's documented contract and its behavior on the request side point in opposite directions.

/// Returns None only for genuinely undecidable inputs (a malformed % escape,
/// non-UTF-8 after decoding, or a control/null byte). The caller treats None
/// as "reject" (fail closed).

but on the request side None is turned into "this is not the hook path" and the request is forwarded to the app. For configured values the PR does fail closed (startup rejects them, per docs/guide/src/features/snapstart.md), which makes the asymmetry easy to miss in future edits: a request path whose decoded form contains a control byte, or that carries a spelling the canonicalizer deliberately does not model (e.g. a matrix/path-parameter suffix such as /snapstart/after;x=1, which Url::set_path leaves intact but several frameworks strip before routing), skips the 403 and reaches a state-mutating route.

Two options: either make the request side match the doc — treat None as "reject" and return 403 — or, if forwarding undecidable paths is deliberate, correct the rustdoc so it states that only the configured side fails closed, and say why forwarding is safe. As written, the comment asserts a property the code does not have on the path that faces untrusted input.

Not re-raised, as the author explicitly dismissed them: the unconditional post-restore readiness check, the hook-path normalization-divergence thread, publish = false, PackageType: Image + SnapStart in the container example, and the unreleased 1.1.0 image / layer 30 pins in both examples. Several other earlier findings (the 0-means-unbounded and cold-start-failure documentation, the AWS_LWA_REMOVE_BASE_PATH doc update, the guide's missing "adapter not in the request path" warning, the contradictory example README wording, and the Adapter::new SnapStart pooling branch) appear addressed in the current diffs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant