Skip to content

feat(koa): defer AsyncLocalStorage creation for V8 startup snapshot support#5851

Merged
killagu merged 2 commits intonextfrom
feat/snapshot-koa
Mar 31, 2026
Merged

feat(koa): defer AsyncLocalStorage creation for V8 startup snapshot support#5851
killagu merged 2 commits intonextfrom
feat/snapshot-koa

Conversation

@killagu
Copy link
Copy Markdown
Contributor

@killagu killagu commented Mar 31, 2026

Summary

  • Defer AsyncLocalStorage creation during V8 snapshot building via v8.startupSnapshot.isBuildingSnapshot()
  • Make ctxStorage nullable (AsyncLocalStorage<Context> | null) with null-safe access in callback() and currentContext
  • Register deserialize callback to restore ALS when snapshot is restored

This is PR1 of 6 in the V8 startup snapshot series. Independent, no dependencies.

Changes

  • packages/koa/src/application.ts — Defer ALS creation, null-safe ctxStorage access
  • packages/koa/test/application/snapshot.test.ts — 5 new tests covering defer, restore, null safety

Test plan

  • All 70 koa test files pass (381 tests, 1 skipped)
  • 5 new snapshot-specific tests
  • oxlint --type-aware clean

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Improvements

    • App initialization now safely handles absent context storage during special runtime startup modes, preventing crashes and improving stability.
    • Context accessor now returns undefined when storage is not available, avoiding errors in early startup.
  • Tests

    • Added tests verifying initialization, context exposure, and request handling both during startup-snapshot mode and normal startup.

Copilot AI review requested due to automatic review settings March 31, 2026 02:13
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 31, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5091b487-c202-4811-a828-db1d5e16a025

📥 Commits

Reviewing files that changed from the base of the PR and between 49cdbe4 and 421b7b1.

📒 Files selected for processing (2)
  • packages/koa/src/application.ts
  • packages/koa/test/application/snapshot.test.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/koa/test/application/snapshot.test.ts

📝 Walkthrough

Walkthrough

Application defers AsyncLocalStorage initialization during V8 startup snapshot building by setting ctxStorage to null and registering a deserialize callback; request handling and currentContext now guard against ctxStorage being null. Tests simulate snapshot-building and deserialization behavior.

Changes

Cohort / File(s) Summary
Core Application Logic
packages/koa/src/application.ts
Made ctxStorage nullable (AsyncLocalStorage<Context> | null). During snapshot build, ctxStorage is set to null and a v8.startupSnapshot.addDeserializeCallback is registered to initialize it later. Request handling and currentContext getter now check for ctxStorage before using it.
Snapshot Integration Tests
packages/koa/test/application/snapshot.test.ts
Added tests that stub v8.startupSnapshot to simulate snapshot-building mode, assert ctxStorage is null during build and becomes an AsyncLocalStorage after the deserialize callback, verify currentContext is undefined pre-init, and ensure middleware/request handling works when ctxStorage is null. Restores original v8.startupSnapshot after each test.

Sequence Diagram(s)

sequenceDiagram
    participant App as Application
    participant V8 as V8 StartupSnapshot
    participant Callback as Deserialize Callback
    participant ALS as AsyncLocalStorage

    Note over App,V8: Snapshot build phase
    App->>V8: isBuildingSnapshot()
    V8-->>App: true
    App->>App: set ctxStorage = null
    App->>V8: register deserialize callback
    V8->>Callback: store callback
    Note over V8,App: Later at deserialization
    V8->>Callback: invoke callback
    Callback->>ALS: create AsyncLocalStorage()
    ALS-->>Callback: instance
    Callback->>App: set ctxStorage = ALS instance
