Skip to content

Commit 2503a1f

Browse files
killaguclaude
andauthored
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>
1 parent d3ea8bf commit 2503a1f

2 files changed

Lines changed: 97 additions & 4 deletions

File tree

__tests__/application/currentContext.test.js

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict'
22

3-
const { describe, it } = require('node:test')
3+
const { describe, it, beforeEach, afterEach } = require('node:test')
4+
const v8 = require('node:v8')
45
const request = require('supertest')
56
const assert = require('node:assert/strict')
67
const Koa = require('../..')
@@ -113,4 +114,85 @@ describe('app.currentContext', () => {
113114
await request(app.callback()).get('/').expect('ok')
114115
assert(app.currentContext === undefined)
115116
})
117+
118+
describe('v8 startup snapshot', () => {
119+
let originalStartupSnapshot
120+
121+
beforeEach(() => {
122+
originalStartupSnapshot = v8.startupSnapshot
123+
})
124+
125+
afterEach(() => {
126+
v8.startupSnapshot = originalStartupSnapshot
127+
})
128+
129+
it('should defer AsyncLocalStorage creation when building snapshot', () => {
130+
let deserializeCallback
131+
v8.startupSnapshot = {
132+
isBuildingSnapshot: () => true,
133+
addDeserializeCallback: (cb, data) => {
134+
deserializeCallback = { cb, data }
135+
}
136+
}
137+
138+
const app = new Koa({ asyncLocalStorage: true })
139+
assert.strictEqual(app.ctxStorage, null)
140+
assert(deserializeCallback, 'deserialize callback should be registered')
141+
142+
// simulate snapshot deserialization
143+
deserializeCallback.cb(deserializeCallback.data)
144+
assert(app.ctxStorage instanceof AsyncLocalStorage)
145+
})
146+
147+
it('should defer with custom AsyncLocalStorage when building snapshot', () => {
148+
const customStorage = new AsyncLocalStorage()
149+
let deserializeCallback
150+
v8.startupSnapshot = {
151+
isBuildingSnapshot: () => true,
152+
addDeserializeCallback: (cb, data) => {
153+
deserializeCallback = { cb, data }
154+
}
155+
}
156+
157+
const app = new Koa({ asyncLocalStorage: customStorage })
158+
assert.strictEqual(app.ctxStorage, null)
159+
160+
// simulate snapshot deserialization
161+
deserializeCallback.cb(deserializeCallback.data)
162+
assert(app.ctxStorage instanceof AsyncLocalStorage)
163+
assert.strictEqual(app.ctxStorage, customStorage)
164+
})
165+
166+
it('should work normally after deserialization', async () => {
167+
let deserializeCallback
168+
v8.startupSnapshot = {
169+
isBuildingSnapshot: () => true,
170+
addDeserializeCallback: (cb, data) => {
171+
deserializeCallback = { cb, data }
172+
}
173+
}
174+
175+
const app = new Koa({ asyncLocalStorage: true })
176+
177+
// simulate snapshot deserialization
178+
deserializeCallback.cb(deserializeCallback.data)
179+
180+
app.use(async ctx => {
181+
assert(ctx === app.currentContext)
182+
ctx.body = 'ok'
183+
})
184+
185+
await request(app.callback()).get('/').expect('ok')
186+
assert(app.currentContext === undefined)
187+
})
188+
189+
it('should not defer when not building snapshot', () => {
190+
v8.startupSnapshot = {
191+
isBuildingSnapshot: () => false
192+
}
193+
194+
const app = new Koa({ asyncLocalStorage: true })
195+
assert(app.ctxStorage instanceof AsyncLocalStorage)
196+
})
197+
})
116198
})

lib/application.js

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* Module dependencies.
55
*/
66
const util = require('node:util')
7+
const v8 = require('node:v8')
78
const debug = util.debuglog('koa:application')
89
const Emitter = require('node:events')
910
const Stream = require('node:stream')
@@ -40,6 +41,13 @@ const only = require('./only.js')
4041
* Inherits from `Emitter.prototype`.
4142
*/
4243

44+
function getAsyncLocalStorage (options) {
45+
if (options.asyncLocalStorage instanceof AsyncLocalStorage) {
46+
return options.asyncLocalStorage
47+
}
48+
return new AsyncLocalStorage()
49+
}
50+
4351
module.exports = class Application extends Emitter {
4452
/**
4553
* Initialize a new `Application`.
@@ -81,10 +89,13 @@ module.exports = class Application extends Emitter {
8189
this[util.inspect.custom] = this.inspect
8290
}
8391
if (options.asyncLocalStorage) {
84-
if (options.asyncLocalStorage instanceof AsyncLocalStorage) {
85-
this.ctxStorage = options.asyncLocalStorage
92+
if (v8.startupSnapshot?.isBuildingSnapshot?.()) {
93+
this.ctxStorage = null
94+
v8.startupSnapshot.addDeserializeCallback(({ app, options }) => {
95+
app.ctxStorage = getAsyncLocalStorage(options)
96+
}, { app: this, options })
8697
} else {
87-
this.ctxStorage = new AsyncLocalStorage()
98+
this.ctxStorage = getAsyncLocalStorage(options)
8899
}
89100
}
90101
}

0 commit comments

Comments
 (0)