diff --git a/src/main/services/TaskLifecycleService.ts b/src/main/services/TaskLifecycleService.ts index adaf2ad5f..d20c62195 100644 --- a/src/main/services/TaskLifecycleService.ts +++ b/src/main/services/TaskLifecycleService.ts @@ -1,5 +1,4 @@ import { EventEmitter } from 'node:events'; -import { spawn, type ChildProcess } from 'node:child_process'; import path from 'node:path'; import { promisify } from 'node:util'; import { lifecycleScriptsService } from './LifecycleScriptsService'; @@ -15,6 +14,7 @@ import { import { getTaskEnvVars } from '@shared/task/envVars'; import { log } from '../lib/logger'; import { execFile } from 'node:child_process'; +import { startLifecyclePty, type LifecyclePtyHandle } from './ptyManager'; const execFileAsync = promisify(execFile); @@ -27,8 +27,8 @@ type LifecycleResult = { class TaskLifecycleService extends EventEmitter { private states = new Map(); private logBuffers = new Map(); - private runProcesses = new Map(); - private finiteProcesses = new Map>(); + private runPtys = new Map(); + private finitePtys = new Map>(); private runStartInflight = new Map>(); private setupInflight = new Map>(); private teardownInflight = new Map>(); @@ -42,42 +42,63 @@ class TaskLifecycleService extends EventEmitter { return `${taskId}::${taskPath}`; } - private killProcessTree(proc: ChildProcess, signal: NodeJS.Signals): void { - const pid = proc.pid; - if (!pid) return; - - if (process.platform === 'win32') { - const args = ['/PID', String(pid), '/T']; - if (signal === 'SIGKILL') { - args.push('/F'); - } - const killer = spawn('taskkill', args, { stdio: 'ignore' }); - killer.unref(); - return; - } - - try { - // Detached shell commands run as their own process group. - process.kill(-pid, signal); - } catch { - proc.kill(signal); - } - } - - private trackFiniteProcess(taskId: string, proc: ChildProcess): () => void { - const set = this.finiteProcesses.get(taskId) ?? new Set(); - set.add(proc); - this.finiteProcesses.set(taskId, set); + private trackFinitePty(taskId: string, pty: LifecyclePtyHandle): () => void { + const set = this.finitePtys.get(taskId) ?? new Set(); + set.add(pty); + this.finitePtys.set(taskId, set); return () => { - const current = this.finiteProcesses.get(taskId); + const current = this.finitePtys.get(taskId); if (!current) return; - current.delete(proc); + current.delete(pty); if (current.size === 0) { - this.finiteProcesses.delete(taskId); + this.finitePtys.delete(taskId); } }; } + private createLifecyclePty( + id: string, + script: string, + cwd: string, + env: NodeJS.ProcessEnv + ): LifecyclePtyHandle { + return startLifecyclePty({ + id, + command: script, + cwd, + env, + }); + } + + private waitForPtyExit( + handle: LifecyclePtyHandle, + isTracked: () => boolean, + timeoutMs: number, + timeoutMessage: string + ): Promise { + if (!isTracked()) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + log.warn(timeoutMessage); + finish(); + }, timeoutMs); + handle.onExit(() => finish()); + if (!isTracked()) { + finish(); + } + }); + } + private async resolveDefaultBranch(projectPath: string): Promise { try { const { stdout } = await execFileAsync( @@ -227,20 +248,19 @@ class TaskLifecycleService extends EventEmitter { }; try { const env = await this.buildLifecycleEnv(taskId, taskPath, projectPath, taskName); - const child = spawn(script, { - cwd: taskPath, - shell: true, - env, - detached: true, - }); - const untrackFinite = this.trackFiniteProcess(taskId, child); - const onData = (buf: Buffer) => { - const line = buf.toString(); + const pty = this.createLifecyclePty( + `lifecycle-${phase}-${taskId}`, + script, + taskPath, + env + ); + const untrackFinite = this.trackFinitePty(taskId, pty); + pty.onData((line) => { + if (!this.finitePtys.get(taskId)?.has(pty)) return; this.emitLifecycleEvent(taskId, phase, 'line', { line }); - }; - child.stdout?.on('data', onData); - child.stderr?.on('data', onData); - child.on('error', (error) => { + }); + pty.onError((error) => { + if (!this.finitePtys.get(taskId)?.has(pty)) return; untrackFinite(); const message = error?.message || String(error); this.emitLifecycleEvent(taskId, phase, 'error', { error: message }); @@ -255,7 +275,8 @@ class TaskLifecycleService extends EventEmitter { } ); }); - child.on('exit', (code) => { + pty.onExit((code) => { + if (!this.finitePtys.get(taskId)?.has(pty)) return; untrackFinite(); const ok = code === 0; this.emitLifecycleEvent(taskId, phase, ok ? 'done' : 'error', { @@ -347,13 +368,8 @@ class TaskLifecycleService extends EventEmitter { const script = lifecycleScriptsService.getScript(projectPath, 'run'); if (!script) return { ok: true, skipped: true }; - const existing = this.runProcesses.get(taskId); - if ( - existing && - existing.exitCode === null && - !existing.killed && - !this.stopIntents.has(taskId) - ) { + const existing = this.runPtys.get(taskId); + if (existing && !this.stopIntents.has(taskId)) { return { ok: true, skipped: true }; } @@ -373,24 +389,17 @@ class TaskLifecycleService extends EventEmitter { try { const env = await this.buildLifecycleEnv(taskId, taskPath, projectPath, taskName); - const child = spawn(script, { - cwd: taskPath, - shell: true, - env, - detached: true, - }); - this.runProcesses.set(taskId, child); - state.run.pid = child.pid ?? null; + const pty = this.createLifecyclePty(`lifecycle-run-${taskId}`, script, taskPath, env); + this.runPtys.set(taskId, pty); + state.run.pid = pty.pid; - const onData = (buf: Buffer) => { - const line = buf.toString(); + pty.onData((line) => { + if (this.runPtys.get(taskId) !== pty) return; this.emitLifecycleEvent(taskId, 'run', 'line', { line }); - }; - child.stdout?.on('data', onData); - child.stderr?.on('data', onData); - child.on('error', (error) => { - if (this.runProcesses.get(taskId) !== child) return; - this.runProcesses.delete(taskId); + }); + pty.onError((error) => { + if (this.runPtys.get(taskId) !== pty) return; + this.runPtys.delete(taskId); this.stopIntents.delete(taskId); const message = error?.message || String(error); const cur = this.ensureState(taskId); @@ -402,9 +411,9 @@ class TaskLifecycleService extends EventEmitter { }; this.emitLifecycleEvent(taskId, 'run', 'error', { error: message }); }); - child.on('exit', (code) => { - if (this.runProcesses.get(taskId) !== child) return; - this.runProcesses.delete(taskId); + pty.onExit((code) => { + if (this.runPtys.get(taskId) !== pty) return; + this.runPtys.delete(taskId); const wasStopped = this.stopIntents.has(taskId); this.stopIntents.delete(taskId); const cur = this.ensureState(taskId); @@ -435,16 +444,18 @@ class TaskLifecycleService extends EventEmitter { } stopRun(taskId: string): LifecycleResult { - const proc = this.runProcesses.get(taskId); - if (!proc) return { ok: true, skipped: true }; + const pty = this.runPtys.get(taskId); + if (!pty) return { ok: true, skipped: true }; this.stopIntents.add(taskId); try { - this.killProcessTree(proc, 'SIGTERM'); + pty.kill(); setTimeout(() => { - const current = this.runProcesses.get(taskId); - if (!current || current !== proc) return; - this.killProcessTree(proc, 'SIGKILL'); + const current = this.runPtys.get(taskId); + if (!current || current !== pty) return; + try { + current.kill('SIGKILL'); + } catch {} }, 8_000); return { ok: true }; } catch (error) { @@ -480,25 +491,16 @@ class TaskLifecycleService extends EventEmitter { } // Ensure a managed run process is stopped before teardown starts. - const existingRun = this.runProcesses.get(taskId); + const existingRun = this.runPtys.get(taskId); if (existingRun) { + const waitForExit = this.waitForPtyExit( + existingRun, + () => this.runPtys.get(taskId) === existingRun, + 10_000, + 'Timed out waiting for run process to exit before teardown' + ); this.stopRun(taskId); - await new Promise((resolve) => { - let done = false; - const finish = () => { - if (done) return; - done = true; - resolve(); - }; - const timer = setTimeout(() => { - log.warn('Timed out waiting for run process to exit before teardown', { taskId }); - finish(); - }, 10_000); - existingRun.once('exit', () => { - clearTimeout(timer); - finish(); - }); - }); + await waitForExit; } return this.runFinite(taskId, taskPath, projectPath, 'teardown', taskName); })().finally(() => { @@ -537,41 +539,45 @@ class TaskLifecycleService extends EventEmitter { } } - const proc = this.runProcesses.get(taskId); - if (proc) { + const pty = this.runPtys.get(taskId); + if (pty) { + this.runPtys.delete(taskId); try { - this.killProcessTree(proc, 'SIGTERM'); + pty.kill(); } catch {} - this.runProcesses.delete(taskId); } - const finite = this.finiteProcesses.get(taskId); + const finite = this.finitePtys.get(taskId); if (finite) { - for (const child of finite) { + this.finitePtys.delete(taskId); + for (const handle of finite) { try { - this.killProcessTree(child, 'SIGTERM'); + handle.kill(); } catch {} } - this.finiteProcesses.delete(taskId); } } shutdown(): void { - for (const [taskId, proc] of this.runProcesses.entries()) { + const runPtys = [...this.runPtys.entries()]; + const finitePtys = [...this.finitePtys.values()]; + + this.runPtys.clear(); + this.finitePtys.clear(); + + for (const [taskId, pty] of runPtys) { try { this.stopIntents.add(taskId); - this.killProcessTree(proc, 'SIGTERM'); + pty.kill(); } catch {} } - for (const procs of this.finiteProcesses.values()) { - for (const proc of procs) { + for (const handles of finitePtys) { + for (const handle of handles) { try { - this.killProcessTree(proc, 'SIGTERM'); + handle.kill(); } catch {} } } - this.runProcesses.clear(); - this.finiteProcesses.clear(); this.runStartInflight.clear(); this.setupInflight.clear(); this.teardownInflight.clear(); diff --git a/src/main/services/ptyManager.ts b/src/main/services/ptyManager.ts index c3a4f7ef3..ed0b976ca 100644 --- a/src/main/services/ptyManager.ts +++ b/src/main/services/ptyManager.ts @@ -1600,3 +1600,164 @@ export function getPtyKind(id: string): 'local' | 'ssh' | undefined { export function getPtyTmuxSessionName(id: string): string | undefined { return ptys.get(id)?.tmuxSessionName; } + +export interface LifecyclePtyHandle { + pid: number | null; + onData: (callback: (data: string) => void) => void; + onExit: (callback: (exitCode: number | null, signal: string | null) => void) => void; + onError: (callback: (error: Error) => void) => void; + kill: (signal?: string) => void; +} + +function startLifecycleSpawnFallback(options: { + id: string; + command: string; + cwd: string; + env?: NodeJS.ProcessEnv; +}): LifecyclePtyHandle { + const { spawn } = require('node:child_process') as typeof import('node:child_process'); + const { command, cwd, env } = options; + + const child = spawn(command, { + cwd: cwd || os.homedir(), + shell: true, + detached: true, + env: { ...process.env, ...(env || {}) }, + }); + + const dataCallbacks: Array<(data: string) => void> = []; + const exitCallbacks: Array<(exitCode: number | null, signal: string | null) => void> = []; + const errorCallbacks: Array<(error: Error) => void> = []; + + const onData = (buf: Buffer) => { + const str = buf.toString(); + for (const cb of dataCallbacks) cb(str); + }; + child.stdout?.on('data', onData); + child.stderr?.on('data', onData); + + child.on('error', (error: Error) => { + for (const cb of errorCallbacks) cb(error); + }); + + child.on('exit', (code, signal) => { + for (const cb of exitCallbacks) cb(code, signal ?? null); + }); + + return { + pid: child.pid ?? null, + onData: (cb) => dataCallbacks.push(cb), + onExit: (cb) => exitCallbacks.push(cb), + onError: (cb) => errorCallbacks.push(cb), + kill: (signal?: string) => { + const pid = child.pid; + if (!pid) return; + try { + if (process.platform === 'win32') { + const args = ['/PID', String(pid), '/T']; + if (signal === 'SIGKILL') args.push('/F'); + spawn('taskkill', args, { stdio: 'ignore' }).unref(); + } else { + process.kill(-pid, (signal as NodeJS.Signals) || 'SIGTERM'); + } + } catch { + try { + child.kill((signal as NodeJS.Signals) || 'SIGTERM'); + } catch {} + } + }, + }; +} + +export function startLifecyclePty(options: { + id: string; + command: string; + cwd: string; + env?: NodeJS.ProcessEnv; +}): LifecyclePtyHandle { + if (process.env.EMDASH_DISABLE_PTY === '1') { + return startLifecycleSpawnFallback(options); + } + + let pty: typeof import('node-pty'); + try { + pty = require('node-pty'); + } catch { + return startLifecycleSpawnFallback(options); + } + + const { id, command, cwd, env } = options; + const defaultShell = getDefaultShell(); + + const useEnv: Record = { + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + TERM_PROGRAM: 'emdash', + HOME: process.env.HOME || os.homedir(), + USER: process.env.USER || os.userInfo().username, + SHELL: process.env.SHELL || defaultShell, + ...(process.platform === 'win32' ? getWindowsEssentialEnv() : {}), + ...(process.env.LANG && { LANG: process.env.LANG }), + ...(process.env.TMPDIR && { TMPDIR: process.env.TMPDIR }), + ...(process.env.DISPLAY && { DISPLAY: process.env.DISPLAY }), + ...getDisplayEnv(), + ...(process.env.SSH_AUTH_SOCK && { SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK }), + ...(env || {}), + }; + + const proc = pty.spawn(defaultShell, ['-ilc', command], { + name: 'xterm-256color', + cols: 120, + rows: 32, + cwd: cwd || os.homedir(), + env: useEnv, + }); + + const dataCallbacks: Array<(data: string) => void> = []; + const exitCallbacks: Array<(exitCode: number | null, signal: string | null) => void> = []; + const errorCallbacks: Array<(error: Error) => void> = []; + + proc.onData((data: string) => { + for (const cb of dataCallbacks) { + cb(data); + } + }); + + proc.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => { + ptys.delete(id); + for (const cb of exitCallbacks) { + cb(exitCode, signal != null ? String(signal) : null); + } + }); + + // node-pty generally throws on startup failures instead of emitting an error event, + // but keep the same interface as the child_process fallback. + const emitError = (error: unknown) => { + const normalized = error instanceof Error ? error : new Error(String(error)); + for (const cb of errorCallbacks) { + cb(normalized); + } + }; + + try { + const maybeProc = proc as IPty & { + on?: (event: string, listener: (error: unknown) => void) => void; + }; + maybeProc.on?.('error', emitError); + } catch {} + + ptys.set(id, { id, proc, cwd, kind: 'local', cols: 120, rows: 32 }); + + return { + pid: typeof proc.pid === 'number' ? proc.pid : null, + onData: (cb) => dataCallbacks.push(cb), + onExit: (cb) => exitCallbacks.push(cb), + onError: (cb) => errorCallbacks.push(cb), + kill: (signal?: string) => { + ptys.delete(id); + try { + proc.kill(signal); + } catch {} + }, + }; +} diff --git a/src/renderer/components/LifecycleTerminalView.tsx b/src/renderer/components/LifecycleTerminalView.tsx new file mode 100644 index 000000000..62feb8749 --- /dev/null +++ b/src/renderer/components/LifecycleTerminalView.tsx @@ -0,0 +1,241 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Terminal, type ITerminalOptions } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { WebLinksAddon } from '@xterm/addon-web-links'; +import { rpc } from '@/lib/rpc'; +import { log } from '../lib/logger'; + +const FALLBACK_FONTS = 'Menlo, Monaco, Courier New, monospace'; +const DEFAULT_FONT_SIZE = 13; + +type ThemeOverride = NonNullable & { + fontFamily?: string; + fontSize?: number; +}; + +interface Props { + content: string; + variant: 'dark' | 'light'; + themeOverride?: ThemeOverride; + className?: string; +} + +export const LifecycleTerminalView: React.FC = ({ + content, + variant, + themeOverride, + className, +}) => { + const containerRef = useRef(null); + const terminalRef = useRef(null); + const fitAddonRef = useRef(null); + const contentRef = useRef(''); + const [customFontFamily, setCustomFontFamily] = useState(''); + const [customFontSize, setCustomFontSize] = useState(0); + + const resolvedTheme = useMemo>(() => { + const selection = + variant === 'light' + ? { + selectionBackground: 'rgba(59, 130, 246, 0.35)', + selectionForeground: '#0f172a', + } + : { + selectionBackground: 'rgba(96, 165, 250, 0.35)', + selectionForeground: '#f9fafb', + }; + const base = + variant === 'light' + ? { + background: '#ffffff', + foreground: '#1f2933', + cursor: '#1f2933', + ...selection, + } + : { + background: '#1f2937', + foreground: '#f9fafb', + cursor: '#f9fafb', + ...selection, + }; + + const mergedTheme = { ...(themeOverride || {}) }; + delete mergedTheme.fontFamily; + delete mergedTheme.fontSize; + + return { ...base, ...mergedTheme }; + }, [themeOverride, variant]); + + const effectiveFontFamily = useMemo(() => { + const configuredFont = customFontFamily || themeOverride?.fontFamily?.trim() || ''; + return configuredFont ? `${configuredFont}, ${FALLBACK_FONTS}` : FALLBACK_FONTS; + }, [customFontFamily, themeOverride?.fontFamily]); + + const effectiveFontSize = useMemo(() => { + if (customFontSize >= 8 && customFontSize <= 24) { + return customFontSize; + } + if (typeof themeOverride?.fontSize === 'number' && themeOverride.fontSize > 0) { + return themeOverride.fontSize; + } + return DEFAULT_FONT_SIZE; + }, [customFontSize, themeOverride?.fontSize]); + + useEffect(() => { + rpc.appSettings + .get() + .then((settings) => { + setCustomFontFamily(settings?.terminal?.fontFamily?.trim() ?? ''); + const size = settings?.terminal?.fontSize; + if ((typeof size === 'number' && size >= 8 && size <= 24) || size === 0) { + setCustomFontSize(size); + } + }) + .catch((error) => { + log.warn('Failed to load terminal settings for lifecycle terminal', { error }); + }); + + const handleFontChange = (event: Event) => { + const detail = (event as CustomEvent<{ fontFamily?: string }>).detail; + setCustomFontFamily(detail?.fontFamily?.trim() ?? ''); + }; + + const handleFontSizeChange = (event: Event) => { + const detail = (event as CustomEvent<{ fontSize?: number }>).detail; + const size = detail?.fontSize; + if ((typeof size === 'number' && size >= 8 && size <= 24) || size === 0) { + setCustomFontSize(size); + } + }; + + window.addEventListener('terminal-font-changed', handleFontChange); + window.addEventListener('terminal-font-size-changed', handleFontSizeChange); + return () => { + window.removeEventListener('terminal-font-changed', handleFontChange); + window.removeEventListener('terminal-font-size-changed', handleFontSizeChange); + }; + }, []); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const terminal = new Terminal({ + allowProposedApi: true, + convertEol: true, + cursorBlink: false, + disableStdin: true, + fontFamily: effectiveFontFamily, + fontSize: effectiveFontSize, + lineHeight: 1.2, + scrollback: 10_000, + theme: resolvedTheme, + }); + terminal.attachCustomKeyEventHandler(() => false); + + const fitAddon = new FitAddon(); + const webLinksAddon = new WebLinksAddon((event, uri) => { + event.preventDefault(); + window.electronAPI.openExternal(uri).catch((error) => { + log.warn('Failed to open lifecycle terminal link', { uri, error }); + }); + }); + + terminal.loadAddon(fitAddon); + terminal.loadAddon(webLinksAddon); + terminal.open(container); + + terminalRef.current = terminal; + fitAddonRef.current = fitAddon; + contentRef.current = ''; + + const resizeObserver = new ResizeObserver(() => { + try { + fitAddon.fit(); + } catch (error) { + log.warn('Failed to fit lifecycle terminal', { error }); + } + }); + resizeObserver.observe(container); + + requestAnimationFrame(() => { + try { + fitAddon.fit(); + } catch (error) { + log.warn('Failed to fit lifecycle terminal on mount', { error }); + } + }); + + return () => { + resizeObserver.disconnect(); + fitAddonRef.current = null; + terminalRef.current = null; + terminal.dispose(); + }; + }, []); + + useEffect(() => { + const terminal = terminalRef.current; + if (!terminal) return; + + terminal.options.theme = resolvedTheme; + terminal.options.fontFamily = effectiveFontFamily; + terminal.options.fontSize = effectiveFontSize; + + requestAnimationFrame(() => { + try { + fitAddonRef.current?.fit(); + } catch (error) { + log.warn('Failed to refit lifecycle terminal after theme change', { error }); + } + }); + }, [effectiveFontFamily, effectiveFontSize, resolvedTheme]); + + useEffect(() => { + const terminal = terminalRef.current; + if (!terminal) return; + if (content === contentRef.current) return; + + const previous = contentRef.current; + contentRef.current = content; + + if (previous && content.startsWith(previous)) { + const appended = content.slice(previous.length); + if (!appended) return; + terminal.write(appended, () => terminal.scrollToBottom()); + return; + } + + terminal.reset(); + if (content) { + terminal.write(content, () => terminal.scrollToBottom()); + } + }, [content]); + + return ( +
+
+
+ ); +}; + +export default LifecycleTerminalView; diff --git a/src/renderer/components/TaskTerminalPanel.tsx b/src/renderer/components/TaskTerminalPanel.tsx index d8f6b3219..8cb44c01d 100644 --- a/src/renderer/components/TaskTerminalPanel.tsx +++ b/src/renderer/components/TaskTerminalPanel.tsx @@ -1,5 +1,6 @@ import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react'; import { TerminalPane } from './TerminalPane'; +import { LifecycleTerminalView } from './LifecycleTerminalView'; import { Plus, Play, RotateCw, Square, X } from 'lucide-react'; import { useTheme } from '../hooks/useTheme'; import { useTaskTerminals } from '@/lib/taskTerminalsStore'; @@ -245,6 +246,13 @@ const TaskTerminalPanelComponent: React.FC = ({ }, [runStatus, selection.onChange]); const totalTerminals = taskTerminals.terminals.length + globalTerminals.terminals.length; + + const lifecycleLogContent = useMemo(() => { + if (!selection.selectedLifecycle) return ''; + const content = lifecycleLogs[selection.selectedLifecycle].join(''); + return content || 'No lifecycle output yet.'; + }, [lifecycleLogs, selection.selectedLifecycle]); + const hasActiveTerminal = !selection.selectedLifecycle && !!selection.activeTerminalId; const canStartRun = @@ -690,9 +698,15 @@ const TaskTerminalPanelComponent: React.FC = ({ ? `Teardown status: ${teardownStatus}` : `Run status: ${runStatus}`}
-
-            {lifecycleLogs[selection.selectedLifecycle].join('') || 'No lifecycle output yet.'}
-          
+ ) : (
boolean; + onData: (callback: (data: string) => void) => void; + onExit: (callback: (exitCode: number | null, signal: string | null) => void) => void; + onError: (callback: (error: Error) => void) => void; + kill: () => void; + emitData: (data: string) => void; + emitExit: (exitCode: number | null, signal?: string | null) => void; + emitError: (error: Error) => void; }; -const spawnMock = vi.fn(); const execFileMock = vi.fn(); const getScriptMock = vi.fn(); +const startLifecyclePtyMock = vi.fn(); vi.mock('node:child_process', () => ({ - spawn: (...args: any[]) => spawnMock(...args), execFile: (...args: any[]) => execFileMock(...args), })); @@ -25,6 +26,10 @@ vi.mock('../../main/services/LifecycleScriptsService', () => ({ }, })); +vi.mock('../../main/services/ptyManager', () => ({ + startLifecyclePty: (...args: any[]) => startLifecyclePtyMock(...args), +})); + vi.mock('../../main/lib/logger', () => ({ log: { info: vi.fn(), @@ -34,26 +39,52 @@ vi.mock('../../main/lib/logger', () => ({ }, })); -function createChild(pid: number, killImpl?: (signal?: NodeJS.Signals) => boolean): MockChild { - const child = new EventEmitter() as MockChild; - child.stdout = new EventEmitter(); - child.stderr = new EventEmitter(); - child.pid = pid; - child.exitCode = null; - child.killed = false; - child.kill = (signal?: NodeJS.Signals) => { - child.killed = true; - if (killImpl) return killImpl(signal); - return true; +function createLifecyclePty( + pid: number, + options?: { + killImpl?: () => void; + } +): MockLifecyclePtyHandle { + const dataCallbacks: Array<(data: string) => void> = []; + const exitCallbacks: Array<(exitCode: number | null, signal: string | null) => void> = []; + const errorCallbacks: Array<(error: Error) => void> = []; + const handle: MockLifecyclePtyHandle = { + pid, + killed: false, + onData: (callback) => dataCallbacks.push(callback), + onExit: (callback) => exitCallbacks.push(callback), + onError: (callback) => errorCallbacks.push(callback), + kill: () => { + if (options?.killImpl) { + options.killImpl(); + return; + } + handle.killed = true; + }, + emitData: (data) => { + for (const callback of dataCallbacks) { + callback(data); + } + }, + emitExit: (exitCode, signal = null) => { + for (const callback of exitCallbacks) { + callback(exitCode, signal); + } + }, + emitError: (error) => { + for (const callback of errorCallbacks) { + callback(error); + } + }, }; - return child; + + return handle; } describe('TaskLifecycleService', () => { beforeEach(() => { vi.clearAllMocks(); - // Return default branch asynchronously to surface races around awaits. execFileMock.mockImplementation((_: any, __: any, ___: any, cb: any) => { setTimeout(() => cb(null, 'origin/main\n', ''), 10); }); @@ -64,11 +95,11 @@ describe('TaskLifecycleService', () => { }); }); - it('dedupes concurrent startRun calls so only one process spawns', async () => { + it('dedupes concurrent startRun calls so only one PTY starts', async () => { vi.resetModules(); - const child = createChild(1001); - spawnMock.mockReturnValue(child); + const handle = createLifecyclePty(1001); + startLifecyclePtyMock.mockReturnValue(handle); const { taskLifecycleService } = await import('../../main/services/TaskLifecycleService'); @@ -83,16 +114,18 @@ describe('TaskLifecycleService', () => { expect(a.ok).toBe(true); expect(b.ok).toBe(true); - expect(spawnMock).toHaveBeenCalledTimes(1); + expect(startLifecyclePtyMock).toHaveBeenCalledTimes(1); }); it('does not leave stop intent set when stopRun fails', async () => { vi.resetModules(); - const child = createChild(1002, () => { - throw new Error('kill failed'); + const handle = createLifecyclePty(1002, { + killImpl: () => { + throw new Error('kill failed'); + }, }); - spawnMock.mockReturnValue(child); + startLifecyclePtyMock.mockReturnValue(handle); const { taskLifecycleService } = await import('../../main/services/TaskLifecycleService'); @@ -106,20 +139,19 @@ describe('TaskLifecycleService', () => { const stopResult = taskLifecycleService.stopRun(taskId); expect(stopResult.ok).toBe(false); - // If stop intent were leaked, exit would incorrectly force state to idle. - child.emit('exit', 143); + handle.emitExit(143); const state = taskLifecycleService.getState(taskId); expect(state.run.status).toBe('failed'); expect(state.run.error).toBe('Exited with code 143'); }); - it('ignores stale child exit and keeps latest run process tracked', async () => { + it('ignores stale PTY exit and keeps latest run process tracked', async () => { vi.resetModules(); - const first = createChild(2001); - const second = createChild(2002); - spawnMock.mockReturnValueOnce(first).mockReturnValueOnce(second); + const first = createLifecyclePty(2001); + const second = createLifecyclePty(2002); + startLifecyclePtyMock.mockReturnValueOnce(first).mockReturnValueOnce(second); const { taskLifecycleService } = await import('../../main/services/TaskLifecycleService'); @@ -131,20 +163,19 @@ describe('TaskLifecycleService', () => { taskLifecycleService.stopRun(taskId); await taskLifecycleService.startRun(taskId, taskPath, projectPath); - // Old process exits after new process started; should be ignored. - first.emit('exit', 143); + first.emitExit(143); const afterStaleExit = taskLifecycleService.getState(taskId); expect(afterStaleExit.run.status).toBe('running'); expect(afterStaleExit.run.pid).toBe(2002); }); - it('ignores stale child error and keeps latest run process state', async () => { + it('ignores stale PTY error and keeps latest run process state', async () => { vi.resetModules(); - const first = createChild(2101); - const second = createChild(2102); - spawnMock.mockReturnValueOnce(first).mockReturnValueOnce(second); + const first = createLifecyclePty(2101); + const second = createLifecyclePty(2102); + startLifecyclePtyMock.mockReturnValueOnce(first).mockReturnValueOnce(second); const { taskLifecycleService } = await import('../../main/services/TaskLifecycleService'); @@ -156,8 +187,7 @@ describe('TaskLifecycleService', () => { taskLifecycleService.stopRun(taskId); await taskLifecycleService.startRun(taskId, taskPath, projectPath); - // Old process emits error after new process started; should be ignored. - first.emit('error', new Error('stale child error')); + first.emitError(new Error('stale PTY error')); const state = taskLifecycleService.getState(taskId); expect(state.run.status).toBe('running'); @@ -168,7 +198,7 @@ describe('TaskLifecycleService', () => { it('dedupes concurrent runTeardown calls per task and path', async () => { vi.resetModules(); - const runChild = createChild(2201); + const runHandle = createLifecyclePty(2201); getScriptMock.mockImplementation((_: string, phase: string) => { if (phase === 'run') return 'npm run dev'; if (phase === 'teardown') return 'echo teardown'; @@ -185,13 +215,12 @@ describe('TaskLifecycleService', () => { const taskPath = '/tmp/wt-5'; const projectPath = '/tmp/project'; - serviceAny.runProcesses.set(taskId, runChild); + serviceAny.runPtys.set(taskId, runHandle); const teardownA = taskLifecycleService.runTeardown(taskId, taskPath, projectPath); const teardownB = taskLifecycleService.runTeardown(taskId, taskPath, projectPath); - // Unblock teardown wait-for-exit of run process. - runChild.emit('exit', 143); + runHandle.emitExit(143); const [ra, rb] = await Promise.all([teardownA, teardownB]); @@ -200,12 +229,12 @@ describe('TaskLifecycleService', () => { expect(runFiniteSpy).toHaveBeenCalledTimes(1); }); - it('clears stale run process after spawn error so retry can start', async () => { + it('clears stale run process after PTY error so retry can start', async () => { vi.resetModules(); - const broken = createChild(2301); - const good = createChild(2302); - spawnMock.mockReturnValueOnce(broken).mockReturnValueOnce(good); + const broken = createLifecyclePty(2301); + const good = createLifecyclePty(2302); + startLifecyclePtyMock.mockReturnValueOnce(broken).mockReturnValueOnce(good); const { taskLifecycleService } = await import('../../main/services/TaskLifecycleService'); @@ -216,18 +245,18 @@ describe('TaskLifecycleService', () => { const firstStart = await taskLifecycleService.startRun(taskId, taskPath, projectPath); expect(firstStart.ok).toBe(true); - broken.emit('error', new Error('spawn failed')); + broken.emitError(new Error('pty failed')); const retry = await taskLifecycleService.startRun(taskId, taskPath, projectPath); expect(retry.ok).toBe(true); - expect(spawnMock).toHaveBeenCalledTimes(2); + expect(startLifecyclePtyMock).toHaveBeenCalledTimes(2); }); it('clearTask removes accumulated lifecycle state entries', async () => { vi.resetModules(); - const child = createChild(2401); - spawnMock.mockReturnValue(child); + const handle = createLifecyclePty(2401); + startLifecyclePtyMock.mockReturnValue(handle); getScriptMock.mockImplementation((_: string, phase: string) => { if (phase === 'run') return 'npm run dev'; return null; @@ -244,19 +273,20 @@ describe('TaskLifecycleService', () => { await taskLifecycleService.startRun(taskId, taskPath, projectPath); expect(serviceAny.states.has(taskId)).toBe(true); - expect(serviceAny.runProcesses.has(taskId)).toBe(true); + expect(serviceAny.runPtys.has(taskId)).toBe(true); taskLifecycleService.clearTask(taskId); + expect(handle.killed).toBe(true); expect(serviceAny.states.has(taskId)).toBe(false); - expect(serviceAny.runProcesses.has(taskId)).toBe(false); + expect(serviceAny.runPtys.has(taskId)).toBe(false); }); - it('keeps setup failed when child emits error and exit', async () => { + it('keeps setup failed when PTY emits error and exit', async () => { vi.resetModules(); - const child = createChild(2501); - spawnMock.mockReturnValue(child); + const handle = createLifecyclePty(2501); + startLifecyclePtyMock.mockReturnValue(handle); getScriptMock.mockImplementation((_: string, phase: string) => { if (phase === 'setup') return 'npm i'; return null; @@ -270,22 +300,22 @@ describe('TaskLifecycleService', () => { const setupPromise = taskLifecycleService.runSetup(taskId, taskPath, projectPath); await new Promise((resolve) => setTimeout(resolve, 25)); - child.emit('error', new Error('spawn failed')); - child.emit('exit', 0); + handle.emitError(new Error('pty failed')); + handle.emitExit(0); const setupResult = await setupPromise; const state = taskLifecycleService.getState(taskId); expect(setupResult.ok).toBe(false); expect(state.setup.status).toBe('failed'); - expect(state.setup.error).toBe('spawn failed'); + expect(state.setup.error).toBe('pty failed'); }); - it('clearTask stops in-flight setup/teardown processes', async () => { + it('clearTask stops in-flight setup/teardown PTYs', async () => { vi.resetModules(); - const setupChild = createChild(2601); - spawnMock.mockReturnValue(setupChild); + const setupHandle = createLifecyclePty(2601); + startLifecyclePtyMock.mockReturnValue(setupHandle); getScriptMock.mockImplementation((_: string, phase: string) => { if (phase === 'setup') return 'npm i'; return null; @@ -301,9 +331,9 @@ describe('TaskLifecycleService', () => { void taskLifecycleService.runSetup(taskId, taskPath, projectPath); await new Promise((resolve) => setTimeout(resolve, 25)); - expect(serviceAny.finiteProcesses.has(taskId)).toBe(true); + expect(serviceAny.finitePtys.has(taskId)).toBe(true); taskLifecycleService.clearTask(taskId); - expect(setupChild.killed).toBe(true); - expect(serviceAny.finiteProcesses.has(taskId)).toBe(false); + expect(setupHandle.killed).toBe(true); + expect(serviceAny.finitePtys.has(taskId)).toBe(false); }); });