fix: reject legacy session events on every path

This commit is contained in:
Tianyi Cui
2026-07-14 00:59:25 +08:00
parent 461c0c598c
commit b35d66b86d
4 changed files with 91 additions and 29 deletions

View File

@@ -251,11 +251,15 @@ export class PersistenceCoordinator<TornMarker = unknown> {
if (batch === undefined) {
throw new TypeError('session event batch is not losslessly JSON-serializable because it contains non-JSON-serializable data')
}
assertSupportedEvents(batch, id)
return this.serialize(id, () => this.appendCore(id, batch))
}
private async appendCore(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
// Every append route converges here: the public service, live write-behind
// drains, and HMR seed/suffix adoption. Keep vocabulary rejection at that
// shared boundary so a stale JavaScript plugin cannot persist an event that
// this same backend will refuse to load.
assertSupportedEvents(events, id)
if (events.length === 0) return
let state = this.states.get(id)
if (state === undefined) state = await this.adopt(id) // calls loadCore, not load
@@ -528,6 +532,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
private async adoptLivePrefix(session: Session, seed: readonly SessionEvent[], stored: StoredPrefix<TornMarker>): Promise<void> {
const { meta, events, tornMarker } = stored
this.assertVersion(meta)
assertSupportedEvents(events, session.header.id)
if (!seedCoversPrefix(seed, events)) {
throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`)
}

View File

@@ -12,6 +12,16 @@ import { runCoordinatorContract, type CoordinatorFixture } from './coordinator-c
/** The durable store shape: materialized sessions only (no lazy entries). */
type MemoryStore = Map<string, { meta: SessionHeader; events: SessionEvent[] }>
/** An obsolete event fixture that emulates an untyped pre-change producer. */
function legacyHeaderDelta(seq = 0): SessionEvent {
return {
type: 'request/header-delta',
seq,
time: 1,
data: { config: { model: 'legacy' } },
} as unknown as SessionEvent
}
/** Optional plugin config: an EXTERNAL store shared across backend instances. */
interface MemoryConfig { store?: MemoryStore }
@@ -164,4 +174,37 @@ describe('SessionPersistence service registration', () => {
.rejects.toThrow('session metadata must be losslessly JSON-serializable')
await fiber.dispose()
})
it('rejects a legacy header delta buffered by a pre-change live producer', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(MemoryPersistence)
const session = ctx.sessions.create(SessionId('legacy-live'), { meta: { cwd: '/legacy' } })
// Model the runtime shape available to JavaScript or a hot-loaded plugin
// compiled against the obsolete event vocabulary.
const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent
appendLegacy('request/header-delta', { config: { model: 'legacy' } })
await expect(ctx.sessions.flush(session))
.rejects.toThrow(/unsupported legacy request\/header-delta event at seq 0/)
await fiber.dispose()
})
it('rejects a legacy stored prefix during live HMR adoption', async () => {
const id = SessionId('legacy-hmr')
const m = meta(id, '/legacy')
const legacy = legacyHeaderDelta()
const store: MemoryStore = new Map([[id, { meta: m, events: [legacy] }]])
const ctx = new Context()
await ctx.plugin(SessionStore)
// A current live session cannot carry the obsolete event in its seed, but
// HMR still has to identify the persisted prefix as unsupported rather than
// treating it as an ordinary live-prefix collision.
const session = ctx.sessions.create(id, { meta: { cwd: '/legacy' } })
const fiber = await ctx.plugin(MemoryPersistence, { store })
await expect(ctx.sessions.flush(session))
.rejects.toThrow(/unsupported legacy request\/header-delta event at seq 0/)
await Promise.allSettled([fiber.dispose()])
})
})