source dump of claude code
at main 215 lines 7.1 kB view raw
1import { mkdir, readFile, unlink, writeFile } from 'fs/promises' 2import { join } from 'path' 3import { getSessionId } from '../../bootstrap/state.js' 4import { registerCleanup } from '../../utils/cleanupRegistry.js' 5import { logForDebugging } from '../../utils/debug.js' 6import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' 7import { jsonParse, jsonStringify } from '../../utils/slowOperations.js' 8import { getErrnoCode } from '../errors.js' 9 10const LOCK_FILENAME = 'computer-use.lock' 11 12// Holds the unregister function for the shutdown cleanup handler. 13// Set when the lock is acquired, cleared when released. 14let unregisterCleanup: (() => void) | undefined 15 16type ComputerUseLock = { 17 readonly sessionId: string 18 readonly pid: number 19 readonly acquiredAt: number 20} 21 22export type AcquireResult = 23 | { readonly kind: 'acquired'; readonly fresh: boolean } 24 | { readonly kind: 'blocked'; readonly by: string } 25 26export type CheckResult = 27 | { readonly kind: 'free' } 28 | { readonly kind: 'held_by_self' } 29 | { readonly kind: 'blocked'; readonly by: string } 30 31const FRESH: AcquireResult = { kind: 'acquired', fresh: true } 32const REENTRANT: AcquireResult = { kind: 'acquired', fresh: false } 33 34function isComputerUseLock(value: unknown): value is ComputerUseLock { 35 if (typeof value !== 'object' || value === null) return false 36 return ( 37 'sessionId' in value && 38 typeof value.sessionId === 'string' && 39 'pid' in value && 40 typeof value.pid === 'number' 41 ) 42} 43 44function getLockPath(): string { 45 return join(getClaudeConfigHomeDir(), LOCK_FILENAME) 46} 47 48async function readLock(): Promise<ComputerUseLock | undefined> { 49 try { 50 const raw = await readFile(getLockPath(), 'utf8') 51 const parsed: unknown = jsonParse(raw) 52 return isComputerUseLock(parsed) ? parsed : undefined 53 } catch { 54 return undefined 55 } 56} 57 58/** 59 * Check whether a process is still running (signal 0 probe). 60 * 61 * Note: there is a small window for PID reuse — if the owning process 62 * exits and an unrelated process is assigned the same PID, the check 63 * will return true. This is extremely unlikely in practice. 64 */ 65function isProcessRunning(pid: number): boolean { 66 try { 67 process.kill(pid, 0) 68 return true 69 } catch { 70 return false 71 } 72} 73 74/** 75 * Attempt to create the lock file atomically with O_EXCL. 76 * Returns true on success, false if the file already exists. 77 * Throws for other errors. 78 */ 79async function tryCreateExclusive(lock: ComputerUseLock): Promise<boolean> { 80 try { 81 await writeFile(getLockPath(), jsonStringify(lock), { flag: 'wx' }) 82 return true 83 } catch (e: unknown) { 84 if (getErrnoCode(e) === 'EEXIST') return false 85 throw e 86 } 87} 88 89/** 90 * Register a shutdown cleanup handler so the lock is released even if 91 * turn-end cleanup is never reached (e.g. the user runs /exit while 92 * a tool call is in progress). 93 */ 94function registerLockCleanup(): void { 95 unregisterCleanup?.() 96 unregisterCleanup = registerCleanup(async () => { 97 await releaseComputerUseLock() 98 }) 99} 100 101/** 102 * Check lock state without acquiring. Used for `request_access` / 103 * `list_granted_applications` — the package's `defersLockAcquire` contract: 104 * these tools check but don't take the lock, so the enter-notification and 105 * overlay don't fire while the model is only asking for permission. 106 * 107 * Does stale-PID recovery (unlinks) so a dead session's lock doesn't block 108 * `request_access`. Does NOT create — that's `tryAcquireComputerUseLock`'s job. 109 */ 110export async function checkComputerUseLock(): Promise<CheckResult> { 111 const existing = await readLock() 112 if (!existing) return { kind: 'free' } 113 if (existing.sessionId === getSessionId()) return { kind: 'held_by_self' } 114 if (isProcessRunning(existing.pid)) { 115 return { kind: 'blocked', by: existing.sessionId } 116 } 117 logForDebugging( 118 `Recovering stale computer-use lock from session ${existing.sessionId} (PID ${existing.pid})`, 119 ) 120 await unlink(getLockPath()).catch(() => {}) 121 return { kind: 'free' } 122} 123 124/** 125 * Zero-syscall check: does THIS process believe it holds the lock? 126 * True iff `tryAcquireComputerUseLock` succeeded and `releaseComputerUseLock` 127 * hasn't run yet. Used to gate the per-turn release in `cleanup.ts` so 128 * non-CU turns don't touch disk. 129 */ 130export function isLockHeldLocally(): boolean { 131 return unregisterCleanup !== undefined 132} 133 134/** 135 * Try to acquire the computer-use lock for the current session. 136 * 137 * `{kind: 'acquired', fresh: true}` — first tool call of a CU turn. Callers fire 138 * enter notifications on this. `{kind: 'acquired', fresh: false}` — re-entrant, 139 * same session already holds it. `{kind: 'blocked', by}` — another live session 140 * holds it. 141 * 142 * Uses O_EXCL (open 'wx') for atomic test-and-set — the OS guarantees at 143 * most one process sees the create succeed. If the file already exists, 144 * we check ownership and PID liveness; for a stale lock we unlink and 145 * retry the exclusive create once. If two sessions race to recover the 146 * same stale lock, only one create succeeds (the other reads the winner). 147 */ 148export async function tryAcquireComputerUseLock(): Promise<AcquireResult> { 149 const sessionId = getSessionId() 150 const lock: ComputerUseLock = { 151 sessionId, 152 pid: process.pid, 153 acquiredAt: Date.now(), 154 } 155 156 await mkdir(getClaudeConfigHomeDir(), { recursive: true }) 157 158 // Fresh acquisition. 159 if (await tryCreateExclusive(lock)) { 160 registerLockCleanup() 161 return FRESH 162 } 163 164 const existing = await readLock() 165 166 // Corrupt/unparseable — treat as stale (can't extract a blocking ID). 167 if (!existing) { 168 await unlink(getLockPath()).catch(() => {}) 169 if (await tryCreateExclusive(lock)) { 170 registerLockCleanup() 171 return FRESH 172 } 173 return { kind: 'blocked', by: (await readLock())?.sessionId ?? 'unknown' } 174 } 175 176 // Already held by this session. 177 if (existing.sessionId === sessionId) return REENTRANT 178 179 // Another live session holds it — blocked. 180 if (isProcessRunning(existing.pid)) { 181 return { kind: 'blocked', by: existing.sessionId } 182 } 183 184 // Stale lock — recover. Unlink then retry the exclusive create. 185 // If another session is also recovering, one EEXISTs and reads the winner. 186 logForDebugging( 187 `Recovering stale computer-use lock from session ${existing.sessionId} (PID ${existing.pid})`, 188 ) 189 await unlink(getLockPath()).catch(() => {}) 190 if (await tryCreateExclusive(lock)) { 191 registerLockCleanup() 192 return FRESH 193 } 194 return { kind: 'blocked', by: (await readLock())?.sessionId ?? 'unknown' } 195} 196 197/** 198 * Release the computer-use lock if the current session owns it. Returns 199 * `true` if we actually unlinked the file (i.e., we held it) — callers fire 200 * exit notifications on this. Idempotent: subsequent calls return `false`. 201 */ 202export async function releaseComputerUseLock(): Promise<boolean> { 203 unregisterCleanup?.() 204 unregisterCleanup = undefined 205 206 const existing = await readLock() 207 if (!existing || existing.sessionId !== getSessionId()) return false 208 try { 209 await unlink(getLockPath()) 210 logForDebugging('Released computer-use lock') 211 return true 212 } catch { 213 return false 214 } 215}