mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-14 04:01:35 +00:00
435 lines
19 KiB
TypeScript
435 lines
19 KiB
TypeScript
/**
|
|
* 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()
|
|
})
|
|
})
|