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
23 changes: 23 additions & 0 deletions .ai/skills/hack-repo-verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,29 @@ source provenance. Raw `bun test <paths>` remains available for focused tests;
it bypasses the suite preflight and does not prepare compiler-dependent fixtures.

All test files share one Bun process. `bunfig.toml` preloads
private tmux isolation before the state guard: every run owns a short socket
directory and stops its server in `afterAll`, including assertion failures.
`privateCliEnv` forwards that socket root, never inherited `TMUX`. Direct
`Bun.spawn`/`spawnSync` tmux calls require a private 0700 directory registered
by the fixture with `registerPrivateTmuxRoot`; a separate socket fixture must
stop its own server before removing its root. The E2E scenario runner owns a
separate tmux scope and closes it in `finally`. All Bun and Node child-process
calls receive an explicit live environment with inherited `TMUX` removed and
a registered private `TMUX_TMPDIR`, even when a caller supplies a minimal env.
Scripts therefore inherit isolation too; a script that deliberately resets its
environment or names an explicit socket must maintain that isolation itself.
`Bun.$` uses the live environment by default, but an explicit `.env(...)` is
not wrapped: supply the private `TMUX_TMPDIR` in that object too.
The exact-environment compiler transport tests use
`withCompilerFixtureEnvironment` only around a freshly-created pure compiler
fixture that reports its input/env and never invokes tmux. Only that exact
executable keeps the explicit compiler env; other children in the callback
and every child outside it remain isolated. Keep the PATH-only assertions.
The fixed `/usr/bin/env -0` native-state probe remains unwrapped so the global
state guard can still detect Bun's stale native environment. Tmux cleanup is
registered last, after the checkout guard has finished spawning Git children.

