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
177 lines
8.2 KiB
TypeScript
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);
|
|
});
|