Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
949504f
fix(server): append terminal history output
tarik02 Aug 22, 2026
548246b
Merge remote-tracking branch 'upstream/main' into fix/terminal-histor…
tarik02 Aug 22, 2026
1556266
fix(server): bound appended terminal history
tarik02 Aug 22, 2026
b18dcb8
fix(server): retry terminal history writes
tarik02 Aug 22, 2026
600d01e
fix(server): order terminal history resets
tarik02 Aug 22, 2026
3ab5e39
fix(server): discard deleted history retries
tarik02 Aug 22, 2026
fd4be84
fix(server): order terminal event publication
tarik02 Aug 22, 2026
68e4f0c
fix(server): preserve event test literal types
tarik02 Aug 22, 2026
ac6caa7
fix(server): discard stale terminal attach events
tarik02 Aug 22, 2026
49fc176
fix(server): reopen attach streams after restart
tarik02 Aug 22, 2026
2503467
fix(server): preserve terminal event and history ordering
tarik02 Aug 23, 2026
7ac8228
fix(server): flush queued terminal output on shutdown
tarik02 Aug 23, 2026
be90646
fix(server): preserve terminal history during close and retry
tarik02 Aug 23, 2026
734faf0
fix(server): preserve terminal history during final flushes
tarik02 Aug 23, 2026
6232d5e
fix(server): preserve ordered terminal output
tarik02 Aug 23, 2026
866d71c
fix(server): retain terminal history at byte boundaries
tarik02 Aug 23, 2026
54de904
fix(server): avoid terminal event drain deadlock
tarik02 Aug 23, 2026
63fb231
fix(server): release evicted terminal history retries
tarik02 Aug 23, 2026
7ae5d7b
fix(server): preserve terminal history retry state
tarik02 Aug 23, 2026
be6378e
Merge remote-tracking branch 'upstream/main' into fix/terminal-histor…
tarik02 Aug 23, 2026
bb9ddc4
fix(server): simplify terminal history persistence
tarik02 Aug 23, 2026
4cfce6a
fix(server): preserve split terminal unicode
tarik02 Aug 23, 2026
5d89abf
fix(server): keep attach history ordered
tarik02 Aug 23, 2026
9c3d4b7
Merge remote-tracking branch 'upstream/main' into fix/terminal-histor…
tarik02 Aug 23, 2026
24fc2d5
Merge remote-tracking branch 'upstream/main' into fix/terminal-histor…
tarik02 Aug 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 254 additions & 0 deletions apps/server/src/terminal/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "@t3tools/contracts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Data from "effect/Data";
import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Encoding from "effect/Encoding";
Expand Down Expand Up @@ -203,6 +204,8 @@ const multiTerminalHistoryLogPath = (
);

