/ utils / computerUse / drainRunLoop.ts
drainRunLoop.ts
 1  import { logForDebugging } from '../debug.js'
 2  import { withResolvers } from '../withResolvers.js'
 3  import { requireComputerUseSwift } from './swiftLoader.js'
 4  
 5  /**
 6   * Shared CFRunLoop pump. Swift's four `@MainActor` async methods
 7   * (captureExcluding, captureRegion, apps.listInstalled, resolvePrepareCapture)
 8   * and `@ant/computer-use-input`'s key()/keys() all dispatch to
 9   * DispatchQueue.main. Under libuv (Node/bun) that queue never drains — the
10   * promises hang. Electron drains it via CFRunLoop so Cowork doesn't need this.
11   *
12   * One refcounted setInterval calls `_drainMainRunLoop` (RunLoop.main.run)
13   * every 1ms while any main-queue-dependent call is pending. Multiple
14   * concurrent drainRunLoop() calls share the single pump via retain/release.
15   */
16  
17  let pump: ReturnType<typeof setInterval> | undefined
18  let pending = 0
19  
20  function drainTick(cu: ReturnType<typeof requireComputerUseSwift>): void {
21    cu._drainMainRunLoop()
22  }
23  
24  function retain(): void {
25    pending++
26    if (pump === undefined) {
27      pump = setInterval(drainTick, 1, requireComputerUseSwift())
28      logForDebugging('[drainRunLoop] pump started', { level: 'verbose' })
29    }
30  }
31  
32  function release(): void {
33    pending--
34    if (pending <= 0 && pump !== undefined) {
35      clearInterval(pump)
36      pump = undefined
37      logForDebugging('[drainRunLoop] pump stopped', { level: 'verbose' })
38      pending = 0
39    }
40  }
41  
42  const TIMEOUT_MS = 30_000
43  
44  function timeoutReject(reject: (e: Error) => void): void {
45    reject(new Error(`computer-use native call exceeded ${TIMEOUT_MS}ms`))
46  }
47  
48  /**
49   * Hold a pump reference for the lifetime of a long-lived registration
50   * (e.g. the CGEventTap Escape handler). Unlike `drainRunLoop(fn)` this has
51   * no timeout — the caller is responsible for calling `releasePump()`. Same
52   * refcount as drainRunLoop calls, so nesting is safe.
53   */
54  export const retainPump = retain
55  export const releasePump = release
56  
57  /**
58   * Await `fn()` with the shared drain pump running. Safe to nest — multiple
59   * concurrent drainRunLoop() calls share one setInterval.
60   */
61  export async function drainRunLoop<T>(fn: () => Promise<T>): Promise<T> {
62    retain()
63    let timer: ReturnType<typeof setTimeout> | undefined
64    try {
65      // If the timeout wins the race, fn()'s promise is orphaned — a late
66      // rejection from the native layer would become an unhandledRejection.
67      // Attaching a no-op catch swallows it; the timeout error is what surfaces.
68      // fn() sits inside try so a synchronous throw (e.g. NAPI argument
69      // validation) still reaches release() — otherwise the pump leaks.
70      const work = fn()
71      work.catch(() => {})
72      const timeout = withResolvers<never>()
73      timer = setTimeout(timeoutReject, TIMEOUT_MS, timeout.reject)
74      return await Promise.race([work, timeout.promise])
75    } finally {
76      clearTimeout(timer)
77      release()
78    }
79  }