feat: defer AsyncLocalStorage creation for v8 startup snapshots (#1946)

## Summary

- Defer `AsyncLocalStorage` creation when
`v8.startupSnapshot.isBuildingSnapshot()` is true, making Koa compatible
with Node.js startup snapshots
- Register a `v8.startupSnapshot.addDeserializeCallback` to properly
initialize `ctxStorage` after snapshot restoration
- Extract `getAsyncLocalStorage()` helper to consolidate creation logic

## Test plan

- [x] All 429 existing tests pass with 0 failures
- [x] `currentContext` tests verify both `asyncLocalStorage: true` and
custom `AsyncLocalStorage` instance paths work correctly
- [x] Normal (non-snapshot) code path is unchanged —
`v8.startupSnapshot?.isBuildingSnapshot?.()` returns `undefined` in
regular execution

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
killa
2026-03-28 13:37:53 +08:00
committed by GitHub
parent d3ea8bf964
commit 2503a1fb14
2 changed files with 97 additions and 4 deletions

View File

@@ -1,6 +1,7 @@
'use strict'
const { describe, it } = require('node:test')
const { describe, it, beforeEach, afterEach } = require('node:test')
const v8 = require('node:v8')
const request = require('supertest')
const assert = require('node:assert/strict')
const Koa = require('../..')
@@ -113,4 +114,85 @@ describe('app.currentContext', () => {
await request(app.callback()).get('/').expect('ok')
assert(app.currentContext === undefined)
})
describe('v8 startup snapshot', () => {
let originalStartupSnapshot
beforeEach(() => {
originalStartupSnapshot = v8.startupSnapshot
})
afterEach(() => {
v8.startupSnapshot = originalStartupSnapshot
})
it('should defer AsyncLocalStorage creation when building snapshot', () => {
let deserializeCallback
v8.startupSnapshot = {
isBuildingSnapshot: () => true,
addDeserializeCallback: (cb, data) => {
deserializeCallback = { cb, data }
}
}
const app = new Koa({ asyncLocalStorage: true })
assert.strictEqual(app.ctxStorage, null)
assert(deserializeCallback, 'deserialize callback should be registered')
// simulate snapshot deserialization
deserializeCallback.cb(deserializeCallback.data)
assert(app.ctxStorage instanceof AsyncLocalStorage)
})
it('should defer with custom AsyncLocalStorage when building snapshot', () => {
const customStorage = new AsyncLocalStorage()
let deserializeCallback
v8.startupSnapshot = {
isBuildingSnapshot: () => true,
addDeserializeCallback: (cb, data) => {
deserializeCallback = { cb, data }
}
}
const app = new Koa({ asyncLocalStorage: customStorage })
assert.strictEqual(app.ctxStorage, null)
// simulate snapshot deserialization
deserializeCallback.cb(deserializeCallback.data)
assert(app.ctxStorage instanceof AsyncLocalStorage)
assert.strictEqual(app.ctxStorage, customStorage)
})
it('should work normally after deserialization', async () => {
let deserializeCallback
v8.startupSnapshot = {
isBuildingSnapshot: () => true,
addDeserializeCallback: (cb, data) => {
deserializeCallback = { cb, data }
}
}
const app = new Koa({ asyncLocalStorage: true })
// simulate snapshot deserialization
deserializeCallback.cb(deserializeCallback.data)
app.use(async ctx => {
assert(ctx === app.currentContext)
ctx.body = 'ok'
})
await request(app.callback()).get('/').expect('ok')
assert(app.currentContext === undefined)
})
it('should not defer when not building snapshot', () => {
v8.startupSnapshot = {
isBuildingSnapshot: () => false
}
const app = new Koa({ asyncLocalStorage: true })
assert(app.ctxStorage instanceof AsyncLocalStorage)
})
})
})

View File

@@ -4,6 +4,7 @@
* Module dependencies.
*/
const util = require('node:util')
const v8 = require('node:v8')
const debug = util.debuglog('koa:application')
const Emitter = require('node:events')
const Stream = require('node:stream')
@@ -40,6 +41,13 @@ const only = require('./only.js')
* Inherits from `Emitter.prototype`.
*/
function getAsyncLocalStorage (options) {
if (options.asyncLocalStorage instanceof AsyncLocalStorage) {
return options.asyncLocalStorage
}
return new AsyncLocalStorage()
}
module.exports = class Application extends Emitter {
/**
* Initialize a new `Application`.
@@ -81,10 +89,13 @@ module.exports = class Application extends Emitter {
this[util.inspect.custom] = this.inspect
}
if (options.asyncLocalStorage) {
if (options.asyncLocalStorage instanceof AsyncLocalStorage) {
this.ctxStorage = options.asyncLocalStorage
if (v8.startupSnapshot?.isBuildingSnapshot?.()) {
this.ctxStorage = null
v8.startupSnapshot.addDeserializeCallback(({ app, options }) => {
app.ctxStorage = getAsyncLocalStorage(options)
}, { app: this, options })
} else {
this.ctxStorage = new AsyncLocalStorage()
this.ctxStorage = getAsyncLocalStorage(options)
}
}
}