Files
deepseek-harness/packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts

383 lines
17 KiB
TypeScript

/**
* Tests for the cross-process live-session registry: records survive a
* round-trip, dead pids are pruned, a recycled pid cannot resurrect a foreign
* record, the file format rejects foreign and torn media without hiding live
* sessions, disposal deregisters, and concurrent registrations from independent
* processes all survive (the failure the advisory lock exists to prevent).
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { execFile, spawn } from 'node:child_process'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { SessionId } from '@deepseek-ai/dsh-session'
import { BootId } from '@deepseek-ai/dsh-session-registry'
import SessionRegistryFile, {
REGISTRY_FILE_NAME,
SESSION_REGISTRY_FORMAT_VERSION,
isPidAlive,
parseRegistry,
serializeRegistry,
} from '@deepseek-ai/dsh-session-registry-file'
const run = promisify(execFile)
let root: string
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'dsh-session-registry-test-'))
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
})
/** Mount the service on a fresh Cordis fiber, returning it with its context. */
async function service(): Promise<{ ctx: Context; registry: SessionRegistryFile }> {
const ctx = new Context()
await ctx.plugin(SessionRegistryFile, { root })
return { ctx, registry: ctx.sessionRegistry as SessionRegistryFile }
}
const file = (): string => join(root, REGISTRY_FILE_NAME)
describe('config resolution', () => {
it('applies the shipped lock defaults when a caller omits them', async () => {
// `ctx.plugin` runs the schema, which fills these in, so the constructor's
// own resolution is reachable only by constructing the service directly —
// the path a programmatic embedder takes.
const ctx = new Context()
const service = new SessionRegistryFile(ctx, { root })
await service.register({ sessionId: SessionId('defaulted'), cwd: '/w' })
expect((await service.list()).map(record => record.sessionId)).toEqual(['defaulted'])
await ctx.fiber.dispose()
})
it('honors explicitly configured lock tunables', async () => {
const ctx = new Context()
await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 5_000, lockRetries: 3 })
await ctx.sessionRegistry.register({ sessionId: SessionId('tuned'), cwd: '/w' })
expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['tuned'])
await ctx.fiber.dispose()
})
})
describe('register and list', () => {
it('publishes a record readable by an independent service instance', async () => {
const first = await service()
await first.registry.register({ sessionId: SessionId('sess-1'), cwd: '/tmp/project' })
// A second instance stands in for another process reading the same file.
const reader = await service()
const listed = await reader.registry.list()
expect(listed).toHaveLength(1)
expect(listed[0]).toMatchObject({
sessionId: 'sess-1',
cwd: '/tmp/project',
pid: process.pid,
})
await first.ctx.fiber.dispose()
await reader.ctx.fiber.dispose()
})
it('replaces an earlier record for the same session id', async () => {
const { ctx, registry } = await service()
await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' })
await registry.register({ sessionId: SessionId('sess-1'), cwd: '/b' })
const listed = await registry.list()
expect(listed).toHaveLength(1)
// The later registration wins: `cwd` distinguishes the two calls.
expect(listed[0]?.cwd).toBe('/b')
await ctx.fiber.dispose()
})
it('creates the registry root private and the file owner-only', async () => {
const { ctx, registry } = await service()
await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' })
expect(statSync(root).mode & 0o777).toBe(0o700)
expect(statSync(file()).mode & 0o777).toBe(0o600)
await ctx.fiber.dispose()
})
})
describe('liveness pruning', () => {
it('drops a record whose process is gone', async () => {
const { ctx, registry } = await service()
await registry.register({ sessionId: SessionId('live'), cwd: '/a' })
// A real exited pid: spawn a process, wait for it, then claim its id. The
// kernel has reaped it, so signal 0 reports ESRCH.
const dead = await run(process.execPath, ['-e', 'process.stdout.write(String(process.pid))'])
const deadPid = Number(dead.stdout)
expect(isPidAlive(deadPid)).toBe(false)
const stored = parseRegistry(readFileSync(file(), 'utf8')).records
writeFileSync(file(), serializeRegistry([
...stored,
{ sessionId: SessionId('ghost'), pid: deadPid, cwd: '/b', startedAt: 1, bootId: BootId('boot-x') },
]))
const listed = await registry.list()
expect(listed.map(record => record.sessionId)).toEqual(['live'])
// The prune is durable, not just filtered in memory.
expect(parseRegistry(readFileSync(file(), 'utf8')).records.map(r => r.sessionId)).toEqual(['live'])
await ctx.fiber.dispose()
})
it('keeps a live record owned by another user (EPERM means alive)', () => {
const eperm = (): never => {
const error = new Error('operation not permitted') as NodeJS.ErrnoException
error.code = 'EPERM'
throw error
}
expect(isPidAlive(1, eperm)).toBe(true)
})
it('propagates an unexpected errno instead of guessing liveness', () => {
const einval = (): never => {
const error = new Error('invalid') as NodeJS.ErrnoException
error.code = 'EINVAL'
throw error
}
expect(() => isPidAlive(1, einval)).toThrow('invalid')
})
})
describe('pid recycling', () => {
it('deregistration removes only this incarnation, not a namesake pid', async () => {
const { ctx, registry } = await service()
const disposer = await registry.register({ sessionId: SessionId('mine'), cwd: '/a' })
// A foreign record reusing THIS live pid under a different session and boot
// id: deregistering must not delete it.
const stored = parseRegistry(readFileSync(file(), 'utf8')).records
writeFileSync(file(), serializeRegistry([
...stored,
{ sessionId: SessionId('other'), pid: process.pid, cwd: '/b', startedAt: 2, bootId: BootId('boot-other') },
]))
// Awaiting the disposer is the contract: the record is durably gone when it
// settles, so the assertion needs no timing slack.
await disposer()
const listed = await registry.list()
expect(listed.map(record => record.sessionId)).toEqual(['other'])
await ctx.fiber.dispose()
})
})
describe('file format', () => {
it('round-trips records', () => {
const records = [{
sessionId: SessionId('s'), pid: 5 as const, cwd: '/c', startedAt: 7, bootId: BootId('b'),
}]
expect(parseRegistry(serializeRegistry(records))).toEqual({ records, intact: true })
})
it('stamps the format version', () => {
const stamped = JSON.parse(serializeRegistry([])) as { version: number }
expect(stamped.version).toBe(SESSION_REGISTRY_FORMAT_VERSION)
})
it.each([
['torn json', '{"version":0,"records":[{'],
['a foreign version', '{"version":99,"records":[]}'],
['a non-object root', '[]'],
['a null root', 'null'],
['a non-array records field', '{"version":0,"records":{}}'],
])('reads %s as an empty, non-intact registry', (_label, text) => {
expect(parseRegistry(text)).toEqual({ records: [], intact: false })
})
it.each([
['a missing session id', { pid: 1, cwd: '/a', startedAt: 0, bootId: 'b' }],
['a non-integer pid', { sessionId: 's', pid: 1.5, cwd: '/a', startedAt: 0, bootId: 'b' }],
['a non-positive pid', { sessionId: 's', pid: 0, cwd: '/a', startedAt: 0, bootId: 'b' }],
['an empty cwd', { sessionId: 's', pid: 1, cwd: '', startedAt: 0, bootId: 'b' }],
['a negative startedAt', { sessionId: 's', pid: 1, cwd: '/a', startedAt: -1, bootId: 'b' }],
['a missing boot id', { sessionId: 's', pid: 1, cwd: '/a', startedAt: 0 }],
['a non-string title', { sessionId: 's', pid: 1, cwd: '/a', startedAt: 0, bootId: 'b', title: 7 }],
['a non-object row', 'nonsense'],
])('drops a row with %s but keeps its intact siblings', (_label, row) => {
const good = { sessionId: 'keep', pid: 1, cwd: '/a', startedAt: 0, bootId: 'b' }
const text = JSON.stringify({ version: SESSION_REGISTRY_FORMAT_VERSION, records: [row, good] })
const parsed = parseRegistry(text)
expect(parsed.records.map(record => record.sessionId)).toEqual(['keep'])
expect(parsed.intact).toBe(false)
})
it('heals a damaged medium on the next locked write', async () => {
writeFileSync(file(), 'not json at all')
const { ctx, registry } = await service()
await registry.list()
expect(parseRegistry(readFileSync(file(), 'utf8')).intact).toBe(true)
await ctx.fiber.dispose()
})
it('reads a missing file as no live sessions', async () => {
const { ctx, registry } = await service()
rmSync(file(), { force: true })
expect(await registry.list()).toEqual([])
await ctx.fiber.dispose()
})
})
describe('failure reporting', () => {
it('tolerates a registry file another process created first', async () => {
// Two services racing `ensureFile`: the loser sees EEXIST, which is the
// intended outcome rather than an error, and both still publish.
const first = await service()
const second = await service()
await Promise.all([
first.registry.register({ sessionId: SessionId('a'), cwd: '/a' }),
second.registry.register({ sessionId: SessionId('b'), cwd: '/b' }),
])
expect((await first.registry.list()).map(record => record.sessionId).sort()).toEqual(['a', 'b'])
await first.ctx.fiber.dispose()
await second.ctx.fiber.dispose()
})
it('warns instead of throwing when deregistration fails during teardown', async () => {
const { ctx, registry } = await service()
await registry.register({ sessionId: SessionId('doomed'), cwd: '/w' })
// Make the registry path unusable, so the disposer's own write fails while the
// fiber is already unwinding. Teardown must still complete.
rmSync(root, { recursive: true, force: true })
mkdirSync(join(root, REGISTRY_FILE_NAME), { recursive: true })
await expect(ctx.fiber.dispose()).resolves.not.toThrow()
})
it('propagates a read failure that is not a missing file', async () => {
const { ctx, registry } = await service()
await registry.register({ sessionId: SessionId('sess-1'), cwd: '/w' })
// A directory where the file belongs makes the read fail with EISDIR, which
// is corruption rather than "no live sessions" and must not read as empty.
rmSync(file(), { force: true })
mkdirSync(file(), { recursive: true })
await expect(registry.list()).rejects.toThrow()
rmSync(file(), { recursive: true, force: true })
await ctx.fiber.dispose()
})
})
describe('retitle', () => {
it('replaces the recorded title of a session this process owns', async () => {
const { ctx, registry } = await service()
await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' })
expect((await registry.list())[0]?.title).toBeUndefined()
await registry.retitle(SessionId('sess-1'), 'first')
expect((await registry.list())[0]?.title).toBe('first')
await registry.retitle(SessionId('sess-1'), 'second')
expect((await registry.list())[0]?.title).toBe('second')
await ctx.fiber.dispose()
})
it('accepts a registration that already carries a title', async () => {
const { ctx, registry } = await service()
await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a', title: 'preset' })
expect((await registry.list())[0]?.title).toBe('preset')
await ctx.fiber.dispose()
})
it('leaves a same-id record owned by another incarnation untouched', async () => {
const { ctx, registry } = await service()
// Same live pid, different boot id: another incarnation's record must not be
// retitled by this one.
writeFileSync(file(), serializeRegistry([
{ sessionId: SessionId('foreign'), pid: process.pid, cwd: '/b', startedAt: 2, bootId: BootId('boot-other') },
]))
await registry.retitle(SessionId('foreign'), 'not mine')
expect((await registry.list())[0]?.title).toBeUndefined()
await ctx.fiber.dispose()
})
it('ignores an unknown session id, since a title can resolve after removal', async () => {
const { ctx, registry } = await service()
await expect(registry.retitle(SessionId('never-registered'), 'ghost')).resolves.toBeUndefined()
expect(await registry.list()).toEqual([])
await ctx.fiber.dispose()
})
})
describe('same-process concurrency', () => {
it('keeps every record when one process registers several sessions at once', async () => {
// The advisory lock is tracked per process, so same-process callers contend
// for it through its bounded retry budget instead of queueing. Past a dozen
// or so overlapping calls that budget runs out and a registration rejects —
// and callers publish fire-and-forget, so the rejection is swallowed and the
// session silently vanishes from the listing. The service therefore
// serializes its own callers; the lock only excludes other processes.
const { ctx, registry } = await service()
// Register once first so the file and directory already exist: without that,
// the concurrent calls serialize behind their own mkdir/create awaits and the
// overlap under test never happens.
await registry.register({ sessionId: SessionId('warm'), cwd: '/w' })
const settled = await Promise.allSettled(Array.from({ length: 24 }, (_unused, index) =>
registry.register({ sessionId: SessionId(`bulk-${String(index)}`), cwd: `/w/${String(index)}` })))
// Every call must SUCCEED, not merely leave the file consistent. Callers
// publish fire-and-forget, so a rejection is swallowed and the session
// silently vanishes from the listing rather than failing loudly.
expect(settled.filter(outcome => outcome.status === 'rejected')).toEqual([])
const expected = [...Array.from({ length: 24 }, (_unused, index) => `bulk-${String(index)}`), 'warm'].sort()
expect((await registry.list()).map(record => record.sessionId).sort()).toEqual(expected)
await ctx.fiber.dispose()
})
it('keeps serving later callers after one cycle fails', async () => {
const { ctx, registry } = await service()
// A directory sitting where the registry file must be makes one cycle fail
// without breaking the shared chain for the calls queued behind it.
rmSync(root, { recursive: true, force: true })
mkdirSync(join(root, REGISTRY_FILE_NAME), { recursive: true })
await expect(registry.register({ sessionId: SessionId('doomed'), cwd: '/w' })).rejects.toThrow()
rmSync(root, { recursive: true, force: true })
await registry.register({ sessionId: SessionId('after'), cwd: '/w' })
expect((await registry.list()).map(record => record.sessionId)).toEqual(['after'])
await ctx.fiber.dispose()
})
})
describe('cross-process concurrency', () => {
it('keeps every record when independent processes register at once', async () => {
// The regression that motivates the advisory lock: unlocked whole-file
// republication loses records under concurrent writers. Real processes are
// required — same-process promises would serialize on the event loop.
const driver = fileURLToPath(new URL('./fixtures/register-once.ts', import.meta.url))
const count = 8
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const tsx = join(repoRoot, 'node_modules/tsx/dist/loader.mjs')
// Source plane: tsx resolves the workspace import through the root
// tsconfig `paths` to `src`, so this runs without a build step.
const env = { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') }
const children = Array.from({ length: count }, (_unused, index) =>
spawn(process.execPath, ['--import', tsx, driver, root, `sess-${String(index)}`], {
env,
stdio: ['pipe', 'pipe', 'inherit'],
}))
try {
// Every child must have committed its record AND still be alive when the
// file is read, so the assertion sees concurrent writes rather than prunes.
await Promise.all(children.map(child => new Promise<void>((resolve, reject) => {
child.stdout.once('data', () => { resolve() })
child.once('error', reject)
child.once('exit', (code) => { reject(new Error(`driver exited early with ${String(code)}`)) })
})))
const stored = parseRegistry(readFileSync(file(), 'utf8'))
expect(stored.intact).toBe(true)
expect(stored.records.map(record => record.sessionId).sort()).toEqual(
Array.from({ length: count }, (_unused, index) => `sess-${String(index)}`).sort(),
)
} finally {
for (const child of children) child.stdin.end()
await Promise.all(children.map(child => new Promise<void>((resolve) => { child.once('exit', () => { resolve() }) })))
}
}, 60_000)
})