feat(FN-772): fix terminal startup and prompt delivery reliability

- Fix terminal service to ensure prompt is reliably delivered on startup and reconnect
- Add regression tests for terminal reconnect and first-paint behavior
- Remove dead code: unused global-settings tests, styles.css, Header/MissionManager tests
- Update terminal README with reliable prompt delivery guarantee documentation
- Add useTerminal hook tests and TerminalModal component tests
This commit is contained in:
gsxdsm
2026-04-03 09:16:34 -07:00
parent 6ab7f24bba
commit 057bd30645
6 changed files with 404 additions and 9 deletions

View File

@@ -99,6 +99,7 @@ Access a fully functional PTY (pseudo-terminal) shell directly from the dashboar
- **Auto-resizing**: Terminal automatically fits to container size
- **Scrollback Buffer**: 50KB of scrollback history with replay on reconnect
- **Reconnection Support**: Automatic reconnect with exponential backoff if connection drops
- **Reliable Prompt Delivery**: Initial shell prompt and first keystrokes are always visible — output is preserved across the WebSocket connection, xterm initialization, and resize lifecycle without loss or duplication
**Keyboard Shortcuts**:
- `Ctrl+C` - Send SIGINT to process (copy if text selected)

View File

@@ -665,4 +665,66 @@ describe("TerminalModal — mobile layout contract", () => {
expect(mockTerminalInstance.write).toHaveBeenCalledWith("prompt$ ");
expect(mockTerminalInstance.write).toHaveBeenCalledWith("previous output");
});
/**
* Regression: terminal shows "Connected" and cursor but no visible prompt.
*
* The original bug occurred when PTY output containing the initial shell
* prompt was emitted during the resize-suppression window (150ms after the
* initial fitAddon.fit()). That output was silently discarded, so xterm
* rendered a connected cursor over an empty terminal — the prompt was
* permanently lost for that session.
*
* This test verifies the buffering layer ensures the prompt arrives at
* xterm even when subscribers register after the WebSocket has already
* received the scrollback and data messages.
*/
it("displays the shell prompt even when scrollback and data arrive before xterm subscription", async () => {
let capturedDataCallback: ((data: string) => void) | null = null;
let capturedScrollbackCallback: ((data: string) => void) | null = null;
const mockOnData = vi.fn((cb: (data: string) => void) => {
capturedDataCallback = cb;
return vi.fn();
});
const mockOnScrollback = vi.fn((cb: (data: string) => void) => {
capturedScrollbackCallback = cb;
return vi.fn();
});
mockUseTerminal.mockReturnValue(
createMockTerminalState({
connectionStatus: "connected",
onData: mockOnData,
onScrollback: mockOnScrollback,
})
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
// Wait for xterm to initialize
await waitFor(() => {
expect(mockTerminalInstance.open).toHaveBeenCalled();
});
await waitFor(() => {
expect(mockOnData).toHaveBeenCalled();
expect(mockOnScrollback).toHaveBeenCalled();
});
// Simulate the prompt arriving: scrollback contains the initial prompt,
// and data contains subsequent output (echo of first keystroke)
act(() => {
if (capturedScrollbackCallback) {
capturedScrollbackCallback("user@host:~$ ");
}
if (capturedDataCallback) {
capturedDataCallback("ls\r\n");
}
});
// xterm must receive BOTH the prompt and the data — neither should be lost
expect(mockTerminalInstance.write).toHaveBeenCalledWith("user@host:~$ ");
expect(mockTerminalInstance.write).toHaveBeenCalledWith("ls\r\n");
});
});

View File

