返回源码地图

packages/session/session-persistence-jsonl/src/lease.ts

main snapshot · da00f7f5358f · 正文引用章节 16;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

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 from
6 * that path — never a file lock or handle, so readers, searches, and
7 * directory removal proceed freely while the lock is held. Contention maps
8 * to `SessionAlreadyOwnedError`; the kernel releases the lock when the
9 * holder's descriptor or last object handle closes, including on any process
10 * death, so a crashed holder never blocks a successor. A live but wedged
11 * holder keeps the lock until its process exits: there is deliberately no
12 * expiry that could expropriate a stalled writer whose resumed appends would
13 * tear the log.
14 * A POSIX lock names an inode, not a path, so after locking the holder
15 * verifies the locked inode is still the file at the lock path and retries
16 * otherwise: an unlinked-and-recreated lock file carries a fresh inode, and
17 * a lock on the orphaned one proves nothing. Removing a live session's lock
18 * file therefore forfeits exclusion on POSIX (nothing in the harness does
19 * 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 a
21 * created session, only right before its first materializing write — an
22 * unmaterialized session has no filesystem footprint. Release never removes
23 * the POSIX lock file: every acquired lock belongs to a materialized or
24 * materializing session, and the surviving file keeps the stable inode later
25 * lockers verify against. The browser worker stubs the native flock entry to
26 * immediate success: it is single-process, so the in-process write claim
27 * already excludes every writer.
28 * @module @deepseek-ai/dsh-session-persistence-jsonl/lease
29 */
30
31import { mkdir, open, stat } from 'node:fs/promises'
32import type { FileHandle } from 'node:fs/promises'
33import { join } from 'node:path'
34import { tryLockExclusive } from '@deepseek-ai/node-addon-system/flock'
35import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence'
36import type { SessionId } from '@deepseek-ai/dsh-session'
37import { acquireLockHandleWin32, releaseLockHandleWin32 } from './win32.ts'
38
39/** Base name of the kernel lock file inside a session's directory. */
40export const LEASE_FILENAME = 'session.lock'
41
42/** The held kernel lock: a POSIX descriptor or a Win32 semaphore handle. */
43type HeldLock =
44 | { readonly kind: 'posix'; readonly handle: FileHandle }
45 | { readonly kind: 'win32'; readonly handle: number }
46
47/** Whether a flock failure means another descriptor holds the lock. */
48function isLockContention(error: unknown): boolean {
49 const code = (error as NodeJS.ErrnoException | null)?.code
50 // flock(2) reports EAGAIN; some libcs spell it EWOULDBLOCK.
51 return code === 'EAGAIN' || code === 'EWOULDBLOCK'
52}
53
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 */
58export class SessionWriteLease {
59 private released = false
60
61 private constructor(private readonly held: HeldLock) {}
62
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 the
73 // 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: number
78 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 error
84 }
85 return new SessionWriteLease({ kind: 'win32', handle })
86 }
87 /* v8 ignore stop */
88 // Bounded retry: locking an inode a releasing creator just unlinked (or a
89 // 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 error
98 }
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 undefined
102 throw error
103 })
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 error
110 }
111 // The locked inode is no longer the file at the lock path: start over
112 // against whatever now stands there.
113 await handle.close()
114 }
115 throw new SessionAlreadyOwnedError(id)
116 }
117
118 /**
119 * Release the kernel lock by closing its descriptor or handle. The POSIX
120 * lock file is never removed: every acquired lock belongs to a
121 * materialized or materializing session, and keeping the file preserves
122 * the stable inode later lockers verify against. Idempotent.
123 */
124 async release(): Promise<void> {
125 if (this.released) return
126 this.released = true
127 /* 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 return
131 }
132 /* v8 ignore stop */
133 await this.held.handle.close()
134 }
135}