From 05b78b8f2fb92ea1f814119d9dfb85fa418bd880 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 11:04:48 -0700 Subject: [PATCH] fix: time out process cwd lsof lookup (#3809) --- src/main/providers/process-cwd.test.ts | 34 +++++++++++++++- src/main/providers/process-cwd.ts | 54 ++++++++++++++++++++++---- 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/src/main/providers/process-cwd.test.ts b/src/main/providers/process-cwd.test.ts index 84a958c68a2..d0b2e8f0830 100644 --- a/src/main/providers/process-cwd.test.ts +++ b/src/main/providers/process-cwd.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { execFileMock, readlinkMock } = vi.hoisted(() => ({ execFileMock: vi.fn(), @@ -26,6 +26,10 @@ describe('resolveProcessCwd', () => { }) }) + afterEach(() => { + vi.useRealTimers() + }) + it('bounds cached cwd results across unique process ids', async () => { const { resolveProcessCwd } = await import('./process-cwd') @@ -41,4 +45,32 @@ describe('resolveProcessCwd', () => { await expect(resolveProcessCwd(1)).resolves.toBe('/cwd/1') expect(readlinkMock).toHaveBeenCalledTimes(258) }) + + it('falls back when lsof never reports completion', async () => { + vi.useFakeTimers() + readlinkMock.mockRejectedValue(new Error('proc unavailable')) + const killMock = vi.fn() + execFileMock.mockImplementation(() => ({ kill: killMock })) + const { resolveProcessCwd } = await import('./process-cwd') + + let settled = false + const cwdPromise = resolveProcessCwd(42).then((cwd) => { + settled = true + return cwd + }) + + await vi.waitFor(() => + expect(execFileMock).toHaveBeenCalledWith( + 'lsof', + ['-a', '-p', '42', '-d', 'cwd', '-Fn'], + { encoding: 'utf-8', timeout: 1500 }, + expect.any(Function) + ) + ) + await vi.advanceTimersByTimeAsync(1500) + + expect(settled).toBe(true) + await expect(cwdPromise).resolves.toBe('') + expect(killMock).toHaveBeenCalled() + }) }) diff --git a/src/main/providers/process-cwd.ts b/src/main/providers/process-cwd.ts index 9df14b6f393..dc70edca089 100644 --- a/src/main/providers/process-cwd.ts +++ b/src/main/providers/process-cwd.ts @@ -1,8 +1,5 @@ import { execFile as execFileCb } from 'child_process' import { readlink } from 'fs/promises' -import { promisify } from 'util' - -const execFile = promisify(execFileCb) /** * Resolve the current working directory of a local process by pid. @@ -92,10 +89,7 @@ async function doResolve(pid: number): Promise { // and emits cwd records for every process on the system, so the n-line // scan below picks up the first unrelated process (often pid ~391 with // cwd `/`) and returns `/` regardless of the target pid's real cwd. - const { stdout } = await execFile('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { - encoding: 'utf-8', - timeout: LSOF_TIMEOUT_MS - }) + const stdout = await readCwdWithLsof(pid) for (const line of stdout.split('\n')) { if (line.startsWith('n') && line.includes('/')) { // Why: lsof -d cwd is authoritative — don't second-guess it with @@ -111,3 +105,49 @@ async function doResolve(pid: number): Promise { return '' } + +function readCwdWithLsof(pid: number): Promise { + return new Promise((resolve, reject) => { + let settled = false + let child: ReturnType | undefined + const timer = setTimeout(() => { + if (settled) { + return + } + settled = true + child?.kill() + reject(new Error(`lsof timed out after ${LSOF_TIMEOUT_MS}ms`)) + }, LSOF_TIMEOUT_MS) + + const settle = (callback: () => void): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + callback() + } + + // Why: execFile's timeout only signals lsof; a missing callback would + // otherwise leave the shared per-pid cwd lookup promise cached forever. + try { + child = execFileCb( + 'lsof', + ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], + { + encoding: 'utf-8', + timeout: LSOF_TIMEOUT_MS + }, + (error, stdout) => { + if (error) { + settle(() => reject(error)) + return + } + settle(() => resolve(String(stdout))) + } + ) + } catch (error) { + settle(() => reject(error)) + } + }) +}