Loading
sequenceDiagram
    participant Client as Client
    participant App as Application
    participant ALS as AsyncLocalStorage
    participant Handler as Request Handler
    participant Middleware as Middleware

    Client->>App: HTTP request
    App->>App: check ctxStorage
    alt ctxStorage initialized
        App->>ALS: ctxStorage.run(ctx, handler)
        ALS->>Handler: run with context
    else ctxStorage is null
        App->>Handler: call handleRequest() directly
    end
    Handler->>Middleware: invoke middleware
    Middleware->>App: access currentContext
    App->>ALS: ctxStorage?.getStore()
    ALS-->>Middleware: current context (or undefined)
    Handler-->>Client: response
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 I paused my little Async store,
While V8 paints a startup door,
A callback nudged me from my nap,
I hop and set the context map,
Requests still dance — whether null or more. 🥕

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: deferring AsyncLocalStorage creation for V8 startup snapshot support, which is the primary focus of the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/snapshot-koa

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 31, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 85.24%. Comparing base (a28abce) to head (421b7b1).
⚠️ Report is 3 commits behind head on next.

Additional details and impacted files
@@            Coverage Diff             @@
##             next    #5851      +/-   ##
==========================================
- Coverage   88.37%   85.24%   -3.13%     
==========================================
  Files           9      666     +657     
  Lines          43    13177   +13134     
  Branches        8     1525    +1517     
==========================================
+ Hits           38    11233   +11195     
- Misses          5     1813    +1808     
- Partials        0      131     +131     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces support for V8 startup snapshots by deferring the initialization of AsyncLocalStorage when a snapshot is being built. Changes include updating the Application class to handle a potentially null ctxStorage and adding a new test suite for snapshot scenarios. Feedback focuses on improving the robustness of the implementation by using optional chaining for environment-dependent V8 APIs and utilizing proper mocking utilities in tests instead of direct module mutation.


