Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
153 changes: 153 additions & 0 deletions apps/server/src/terminal/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,25 @@ import {
TerminalProviderInstanceNotFoundError,
} from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Clock from "effect/Clock";
import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Base64Url from "effect/encoding/Base64Url";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as PlatformError from "effect/PlatformError";
import * as Path from "effect/Path";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
import * as SynchronizedRef from "effect/SynchronizedRef";
import * as TestClock from "effect/testing/TestClock";
import { expect, vi } from "vite-plus/test";

import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
Expand Down Expand Up @@ -652,6 +657,154 @@ it.layer(
}),
);

// Answers process-table queries after `queryDuration`, honoring the caller's
// timeout the way ProcessRunner's `timedOutResult` mode does.
const slowProcessTable = (
platform: "linux" | "win32",
queryDuration: Duration.Input,
queries: Array<{ start: number; end?: number }>,
started: ReadonlyArray<Deferred.Deferred<void>>,
): ProcessRunner.ProcessRunner["Service"] => ({
run: (input) =>
Effect.gen(function* () {
const query: { start: number; end?: number } = { start: yield* Clock.currentTimeMillis };
queries.push(query);
const deferred = started[queries.length - 1];
if (deferred) yield* Deferred.succeed(deferred, undefined);
const completed = yield* Effect.sleep(queryDuration).pipe(
Effect.timeoutOption(input.timeout ?? "1 hour"),
);
query.end = yield* Clock.currentTimeMillis;
return {
stdout: Option.isNone(completed)
? ""
: platform === "win32"
? "100|9000|node.exe\n"
: " 100 9000 node\n",
stderr: "",
code: Option.isSome(completed) ? ChildProcessSpawner.ExitCode(0) : null,
timedOut: Option.isNone(completed),
stdoutTruncated: false,
stderrTruncated: false,
stdoutInvalidUtf8: false,
stderrInvalidUtf8: false,
};
}),
});

const createFallbackManager = (
platform: "linux" | "win32",
runner: ProcessRunner.ProcessRunner["Service"],
) =>
createManager(5, { subprocessPollIntervalMs: 1_000, processKillGraceMs: 0 }).pipe(
Effect.provideService(ProcessRunner.ProcessRunner, runner),
Effect.provide(layerWithHostPlatform(platform)),
);

it.effect.each(["linux", "win32"] as const)(
"reports activity from a slow but successful %s process-table query",
(platform) =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>();
const queries: Array<{ start: number; end?: number }> = [];
const { manager, getEvents } = yield* createFallbackManager(
platform,
slowProcessTable(platform, "5 seconds", queries, [started]),
);

yield* manager.open(openInput());
yield* TestClock.adjust("1 second");
yield* Deferred.await(started);
yield* TestClock.adjust("5 seconds");

expect(
(yield* getEvents).some(
(event) =>
event.type === "activity" && event.hasRunningSubprocess && event.label === "node",
),
).toBe(true);
yield* manager.close({ threadId: "thread-1" });
}).pipe(Effect.provide(TestClock.layer())),
);

it.effect("idles at least as long as a slow process-table query took before the next", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>();
const queries: Array<{ start: number; end?: number }> = [];
const { manager } = yield* createFallbackManager(
"linux",
slowProcessTable("linux", "5 seconds", queries, [started]),
);

yield* manager.open(openInput());
yield* TestClock.adjust("1 second");
yield* Deferred.await(started);
yield* TestClock.adjust("5 seconds");
yield* TestClock.adjust(Duration.millis(19_999));

expect(queries).toHaveLength(1);
yield* TestClock.adjust("1 millis");
const [slow, next] = queries;
expect(next?.start).toBe(slow!.end! + 20_000);
yield* manager.close({ threadId: "thread-1" });
}).pipe(Effect.provide(TestClock.layer())),
);

it.effect("ends a hung process-table query and paces the retry", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>();
const queries: Array<{ start: number; end?: number }> = [];
const { manager } = yield* createFallbackManager(
"win32",
slowProcessTable("win32", "1 day", queries, [started]),
);

yield* manager.open(openInput());
yield* TestClock.adjust("1 second");
yield* Deferred.await(started);
yield* TestClock.adjust("2 minutes");
yield* TestClock.adjust("2 minutes");

const [hung, next] = queries;
expect(hung!.end! - hung!.start).toBe(15_000);
expect(next!.start - hung!.end!).toBe(60_000);
yield* manager.close({ threadId: "thread-1" });
}).pipe(Effect.provide(TestClock.layer())),
);

it.effect.each([
["linux", 1_000],
["win32", 1_500],
] as const)(
"bounds closeIdle's %s query so other operations on the thread still run",
([platform, budgetMs]) =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>();
const queries: Array<{ start: number; end?: number }> = [];
const { manager, ptyAdapter } = yield* createFallbackManager(
platform,
slowProcessTable(platform, "1 day", queries, [started]),
);
yield* manager.open(openInput());

const closing = yield* manager
.closeIdle({ threadId: "thread-1" })
.pipe(Effect.forkChild({ startImmediately: true }));
yield* Deferred.await(started);
const resizing = yield* manager
.resize({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cols: 80, rows: 30 })
.pipe(Effect.forkChild({ startImmediately: true }));
yield* TestClock.adjust(Duration.millis(budgetMs));

expect(closing.pollUnsafe()).toBeDefined();
expect(resizing.pollUnsafe()).toBeDefined();
expect(queries[0]!.end! - queries[0]!.start).toBe(budgetMs);
expect(ptyAdapter.processes[0]!.resizeCalls).toEqual([{ cols: 80, rows: 30 }]);
expect(ptyAdapter.processes[0]!.killed).toBe(false);
yield* manager.close({ threadId: "thread-1" });
}).pipe(Effect.provide(TestClock.layer())),
);

it.effect("keeps terminals that get input or output while closeIdle checks them", () =>
Effect.gen(function* () {
const ptyAdapter = new FakePtyAdapter();
Expand Down
Loading
Loading