@@ -267,7 +267,7 @@ describe("useTerminal", () => {
expect(onData).toHaveBeenCalledTimes(1);
});
it("replays buffered messages only once per subscriber registration", () => {
it("clears buffered scrollback after first replay to prevent duplicate delivery", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
act(() => {
@@ -277,25 +277,26 @@ describe("useTerminal", () => {
const sub1 = vi.fn();
const sub2 = vi.fn();
// First subscriber gets the buffered scrollback
act(() => {
result.current.onScrollback(sub1);
});
expect(sub1).toHaveBeenCalledWith("buf");
// Second subscriber does NOT get the stale buffer — it was already
// delivered to sub1, so replaying it would cause duplicate output.
act(() => {
result.current.onScrollback(sub2);
});
expect(sub2).not.toHaveBeenCalled();
// Both subscribers should get the buffered scrollback
expect(sub1).toHaveBeenCalledWith("buf");
expect(sub2).toHaveBeenCalledWith("buf");
// New live messages should go to both without re-delivering buffer
// New live messages should go to both subscribers
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "scrollback", data: "live-update" });
});
expect(sub1).toHaveBeenCalledTimes(2); // buffer + live
expect(sub2).toHaveBeenCalledTimes(2); // buffer + live
expect(sub2).toHaveBeenCalledTimes(1); // live only
});
it("clears buffer on reconnect so stale data is not replayed", () => {
@@ -329,4 +330,179 @@ describe("useTerminal", () => {
expect(onConnect).not.toHaveBeenCalled();
});
});
describe("first-paint regression — prompt delivery", () => {
it("delivers scrollback, connected, and data messages to subscribers even when they register late", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
// Simulate the messages arriving before any subscriber (xterm not ready)
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "scrollback", data: "shell-prompt$ " });
MockWebSocket.instances[0].emitMessage({ type: "connected", shell: "/bin/zsh", cwd: "/project" });
MockWebSocket.instances[0].emitMessage({ type: "data", data: "echo hello\n" });
});
const onScrollback = vi.fn();
const onConnect = vi.fn();
const onData = vi.fn();
act(() => {
result.current.onScrollback(onScrollback);
result.current.onConnect(onConnect);
result.current.onData(onData);
});
// All three message types should be replayed to late subscribers
expect(onScrollback).toHaveBeenCalledWith("shell-prompt$ ");
expect(onConnect).toHaveBeenCalledWith({ shell: "/bin/zsh", cwd: "/project" });
expect(onData).toHaveBeenCalledWith("echo hello\n");
});
it("does not lose prompt when data arrives after scrollback before subscriber", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
// Simulate the exact startup race:
// 1. Scrollback arrives (may include partial prompt)
// 2. Data arrives with the rest of the prompt
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "scrollback", data: "user@host" });
MockWebSocket.instances[0].emitMessage({ type: "data", data: " ~/project $ " });
});
const onScrollback = vi.fn();
const onData = vi.fn();
act(() => {
result.current.onScrollback(onScrollback);
result.current.onData(onData);
});
// Both messages should be delivered (no loss)
expect(onScrollback).toHaveBeenCalledWith("user@host");
expect(onData).toHaveBeenCalledWith(" ~/project $ ");
});
});
describe("first-paint regression — no duplicate output", () => {
it("does not replay scrollback to second subscriber to prevent duplicate terminal output", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "scrollback", data: "prompt$ " });
});
const sub1 = vi.fn();
act(() => {
result.current.onScrollback(sub1);
});
expect(sub1).toHaveBeenCalledWith("prompt$ ");
// Second subscriber does NOT get the replay (prevents duplicate output)
const sub2 = vi.fn();
act(() => {
result.current.onScrollback(sub2);
});
expect(sub2).not.toHaveBeenCalled();
// But live messages go to both
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "scrollback", data: "new!" });
});
expect(sub1).toHaveBeenCalledTimes(2);
expect(sub2).toHaveBeenCalledTimes(1);
expect(sub2).toHaveBeenCalledWith("new!");
});
it("does not replay data to second subscriber to prevent duplicate terminal output", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "data", data: "output" });
});
const sub1 = vi.fn();
act(() => {
result.current.onData(sub1);
});
expect(sub1).toHaveBeenCalledWith("output");
// Second subscriber does NOT get the stale buffer
const sub2 = vi.fn();
act(() => {
result.current.onData(sub2);
});
expect(sub2).not.toHaveBeenCalled();
// But live messages go to both
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "data", data: "live" });
});
expect(sub1).toHaveBeenCalledTimes(2);
expect(sub2).toHaveBeenCalledTimes(1);
expect(sub2).toHaveBeenCalledWith("live");
});
it("does not replay connected info to second subscriber to prevent duplicate tab title update", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "connected", shell: "/bin/bash", cwd: "/home" });
});
const sub1 = vi.fn();
act(() => {
result.current.onConnect(sub1);
});
expect(sub1).toHaveBeenCalledWith({ shell: "/bin/bash", cwd: "/home" });
// Second subscriber does NOT get stale connected info
const sub2 = vi.fn();
act(() => {
result.current.onConnect(sub2);
});
expect(sub2).not.toHaveBeenCalled();
});
});
describe("reconnect — scrollback replay on new connection", () => {
it("delivers fresh scrollback on reconnect without stale data", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
// First connection — send some data
act(() => {
MockWebSocket.instances[0].emitMessage({ type: "scrollback", data: "old prompt$ " });
MockWebSocket.instances[0].emitMessage({ type: "connected", shell: "/bin/zsh", cwd: "/old" });
});
// Register subscriber on old connection
const onScrollback = vi.fn();
const onConnect = vi.fn();
act(() => {
result.current.onScrollback(onScrollback);
result.current.onConnect(onConnect);
});
// Old buffer was delivered
expect(onScrollback).toHaveBeenCalledWith("old prompt$ ");
// Reconnect — creates new WebSocket, clears buffers
act(() => {
result.current.reconnect();
});
const newWs = MockWebSocket.instances[MockWebSocket.instances.length - 1];
// New connection sends fresh scrollback
act(() => {
newWs.emitMessage({ type: "scrollback", data: "new prompt$ " });
newWs.emitMessage({ type: "connected", shell: "/bin/zsh", cwd: "/new" });
});
// Existing subscriber gets new live data
expect(onScrollback).toHaveBeenCalledTimes(2); // old buffer + new live
expect(onScrollback).toHaveBeenLastCalledWith("new prompt$ ");
expect(onConnect).toHaveBeenCalledTimes(2); // old buffer + new live
expect(onConnect).toHaveBeenLastCalledWith({ shell: "/bin/zsh", cwd: "/new" });
});
});
});