interface CreateManagerOptions {
historyTargetBytes?: number;
historyMaxBytes?: number;
shellResolver?: () => string;
env?: NodeJS.ProcessEnv;
subprocessInspector?: (terminalPid: number) => Effect.Effect<{
Expand Down Expand Up @@ -242,6 +245,12 @@ const createManager = (
const manager = yield* TerminalManager.makeWithOptions({
logsDir,
historyLineLimit,
...(options.historyTargetBytes !== undefined
? { historyTargetBytes: options.historyTargetBytes }
: {}),
...(options.historyMaxBytes !== undefined
? { historyMaxBytes: options.historyMaxBytes }
: {}),
ptyAdapter,
...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}),
...(options.env !== undefined ? { env: options.env } : {}),
Expand Down Expand Up @@ -444,6 +453,26 @@ it.layer(
fs.writeFileString(filePath, contents),
);

interface RecordedHistoryWrite {
readonly contents: string;
readonly flag: FileSystem.OpenFlag | undefined;
}

function recordHistoryWrites(
fileSystem: FileSystem.FileSystem,
writes: Array<RecordedHistoryWrite>,
): FileSystem.FileSystem {
return FileSystem.FileSystem.of({
...fileSystem,
writeFileString: (path, contents, options) =>
Effect.sync(() => {
if (path.endsWith(".log")) {
writes.push({ contents, flag: options?.flag });
}
}).pipe(Effect.andThen(fileSystem.writeFileString(path, contents, options))),
});
}

it.effect("reports a missing cwd without an artificial cause", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
Expand Down Expand Up @@ -694,6 +723,61 @@ it.layer(
}),
);

it.effect("does not persist output after a concurrent clear", () =>
Effect.gen(function* () {
const { manager, ptyAdapter, logsDir } = yield* createManager();
yield* manager.open(openInput());
const process = ptyAdapter.processes[0];
expect(process).toBeDefined();
if (!process) return;

process.emitData("stale redraw\r");
yield* manager.clear({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID });
yield* manager.close({ threadId: "thread-1" });

const historyPath = yield* historyLogPath(logsDir);
expect(yield* readFileString(historyPath)).toBe("");
}),
);

it.effect("publishes output before a concurrent clear", () =>
Effect.gen(function* () {
const { manager, ptyAdapter } = yield* createManager();
yield* manager.open(openInput());
const process = ptyAdapter.processes[0];
expect(process).toBeDefined();
if (!process) return;

const outputStarted = yield* Deferred.make<void>();
const releaseOutput = yield* Deferred.make<void>();
const publishedEvents = yield* Ref.make<ReadonlyArray<"output" | "cleared">>([]);
const unsubscribe = yield* manager.subscribe((event) =>
Effect.gen(function* () {
if (event.type === "output") {
yield* Deferred.succeed(outputStarted, undefined);
yield* Deferred.await(releaseOutput);
yield* Ref.update(publishedEvents, (events) => [...events, "output"]);
} else if (event.type === "cleared") {
yield* Ref.update(publishedEvents, (events) => [...events, "cleared"]);
}
}),
);
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));

process.emitData("before clear\n");
yield* Deferred.await(outputStarted);

const clearFiber = yield* manager
.clear({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID })
.pipe(Effect.forkScoped);
yield* Effect.yieldNow;
yield* Deferred.succeed(releaseOutput, undefined);
yield* Fiber.join(clearFiber);

expect(yield* Ref.get(publishedEvents)).toEqual(["output", "cleared"]);
}),
);

it.effect("restarts terminal with empty transcript and respawns pty", () =>
Effect.gen(function* () {
const { manager, ptyAdapter, logsDir } = yield* createManager();
Expand Down Expand Up @@ -1090,6 +1174,135 @@ it.layer(
}),
);

it.effect("appends normal terminal history writes", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const writes: Array<RecordedHistoryWrite> = [];
const { manager, ptyAdapter } = yield* createManager(100).pipe(
Effect.provideService(FileSystem.FileSystem, recordHistoryWrites(fileSystem, writes)),
);
yield* manager.open(openInput());
const process = ptyAdapter.processes[0];
expect(process).toBeDefined();
if (!process) return;

process.emitData("first redraw\r");
process.emitData("second redraw\r");
yield* manager.close({ threadId: "thread-1" });

expect(writes.length).toBeGreaterThan(0);
expect(writes.every((write) => write.flag === "a")).toBe(true);
expect(writes.map((write) => write.contents).join("")).toBe("first redraw\rsecond redraw\r");
}),
);

it.effect("retries a failed history append before close completes", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const failedAppend = yield* Deferred.make<void>();
const cause = PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "writeFileString",
pathOrDescriptor: "terminal-history",
});
let shouldFailAppend = true;
const recoveringFileSystem = FileSystem.FileSystem.of({
...fileSystem,
writeFileString: (path, contents, options) => {
if (
path.endsWith(".log") &&
options?.flag === "a" &&
contents.length > 0 &&
shouldFailAppend
) {
shouldFailAppend = false;
return Deferred.succeed(failedAppend, undefined).pipe(
Effect.andThen(Effect.fail(cause)),
);
}
return fileSystem.writeFileString(path, contents, options);
},
});
const { manager, ptyAdapter, logsDir } = yield* createManager(100).pipe(
Effect.provideService(FileSystem.FileSystem, recoveringFileSystem),
);
yield* manager.open(openInput());
const process = ptyAdapter.processes[0];
expect(process).toBeDefined();
if (!process) return;

