FN-6602: delay scripted terminal input until PTY readiness

Wait for new terminal sessions to become ready before injecting saved-script commands.

- Add PTY readiness tracking with first-output, quiet-window, timeout, and cleanup resolution paths.
- Wait for readiness before messaging script routes write generated command input.
- Cover readiness timing, cleanup behavior, and route-level delayed write behavior with tests.

Files changed:
 .../src/__tests__/scripts-routes.routes.test.ts    |  54 ++++++++
 .../src/__tests__/terminal-service.test.ts         | 143 ++++++++++++++++++++-
 .../src/routes/register-messaging-scripts.ts       |   5 +
 packages/dashboard/src/terminal-service.ts         |  91 +++++++++++++
 4 files changed, 292 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-6602

Fusion-Task-Lineage: a38a7222-ecb7-4567-9d47-946678088076
This commit is contained in:
gsxdsm
2026-06-17 18:08:55 -07:00
parent ca14a12b94
commit 1d76e9d02c
4 changed files with 292 additions and 1 deletions

View File

@@ -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<void>((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"',
});
});
});

View File

@@ -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", () => {

View File

@@ -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({

View File

@@ -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<void> {
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