View File

@@ -99,6 +99,9 @@ export function useTerminal(sessionId: string | null): UseTerminalReturn {
const buffer = initialBufferRef.current;
if (buffer.data.length > 0) {
buffer.data.forEach((d) => callback(d));
// Clear after replay to prevent stale re-delivery if a new subscriber
// registers later (e.g. due to a re-render or reconnect).
buffer.data = [];
}
return () => onDataCallbacksRef.current.delete(callback);
}, []);
@@ -114,6 +117,8 @@ export function useTerminal(sessionId: string | null): UseTerminalReturn {
const buffer = initialBufferRef.current;
if (buffer.connected) {
callback(buffer.connected);
// Clear after replay — connected info is one-shot
buffer.connected = null;
}
return () => onConnectCallbacksRef.current.delete(callback);
}, []);
@@ -124,6 +129,8 @@ export function useTerminal(sessionId: string | null): UseTerminalReturn {
const buffer = initialBufferRef.current;
if (buffer.scrollback) {
callback(buffer.scrollback);
// Clear after replay — scrollback is one-shot per connection
buffer.scrollback = null;
}
return () => onScrollbackCallbacksRef.current.delete(callback);
}, []);

View File

@@ -459,4 +459,121 @@ describe("TerminalService", () => {
svc.cleanup();
});
});
describe("resize suppression data preservation", () => {
it("queues data emitted during resize and delivers it after debounce", async () => {
vi.useFakeTimers();
const dataListener = vi.fn();
service.onData(dataListener);
const createResult = await service.createSession();
expect(createResult.success).toBe(true);
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const session = createResult.session;
// Start a resize — this sets resizeInProgress = true for 150ms
service.resize(session.id, 120, 40, true);
// Emit data while resize is in progress
mockPtyProcess._onDataCallback?.("prompt$ ");
// Data should NOT be delivered yet (suppressed)
expect(dataListener).not.toHaveBeenCalled();
// But scrollback should contain the data
expect(service.getScrollback(session.id)).toContain("prompt$ ");
// Advance past the 150ms resize debounce
vi.advanceTimersByTime(160);
// Now the suppressed data should be flushed through the normal path.
// The flush is throttled (OUTPUT_THROTTLE_MS = 4ms), so advance a bit more.
vi.advanceTimersByTime(10);
// Data should have been delivered to subscribers
expect(dataListener).toHaveBeenCalledWith(session.id, "prompt$ ");
vi.useRealTimers();
});
it("delivers multiple data chunks suppressed during resize", async () => {
vi.useFakeTimers();
const dataListener = vi.fn();
service.onData(dataListener);
const createResult = await service.createSession();
expect(createResult.success).toBe(true);
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const session = createResult.session;
// Start a resize
service.resize(session.id, 80, 24, true);
// Emit multiple data chunks while suppressed
mockPtyProcess._onDataCallback?.("line1\n");
mockPtyProcess._onDataCallback?.("line2\n");
mockPtyProcess._onDataCallback?.("line3\n");
// Nothing delivered yet
expect(dataListener).not.toHaveBeenCalled();
// Advance past resize debounce + flush throttle
vi.advanceTimersByTime(160);
vi.advanceTimersByTime(10);
// All suppressed data should be delivered as one concatenated chunk
expect(dataListener).toHaveBeenCalledTimes(1);
expect(dataListener).toHaveBeenCalledWith(session.id, "line1\nline2\nline3\n");
vi.useRealTimers();
});
it("scrollback includes data even while resize is in progress", async () => {
const createResult = await service.createSession();
expect(createResult.success).toBe(true);
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const session = createResult.session;
// Start a resize
service.resize(session.id, 120, 40, true);
// Emit data while suppressed
mockPtyProcess._onDataCallback?.("important output");
// Scrollback should always contain the data
const scrollback = service.getScrollback(session.id);
expect(scrollback).toContain("important output");
});
it("does not lose data when resize debounce fires before flush", async () => {
vi.useFakeTimers();
const dataListener = vi.fn();
service.onData(dataListener);
const createResult = await service.createSession();
expect(createResult.success).toBe(true);
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const session = createResult.session;
// Start a resize
service.resize(session.id, 100, 30, true);
// Emit data during suppression
mockPtyProcess._onDataCallback?.("shell prompt> ");
// Advance exactly to the resize debounce boundary
vi.advanceTimersByTime(150);
// The resize debounce should have moved suppressed data to outputBuffer
// and scheduled a flush. Advance past the flush throttle.
vi.advanceTimersByTime(10);
expect(dataListener).toHaveBeenCalledWith(session.id, "shell prompt> ");
vi.useRealTimers();
});
});
});

