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
8 changes: 7 additions & 1 deletion e2e/pty/glitter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@ const PAINT_TIMEOUT = 15_000;
beforeAll(() => {
// The board is half of the binary under test; a run against a stale or
// missing helper would gate nothing.
execFileSync("bun", ["run", "ui:build"], { cwd: REPO_ROOT, stdio: "pipe" });
// HOME is the run's throwaway dir, so the module cache lands in it and
// must stay deletable.
execFileSync("bun", ["run", "ui:build"], {
cwd: REPO_ROOT,
stdio: "pipe",
env: { ...process.env, GOFLAGS: [process.env.GOFLAGS, "-modcacherw"].filter(Boolean).join(" ") },
});
if (!existsSync(RT_UI_BIN)) throw new Error(`ui:build produced no binary at ${RT_UI_BIN}`);
});

Expand Down
41 changes: 40 additions & 1 deletion lib/__tests__/test-home-isolation.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test } from "bun:test";
import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { dirname, join } from "path";

const REPO_ROOT = join(import.meta.dir, "..", "..");
const PROBE = join("lib", "__tests__", "env-scrub-probe.test.ts");
Expand Down Expand Up @@ -30,3 +31,41 @@ test("an ambient RT_DAEMON_SOCK is scrubbed and forbidden for a bun test run", a
}
expect(exitCode).toBe(0);
}, 30_000);

// A run killed before its afterAll leaves its dirs behind, and a Go build
// under that run's HOME leaves a module cache whose dirs are read-only. The
// next run's preload must still sweep it, or every test in that run fails
// before starting.
test("a dead run's dir holding a read-only Go module cache is swept by the next run", async () => {
const testRoot = dirname(tmpdir());
const dead = Bun.spawn(["true"]);
await dead.exited;
const stale = join(testRoot, `${dead.pid}-home-sweep-probe`);
const modDir = join(stale, "go", "pkg", "mod", "example.com", "m@v1.0.0");
mkdirSync(modDir, { recursive: true });
writeFileSync(join(modDir, "go.mod"), "module example.com/m\n");
chmodSync(modDir, 0o555);
chmodSync(dirname(modDir), 0o555);
try {
const proc = Bun.spawn(["bun", "test", PROBE], {
cwd: REPO_ROOT,
env: { ...process.env, TMPDIR: dirname(testRoot) },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`probe run failed (exit ${exitCode})\nstdout:\n${stdout}\nstderr:\n${stderr}`);
}
expect(existsSync(stale)).toBe(false);
} finally {
if (existsSync(stale)) {
Bun.spawnSync(["chmod", "-R", "u+w", stale]);
rmSync(stale, { recursive: true, force: true });
}
}
}, 30_000);
27 changes: 23 additions & 4 deletions test-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* own explicit HOME when spawning the binary, so this never reaches them.
*/
import { afterAll } from "bun:test";
import { lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from "fs";
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from "fs";
import { spawn } from "child_process";
import { tmpdir } from "os";
import { join } from "path";
Expand Down Expand Up @@ -48,7 +48,7 @@ if (!rootStat.isDirectory() || rootStat.uid !== process.getuid?.()) {
for (const name of readdirSync(testRoot)) {
const pid = Number(name.split("-")[0]);
if (Number.isInteger(pid) && pid > 0 && pidIsAlive(pid)) continue;
rmSync(join(testRoot, name), { recursive: true, force: true });
removeTree(join(testRoot, name));
}
const runTmp = mkdtempSync(join(testRoot, `${process.pid}-run-`));
const home = mkdtempSync(join(testRoot, `${process.pid}-home-`));
Expand All @@ -57,11 +57,30 @@ process.env.TMPDIR = runTmp;
process.env.HOME = home;
process.env.RT_TEST_SOCKET_DIR = socketDir;
// A whole run's tree takes longer to delete than bun's 5s hook timeout, so
// a detached rm does it after the process exits.
// a detached rm does it after the process exits. Go writes its module cache
// read-only, so a go build under this HOME needs the chmod first.
afterAll(() => {
spawn("rm", ["-rf", runTmp, home, socketDir], { detached: true, stdio: "ignore" }).unref();
spawn("sh", ["-c", 'chmod -R u+w "$@" 2>/dev/null; exec rm -rf "$@"', "sh", runTmp, home, socketDir], {
detached: true,
stdio: "ignore",
}).unref();
});

function removeTree(path: string): void {
try {
rmSync(path, { recursive: true, force: true });
} catch {
makeDirsWritable(path);
rmSync(path, { recursive: true, force: true });
}
}

function makeDirsWritable(path: string): void {
if (!lstatSync(path).isDirectory()) return;
chmodSync(path, 0o700);
for (const name of readdirSync(path)) makeDirsWritable(join(path, name));
}

function pidIsAlive(pid: number): boolean {
try {
process.kill(pid, 0);
Expand Down
Loading