| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434 |
- /**
- * Cross-process write-lock behavior, exercised through fresh backend
- * instances over one shared root: kernel `flock` locks conflict between two
- * descriptors even inside one process, so a second instance behaves exactly
- * like a second process. Exclusion while a holder is live, immediate
- * admission after close, lock-file residue rules, and the inode verification
- * that defeats an unlinked-and-recreated lock path. Filesystem and flock
- * refusals are injected through the module mocks below: POSIX modes cannot
- * express them on Windows, and an injected error is the only deterministic
- * cross-platform refusal. Real cross-process exclusion and crash release are
- * pinned by lease.two-process.e2e.ts.
- */
- import { existsSync } from 'node:fs'
- import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
- import { tmpdir } from 'node:os'
- import { join } from 'node:path'
- import { afterEach, describe, expect, it, vi } from 'vitest'
- import { Context } from '@deepseek-ai/cordis'
- import { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
- import type { SessionHeader } from '@deepseek-ai/dsh-session'
- import {
- SessionAlreadyExistsError,
- SessionAlreadyOwnedError,
- SessionPersistenceNotFoundError,
- } from '@deepseek-ai/dsh-session-persistence'
- import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
- import JsonlSessionPersistence from '../src/index.ts'
- import { LEASE_FILENAME, SessionWriteLease } from '../src/lease.ts'
- import type { JsonlSessionHandle } from '../src/storage.ts'
- import { sessionDir } from '../src/format.ts'
- // The lock's base name, duplicated for the hoisted mock factories: they run
- // while `../src/lease.ts` is still evaluating, before LEASE_FILENAME exists.
- const LOCK = vi.hoisted(() => 'session.lock')
- const refuse = vi.hoisted(() => ({
- /** Next open of a lock file fails EACCES (read-only directory). */
- lockOpen: false,
- /** Next flock call fails EACCES (a non-contention kernel refusal). */
- flock: false,
- /** Next flock call fails EWOULDBLOCK. */
- flockBusy: false,
- /** Next stat of a lock file fails EACCES (unreadable path). */
- lockStat: false,
- /** For N further lock-path stats: unlink and recreate the file first, so the locked inode is orphaned. */
- swapLockOnStat: 0,
- /** Next lock-path stat: unlink the file first, so the verify read finds nothing. */
- dropLockOnStat: false,
- }))
- vi.mock('node:fs/promises', async (importOriginal) => {
- const actual = await importOriginal<typeof import('node:fs/promises')>()
- const denied = (syscall: string): never => {
- throw Object.assign(new Error(`EACCES: injected ${syscall} refusal`), { code: 'EACCES' })
- }
- return {
- ...actual,
- open: (async (path: unknown, ...rest: never[]) => {
- if (refuse.lockOpen && String(path).endsWith(LOCK)) {
- refuse.lockOpen = false
- denied('open')
- }
- return (actual.open as (path: unknown, ...args: never[]) => Promise<unknown>)(path, ...rest)
- }) as typeof actual.open,
- stat: (async (path: unknown, ...rest: never[]) => {
- const at = String(path)
- if (at.endsWith(LOCK)) {
- if (refuse.lockStat) {
- refuse.lockStat = false
- denied('stat')
- }
- if (refuse.dropLockOnStat) {
- refuse.dropLockOnStat = false
- await actual.unlink(at)
- } else if (refuse.swapLockOnStat > 0) {
- refuse.swapLockOnStat -= 1
- await actual.unlink(at)
- await actual.writeFile(at, '')
- }
- }
- return (actual.stat as (path: unknown, ...args: never[]) => Promise<unknown>)(path, ...rest)
- }) as typeof actual.stat,
- }
- })
- vi.mock('@deepseek-ai/node-addon-system/flock', async (importOriginal) => {
- const actual = await importOriginal<typeof import('@deepseek-ai/node-addon-system/flock')>()
- return {
- tryLockExclusive: async (fd: number): Promise<void> => {
- if (refuse.flock) {
- refuse.flock = false
- throw Object.assign(new Error('EACCES: injected flock refusal'), { code: 'EACCES' })
- }
- if (refuse.flockBusy) {
- refuse.flockBusy = false
- throw Object.assign(new Error('EWOULDBLOCK: injected contention'), { code: 'EWOULDBLOCK' })
- }
- return actual.tryLockExclusive(fd)
- },
- }
- })
- const dirs: string[] = []
- const contexts: Context[] = []
- afterEach(async () => {
- refuse.lockOpen = false
- refuse.flock = false
- refuse.flockBusy = false
- refuse.lockStat = false
- refuse.swapLockOnStat = 0
- refuse.dropLockOnStat = false
- for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
- for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true })
- })
- function meta(id: string, cwd = '/work'): SessionHeader {
- return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt: 1_000, cwd, isSeeded: false }
- }
- async function freshRoot(): Promise<string> {
- const root = await mkdtemp(join(tmpdir(), 'dsh-jsonl-lease-'))
- dirs.push(root)
- return root
- }
- async function mount(root: string): Promise<SessionPersistence> {
- const ctx = new Context()
- contexts.push(ctx)
- await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
- return ctx.sessionPersistence
- }
- function lockPath(root: string, id: string, cwd = '/work'): string {
- return join(sessionDir(root, cwd, SessionId(id)), LEASE_FILENAME)
- }
- /**
- * Make the next lock release do its real work, then report failure — as a
- * close(2) that freed the descriptor but returned EIO would.
- */
- function failReleaseOnce(): void {
- const spy = vi.spyOn(SessionWriteLease.prototype, 'release')
- spy.mockImplementationOnce(async function (this: SessionWriteLease) {
- spy.mockRestore()
- await this.release()
- throw Object.assign(new Error('EIO: injected release failure'), { code: 'EIO' })
- })
- }
- const EVENTS = [
- { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
- { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
- ] as const
- describe('cross-process write lock', () => {
- it('excludes a second instance while the holder is live, and admits it after close', async () => {
- const root = await freshRoot()
- const first = await mount(root)
- const second = await mount(root)
- const holder = await first.create(meta('excluded'))
- await holder.append([...EVENTS])
- // Another instance over the same root cannot create or write-open the id:
- // the materialized duplicate is an existence fact, the write open an
- // ownership one.
- await expect(second.create(meta('excluded'))).rejects.toBeInstanceOf(SessionAlreadyExistsError)
- await expect(second.open(SessionId('excluded'), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError)
- // Unmaterialized creates hold no lock and leave no artifact, so a rival
- // instance's create succeeds; the collision surfaces at the loser's first
- // materializing write, where the winner already holds the lock.
- const pendingWinner = await first.create(meta('excluded-pending'))
- const pendingLoser = await second.create(meta('excluded-pending'))
- await pendingWinner.append([...EVENTS])
- await expect(pendingLoser.append([...EVENTS])).rejects.toBeInstanceOf(SessionAlreadyOwnedError)
- await pendingLoser.close()
- await pendingWinner.close()
- // Reads never touch the lock.
- const reader = await second.open(SessionId('excluded'), 'read')
- expect((await reader.read()).events.map(event => event.seq)).toEqual([0, 1])
- await reader.close()
- await holder.close()
- // POSIX keeps the materialized session's lock file (Windows locks a kernel
- // object with no filesystem footprint); the kernel lock itself is gone.
- if (process.platform !== 'win32') expect(existsSync(lockPath(root, 'excluded'))).toBe(true)
- const reopened = await second.open(SessionId('excluded'), 'write')
- await reopened.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }])
- await reopened.close()
- })
- it.skipIf(process.platform === 'win32')('removing the lock file forfeits a wedged holder: a fresh inode admits a successor', async () => {
- const root = await freshRoot()
- const first = await mount(root)
- const second = await mount(root)
- const wedged = await first.create(meta('wedged'))
- await wedged.append([...EVENTS])
- // The documented escape hatch for a live-but-stuck holder: deleting the
- // lock file orphans the held inode, and a successor locks the fresh one.
- await rm(lockPath(root, 'wedged'))
- const successor = await second.open(SessionId('wedged'), 'write')
- await successor.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }])
- await successor.close()
- await wedged.close()
- })
- it('write-opening an absent session leaves no lock residue', async () => {
- const root = await freshRoot()
- const backend = await mount(root)
- await expect(backend.open(SessionId('absent'), 'write')).rejects.toBeInstanceOf(SessionPersistenceNotFoundError)
- expect(existsSync(join(root, LEASE_FILENAME))).toBe(false)
- })
- it('write-opening an absent id under an existing project directory reports not-found', async () => {
- const root = await freshRoot()
- const backend = await mount(root)
- const writer = await backend.create(meta('present-sibling'))
- await writer.append([...EVENTS])
- await writer.close()
- // The project directory exists but the id's session directory does not:
- // the generation scan reports absence rather than misreading a sibling.
- await expect(backend.open(SessionId('absent-sibling'), 'write')).rejects.toBeInstanceOf(SessionPersistenceNotFoundError)
- })
- it('a never-materialized create leaves no filesystem footprint at all', async () => {
- const root = await freshRoot()
- const backend = await mount(root)
- const handle = await backend.create(meta('erased'))
- // The lock is taken only at the first materializing write, so an
- // unmaterialized session creates neither its directory nor a lock file.
- expect(existsSync(join(lockPath(root, 'erased'), '..'))).toBe(false)
- await handle.close()
- expect(existsSync(join(lockPath(root, 'erased'), '..'))).toBe(false)
- await expect(backend.stat(SessionId('erased'))).resolves.toBeUndefined()
- })
- it('materialization publishes the lock before the first log bytes and keeps it on the handle', async () => {
- const root = await freshRoot()
- const first = await mount(root)
- const second = await mount(root)
- const creator = await first.create(meta('lazy-lock'))
- await creator.append([...EVENTS])
- // The materializing append acquired and retained the lock.
- if (process.platform !== 'win32') expect(existsSync(lockPath(root, 'lazy-lock'))).toBe(true)
- await expect(second.open(SessionId('lazy-lock'), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError)
- // A later append reuses the held lock rather than re-acquiring.
- await creator.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }])
- await creator.close()
- const reopened = await second.open(SessionId('lazy-lock'), 'write')
- await reopened.close()
- })
- it('an explicitly flushed empty session takes the lock with its header', async () => {
- const root = await freshRoot()
- const first = await mount(root)
- const second = await mount(root)
- const creator = await first.create(meta('flush-lock'))
- await creator.flush()
- await expect(second.open(SessionId('flush-lock'), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError)
- await creator.close()
- })
- it.skipIf(process.platform === 'win32')('surfaces a filesystem refusal opening the lock file', async () => {
- const root = await freshRoot()
- const backend = await mount(root)
- const writer = await backend.create(meta('open-blocked'))
- await writer.append([...EVENTS])
- await writer.close()
- refuse.lockOpen = true
- await expect(backend.open(SessionId('open-blocked'), 'write')).rejects.toThrow(/EACCES/)
- })
- it.skipIf(process.platform === 'win32')('surfaces a non-contention flock failure', async () => {
- const root = await freshRoot()
- const backend = await mount(root)
- const writer = await backend.create(meta('flock-blocked'))
- await writer.append([...EVENTS])
- await writer.close()
- refuse.flock = true
- await expect(backend.open(SessionId('flock-blocked'), 'write')).rejects.toThrow(/EACCES/)
- })
- it.skipIf(process.platform === 'win32')('maps the EWOULDBLOCK contention spelling to already-owned', async () => {
- const root = await freshRoot()
- const backend = await mount(root)
- const writer = await backend.create(meta('win-contended'))
- await writer.append([...EVENTS])
- await writer.close()
- // Some libcs spell flock(2) contention EWOULDBLOCK rather than EAGAIN.
- refuse.flockBusy = true
- await expect(backend.open(SessionId('win-contended'), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError)
- })
- it.skipIf(process.platform === 'win32')('surfaces a lock-path stat refusal from the inode verification', async () => {
- const root = await freshRoot()
- const backend = await mount(root)
- const writer = await backend.create(meta('stat-blocked'))
- await writer.append([...EVENTS])
- await writer.close()
- refuse.lockStat = true
- await expect(backend.open(SessionId('stat-blocked'), 'write')).rejects.toThrow(/EACCES/)
- })
- it.skipIf(process.platform === 'win32')('retries when the locked inode is no longer the lock path, and wins on a stable pass', async () => {
- const root = await freshRoot()
- const backend = await mount(root)
- const writer = await backend.create(meta('churned'))
- await writer.append([...EVENTS])
- await writer.close()
- // One churn (unlink+recreate under the verify stat) orphans the first
- // locked inode; the retry locks the fresh file and verifies clean.
- refuse.swapLockOnStat = 1
- const reopened = await backend.open(SessionId('churned'), 'write')
- await reopened.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }])
- await reopened.close()
- })
- it.skipIf(process.platform === 'win32')('retries when the lock path vanishes under the verify read', async () => {
- const root = await freshRoot()
- const backend = await mount(root)
- const writer = await backend.create(meta('vanished'))
- await writer.append([...EVENTS])
- await writer.close()
- refuse.dropLockOnStat = true
- const reopened = await backend.open(SessionId('vanished'), 'write')
- await reopened.close()
- })
- it.skipIf(process.platform === 'win32')('gives up as already-owned when the lock path never stabilizes', async () => {
- const root = await freshRoot()
- const backend = await mount(root)
- const writer = await backend.create(meta('unstable'))
- await writer.append([...EVENTS])
- await writer.close()
- // Churn on every attempt: the bounded retry refuses rather than spinning.
- refuse.swapLockOnStat = 3
- await expect(backend.open(SessionId('unstable'), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError)
- })
- it('a failing lock release still frees the in-process claim on close', async () => {
- const root = await freshRoot()
- const backend = await mount(root)
- const holder = await backend.create(meta('release-fails'))
- await holder.append([...EVENTS])
- failReleaseOnce()
- await expect(holder.close()).rejects.toThrow(/injected release failure/)
- // The claim is freed despite the failed release: the id is not wedged.
- const reopened = await backend.open(SessionId('release-fails'), 'write')
- await reopened.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }])
- await reopened.close()
- })
- it('a write-open failure with a failing release aggregates both and frees the claim', async () => {
- const root = await freshRoot()
- const backend = await mount(root)
- const writer = await backend.create(meta('open-and-release-fail'))
- await writer.append([...EVENTS])
- await writer.close()
- // Corrupt the stored header line so the open fails after the lock is
- // acquired (a garbled tail would be recovered as torn, not refused).
- const dir = join(lockPath(root, 'open-and-release-fail'), '..')
- const log = (await readdir(dir)).find(name => name.endsWith('.jsonl'))
- const stored = await readFile(join(dir, String(log)), 'utf8')
- await writeFile(join(dir, String(log)), `#${stored.slice(1)}`)
- failReleaseOnce()
- const outcome = await backend.open(SessionId('open-and-release-fail'), 'write').then(() => undefined, (error: unknown) => error)
- expect(outcome).toBeInstanceOf(AggregateError)
- const errors = (outcome as AggregateError).errors as Error[]
- expect(errors).toHaveLength(2)
- expect(String(errors[0])).toMatch(/corrupt/i)
- expect(String(errors[1])).toMatch(/injected release failure/)
- // The original diagnostic survives, and the claim is freed: the next
- // attempt reports the corruption again rather than a phantom owner.
- await expect(backend.open(SessionId('open-and-release-fail'), 'write')).rejects.toThrow(/corrupt/i)
- })
- it('a drain failure and a release failure reject close as one AggregateError', async () => {
- const root = await freshRoot()
- const backend = await mount(root)
- const holder = await backend.create(meta('drain-and-release-fail')) as unknown as JsonlSessionHandle
- await holder.append([...EVENTS])
- vi.spyOn(backend as unknown as { persistBatch: () => Promise<void> }, 'persistBatch')
- .mockRejectedValueOnce(new Error('injected drain refusal'))
- holder.enqueueLive({ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }, () => {})
- failReleaseOnce()
- const outcome = await holder.close().then(() => undefined, (error: unknown) => error)
- expect(outcome).toBeInstanceOf(AggregateError)
- expect((outcome as AggregateError).errors.map(String).join('\n')).toMatch(/drain refusal[\s\S]*release failure/)
- // Both failures reported, and the id is still not wedged.
- const reopened = await backend.open(SessionId('drain-and-release-fail'), 'write')
- await reopened.close()
- })
- it.skipIf(process.platform === 'win32')('release is idempotent and never removes the lock file', async () => {
- const root = await freshRoot()
- const dir = join(root, 'solo')
- const lease = await SessionWriteLease.acquire(dir, SessionId('solo'))
- await lease.release()
- await lease.release()
- // The file survives every release, keeping the stable inode later
- // lockers verify against; the kernel lock died with the descriptor.
- expect(existsSync(join(dir, LOCK))).toBe(true)
- const successor = await SessionWriteLease.acquire(dir, SessionId('solo'))
- await successor.release()
- expect(existsSync(join(dir, LOCK))).toBe(true)
- })
- it('keeps distinct sessions independently lockable', async () => {
- const root = await freshRoot()
- const backend = await mount(root)
- const a = await backend.create(meta('indep-a'))
- const b = await backend.create(meta('indep-b'))
- await a.append([...EVENTS])
- await b.append([...EVENTS])
- if (process.platform !== 'win32') {
- expect((await readdir(join(lockPath(root, 'indep-a'), '..'))).filter(name => name === LOCK)).toHaveLength(1)
- }
- await a.close()
- await b.close()
- })
- })
|