diff --git a/.changeset/tmp-artifact-sweep.md b/.changeset/tmp-artifact-sweep.md new file mode 100644 index 00000000..e24beced --- /dev/null +++ b/.changeset/tmp-artifact-sweep.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Sweep stale Bun-extracted native libraries from the OS temp directory at startup. Bun single-file executables currently leak one extracted native library per launch (oven-sh/bun#30962), which can fill a tmpfs under high-frequency invocation. Hunk now removes its own stale leaked copies (same user, older than one hour, at most one scan per hour) until the upstream Bun fix ships. Set `HUNK_DISABLE_TMP_SWEEP=1` to opt out. diff --git a/src/core/tmpArtifactSweep.test.ts b/src/core/tmpArtifactSweep.test.ts new file mode 100644 index 00000000..3eb172bd --- /dev/null +++ b/src/core/tmpArtifactSweep.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, test } from "bun:test"; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { sweepStaleTmpArtifacts } from "./tmpArtifactSweep"; + +const SAMPLE_ARTIFACT = ".79ef6acde42ffee7-00000000.so"; +const STAMP_BASENAME = ".hunk-tmp-sweep-stamp"; +const HOUR_MS = 60 * 60 * 1000; + +/** Run one test against a throwaway fake temp directory that is always cleaned up. */ +async function withFakeTmpdir(run: (dir: string) => Promise) { + const dir = mkdtempSync(join(tmpdir(), "hunk-tmp-sweep-test-")); + + try { + await run(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** Create one file whose mtime is set the given number of milliseconds before `nowMs`. */ +function writeFileAgedBy(path: string, nowMs: number, ageMs: number) { + writeFileSync(path, "x"); + const seconds = (nowMs - ageMs) / 1000; + utimesSync(path, seconds, seconds); +} + +describe("stale tmp artifact sweep", () => { + test("removes a matching artifact older than the threshold", async () => { + await withFakeTmpdir(async (dir) => { + const nowMs = 10 * HOUR_MS; + const artifact = join(dir, SAMPLE_ARTIFACT); + writeFileAgedBy(artifact, nowMs, 2 * HOUR_MS); + + await sweepStaleTmpArtifacts({ + tmpdirImpl: () => dir, + now: () => nowMs, + }); + + expect(existsSync(artifact)).toBe(false); + }); + }); + + test("keeps a matching artifact newer than the threshold", async () => { + await withFakeTmpdir(async (dir) => { + const nowMs = 10 * HOUR_MS; + const artifact = join(dir, SAMPLE_ARTIFACT); + writeFileAgedBy(artifact, nowMs, 30 * 60 * 1000); + + await sweepStaleTmpArtifacts({ + tmpdirImpl: () => dir, + now: () => nowMs, + }); + + expect(existsSync(artifact)).toBe(true); + }); + }); + + test("ignores entries that do not match the artifact pattern", async () => { + await withFakeTmpdir(async (dir) => { + const nowMs = 10 * HOUR_MS; + const names = [ + "79ef6acde42ffee7-00000000.so", // not hidden + ".79ef6acde42ffee7-00000000.txt", // wrong extension + ".79ef6acde42ffee7-000000.so", // wrong hex length + ".79ef6acde42ffee.so", // missing suffix segment + ]; + for (const name of names) { + writeFileAgedBy(join(dir, name), nowMs, 2 * HOUR_MS); + } + + await sweepStaleTmpArtifacts({ + tmpdirImpl: () => dir, + now: () => nowMs, + }); + + for (const name of names) { + expect(existsSync(join(dir, name))).toBe(true); + } + }); + }); + + test("does not scan when a recent stamp is present", async () => { + await withFakeTmpdir(async (dir) => { + const nowMs = 10 * HOUR_MS; + const artifact = join(dir, SAMPLE_ARTIFACT); + writeFileAgedBy(artifact, nowMs, 2 * HOUR_MS); + + // A stamp refreshed 10 minutes ago must suppress the sweep entirely. + const stampPath = join(dir, ".hunk-tmp-sweep-stamp"); + writeFileAgedBy(stampPath, nowMs, 10 * 60 * 1000); + + await sweepStaleTmpArtifacts({ + tmpdirImpl: () => dir, + now: () => nowMs, + }); + + expect(existsSync(artifact)).toBe(true); + }); + }); + + test("does nothing when disabled via the opt-out environment variable", async () => { + await withFakeTmpdir(async (dir) => { + const nowMs = 10 * HOUR_MS; + const artifact = join(dir, SAMPLE_ARTIFACT); + writeFileAgedBy(artifact, nowMs, 2 * HOUR_MS); + + await sweepStaleTmpArtifacts({ + env: { HUNK_DISABLE_TMP_SWEEP: "1" }, + tmpdirImpl: () => dir, + now: () => nowMs, + }); + + expect(existsSync(artifact)).toBe(true); + }); + }); + + test("never throws when a matching entry cannot be removed", async () => { + await withFakeTmpdir(async (dir) => { + const nowMs = 10 * HOUR_MS; + // A directory that matches the pattern is not a regular file, so unlink is + // never attempted and the sweep must complete without surfacing an error. + const artifactDir = join(dir, SAMPLE_ARTIFACT); + mkdirSync(artifactDir); + utimesSync(artifactDir, (nowMs - 2 * HOUR_MS) / 1000, (nowMs - 2 * HOUR_MS) / 1000); + + await expect( + sweepStaleTmpArtifacts({ + tmpdirImpl: () => dir, + now: () => nowMs, + }), + ).resolves.toBeUndefined(); + + expect(existsSync(artifactDir)).toBe(true); + }); + }); + + test("never throws when the temp directory does not exist", async () => { + const missingDir = join(tmpdir(), "hunk-tmp-sweep-missing-0123456789"); + + await expect( + sweepStaleTmpArtifacts({ + tmpdirImpl: () => missingDir, + now: () => 10 * HOUR_MS, + }), + ).resolves.toBeUndefined(); + }); + + test("aborts without touching a symlinked stamp on a hostile temp directory", async () => { + await withFakeTmpdir(async (dir) => { + const nowMs = 10 * HOUR_MS; + const artifact = join(dir, SAMPLE_ARTIFACT); + writeFileAgedBy(artifact, nowMs, 2 * HOUR_MS); + + // Simulate tmp squatting: the stamp path is a symlink to an unrelated file + // the current user can write. The sweep must refuse to truncate the target. + const victim = join(dir, "victim.txt"); + writeFileSync(victim, "important"); + symlinkSync(victim, join(dir, STAMP_BASENAME)); + + await sweepStaleTmpArtifacts({ + tmpdirImpl: () => dir, + now: () => nowMs, + }); + + expect(existsSync(artifact)).toBe(true); + expect(readFileSync(victim, "utf8")).toBe("important"); + }); + }); + + test("exclusively creates a regular-file stamp on the first run and sweeps", async () => { + await withFakeTmpdir(async (dir) => { + const nowMs = 10 * HOUR_MS; + const artifact = join(dir, SAMPLE_ARTIFACT); + writeFileAgedBy(artifact, nowMs, 2 * HOUR_MS); + + const stampPath = join(dir, STAMP_BASENAME); + expect(existsSync(stampPath)).toBe(false); + + await sweepStaleTmpArtifacts({ + tmpdirImpl: () => dir, + now: () => nowMs, + }); + + expect(existsSync(artifact)).toBe(false); + expect(lstatSync(stampPath).isFile()).toBe(true); + }); + }); +}); diff --git a/src/core/tmpArtifactSweep.ts b/src/core/tmpArtifactSweep.ts new file mode 100644 index 00000000..318e7bea --- /dev/null +++ b/src/core/tmpArtifactSweep.ts @@ -0,0 +1,150 @@ +// Interim mitigation for Bun single-file executables leaking their extracted +// native libraries into the OS temp directory on every launch (oven-sh/bun#30962). +// Each run drops a hidden `.{16hex}-{8hex}.(so|dylib|dll)` file that is never +// reused, so high-frequency invocations can fill a tmpfs. This best-effort +// startup sweep removes stale copies until Bun's own extraction dedupe fix +// (oven-sh/bun#29587) ships in a hunk release, at which point this module can be +// deleted outright. + +import { lstat, readdir, unlink, utimes, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const DISABLE_TMP_SWEEP_ENV = "HUNK_DISABLE_TMP_SWEEP"; +const SWEEP_STAMP_BASENAME = ".hunk-tmp-sweep-stamp"; +const DEFAULT_MAX_AGE_MS = 60 * 60 * 1000; +const DEFAULT_SWEEP_INTERVAL_MS = 60 * 60 * 1000; +const ARTIFACT_PATTERN = /^\.[0-9a-f]{16}-[0-9a-f]{8}\.(so|dylib|dll)$/; + +export interface TmpArtifactSweepDeps { + env?: NodeJS.ProcessEnv; + tmpdirImpl?: () => string; + now?: () => number; + maxAgeMs?: number; + sweepIntervalMs?: number; + getuid?: () => number | undefined; +} + +/** Resolve the current process uid, or undefined on platforms without one (Windows). */ +function resolveProcessUid(): number | undefined { + return typeof process.getuid === "function" ? process.getuid() : undefined; +} + +/** Refresh the stamp's modification time to the sweep clock. */ +async function stampNow(stampPath: string, nowMs: number): Promise { + const seconds = nowMs / 1000; + await utimes(stampPath, seconds, seconds); +} + +/** + * Rate-limit and claim the sweep, returning whether the caller may proceed. + * + * The stamp must be a regular file owned by the current user; anything else + * (symlink, directory, foreign owner) aborts the sweep so a hostile shared temp + * directory cannot redirect the truncate. Claiming happens before the readdir + * (claim-first) so concurrent starts do not all scan at once. + */ +async function claimSweep( + stampPath: string, + nowMs: number, + intervalMs: number, + uid: number | undefined, +): Promise { + let stats: Awaited> | undefined; + try { + stats = await lstat(stampPath); + } catch { + // Most likely ENOENT: fall through to the exclusive-create path below. + stats = undefined; + } + + if (stats) { + if (!stats.isFile() || (uid !== undefined && stats.uid !== uid)) { + return false; + } + + if (nowMs - stats.mtimeMs < intervalMs) { + return false; + } + + try { + await writeFile(stampPath, ""); + await stampNow(stampPath, nowMs); + return true; + } catch { + return false; + } + } + + try { + // Exclusive create: if another start wins the race, treat the sweep as claimed. + await writeFile(stampPath, "", { flag: "wx" }); + await stampNow(stampPath, nowMs); + return true; + } catch { + return false; + } +} + +/** Remove one directory entry when it is a stale, same-owner Bun artifact. */ +async function maybeRemoveArtifact( + dir: string, + name: string, + cutoffMs: number, + uid: number | undefined, +): Promise { + if (!ARTIFACT_PATTERN.test(name)) { + return; + } + + const fullPath = join(dir, name); + try { + const stats = await lstat(fullPath); + if (!stats.isFile()) { + return; + } + + if (uid !== undefined && stats.uid !== uid) { + return; + } + + if (stats.mtimeMs > cutoffMs) { + return; + } + + await unlink(fullPath); + } catch { + // Best-effort: a locked, vanished, or otherwise unremovable artifact is skipped. + } +} + +/** Remove stale Bun-extracted native artifacts from the OS temp directory, best-effort. */ +export async function sweepStaleTmpArtifacts(deps: TmpArtifactSweepDeps = {}): Promise { + const env = deps.env ?? process.env; + if (env[DISABLE_TMP_SWEEP_ENV] === "1") { + return; + } + + const tmpdirImpl = deps.tmpdirImpl ?? tmpdir; + const now = deps.now ?? Date.now; + const maxAgeMs = deps.maxAgeMs ?? DEFAULT_MAX_AGE_MS; + const sweepIntervalMs = deps.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS; + const getuid = deps.getuid ?? resolveProcessUid; + + try { + const dir = tmpdirImpl(); + const stampPath = join(dir, SWEEP_STAMP_BASENAME); + const nowMs = now(); + const uid = getuid(); + + if (!(await claimSweep(stampPath, nowMs, sweepIntervalMs, uid))) { + return; + } + + const cutoffMs = nowMs - maxAgeMs; + const entries = await readdir(dir); + await Promise.all(entries.map((name) => maybeRemoveArtifact(dir, name, cutoffMs, uid))); + } catch { + // Never surface temp-directory access errors to the caller. + } +} diff --git a/src/main.tsx b/src/main.tsx index b11c79e7..ffe92dc7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -15,6 +15,7 @@ import { shutdownSession } from "./core/shutdown"; import { renderStaticDiffPager } from "./ui/staticDiffPager"; import { prepareStartupPlan } from "./core/startup"; import { shouldUseMouseForApp } from "./core/terminal"; +import { sweepStaleTmpArtifacts } from "./core/tmpArtifactSweep"; import { resolveStartupUpdateNotice } from "./core/updateNotice"; import { AppHost } from "./ui/AppHost"; import { SessionBrokerClient } from "./session-broker/brokerClient"; @@ -32,11 +33,21 @@ import type { import { runSessionCommand } from "./session/commands"; async function main() { + // Start the best-effort sweep of stale Bun-extracted tmp artifacts up front so + // even the shortest-lived commands can await it right before exiting. + const sweep = sweepStaleTmpArtifacts(); + + /** Await the best-effort tmp sweep before exiting, since process.exit drops pending work. */ + async function exitAfterSweep(code: number): Promise { + await sweep; + process.exit(code); + } + const startupPlan = await prepareStartupPlan(); if (startupPlan.kind === "help") { process.stdout.write(startupPlan.text); - process.exit(0); + await exitAfterSweep(0); } if (startupPlan.kind === "daemon-serve") { @@ -47,17 +58,17 @@ async function main() { if (startupPlan.kind === "session-command") { process.stdout.write(await runSessionCommand(startupPlan.input)); - process.exit(0); + await exitAfterSweep(0); } if (startupPlan.kind === "markup-guide") { const { runMarkupGuideCommand } = await import("./ui/lib/stml/cli"); - process.exit(runMarkupGuideCommand({ stdout: (text) => process.stdout.write(text) })); + await exitAfterSweep(runMarkupGuideCommand({ stdout: (text) => process.stdout.write(text) })); } if (startupPlan.kind === "markup-render") { const { runMarkupRenderCommand } = await import("./ui/lib/stml/cli"); - process.exit( + await exitAfterSweep( await runMarkupRenderCommand(startupPlan.input, { stdout: (text) => process.stdout.write(text), stderr: (text) => process.stderr.write(text), @@ -69,12 +80,12 @@ async function main() { if (startupPlan.kind === "plain-text-pager") { await pagePlainText(startupPlan.text); - process.exit(0); + await exitAfterSweep(0); } if (startupPlan.kind === "passthrough") { process.stdout.write(sanitizeTerminalText(startupPlan.text)); - process.exit(0); + await exitAfterSweep(0); } if (startupPlan.kind === "static-diff-pager") { @@ -84,7 +95,7 @@ async function main() { stderr: process.stderr, }), ); - process.exit(0); + await exitAfterSweep(0); } if (startupPlan.kind !== "app") {