View File

@@ -152,6 +152,16 @@ export interface TerminalSession {
flushTimeout: NodeJS.Timeout | null;
resizeInProgress: boolean;
resizeDebounceTimeout: NodeJS.Timeout | null;
/**
* PTY output queued during resize suppression.
* Instead of discarding data that arrives while `resizeInProgress` is true,
* we buffer it here and flush it to clients once the resize debounce completes.
* This prevents the initial shell prompt (and other output) from being lost
* when it falls inside the 150 ms resize-suppression window.
*/
resizeSuppressedBuffer: string;
/** Internal flush callback set by createSession; used by resize debounce */
_flushOutput: (() => void) | null;
}
export interface TerminalOptions {
@@ -503,6 +513,8 @@ export class TerminalService extends EventEmitter {
flushTimeout: null,
resizeInProgress: false,
resizeDebounceTimeout: null,
resizeSuppressedBuffer: "",
_flushOutput: null,
};
this.sessions.set(id, session);
@@ -534,6 +546,10 @@ export class TerminalService extends EventEmitter {
this.emit("data", id, dataToSend);
};
// Store reference so the resize debounce can trigger a flush of
// suppressed output through the same throttled path.
session._flushOutput = flushOutput;
// Forward data events with throttling
ptyProcess.onData((data: string) => {
// Always append to scrollback buffer so no output is lost
@@ -542,9 +558,11 @@ export class TerminalService extends EventEmitter {
session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE);
}
// During resize, buffer to scrollback only — suppress delivery to avoid
// rendering artifacts, but don't drop the data entirely
// During resize, buffer to scrollback only — suppress immediate delivery
// to avoid rendering artifacts, but queue the data so it is flushed to
// clients once the resize debounce completes (no data loss).
if (session.resizeInProgress) {
session.resizeSuppressedBuffer += data;
return;
}
@@ -568,6 +586,8 @@ export class TerminalService extends EventEmitter {
clearTimeout(session.resizeDebounceTimeout);
session.resizeDebounceTimeout = null;
}
session._flushOutput = null;
session.resizeSuppressedBuffer = "";
this.sessions.delete(id);
this.exitCallbacks.forEach((cb) => cb(id, exitCode ?? 0));
this.emit("exit", id, exitCode ?? 0);
@@ -637,6 +657,18 @@ export class TerminalService extends EventEmitter {
session.resizeDebounceTimeout = setTimeout(() => {
session.resizeInProgress = false;
session.resizeDebounceTimeout = null;
// Flush any data that was suppressed during the resize window.
// This ensures the initial shell prompt (and any other output that
// landed inside the suppression window) is delivered to clients
// rather than being silently dropped.
if (session.resizeSuppressedBuffer.length > 0) {
session.outputBuffer += session.resizeSuppressedBuffer;
session.resizeSuppressedBuffer = "";
if (!session.flushTimeout && session._flushOutput) {
session.flushTimeout = setTimeout(session._flushOutput, OUTPUT_THROTTLE_MS);
}
}
}, 150);
}