Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/tmp-artifact-sweep.md
Original file line number Diff line number Diff line change
@@ -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.
200 changes: 200 additions & 0 deletions src/core/tmpArtifactSweep.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>) {
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);
});
});
});
150 changes: 150 additions & 0 deletions src/core/tmpArtifactSweep.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<boolean> {
let stats: Awaited<ReturnType<typeof lstat>> | 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<void> {
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<void> {
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.
}
}
Loading