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

@@ -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);
}