diff --git a/packages/dashboard/src/__tests__/scripts-routes.routes.test.ts b/packages/dashboard/src/__tests__/scripts-routes.routes.test.ts index 99d84ef1e6..31ae3b3d39 100644 --- a/packages/dashboard/src/__tests__/scripts-routes.routes.test.ts +++ b/packages/dashboard/src/__tests__/scripts-routes.routes.test.ts @@ -6,6 +6,16 @@ import type { TaskStore } from "@fusion/core"; import { createApiRoutes } from "../routes.js"; import { request as performRequest, get as performGet } from "../test-request.js"; +const terminalServiceMock = vi.hoisted(() => ({ + createSession: vi.fn(), + waitForReady: vi.fn(), + writeInput: vi.fn(), +})); + +vi.mock("../terminal-service.js", () => ({ + getTerminalService: vi.fn(() => terminalServiceMock), +})); + function createMockGlobalSettingsStore() { return { getSettings: vi.fn().mockResolvedValue({}), @@ -84,6 +94,12 @@ describe("Scripts routes", () => { beforeEach(() => { store = createMockStore(); vi.clearAllMocks(); + terminalServiceMock.createSession.mockResolvedValue({ + success: true, + session: { id: "term-script" }, + }); + terminalServiceMock.waitForReady.mockResolvedValue(undefined); + terminalServiceMock.writeInput.mockReturnValue(true); }); function buildApp() { @@ -202,4 +218,42 @@ describe("Scripts routes", () => { expect(res.status).toBe(200); expect(store.updateSettings).toHaveBeenCalledWith({ scripts: {} }); }); + + it("POST /api/scripts/:name/run defers command write until terminal readiness", async () => { + vi.mocked(store.getSettings).mockResolvedValueOnce({ scripts: { build: "pnpm build" } } as any); + let resolveReady!: () => void; + terminalServiceMock.waitForReady.mockReturnValueOnce( + new Promise((resolve) => { + resolveReady = resolve; + }), + ); + + const responsePromise = REQUEST( + buildApp(), + "POST", + "/api/scripts/build/run", + JSON.stringify({ args: ["--filter", "@fusion/dashboard"] }), + { "Content-Type": "application/json" }, + ); + + await vi.waitFor(() => { + expect(terminalServiceMock.waitForReady).toHaveBeenCalledWith("term-script"); + }); + expect(terminalServiceMock.writeInput).not.toHaveBeenCalled(); + + resolveReady(); + const res = await responsePromise; + + expect(res.status).toBe(201); + expect(terminalServiceMock.createSession).toHaveBeenCalledWith({ cwd: "/fake/root" }); + expect(terminalServiceMock.writeInput).toHaveBeenCalledTimes(1); + expect(terminalServiceMock.writeInput).toHaveBeenCalledWith( + "term-script", + 'pnpm build "--filter" "@fusion/dashboard"\n', + ); + expect(res.body).toEqual({ + sessionId: "term-script", + command: 'pnpm build "--filter" "@fusion/dashboard"', + }); + }); }); diff --git a/packages/dashboard/src/__tests__/terminal-service.test.ts b/packages/dashboard/src/__tests__/terminal-service.test.ts index ac4659b8af..6e1d358cfb 100644 --- a/packages/dashboard/src/__tests__/terminal-service.test.ts +++ b/packages/dashboard/src/__tests__/terminal-service.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { TerminalService, STALE_SESSION_THRESHOLD_MS } from "../terminal-service.js"; +import { + READY_QUIET_WINDOW_MS, + READY_TIMEOUT_MS, + TerminalService, + STALE_SESSION_THRESHOLD_MS, +} from "../terminal-service.js"; // Mock node-pty const mockPtyProcess = { @@ -85,6 +90,124 @@ describe("TerminalService", () => { }); + describe("waitForReady", () => { + it("does not resolve before any PTY output or timeout", async () => { + vi.useFakeTimers(); + const createResult = await service.createSession(); + expect(createResult.success).toBe(true); + if (!createResult.success) throw new Error("Expected terminal session creation to succeed"); + + let resolved = false; + const ready = service.waitForReady(createResult.session.id).then(() => { + resolved = true; + }); + + await Promise.resolve(); + expect(resolved).toBe(false); + + vi.advanceTimersByTime(READY_TIMEOUT_MS - 1); + await Promise.resolve(); + expect(resolved).toBe(false); + + service.cleanup(); + await ready; + vi.useRealTimers(); + }); + + it("resolves after first PTY output and a quiet window", async () => { + vi.useFakeTimers(); + const createResult = await service.createSession(); + expect(createResult.success).toBe(true); + if (!createResult.success) throw new Error("Expected terminal session creation to succeed"); + + let resolved = false; + const ready = service.waitForReady(createResult.session.id).then(() => { + resolved = true; + }); + + mockPtyProcess._onDataCallback?.("prompt$ "); + vi.advanceTimersByTime(READY_QUIET_WINDOW_MS - 1); + await Promise.resolve(); + expect(resolved).toBe(false); + + vi.advanceTimersByTime(1); + await ready; + expect(resolved).toBe(true); + expect(createResult.session.ready).toBe(true); + expect(createResult.session.firstOutputSeen).toBe(true); + expect(createResult.session.lastOutputAt).toEqual(expect.any(Number)); + expect(vi.getTimerCount()).toBeGreaterThanOrEqual(0); + vi.useRealTimers(); + }); + + it("restarts the quiet window when shell output continues", async () => { + vi.useFakeTimers(); + const createResult = await service.createSession(); + expect(createResult.success).toBe(true); + if (!createResult.success) throw new Error("Expected terminal session creation to succeed"); + + let resolved = false; + const ready = service.waitForReady(createResult.session.id).then(() => { + resolved = true; + }); + + mockPtyProcess._onDataCallback?.("loading profile\n"); + vi.advanceTimersByTime(READY_QUIET_WINDOW_MS - 1); + mockPtyProcess._onDataCallback?.("prompt$ "); + vi.advanceTimersByTime(1); + await Promise.resolve(); + expect(resolved).toBe(false); + + vi.advanceTimersByTime(READY_QUIET_WINDOW_MS - 2); + await Promise.resolve(); + expect(resolved).toBe(false); + + vi.advanceTimersByTime(1); + await ready; + expect(resolved).toBe(true); + vi.useRealTimers(); + }); + + it("resolves via timeout when the PTY emits nothing", async () => { + vi.useFakeTimers(); + const createResult = await service.createSession(); + expect(createResult.success).toBe(true); + if (!createResult.success) throw new Error("Expected terminal session creation to succeed"); + + let resolved = false; + const ready = service.waitForReady(createResult.session.id).then(() => { + resolved = true; + }); + + vi.advanceTimersByTime(READY_TIMEOUT_MS); + await ready; + expect(resolved).toBe(true); + expect(createResult.session.ready).toBe(true); + expect(createResult.session.firstOutputSeen).toBe(false); + vi.useRealTimers(); + }); + + it("resolves on early exit and clears readiness timers", async () => { + vi.useFakeTimers(); + const createResult = await service.createSession(); + expect(createResult.success).toBe(true); + if (!createResult.success) throw new Error("Expected terminal session creation to succeed"); + + let resolved = false; + const ready = service.waitForReady(createResult.session.id).then(() => { + resolved = true; + }); + + mockPtyProcess._onExitCallback?.({ exitCode: 0 }); + await ready; + expect(resolved).toBe(true); + expect(createResult.session.readyTimeout).toBeNull(); + expect(createResult.session.readyQuietTimeout).toBeNull(); + expect(vi.getTimerCount()).toBe(0); + vi.useRealTimers(); + }); + }); + describe("write", () => { it("sends data to PTY", async () => { const createResult = await service.createSession(); @@ -112,6 +235,24 @@ describe("TerminalService", () => { const result = service.write(session.id, "test\0malicious"); expect(result).toBe(false); }); + + it("keeps user keystroke writes immediate before readiness", async () => { + vi.useFakeTimers(); + const createResult = await service.createSession(); + expect(createResult.success).toBe(true); + if (!createResult.success) throw new Error("Expected terminal session creation to succeed"); + + const waitingForReady = service.waitForReady(createResult.session.id); + const result = service.write(createResult.session.id, "user typed input"); + + expect(result).toBe(true); + expect(mockPtyProcess.write).toHaveBeenCalledWith("user typed input"); + expect(createResult.session.ready).toBe(false); + + service.cleanup(); + await waitingForReady; + vi.useRealTimers(); + }); }); describe("resize", () => { diff --git a/packages/dashboard/src/routes/register-messaging-scripts.ts b/packages/dashboard/src/routes/register-messaging-scripts.ts index 86a1452ad1..6160e51e13 100644 --- a/packages/dashboard/src/routes/register-messaging-scripts.ts +++ b/packages/dashboard/src/routes/register-messaging-scripts.ts @@ -148,6 +148,11 @@ export function registerMessagingScriptRoutes(ctx: ApiRoutesContext): void { } const sessionId = result.session.id; + /* + FNXC:ScriptRunTerminalReadiness 2026-06-17-17:38: + Saved-script execution creates a fresh PTY and injects the command programmatically, so wait for the shell's initial output plus the bounded quiet window before writing to avoid dropped or garbled leading bytes. + */ + await terminalService.waitForReady(sessionId); terminalService.writeInput(sessionId, `${fullCommand}\n`); res.status(201).json({ diff --git a/packages/dashboard/src/terminal-service.ts b/packages/dashboard/src/terminal-service.ts index cb674dc1f8..e14942e0c0 100644 --- a/packages/dashboard/src/terminal-service.ts +++ b/packages/dashboard/src/terminal-service.ts @@ -31,6 +31,14 @@ const DEFAULT_MAX_SESSIONS = 10; const OUTPUT_THROTTLE_MS = 16; const OUTPUT_BATCH_SIZE = 64 * 1024; // 64KB per WebSocket frame +/* +FNXC:TerminalReadiness 2026-06-17-17:38: +Programmatic command injection into a brand-new PTY must wait until the shell has emitted initial output and then stayed quiet, because login shell rc/profile startup can otherwise drop or interleave leading command bytes. +Use a short quiet window to avoid writing into an actively streaming prompt/banner and a bounded timeout so silent shells never hang script execution. +*/ +export const READY_QUIET_WINDOW_MS = 150; +export const READY_TIMEOUT_MS = 5_000; + // Stale session threshold: sessions inactive for more than 5 minutes are eligible for eviction export const STALE_SESSION_THRESHOLD_MS = 300_000; // 5 minutes @@ -86,6 +94,12 @@ export interface TerminalSession { * when it falls inside the 150 ms resize-suppression window. */ resizeSuppressedChunks: string[]; + ready: boolean; + firstOutputSeen: boolean; + lastOutputAt: number | null; + readyWaiters: Array<() => void>; + readyTimeout: NodeJS.Timeout | null; + readyQuietTimeout: NodeJS.Timeout | null; /** Internal flush callback set by createSession; used by resize debounce */ _flushOutput: (() => void) | null; } @@ -304,6 +318,47 @@ export class TerminalService extends EventEmitter { } } + /** + * Resolve all readiness waiters exactly once and clear readiness timers. + */ + private resolveReady(session: TerminalSession): void { + if (session.readyTimeout) { + clearTimeout(session.readyTimeout); + session.readyTimeout = null; + } + if (session.readyQuietTimeout) { + clearTimeout(session.readyQuietTimeout); + session.readyQuietTimeout = null; + } + + if (!session.ready) { + session.ready = true; + } + + const waiters = session.readyWaiters.splice(0); + for (const resolve of waiters) { + resolve(); + } + } + + /** + * Observe PTY output for readiness using first-output plus quiet-window semantics. + */ + private observeReadinessOutput(session: TerminalSession): void { + if (session.ready) return; + + session.firstOutputSeen = true; + session.lastOutputAt = Date.now(); + + if (session.readyQuietTimeout) { + clearTimeout(session.readyQuietTimeout); + } + session.readyQuietTimeout = setTimeout(() => { + session.readyQuietTimeout = null; + this.resolveReady(session); + }, READY_QUIET_WINDOW_MS); + } + /** * Update the last activity timestamp for a session */ @@ -509,9 +564,20 @@ export class TerminalService extends EventEmitter { resizeInProgress: false, resizeDebounceTimeout: null, resizeSuppressedChunks: [], + ready: false, + firstOutputSeen: false, + lastOutputAt: null, + readyWaiters: [], + readyTimeout: null, + readyQuietTimeout: null, _flushOutput: null, }; + session.readyTimeout = setTimeout(() => { + session.readyTimeout = null; + this.resolveReady(session); + }, READY_TIMEOUT_MS); + this.sessions.set(id, session); // Flush buffered output to clients (throttled). @@ -558,6 +624,8 @@ export class TerminalService extends EventEmitter { // Forward data events with throttling ptyProcess.onData((data: string) => { + this.observeReadinessOutput(session); + // Always append to scrollback buffer so no output is lost session.scrollbackBuffer += data; if (session.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) { @@ -593,6 +661,7 @@ export class TerminalService extends EventEmitter { clearTimeout(session.resizeDebounceTimeout); session.resizeDebounceTimeout = null; } + this.resolveReady(session); session._flushOutput = null; session.resizeSuppressedChunks.length = 0; session.outputChunks.length = 0; @@ -606,6 +675,25 @@ export class TerminalService extends EventEmitter { return { success: true, session }; } + /** + * Wait until a fresh PTY shell has produced initial output and quieted. + * Programmatic callers use this before sending a command; user keystrokes still call write() directly. + */ + waitForReady(sessionId: string): Promise { + if (!this.isValidSessionId(sessionId)) { + return Promise.resolve(); + } + + const session = this.sessions.get(sessionId); + if (!session || session.ready) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + session.readyWaiters.push(resolve); + }); + } + /** * Write data to a terminal session */ @@ -734,6 +822,7 @@ export class TerminalService extends EventEmitter { clearTimeout(session.resizeDebounceTimeout); session.resizeDebounceTimeout = null; } + this.resolveReady(session); try { this.killPtyProcess(session.pty, "SIGKILL"); } catch { @@ -746,6 +835,7 @@ export class TerminalService extends EventEmitter { return true; } catch (error) { console.error(`Error killing session ${sessionId}:`, error); + this.resolveReady(session); this.sessions.delete(sessionId); return false; } @@ -836,6 +926,7 @@ export class TerminalService extends EventEmitter { clearTimeout(session.resizeDebounceTimeout); session.resizeDebounceTimeout = null; } + this.resolveReady(session); this.killPtyProcess(session.pty); } catch { // Ignore errors during cleanup