1
/**2
* Cross-process write-ownership lock for one session's artifact directory,3
* held for the whole life of a write handle. The arbiter is the kernel:4
* POSIX takes a non-blocking `flock(2)` via native system support on `session.lock`5
* beside the log, and Windows holds a named kernel semaphore derived from6
* that path — never a file lock or handle, so readers, searches, and7
* directory removal proceed freely while the lock is held. Contention maps8
* to `SessionAlreadyOwnedError`; the kernel releases the lock when the9
* holder's descriptor or last object handle closes, including on any process10
* death, so a crashed holder never blocks a successor. A live but wedged11
* holder keeps the lock until its process exits: there is deliberately no12
* expiry that could expropriate a stalled writer whose resumed appends would13
* tear the log.14
* A POSIX lock names an inode, not a path, so after locking the holder15
* verifies the locked inode is still the file at the lock path and retries16
* otherwise: an unlinked-and-recreated lock file carries a fresh inode, and17
* a lock on the orphaned one proves nothing. Removing a live session's lock18
* file therefore forfeits exclusion on POSIX (nothing in the harness does19
* so); Windows has no lock file at all. Readers never touch the lock.20
* The lock is acquired at write-open of an existing artifact and, for a21
* created session, only right before its first materializing write — an22
* unmaterialized session has no filesystem footprint. Release never removes23
* the POSIX lock file: every acquired lock belongs to a materialized or24
* materializing session, and the surviving file keeps the stable inode later25
* lockers verify against. The browser worker stubs the native flock entry to26
* immediate success: it is single-process, so the in-process write claim27
* already excludes every writer.28
* @module @deepseek-ai/dsh-session-persistence-jsonl/lease29
*/31
import { mkdir, open, stat } from 'node:fs/promises'32
import type { FileHandle } from 'node:fs/promises'33
import { join } from 'node:path'34
import { tryLockExclusive } from '@deepseek-ai/node-addon-system/flock'35
import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence'36
import type { SessionId } from '@deepseek-ai/dsh-session'37
import { acquireLockHandleWin32, releaseLockHandleWin32 } from './win32.ts'39
/** Base name of the kernel lock file inside a session's directory. */40
export const LEASE_FILENAME = 'session.lock'42
/** The held kernel lock: a POSIX descriptor or a Win32 semaphore handle. */43
type HeldLock =44
| { readonly kind: 'posix'; readonly handle: FileHandle }45
| { readonly kind: 'win32'; readonly handle: number }47
/** Whether a flock failure means another descriptor holds the lock. */48
function isLockContention(error: unknown): boolean {49
const code = (error as NodeJS.ErrnoException | null)?.code50
// flock(2) reports EAGAIN; some libcs spell it EWOULDBLOCK.51
return code === 'EAGAIN' || code === 'EWOULDBLOCK'52
}54
/**55
* One held write lock. Constructed only by {@link SessionWriteLease.acquire};56
* `release` closes the descriptor or handle, which is what releases the lock.57
*/58
export class SessionWriteLease {59
private released = false61
private constructor(private readonly held: HeldLock) {}63
/**64
* Acquire the session directory's kernel write lock.65
* @param dir - the session's artifact directory (created if absent).66
* @param id - the session the lock guards, for error identities.67
* @returns the held lock.68
* @throws {SessionAlreadyOwnedError} while another holder keeps the lock.69
*/70
static async acquire(dir: string, id: SessionId): Promise<SessionWriteLease> {71
const path = join(dir, LEASE_FILENAME)72
// Owner-only like materializePosix's directories: the lock may create the73
// session directory first, and both creators must agree on the mode.74
await mkdir(dir, { recursive: true, mode: 0o700 })75
/* v8 ignore start -- native Windows coverage exercises this platform branch; Linux covers the POSIX peer */76
if (process.platform === 'win32') {77
let handle: number78
try {79
handle = await acquireLockHandleWin32(path)80
} catch (error: unknown) {81
// Sharing violation: another handle already holds the write exclusion.82
if ((error as NodeJS.ErrnoException | null)?.code === 'EBUSY') throw new SessionAlreadyOwnedError(id)83
throw error84
}85
return new SessionWriteLease({ kind: 'win32', handle })86
}87
/* v8 ignore stop */88
// Bounded retry: locking an inode a releasing creator just unlinked (or a89
// recreated path) re-opens the fresh file; steady state needs one pass.90
for (let attempt = 0; attempt < 3; attempt += 1) {91
const handle = await open(path, 'w')92
try {93
try {94
await tryLockExclusive(handle.fd)95
} catch (error: unknown) {96
if (isLockContention(error)) throw new SessionAlreadyOwnedError(id)97
throw error98
}99
const held = await handle.stat({ bigint: true })100
const current = await stat(path, { bigint: true }).catch((error: unknown) => {101
if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined102
throw error103
})104
if (current !== undefined && current.ino === held.ino && current.dev === held.dev) {105
return new SessionWriteLease({ kind: 'posix', handle })106
}107
} catch (error: unknown) {108
await handle.close()109
throw error110
}111
// The locked inode is no longer the file at the lock path: start over112
// against whatever now stands there.113
await handle.close()114
}115
throw new SessionAlreadyOwnedError(id)116
}118
/**119
* Release the kernel lock by closing its descriptor or handle. The POSIX120
* lock file is never removed: every acquired lock belongs to a121
* materialized or materializing session, and keeping the file preserves122
* the stable inode later lockers verify against. Idempotent.123
*/124
async release(): Promise<void> {125
if (this.released) return126
this.released = true127
/* v8 ignore start -- native Windows coverage exercises this platform branch; Linux covers the POSIX peer */128
if (this.held.kind === 'win32') {129
await releaseLockHandleWin32(this.held.handle)130
return131
}132
/* v8 ignore stop */133
await this.held.handle.close()134
}135
}