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