Files
dev/third_party/pi/packages/env/test/remote.test.ts
alexion 6081e3e325 chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
2026-10-15 17:58:48 -04:00

177 lines
8.2 KiB
TypeScript

import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT, withAbortSignal } from "@earendil-works/chord/context";
import { getOrThrow, type ShellOutputInfo } from "@earendil-works/pi-durable/env";
import { afterAll, describe, expect, it } from "vitest";
import { Connection, nodePlatform } from "../src/connection.ts";
import { RemoteExecutionEnv } from "../src/remote-env.ts";
import { daemon } from "./daemon.ts";
const context = BACKGROUND_CONTEXT;
const connections: Connection[] = [];
const dirs: string[] = [];
afterAll(() => {
for (const connection of connections) connection.close();
for (const dir of dirs) rmSync(dir, { recursive: true, force: true, maxRetries: 50, retryDelay: 100 });
});
function environment(): { env: RemoteExecutionEnv; connection: Connection } {
const cwd = mkdtempSync(join(tmpdir(), "pi-env-remote-"));
dirs.push(cwd);
const connection = new Connection({ command: [daemon] });
connections.push(connection);
return { env: new RemoteExecutionEnv({ connection, id: "pi-env:test", cwd }), connection };
}
describe("RemoteExecutionEnv", () => {
it("transfers only about a window of output a caller keeps, with exact counts", async () => {
const { env } = environment();
const total = 5_000_000;
let delivered = 0;
let skipped = 0;
let tail = "";
const result = await env.exec(
["sh", "-c", `yes 0123456789abcdef | head -c ${total}`],
{
window: { maxBytes: 1000, maxLines: 20, minIntervalMs: 20, bytesPerSecond: 1_000_000 },
onOutput: (text: string, _context, info: ShellOutputInfo) => {
delivered += Buffer.byteLength(text);
if (info.skipped) {
skipped += info.skipped.bytes;
tail = "";
}
tail += text;
},
},
context,
);
expect(getOrThrow(result).exitCode).toBe(0);
expect(delivered + skipped).toBe(total);
expect(skipped).toBeGreaterThan(0);
expect(delivered).toBeLessThan(total / 10);
// After the last skip comes everything to the end, more than the window.
const full = "0123456789abcdef\n".repeat(Math.ceil(total / 17)).slice(0, total);
expect(full.endsWith(tail)).toBe(true);
expect(tail.length > 1000 || tail.split("\n").length - 1 > 20).toBe(true);
}, 60_000);
it("aborts commands cancelled right after they are sent", async () => {
const { env } = environment();
const started = Date.now();
for (let attempt = 0; attempt < 20; attempt++) {
const controller = new AbortController();
const running = env.exec(["sleep", "5"], undefined, withAbortSignal(controller.signal, context));
if (attempt % 2 === 0) controller.abort();
else setTimeout(() => controller.abort(), attempt % 5);
const result = await running;
expect(result.ok ? "ok" : result.error.code).toBe("aborted");
}
expect(Date.now() - started).toBeLessThan(10_000);
}, 30_000);
it("reports the remote system in Node's names", async () => {
const { connection } = environment();
const { platform, arch } = nodePlatform(await connection.info());
expect(platform).toBe(process.platform);
// CI runs the darwin-x64 release daemon on arm64 macOS through Rosetta, so it reports x64 to arm64 Node.
const rosetta = process.platform === "darwin" && process.arch === "arm64" && arch === "x64";
if (!rosetta) expect(arch).toBe(process.arch);
expect(nodePlatform({ os: "windows", arch: "aarch64" })).toEqual({ platform: "win32", arch: "arm64" });
expect(nodePlatform({ os: "macos", arch: "x86_64" })).toEqual({ platform: "darwin", arch: "x64" });
});
it("reports listFiles as not supported by daemons released before it", async () => {
const { env } = environment();
expect((await env.connection.info()).features).toContain("listFiles");
const connection = new Connection({ command: [process.execPath, join(import.meta.dirname, "old-daemon.ts")] });
connections.push(connection);
const old = new RemoteExecutionEnv({ connection, id: "pi-env:old", cwd: env.cwd });
const listed = await old.listFiles(".", undefined, context);
expect(listed.ok ? "ok" : listed.error.code).toBe("not_supported");
});
it("cancels a listing in the daemon", async () => {
const { env, connection } = environment();
for (let dir = 0; dir < 100; dir++) {
mkdirSync(join(env.cwd, `d${dir}`));
for (let file = 0; file < 100; file++) writeFileSync(join(env.cwd, `d${dir}`, `f${file}`), "");
}
// The cancel follows the request at once, bypassing the client's own abort check, so the daemon stops the walk.
const controller = new AbortController();
controller.abort();
const cancelled = connection.request("listFiles", { path: env.cwd }, { signal: controller.signal });
await expect(cancelled).rejects.toMatchObject({ code: "aborted" });
const listed = getOrThrow(await env.listFiles(".", undefined, context));
expect([listed.entries.length, listed.truncated]).toEqual([10_100, false]);
const bounded = getOrThrow(await env.listFiles(".", { maxEntries: 5000 }, context));
expect([bounded.entries.length, bounded.truncated]).toEqual([5000, true]);
// Creating 10,000 files takes several seconds on Windows CI.
}, 30_000);
it("times out a command that produces no output", async () => {
const { env } = environment();
const started = Date.now();
const result = await env.exec(["sleep", "5"], { timeout: 0.3 }, context);
expect(result.ok ? "ok" : result.error.code).toBe("timeout");
expect(Date.now() - started).toBeLessThan(3000);
});
it("fails requests on handles of a lost connection instead of reaching the new daemon", async () => {
const { env, connection } = environment();
getOrThrow(await env.writeFile("a.txt", "AAA", context));
getOrThrow(await env.writeFile("b.txt", "BBB", context));
const a = getOrThrow(await env.openBinaryReader("a.txt", undefined, context));
const { pid } = await connection.info();
process.kill(pid, "SIGKILL");
await new Promise((done) => setTimeout(done, 300));
// The new daemon numbers its handles from 1 again.
const b = getOrThrow(await env.openBinaryReader("b.txt", undefined, context));
expect((await connection.info()).pid).not.toBe(pid);
const stale = await a.read(0, 3, context);
expect(stale.ok ? new TextDecoder().decode(stale.value) : stale.error.code).toBe("unknown");
expect(new TextDecoder().decode(getOrThrow(await b.read(0, 3, context)))).toBe("BBB");
await a.close(context);
await b.close(context);
});
it("computes the command at each start and retries a start that failed", async () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-env-remote-"));
dirs.push(cwd);
let starts = 0;
const connection = new Connection({
command: async () => {
starts++;
if (starts === 1) throw new Error("ssh gpu-box failed: Network is unreachable");
return [daemon];
},
});
connections.push(connection);
const env = new RemoteExecutionEnv({ connection, id: "pi-env:test", cwd });
const offline = await env.readTextFile("missing.txt", context);
expect(offline.ok ? "ok" : [offline.error.code, offline.error.message]).toEqual([
"unknown",
"ssh gpu-box failed: Network is unreachable",
]);
const command = await env.exec(["sh", "-c", "exit 0"], undefined, context);
expect(command.ok ? command.value.exitCode : command.error.code).toBe(0);
expect(starts).toBe(2);
});
it("writes and reads files larger than one transfer chunk in order", async () => {
const { env } = environment();
const content = new Uint8Array(3_500_017);
for (let index = 0; index < content.length; index++) content[index] = (index * 7919) % 251;
getOrThrow(await env.writeFile("big.bin", content, context));
getOrThrow(await env.appendFile("big.bin", content, context));
const read = getOrThrow(await env.readBinaryFile("big.bin", context));
expect(read.length).toBe(content.length * 2);
expect(Buffer.compare(Buffer.from(read.subarray(0, content.length)), Buffer.from(content))).toBe(0);
expect(Buffer.compare(Buffer.from(read.subarray(content.length)), Buffer.from(content))).toBe(0);
const reader = getOrThrow(await env.openBinaryReader("big.bin", undefined, context));
const range = getOrThrow(await reader.read(1_000_003, 2_000_000, context));
await reader.close(context);
expect(Buffer.compare(Buffer.from(range), Buffer.from(read.subarray(1_000_003, 3_000_003)))).toBe(0);
}, 60_000);
});