/ utils / computerUse / computerUseLock.ts
computerUseLock.ts
  1  import { mkdir, readFile, unlink, writeFile } from 'fs/promises'
  2  import { join } from 'path'
  3  import { getSessionId } from '../../bootstrap/state.js'
  4  import { registerCleanup } from '../../utils/cleanupRegistry.js'
  5  import { logForDebugging } from '../../utils/debug.js'
  6  import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
  7  import { jsonParse, jsonStringify } from '../../utils/slowOperations.js'
  8  import { getErrnoCode } from '../errors.js'
  9  
 10  const 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.
 14  let unregisterCleanup: (() => void) | undefined
 15  
 16  type ComputerUseLock = {
 17    readonly sessionId: string
 18    readonly pid: number
 19    readonly acquiredAt: number
 20  }
 21  
 22  export type AcquireResult =
 23    | { readonly kind: 'acquired'; readonly fresh: boolean }
 24    | { readonly kind: 'blocked'; readonly by: string }
 25  
 26  export type CheckResult =
 27    | { readonly kind: 'free' }
 28    | { readonly kind: 'held_by_self' }
 29    | { readonly kind: 'blocked'; readonly by: string }
 30  
 31  const FRESH: AcquireResult = { kind: 'acquired', fresh: true }
 32  const REENTRANT: AcquireResult = { kind: 'acquired', fresh: false }
 33  
 34  function 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  
 44  function getLockPath(): string {
 45    return join(getClaudeConfigHomeDir(), LOCK_FILENAME)
 46  }
 47  
 48  async 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   */
 65  function 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   */
 79  async 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   */
 94  function 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   */
110  export 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   */
130  export 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   */
148  export 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   */
202  export 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  }