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/stable-native-lib-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Stop leaking one extracted native library per launch from compiled binaries. Bun single-file executables extract their embedded OpenTUI library to the OS temp directory under a fresh random name on every run and never remove it (oven-sh/bun#30962, #556), which could fill a tmpfs under repeated invocation (git pager loops, agent polling). The compiled binary now writes the embedded library once to a stable content-addressed path under the user cache directory (`~/.cache/hunk/native` on Linux, `~/Library/Caches/hunk/native` on macOS, `%LOCALAPPDATA%\hunk\native` on Windows) and reuses it across runs. If the cache is unavailable, hunk falls back to the previous behavior rather than failing to start.
8 changes: 8 additions & 0 deletions .changeset/wrap-agent-notes-by-cells.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"hunkdiff": patch
---

Wrap plain-text agent notes by terminal cells instead of UTF-16 code
units, so CJK and emoji text wraps correctly instead of being truncated
with silent content loss. Long unbroken words split on grapheme
boundaries, so wide characters and surrogate pairs are never cut apart.
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,20 @@ jobs:
- name: Build binary
run: bun run build:bin

# Tripwire for the native-lib leak (oven-sh/bun#30962): if the build
# plugin ever stops matching OpenTUI's platform packages, the binary
# silently goes back to leaking one extracted library per launch.
- name: Verify binary leaves no native-lib artifacts in tmp
run: |
TMP_TEST="$(mktemp -d)"
TMPDIR="$TMP_TEST" ./dist/hunk --help > /dev/null
TMPDIR="$TMP_TEST" ./dist/hunk --help > /dev/null
if [ -n "$(find "$TMP_TEST" -mindepth 1 -print -quit)" ]; then
echo "Binary leaked artifacts into TMPDIR:"
ls -la "$TMP_TEST"
exit 1
fi

- name: Verify compiled binary watch mode
run: bun test ./test/pty/watch.test.ts
env:
Expand Down
41 changes: 19 additions & 22 deletions scripts/build-bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { mkdirSync, rmSync } from "node:fs";
import path from "node:path";
import { createOpentuiStableNativeLibPlugin } from "./opentuiStableNativeLibPlugin";

const repoRoot = path.resolve(import.meta.dir, "..");
const distDir = path.join(repoRoot, "dist");
Expand All @@ -12,31 +13,27 @@ const legacyOutfile = path.join(distDir, process.platform === "win32" ? "otdiff.
mkdirSync(distDir, { recursive: true });
rmSync(legacyOutfile, { force: true });

const proc = Bun.spawnSync(
[
"bun",
"build",
"--compile",
"--no-compile-autoload-bunfig",
path.join(repoRoot, "src", "main.tsx"),
"--outfile",
// Keep the build's own temp usage repo-local, as the previous CLI spawn did
// via env.
process.env.BUN_TMPDIR = path.join(repoRoot, ".bun-tmp");
process.env.BUN_INSTALL = path.join(repoRoot, ".bun-install");

const result = await Bun.build({
entrypoints: [path.join(repoRoot, "src", "main.tsx")],
target: "bun",
compile: {
outfile,
],
{
cwd: repoRoot,
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
env: {
...process.env,
BUN_TMPDIR: path.join(repoRoot, ".bun-tmp"),
BUN_INSTALL: path.join(repoRoot, ".bun-install"),
},
// Matches the previous `--no-compile-autoload-bunfig`.
autoloadBunfig: false,
},
);
plugins: [createOpentuiStableNativeLibPlugin(repoRoot)],
});

if (proc.exitCode !== 0) {
throw new Error(`bun build --compile failed with exit ${proc.exitCode}`);
if (!result.success) {
for (const log of result.logs) {
console.error(log);
}
throw new Error("bun build --compile failed; see logs above");
}

console.log(`Built ${outfile}`);
59 changes: 59 additions & 0 deletions scripts/opentuiStableNativeLibPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Build plugin that routes OpenTUI's embedded native library through hunk's
// stable cache path instead of Bun's leaking tmp extraction.
//
// Each @opentui/core-<platform> package's default export is just a path
// string that @opentui/core dlopens; under the "bun" export condition it
// comes from `import "./libopentui.so" with { type: "file" }`, which in a
// compiled binary resolves to Bun's per-launch temp extraction
// (oven-sh/bun#30962, hunk #556). This plugin intercepts those platform
// package imports and substitutes a shim whose default export is the stable
// content-addressed cache path produced by src/core/nativeLibMaterialize.ts.
// Reading the embedded bytes never extracts; only dlopen does, and dlopen now
// targets the cache path. Deletable together with that module once Bun's
// extraction dedupe (oven-sh/bun#29587) ships.

import { readdirSync } from "node:fs";
import path from "node:path";

const PLATFORM_PACKAGE_FILTER = /^@opentui\/core-/;
const NATIVE_LIB_FILE_PATTERN = /\.(?:so|dylib|dll)$/;
const SHIM_NAMESPACE = "hunk-opentui-native-shim";

/** Create the Bun build plugin that substitutes stable-path shims for OpenTUI platform packages. */
export function createOpentuiStableNativeLibPlugin(repoRoot: string): Bun.BunPlugin {
return {
name: "hunk-opentui-stable-native-lib",
setup(build) {
build.onResolve({ filter: PLATFORM_PACKAGE_FILTER }, (args) => {
// Resolve from the importing module: the platform packages are
// optional deps of @opentui/core and are not visible from the repo
// root under Bun's isolated node_modules layout.
const entryPath = Bun.resolveSync(args.path, path.dirname(args.importer));
return { path: entryPath, namespace: SHIM_NAMESPACE };
});
build.onLoad({ filter: /.*/, namespace: SHIM_NAMESPACE }, (args) => {
const pkgDir = path.dirname(args.path);
// Exactly one native library per platform package is the contract this
// shim relies on; ambiguity must fail the build loudly rather than
// silently dlopen the wrong file.
const libFiles = readdirSync(pkgDir).filter((name) => NATIVE_LIB_FILE_PATTERN.test(name));
if (libFiles.length !== 1) {
throw new Error(
`Expected exactly one native library (.so/.dylib/.dll) in ${pkgDir}, found ${libFiles.length}: ${libFiles.join(", ")}`,
);
}
const libFile = libFiles[0];
const helperPath = path.join(repoRoot, "src", "core", "nativeLibMaterialize.ts");
return {
resolveDir: pkgDir,
loader: "ts",
contents: [
`import embedded from ${JSON.stringify(`./${libFile}`)} with { type: "file" };`,
`import { materializeStableNativeLibPath } from ${JSON.stringify(helperPath)};`,
`export default await materializeStableNativeLibPath(embedded, ${JSON.stringify(libFile)});`,
].join("\n"),
};
});
},
};
}
239 changes: 239 additions & 0 deletions src/core/nativeLibMaterialize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import { afterEach, describe, expect, test } from "bun:test";
import { createHash } from "node:crypto";
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
utimesSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { materializeStableNativeLibPath, resolveNativeCacheRoot } from "./nativeLibMaterialize";

const LIB_BYTES = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
const OTHER_VERSION_BYTES = new Uint8Array([9, 9, 9]);
const HOUR_MS = 60 * 60 * 1000;

let dirsToClean: string[] = [];

afterEach(() => {
for (const dir of dirsToClean) {
rmSync(dir, { recursive: true, force: true });
}
dirsToClean = [];
});

/** Create a throwaway cache root that is always cleaned up. */
function fakeCacheRoot(): string {
const dir = mkdtempSync(join(tmpdir(), "hunk-native-lib-test-"));
dirsToClean.push(dir);
return dir;
}

/** Expected content-addressed file name for the given bytes. */
function expectedName(bytes: Uint8Array, libFileName = "libopentui.so"): string {
const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 16);
const dot = libFileName.lastIndexOf(".");
return `${libFileName.slice(0, dot)}-${hash}${libFileName.slice(dot)}`;
}

/** Deps pointing at a fake cache root with a fixed embedded payload. */
function testDeps(cacheRoot: string, bytes: Uint8Array = LIB_BYTES, now?: number) {
return {
cacheRootImpl: () => cacheRoot,
readEmbedded: async () => bytes,
...(now === undefined ? {} : { now: () => now }),
};
}

describe("resolveNativeCacheRoot", () => {
test("prefers XDG_CACHE_HOME on Linux", () => {
expect(
resolveNativeCacheRoot(
{ XDG_CACHE_HOME: "/xdg" },
() => "linux",
() => "/home/u",
),
).toBe("/xdg");
});

test("falls back to ~/.cache on Linux", () => {
expect(
resolveNativeCacheRoot(
{},
() => "linux",
() => "/home/u",
),
).toBe(join("/home/u", ".cache"));
});

test("uses ~/Library/Caches on macOS", () => {
expect(
resolveNativeCacheRoot(
{},
() => "darwin",
() => "/Users/u",
),
).toBe(join("/Users/u", "Library", "Caches"));
});

test("prefers LOCALAPPDATA on Windows", () => {
expect(
resolveNativeCacheRoot(
{ LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local" },
() => "win32",
() => "C:\\Users\\u",
),
).toBe("C:\\Users\\u\\AppData\\Local");
});

test("falls back to ~/AppData/Local on Windows", () => {
expect(
resolveNativeCacheRoot(
{},
() => "win32",
() => "C:\\Users\\u",
),
).toBe(join("C:\\Users\\u", "AppData", "Local"));
});
});

describe("materializeStableNativeLibPath", () => {
test("writes the library to the content-addressed path and returns it", async () => {
const cacheRoot = fakeCacheRoot();

const result = await materializeStableNativeLibPath(
"/$bunfs/root/libopentui.so",
"libopentui.so",
testDeps(cacheRoot),
);

const expected = join(cacheRoot, "hunk", "native", expectedName(LIB_BYTES));
expect(result).toBe(expected);
expect(readFileSync(result)).toEqual(Buffer.from(LIB_BYTES));
// The exec bit only exists on POSIX filesystems.
if (process.platform !== "win32") {
expect(statSync(result).mode & 0o111).not.toBe(0);
}
});

test("reuses the existing file without rewriting it", async () => {
const cacheRoot = fakeCacheRoot();
const deps = testDeps(cacheRoot);

const first = await materializeStableNativeLibPath("/embedded", "libopentui.so", deps);
const firstMtime = statSync(first).mtimeMs;
const second = await materializeStableNativeLibPath("/embedded", "libopentui.so", deps);

expect(second).toBe(first);
expect(statSync(second).mtimeMs).toBe(firstMtime);
expect(readdirSync(join(cacheRoot, "hunk", "native"))).toHaveLength(1);
});

test("keeps distinct library versions side by side", async () => {
const cacheRoot = fakeCacheRoot();
const now = 10 * HOUR_MS;

const first = await materializeStableNativeLibPath(
"/embedded",
"libopentui.so",
testDeps(cacheRoot, LIB_BYTES, now),
);
// Fresh files are never pruned, so a same-run version change leaves both.
const second = await materializeStableNativeLibPath(
"/embedded",
"libopentui.so",
testDeps(cacheRoot, OTHER_VERSION_BYTES, now),
);

expect(second).not.toBe(first);
expect(existsSync(first)).toBe(true);
expect(existsSync(second)).toBe(true);
});

test("prunes stale superseded versions once they are old", async () => {
const cacheRoot = fakeCacheRoot();
const now = 10 * HOUR_MS;

const old = await materializeStableNativeLibPath(
"/embedded",
"libopentui.so",
testDeps(cacheRoot, LIB_BYTES, now),
);
// Age the old version beyond the prune guard, then materialize a new one.
const oldTime = (now - 2 * HOUR_MS) / 1000;
utimesSync(old, oldTime, oldTime);

const current = await materializeStableNativeLibPath(
"/embedded",
"libopentui.so",
testDeps(cacheRoot, OTHER_VERSION_BYTES, now),
);

expect(existsSync(old)).toBe(false);
expect(existsSync(current)).toBe(true);
});

test("falls back to the embedded path when the cache root is not writable", async () => {
const cacheRoot = fakeCacheRoot();
// A regular file where the cache dir must be created makes mkdir fail.
const blockingFile = join(cacheRoot, "hunk");
writeFileSync(blockingFile, "occupied");

const result = await materializeStableNativeLibPath(
"/$bunfs/root/libopentui.so",
"libopentui.so",
testDeps(cacheRoot),
);

expect(result).toBe("/$bunfs/root/libopentui.so");
});

test("falls back to the embedded path when the embedded bytes cannot be read", async () => {
const cacheRoot = fakeCacheRoot();

const result = await materializeStableNativeLibPath("/missing", "libopentui.so", {
cacheRootImpl: () => cacheRoot,
readEmbedded: async () => {
throw new Error("no such embedded file");
},
});

expect(result).toBe("/missing");
});

test("preserves the library file extension in the cache name", async () => {
const cacheRoot = fakeCacheRoot();

const result = await materializeStableNativeLibPath(
"/embedded",
"opentui.dll",
testDeps(cacheRoot),
);

expect(result.endsWith(".dll")).toBe(true);
expect(result).toContain("opentui-");
});

test("leaves unrelated files in the cache dir alone", async () => {
const cacheRoot = fakeCacheRoot();
const nativeDir = join(cacheRoot, "hunk", "native");
mkdirSync(nativeDir, { recursive: true });
const unrelated = join(nativeDir, "README.txt");
writeFileSync(unrelated, "not a library");
const otherLib = join(nativeDir, "unrelated-deadbeef.so");
writeFileSync(otherLib, "x");
const longAgo = (Date.now() - 2 * HOUR_MS) / 1000;
utimesSync(otherLib, longAgo, longAgo);

await materializeStableNativeLibPath("/embedded", "libopentui.so", testDeps(cacheRoot));

expect(existsSync(unrelated)).toBe(true);
expect(existsSync(otherLib)).toBe(true);
});
});
Loading