process.emitData("survives retry\r");
yield* Deferred.await(failedAppend);
yield* manager.close({ threadId: "thread-1" });

const historyPath = yield* historyLogPath(logsDir);
expect(yield* readFileString(historyPath)).toBe("survives retry\r");
}),
);

it.effect("compacts carriage-return redraw history at the byte limit", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const writes: Array<RecordedHistoryWrite> = [];
const { manager, ptyAdapter, logsDir } = yield* createManager(100, {
historyTargetBytes: 12,
historyMaxBytes: 24,
}).pipe(
Effect.provideService(FileSystem.FileSystem, recordHistoryWrites(fileSystem, writes)),
);
yield* manager.open(openInput());
const process = ptyAdapter.processes[0];
expect(process).toBeDefined();
if (!process) return;

process.emitData("old-one\r");
process.emitData("old-two\r");
process.emitData("new-one\rnew-two\r");
yield* manager.close({ threadId: "thread-1" });

const historyPath = yield* historyLogPath(logsDir);
const persisted = yield* readFileString(historyPath);
expect(persisted).toBe("new-two\r");
expect(Buffer.byteLength(persisted)).toBeLessThanOrEqual(12);
expect(writes.some((write) => write.flag === "w")).toBe(true);
}),
);

it.effect("compacts oversized existing terminal history on open", () =>
Effect.gen(function* () {
const { manager, logsDir } = yield* createManager(100, {
historyTargetBytes: 12,
historyMaxBytes: 24,
});
const historyPath = yield* historyLogPath(logsDir);
yield* writeFileString(historyPath, "old-one\rold-two\rnew-one\rnew-two\r");

const opened = yield* manager.open(openInput());

expect(opened.history).toBe("new-two\r");
expect(yield* readFileString(historyPath)).toBe("new-two\r");
}),
);

it.effect("retains a bounded suffix from oversized history without line breaks", () =>
Effect.gen(function* () {
const { manager, logsDir } = yield* createManager(100, {
historyTargetBytes: 12,
historyMaxBytes: 24,
});
const historyPath = yield* historyLogPath(logsDir);
const oversizedHistory = "abcdefghijklmnopqrstuvwxyz0123456789";
yield* writeFileString(historyPath, oversizedHistory);

const opened = yield* manager.open(openInput());
const expected = oversizedHistory.slice(-12);

expect(opened.history).toBe(expected);
expect(yield* readFileString(historyPath)).toBe(expected);
}),
);

it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () =>
Effect.gen(function* () {
const { manager, ptyAdapter } = yield* createManager();
Expand Down Expand Up @@ -1259,6 +1472,47 @@ it.layer(
}),
);

it.effect("does not restore failed writes after deleting all thread history", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const failedWrite = yield* Deferred.make<void>();
const cause = PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "writeFileString",
pathOrDescriptor: "terminal-history",
});
let writesFail = true;
const recoveringFileSystem = FileSystem.FileSystem.of({
...fileSystem,
writeFileString: (path, contents, options) => {
if (path.endsWith(".log") && writesFail) {
return Deferred.succeed(failedWrite, undefined).pipe(
Effect.andThen(Effect.fail(cause)),
);
}
return fileSystem.writeFileString(path, contents, options);
},
});
const { manager, ptyAdapter } = yield* createManager().pipe(
Effect.provideService(FileSystem.FileSystem, recoveringFileSystem),
);
yield* manager.open(openInput());
const process = ptyAdapter.processes[0];
expect(process).toBeDefined();
if (!process) return;

process.emitData("must stay deleted\r");
yield* Deferred.await(failedWrite);
yield* manager.close({ threadId: "thread-1" });
yield* manager.close({ threadId: "thread-1", deleteHistory: true });
writesFail = false;

const reopened = yield* manager.open(openInput());
expect(reopened.history).toBe("");
}),
);

it.effect("closes all terminals for a thread when close omits terminalId", () =>
Effect.gen(function* () {
const { manager, ptyAdapter, logsDir } = yield* createManager();
Expand Down
Loading
Loading