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:
@@ -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"',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user