From 8fe2abdb24a306b258746bdb06cfab1f963b2aa2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 10:45:11 -0700 Subject: [PATCH] fix: time out automation path probes (#3797) --- src/main/automations/external-manager.test.ts | 51 ++++++++++++++++++- src/main/automations/external-manager.ts | 38 +++++++++++++- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/main/automations/external-manager.test.ts b/src/main/automations/external-manager.test.ts index 05b54874eb1..6f78f35d337 100644 --- a/src/main/automations/external-manager.test.ts +++ b/src/main/automations/external-manager.test.ts @@ -1,13 +1,16 @@ /* eslint-disable max-lines -- Why: external automation mapping and lifecycle IPC share fixtures. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createExternalAutomation, + listExternalAutomationManagers, listExternalAutomationRuns, runExternalAutomationAction, updateExternalAutomation } from './external-manager' import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers' import { getActiveMultiplexer } from '../ipc/ssh' +import type { Store } from '../persistence' +import type * as Fs from 'fs' const execFileMock = vi.hoisted(() => vi.fn((...args: unknown[]) => { @@ -16,20 +19,64 @@ const execFileMock = vi.hoisted(() => const execCallback = callback as (error: Error | null, stdout: string, stderr: string) => void execCallback(null, '', '') } + return { kill: vi.fn() } }) ) +const existsSyncMock = vi.hoisted(() => vi.fn(() => false)) + +function resolveExecFileMock(...args: unknown[]) { + const callback = args.at(-1) + if (typeof callback === 'function') { + const execCallback = callback as (error: Error | null, stdout: string, stderr: string) => void + execCallback(null, '', '') + } + return { kill: vi.fn() } +} vi.mock('child_process', () => ({ execFile: execFileMock })) +vi.mock('fs', async () => { + const actual = await vi.importActual('fs') + return { + ...actual, + existsSync: existsSyncMock + } +}) vi.mock('../ipc/ssh', () => ({ getActiveMultiplexer: vi.fn() })) beforeEach(() => { - execFileMock.mockClear() + execFileMock.mockReset() + execFileMock.mockImplementation(resolveExecFileMock) + existsSyncMock.mockReturnValue(false) vi.mocked(getActiveMultiplexer).mockReset() }) +afterEach(() => { + vi.useRealTimers() +}) + +describe('listExternalAutomationManagers', () => { + it('settles when a local command lookup hangs', async () => { + vi.useFakeTimers() + execFileMock.mockImplementation(() => ({ kill: vi.fn() })) + const promise = listExternalAutomationManagers({ + getSshTargets: () => [] + } as unknown as Store) + let settled = false + void promise.finally(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(5_000) + await Promise.resolve() + + expect(settled).toBe(true) + await expect(promise).resolves.toEqual([]) + }) +}) + describe('mapHermesJobs', () => { it('normalizes Hermes cron jobs into external automation rows', () => { const jobs = mapHermesJobs('hermes:local', [ diff --git a/src/main/automations/external-manager.ts b/src/main/automations/external-manager.ts index 08c3e208cf6..9918b875c2b 100644 --- a/src/main/automations/external-manager.ts +++ b/src/main/automations/external-manager.ts @@ -31,6 +31,7 @@ const HERMES_CRON_DIR = join(HERMES_HOME, 'cron') const HERMES_JOBS_FILE = join(HERMES_CRON_DIR, 'jobs.json') const OPENCLAW_JOBS_FILE = join(homedir(), '.openclaw', 'cron', 'jobs.json') const EXTERNAL_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ +const LOCAL_COMMAND_LOOKUP_TIMEOUT_MS = 5_000 function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -39,13 +40,48 @@ function isRecord(value: unknown): value is Record { async function isCommandOnPath(command: string): Promise { const finder = process.platform === 'win32' ? 'where' : 'which' try { - await execFileAsync(finder, [command], { encoding: 'utf-8' }) + await runLocalCommandLookup(finder, [command]) return true } catch { return false } } +function runLocalCommandLookup(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + let child: ReturnType | null = null + let settled = false + + const finish = (error: Error | null): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + if (error) { + reject(error) + return + } + resolve() + } + + // Why: these probes run while loading Automations; a wedged PATH shim must + // not keep the list IPC pending forever. + const timeout = setTimeout(() => { + child?.kill() + finish(new Error(`Command lookup timed out after ${LOCAL_COMMAND_LOOKUP_TIMEOUT_MS}ms.`)) + }, LOCAL_COMMAND_LOOKUP_TIMEOUT_MS) + + try { + child = execFile(command, args, { encoding: 'utf-8' }, (error) => { + finish(error ?? null) + }) + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + } + }) +} + async function readLocalHermesJobs(): Promise { if (!existsSync(HERMES_JOBS_FILE)) { return []