it('should defer AsyncLocalStorage creation when building snapshot', () => {
let deserializeCallback: { cb: (data: unknown) => void; data: unknown } | undefined;
(v8 as Record<string, unknown>).startupSnapshot = {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Mutating properties of the v8 module directly (e.g., v8.startupSnapshot = ...) is likely to fail in standard Node.js environments because these properties are typically read-only or defined as getters on the module object. This can lead to TypeError at runtime. It is recommended to use Vitest's mocking utilities like vi.spyOn or vi.mock to simulate the v8.startupSnapshot behavior safely.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the suggestion. The direct mutation via (v8 as Record<string, unknown>).startupSnapshot = {...} is intentional here — v8.startupSnapshot is writable at runtime in Node.js (it's not defined as a getter). This pattern is simpler and more explicit than vi.mock('node:v8') which would mock the entire module and require more complex setup. The tests pass on all platforms, confirming the property is writable. Won't change this.

this.ctxStorage = getAsyncLocalStorage();
if (v8.startupSnapshot?.isBuildingSnapshot?.()) {
this.ctxStorage = null;
v8.startupSnapshot.addDeserializeCallback((app: Application) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

While v8.startupSnapshot is checked on line 92, it is safer to use optional chaining or verify the existence of addDeserializeCallback before calling it. This API is environment-dependent and may not be fully present in all Node.js versions or environments where the code might run.

Suggested change
v8.startupSnapshot.addDeserializeCallback((app: Application) => {
v8.startupSnapshot.addDeserializeCallback?.((app: Application) => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Won't fix. Line 92 already guards with v8.startupSnapshot?.isBuildingSnapshot?.() — if this returns true, the full snapshot API (including addDeserializeCallback) is guaranteed to be present per the Node.js docs. Adding ?. here would silently swallow errors if the API were somehow incomplete, which would be a worse failure mode: ctxStorage would stay null forever with no error, causing subtle runtime issues. Failing fast is the safer behavior.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Mar 31, 2026

Deploying egg-v3 with  Cloudflare Pages  Cloudflare Pages

Latest commit: 421b7b1
Status: ✅  Deploy successful!
Preview URL: https://a329974a.egg-v3.pages.dev
Branch Preview URL: https://feat-snapshot-koa.egg-v3.pages.dev

View logs

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Mar 31, 2026

Deploying egg with  Cloudflare Pages  Cloudflare Pages

Latest commit: 421b7b1
Status: ✅  Deploy successful!
Preview URL: https://ee7adaca.egg-cci.pages.dev
Branch Preview URL: https://feat-snapshot-koa.egg-cci.pages.dev

View logs

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/koa/src/application.ts (1)

52-99: ⚠️ Potential issue | 🟠 Major

Fix unguarded ctxStorage accesses in production code.

The ctxStorage field is now nullable (line 52) to support V8 snapshot deserialization, but core framework code in packages/egg/src/lib/egg.ts (line 78, currentContext getter) and packages/egg/src/lib/application.ts (line 234, runInBackground) accesses it without null guards. The parent packages/koa/src/application.ts correctly uses optional chaining (?.) in its getter—the Egg framework should follow the same pattern. This violates strict TypeScript mode and creates runtime hazards in snapshot-mode execution.

Add optional chaining guards or refactor behind a private helper that guarantees initialization.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koa/src/application.ts` around lines 52 - 99, The ctxStorage field
on Application is nullable to support V8 snapshot deserialization, but code in
currentContext (getter in packages/egg/src/lib/egg.ts) and runInBackground
(packages/egg/src/lib/application.ts) accesses it without null checks; update
those accesses to guard against null (use optional chaining like
this.ctxStorage?.getStore() or check and early-return/throw) or introduce a
private helper (e.g., ensureCtxStorage() or getCtxStore()) that returns the
store safely and use that helper inside currentContext and runInBackground so
runtime null dereferences are prevented.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/koa/src/application.ts`:
- Around line 52-99: The ctxStorage field on Application is nullable to support
V8 snapshot deserialization, but code in currentContext (getter in
packages/egg/src/lib/egg.ts) and runInBackground
(packages/egg/src/lib/application.ts) accesses it without null checks; update
those accesses to guard against null (use optional chaining like
this.ctxStorage?.getStore() or check and early-return/throw) or introduce a
private helper (e.g., ensureCtxStorage() or getCtxStore()) that returns the
store safely and use that helper inside currentContext and runInBackground so
runtime null dereferences are prevented.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 698fc2c3-2aa2-431c-bcc0-9df3b43c6928

📥 Commits

Reviewing files that changed from the base of the PR and between 957c693 and bd830b7.

📒 Files selected for processing (2)
  • packages/koa/src/application.ts
  • packages/koa/test/application/snapshot.test.ts

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR updates @eggjs/koa’s Application to support V8 startup snapshot building by avoiding creation of AsyncLocalStorage during snapshot build time, and restoring it upon snapshot deserialization.

Changes:

  • Defer AsyncLocalStorage initialization when v8.startupSnapshot.isBuildingSnapshot() is true, and restore it via addDeserializeCallback.
  • Make ctxStorage nullable and update callback() / currentContext to be null-safe.
  • Add snapshot-focused tests to validate defer/restore behavior and null-safe request handling.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
packages/koa/src/application.ts Defers ALS creation during snapshot build, restores it on deserialization, and makes request handling/currentContext null-safe.
packages/koa/test/application/snapshot.test.ts Adds tests covering snapshot build deferral, restoration, and behavior when ctxStorage is null.

killagu and others added 2 commits March 31, 2026 10:32
…upport

When building a V8 startup snapshot via `v8.startupSnapshot.isBuildingSnapshot()`,
AsyncLocalStorage cannot be created because it relies on async_hooks which are
not available during snapshot serialization. This change defers the creation of
ctxStorage to the deserialization phase, making @eggjs/koa compatible with
Node.js V8 startup snapshots.

- Change `ctxStorage` type to `AsyncLocalStorage<Context> | null`
- Null-guard `ctxStorage` in `callback()` and `currentContext` getter
- Register `addDeserializeCallback` to restore ALS after snapshot restore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ests

tsgo does not support `asserts` type narrowing from assert.ok, so
`app.ctxStorage instanceof AsyncLocalStorage` fails typecheck when
ctxStorage is typed as `AsyncLocalStorage | null`. Use non-null
assertion operator to satisfy the type checker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@killagu killagu force-pushed the feat/snapshot-koa branch from 49cdbe4 to 421b7b1 Compare March 31, 2026 02:32
@killagu
Copy link
Copy Markdown
Contributor Author

killagu commented Mar 31, 2026

Re: @coderabbitai's review about unguarded ctxStorage accesses in packages/egg/src/lib/egg.ts and packages/egg/src/lib/application.ts:

This is a valid observation but out of scope for this PR, which only touches the @eggjs/koa layer. The Egg framework layer uses declare ctxStorage: AsyncLocalStorage<Context> (non-nullable) at egg.ts:70 — this is a deliberate design choice where the Egg layer assumes ctxStorage is always initialized by the time any request handling occurs. During snapshot build mode, no HTTP requests are served, so currentContext and runInBackground won't be called before deserialization restores ctxStorage.

The null-safety changes at the Egg layer (if needed) will be addressed in the separate feat/snapshot-egg PR that builds on top of this one.

Re: @copilot review — no issues found, thanks for confirming.

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 31, 2026

@killagu I've opened a new pull request, #5854, to work on those changes. Once the pull request is ready, I'll request review from you.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 31, 2026

Tip

For best results, initiate chat on the files or code changes.

@killagu, understood — thank you for the clear explanation. The Egg layer's declare ctxStorage: AsyncLocalStorage<Context> as a non-nullable override is a deliberate design contract: since no HTTP requests are processed during snapshot build mode, currentContext and runInBackground are guaranteed to only be called after deserialization has restored ctxStorage. I'll track the null-safety work for the Egg layer in the upcoming feat/snapshot-egg PR.


✏️ Learnings added
Learnt from: killagu
URL: https://github.com/eggjs/egg/pull/5851

Timestamp: 2026-03-31T02:37:56.484Z
Learning: In `packages/egg/src/lib/egg.ts` (eggjs/egg), the `ctxStorage` property is declared as `declare ctxStorage: AsyncLocalStorage<Context>` (non-nullable, at egg.ts:70) — this is intentional by design. The Egg framework layer assumes `ctxStorage` is always initialized by the time any request handling occurs (i.e., `currentContext` or `runInBackground` are called). During V8 snapshot build mode, no HTTP requests are served, so these methods will not be called before deserialization restores `ctxStorage`. Null-safety changes at the Egg layer are deferred to the separate `feat/snapshot-egg` PR. Do not flag unguarded `ctxStorage` accesses in `packages/egg/src/lib/egg.ts` or `packages/egg/src/lib/application.ts` as a bug in PRs that only touch the `eggjs/koa` layer.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: killagu
Repo: eggjs/egg PR: 5853
File: packages/utils/src/import.ts:403-409
Timestamp: 2026-03-31T02:36:54.027Z
Learning: In `packages/utils/src/import.ts`, the `return {}` in the `importModule()` snapshot branch (when `__EGG_SNAPSHOT_CJS_BUNDLE__` is set and a module is not in `__snapshotModuleRegistry`) is intentional by design. During V8 snapshot build, not all modules are preloaded into the registry — absent modules are either optional dependencies or will be properly loaded at restore time (when the snapshot is deserialized and the full Node.js runtime is available). The snapshot builder only needs the module graph structure, not actual runtime values, so callers that dereference exports (e.g. in `packages/utils/src/plugin.ts`, `plugins/mock/src/lib/start-cluster.ts`) are fine receiving `{}`. Throwing an error here would break the snapshot build for any non-pre-registered module. Do not flag this pattern as a bug.

Learnt from: killagu
Repo: eggjs/egg PR: 5846
File: packages/egg/src/lib/egg.ts:547-550
Timestamp: 2026-03-30T09:35:38.102Z
Learning: In `packages/egg/src/lib/egg.ts` (eggjs/egg), `dumpManifest()` is intentionally called from both the agent and application ready hooks without write-locking. This is by design: both processes produce identical, deterministic manifest content, and `ManifestStore.load()` handles partial/corrupt writes gracefully (treating them as a cache miss), so concurrent writes to the same `manifest.json` file are considered safe.

Copy link
Copy Markdown
Contributor

@jerryliang64 jerryliang64 left a comment

Choose a reason for hiding this comment

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

LGTM

@killagu killagu merged commit ece7b7e into next Mar 31, 2026
33 of 38 checks passed
@killagu killagu deleted the feat/snapshot-koa branch March 31, 2026 06:56
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.

4 participants