`bunfig.toml` also preloads
[the global-state guard](../../../tests/helpers/global-state-guard-preload.ts) for
every `bun test` run: a test or file that leaves the environment, cwd, umask,
exit code, a guarded global function or a process listener changed fails with a
Expand Down
4 changes: 4 additions & 0 deletions bunfig.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@
root = "./tests"
# preload: replaces Bun.secrets with an in-memory backend and disables the native
# keychain. It runs first, so the state guard's baseline already has the flag.
# tmux-isolation: owns a private server, guards direct tmux spawns, and stops
# that server in afterAll, including when a test fails (HACK-2242).
# global-state-guard: fails a test or file that leaves PATH, HOME, cwd or other
# process state changed, and restores it so later files run clean (HACK-1327).
# checkout-write-guard: fails the run when a test creates or changes a file in
# the checkout (HACK-1343).
preload = [
"./tests/preload.ts",
"./tests/helpers/tmux-isolation-preload.ts",
"./tests/helpers/global-state-guard-preload.ts",
"./tests/helpers/checkout-write-guard-preload.ts",
"./tests/helpers/tmux-isolation-cleanup-preload.ts",
]
6 changes: 6 additions & 0 deletions tests/cli-spawn-env-isolation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ import {
const ALLOWED: Readonly<
Record<string, { readonly reason: string; readonly count: number }>
> = {
"tests/helpers/tmux-socket-guard.ts": {
reason:
"child spawn forwarding pins the private tmux environment, not a source CLI invocation",
count: 1,
},
// `runCommand` runs git and docker for the real-engine E2E suite, not the
// CLI. CLI invocations in this harness use `buildCliEnv`.
"tests/e2e/harness.ts": {
Expand Down Expand Up @@ -434,6 +439,7 @@ test("privateCliEnv keeps only the allow-list, test values and a private HOME",
"HACK_HOME",
"HOME",
"PATH",
"TMUX_TMPDIR",
PRIVATE_CLI_DAEMON_AUTOSTART_ENV,
].sort()
);
Expand Down
4 changes: 4 additions & 0 deletions tests/daemon-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { startRequestTargetProxy } from "../src/daemon/request-target-proxy.ts";
import { handleSessionRoutes } from "../src/daemon/routes/sessions.ts";
import { exec } from "../src/lib/shell.ts";
import { restoreEnv } from "./helpers/env.ts";
import { registerPrivateTmuxRoot } from "./helpers/tmux-socket-guard.ts";

/** Real session-route tests require tmux, always on a task-owned socket. */
const tmuxPath = Bun.which("tmux");
Expand All @@ -33,6 +34,7 @@ let sessionRoot = "";
let sessionSocket = "";
let savedPath: string | undefined;
let pathSaved = false;
let unregisterSocketRoot = () => {};

/** Never address the default server, even when a route case fails after create. */
function privateTmux(args: readonly string[]) {
Expand Down Expand Up @@ -530,6 +532,7 @@ describe.skipIf(!hasTmux)("handleSessionRoutes", () => {
beforeAll(async () => {
// Short path for Darwin's Unix socket limit; each process owns a new server.
sessionRoot = await mkdtemp("/tmp/hack-session-routes-");
unregisterSocketRoot = registerPrivateTmuxRoot(sessionRoot);
sessionSocket = join(sessionRoot, "tmux.sock");
await mkdir(join(sessionRoot, "bin"));
// The route executes real tmux through this shim. Never inherit a caller's
Expand Down Expand Up @@ -563,6 +566,7 @@ describe.skipIf(!hasTmux)("handleSessionRoutes", () => {
await privateTmux(["kill-server"]);
} finally {
await rm(sessionRoot, { recursive: true, force: true });
unregisterSocketRoot();
sessionRoot = "";
sessionSocket = "";
}
Expand Down
25 changes: 23 additions & 2 deletions tests/e2e/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
LAUNCHD_DISABLE_ENV,
} from "../../src/daemon/launchd.ts";
import { fixtureGitEnv } from "../helpers/fixture-git.ts";
import { privateTmux } from "../helpers/private-tmux.ts";

import {
type ComposeResource,
Expand Down Expand Up @@ -74,6 +75,7 @@ const PASSTHROUGH_ENV_KEYS = [
"PATH",
"HOME",
"TMPDIR",
"TMUX_TMPDIR",
"USER",
"SHELL",
"LANG",
Expand Down Expand Up @@ -1010,6 +1012,17 @@ function newNames(opts: {
*/
export async function runScenarios(
opts: RunScenariosOptions
): Promise<ScenarioOutcome[]> {
const mux = privateTmux();
try {
return await runIsolatedScenarios(opts);
} finally {
mux.close();
}
}

async function runIsolatedScenarios(
opts: RunScenariosOptions
): Promise<ScenarioOutcome[]> {
const outcomes: ScenarioOutcome[] = [];
const selected = selectScenarios(opts);
Expand Down Expand Up @@ -1090,8 +1103,7 @@ export async function runScenarios(
durationMs: Date.now() - startedAt,
reason: error instanceof Error ? error.message : String(error),
};
preserveFailedFixture =
outcome.status === "fail" && scenario.preserveFixtureOnFailure === true;
preserveFailedFixture = shouldPreserveFailedFixture(outcome, scenario);
} finally {
await cleanupScenarioRoots({
keep: opts.keepTempDirs === true || preserveFailedFixture,
Expand All @@ -1117,6 +1129,15 @@ export async function runScenarios(
return outcomes;
}

function shouldPreserveFailedFixture(
outcome: ScenarioOutcome,
scenario: Scenario
): boolean {
return (
outcome.status === "fail" && scenario.preserveFixtureOnFailure === true
);
}

async function applyDockerLeakCheck(opts: {
readonly outcome: ScenarioOutcome;
readonly docker: DockerRunner;
Expand Down
10 changes: 9 additions & 1 deletion tests/e2e/run.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { privateTmux } from "../helpers/private-tmux.ts";
import { installTmuxSocketGuard } from "../helpers/tmux-socket-guard.ts";
import {
describeResources,
listFixtureNetworks,
Expand Down Expand Up @@ -271,7 +273,13 @@ async function main(): Promise<void> {
path: launchAgentPath,
});
try {
await runSuite({ args, shardNames });
installTmuxSocketGuard();
const mux = privateTmux();
try {
await runSuite({ args, shardNames });
} finally {
mux.close();
}
} finally {
const change = describeLaunchAgentChange({
before: launchAgentBefore,
Expand Down
12 changes: 4 additions & 8 deletions tests/helpers/global-state-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
addProcessListener,
isProcessOnceListener,
} from "./process-once-listeners.ts";
import { probeNativeTestEnvironment } from "./tmux-socket-guard.ts";

/**
* Process events whose listener lists are compared. A change is reported per
Expand Down Expand Up @@ -384,9 +385,6 @@ export function restoreProcessListeners(
*/
export type NativeEnv = ReadonlyMap<string, string>;

// Bound at load so a leaked `Bun.spawnSync` fake can never answer for Bun.
const realSpawnSync = Bun.spawnSync.bind(Bun);

let nativeEnvSourceForGuardTest: NativeEnv | undefined;

/**
Expand Down Expand Up @@ -421,11 +419,9 @@ export function captureNativeEnv(): NativeEnv | undefined {
return presented;
}
try {
const child = realSpawnSync(["/usr/bin/env", "-0"], {
stdin: "ignore",
stdout: "pipe",
stderr: "ignore",
});
// Bound before child isolation: the probe must observe Bun's native
// snapshot rather than the live environment injected into test children.
const child = probeNativeTestEnvironment();
if (child.exitCode !== 0 || !child.stdout) {
return;
}
Expand Down
6 changes: 5 additions & 1 deletion tests/helpers/private-cli-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import { requireFixtureRoot } from "./fixture-root.ts";
* `CI`, credentials and shell state. A harness passes what its test needs
* through `env` instead.
*/
export const PRIVATE_CLI_INHERITED_ENV = ["PATH", "TMPDIR"] as const;
export const PRIVATE_CLI_INHERITED_ENV = [
"PATH",
"TMPDIR",
"TMUX_TMPDIR",
] as const;

/**
* Set to `1` on every private child unless the test passes its own value in
Expand Down
80 changes: 80 additions & 0 deletions tests/helpers/private-tmux.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { lstatSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { restoreEnv } from "./env.ts";
import { registerPrivateTmuxRoot } from "./tmux-socket-guard.ts";

/** Every Unix socket under a private root; symlinks are never followed. */
export function privateSockets(root: string): string[] {
const sockets: string[] = [];
for (const entry of readdirSync(root, { withFileTypes: true })) {
const path = join(root, entry.name);
if (entry.isDirectory()) {
sockets.push(...privateSockets(path));
} else if (lstatSync(path).isSocket()) {
sockets.push(path);
}
}
return sockets;
}

/** Own one short socket root, and always stop its server before removing it. */
export function privateTmux(opts: { restoreEnvironment?: boolean } = {}): {
close(): void;
} {
const root = mkdtempSync("/tmp/hack-test-mux-");
const unregister = registerPrivateTmuxRoot(root);
const savedRoot = process.env.TMUX_TMPDIR;
const savedTmux = process.env.TMUX;
const tmux = Bun.which("tmux");
let closed = false;
process.env.TMUX_TMPDIR = root;
Reflect.deleteProperty(process.env, "TMUX");
return {
close() {
if (closed) {
return;
}
let stopped = tmux === null;
try {
if (tmux) {
// The guard accepts `-L name` and `-S <root>/x.sock`, so a test can
// start servers besides the root's default one. Stop every socket
// under the root, not just the default server.
stopped = privateSockets(root).every((socket) => {
// tmux keeps `-L`/default sockets in `<root>/tmux-<uid>/`; that
// directory is inside this owned root, so admit it for the kill.
const unregisterDir = registerPrivateTmuxRoot(dirname(socket));
// Explicit environment cannot be redirected by a failing test.
const result = Bun.spawnSync([tmux, "-S", socket, "kill-server"], {
env: { PATH: process.env.PATH ?? "", TMUX_TMPDIR: root },
stdout: "ignore",
stderr: "ignore",
timeout: 5000,
});
if (dirname(socket) !== root) {
unregisterDir();
}
// 1 means no server answered (a stale socket). A timeout/signal is
// not cleanup: retain the root so a live server's socket is kept.
return [0, 1].includes(result.exitCode) && !result.signalCode;
});
if (!stopped) {
throw new Error(
"Private test tmux server did not stop; retained its socket root."
);
}
}
} finally {
if (opts.restoreEnvironment !== false) {
restoreEnv("TMUX_TMPDIR", savedRoot);
restoreEnv("TMUX", savedTmux);
}
if (stopped) {
unregister();
rmSync(root, { recursive: true, force: true });
closed = true;
}
}
},
};
}
5 changes: 5 additions & 0 deletions tests/helpers/tmux-isolation-cleanup-preload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { afterAll } from "bun:test";
import { testMux } from "./tmux-isolation-preload.ts";

// Register last: the checkout guard also spawns children during its teardown.
afterAll(() => testMux.close());
7 changes: 7 additions & 0 deletions tests/helpers/tmux-isolation-preload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { privateTmux } from "./private-tmux.ts";
import { installTmuxSocketGuard } from "./tmux-socket-guard.ts";

// Before the global-state baseline. This covers in-process mux calls and
// children using privateCliEnv, including daemon route validation tests.
installTmuxSocketGuard();
export const testMux = privateTmux({ restoreEnvironment: false });
Loading
Loading