Skip to content

fix(build): dlopen OpenTUI native lib from a stable cache path#577

Open
benvinegar wants to merge 4 commits into
mainfrom
fix/stable-native-lib-cache
Open

fix(build): dlopen OpenTUI native lib from a stable cache path#577
benvinegar wants to merge 4 commits into
mainfrom
fix/stable-native-lib-cache

Conversation

@benvinegar

@benvinegar benvinegar commented Jul 19, 2026

Copy link
Copy Markdown
Member

What

Compiled hunk binaries leak one copy of the embedded OpenTUI native library into the OS temp dir on every launch (Bun bug oven-sh/bun#30962; #556 hit 190 GB in three weeks). This stops the leak at the source: the library is written once to a stable cache path and reused across runs.

How

@opentui/core-<platform> default-exports a path string that @opentui/core dlopens. In compiled binaries that path comes from Bun's per-launch tmp extraction — but reading the embedded bytes never extracts; only dlopen does. So:

  • A build plugin swaps the platform package's default export for a stable path (scripts/opentuiStableNativeLibPlugin.ts).
  • src/core/nativeLibMaterialize.ts writes the bytes once to <user-cache>/hunk/native/<name>-<hash>.<ext> (atomic rename, reused across runs, old versions pruned).
  • Any failure falls back to the old behavior — leaking but working, never a broken binary.

Net effect: one file per OpenTUI version instead of one per launch. Removable once Bun's dedupe fix (oven-sh/bun#29587) ships.

vs. #568

Complementary: this stops future leaks; #568's sweep cleans up past ones. If both land, the sweep becomes a deletable migration cleaner.

Verification

  • 13 new unit tests; typecheck / lint / format:check / test:tty-smoke pass. Two PTY failures reproduce on clean HEAD — pre-existing, unrelated.
  • Patched binary: zero tmp artifacts across repeated --help + pager runs (baseline: one per run), single reused ~/.cache/hunk/native/libopentui-<hash>.so.

Refs #556.

kataokatsuki and others added 2 commits July 19, 2026 01:28
Bun single-file executables extract the embedded OpenTUI native library
to the OS temp directory under a fresh random name on every launch and
never reuse or remove it (oven-sh/bun#30962). Repeated invocations —
hunk as a git pager, agents polling `hunk session` — leak one ~4-14 MB
copy per run and can fill a tmpfs (#556).

The compiled build now substitutes a stable content-addressed path for
the platform package's default export: a Bun build plugin intercepts the
@opentui/core-<platform> imports and routes them through a shim that
writes the embedded bytes once to <user-cache>/hunk/native and reuses
that path across runs. Reading embedded bytes never extracts; only
dlopen does, and dlopen now targets the cache path. Failures fall back
to the embedded path, preserving the previous leaking-but-working
behavior. Removable once Bun's extraction dedupe (oven-sh/bun#29587)
ships.
@greptile-apps

greptile-apps Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the Bun single-file executable native library leak (#556) by intercepting OpenTUI platform package imports at build time and routing dlopen through a stable content-addressed cache path (~/.cache/hunk/native/<name>-<sha256>.{so,dylib,dll}) instead of Bun's per-launch temp extraction. Any write failure falls back transparently to the leaking-but-working path so startup is never broken.

  • nativeLibMaterialize.ts: writes the embedded bytes once via tmp-file + atomic rename and reuses on subsequent launches; prunes superseded versions older than one hour.
  • opentuiStableNativeLibPlugin.ts: Bun build plugin that replaces each @opentui/core-<platform> import with a generated shim whose export default is the resolved stable path; requires switching build-bin.ts from the CLI to the Bun.build API.
  • agentPopover.ts: independent fix switching wrapText from UTF-16 code-unit counting to terminal-cell width, preventing CJK and emoji text from being silently truncated downstream.

Confidence Score: 4/5

Safe to merge; all failure modes fall back to the previous leaking-but-working behavior and the binary never fails to start.

The materialization logic has a concrete gap where pruneStaleVersions inside the outer try/catch can cause the already-written cache path to be silently discarded and fall back to the leaking embedded path. The Windows rename failure also leaves orphaned .tmp files. Both have graceful fallbacks but represent cases where the fix does not fully hold.

src/core/nativeLibMaterialize.ts — the prune call placement and the tmp-file cleanup on rename failure deserve a second look.

Important Files Changed

Filename Overview
src/core/nativeLibMaterialize.ts New module that writes the embedded OpenTUI native library to a content-addressed cache path; contains a logic gap where pruneStaleVersions inside the main try/catch can cause the successfully-written cache path to be discarded on prune failure, and orphaned .tmp files on Windows rename failures.
scripts/opentuiStableNativeLibPlugin.ts New Bun build plugin that intercepts @opentui/core-* imports and substitutes a shim routing dlopen through the stable cache path; logic is straightforward and well-structured.
scripts/build-bin.ts Migrates from Bun.spawnSync CLI invocation to the Bun.build API so the plugin can be attached; env vars now set directly on process.env rather than passed to a child process, which is equivalent for a build script.
src/core/nativeLibMaterialize.test.ts 13 new unit tests covering cache resolution, content-addressed naming, reuse, stale-version pruning, fallback on read-only cache, and extension handling; comprehensive coverage.
src/ui/lib/agentPopover.ts Switches text wrapping from UTF-16 code-unit counting to terminal-cell width measurement, fixing CJK/emoji truncation; edge case where a cluster is wider than width intentionally defers to fitText at render time.
src/ui/lib/ui-lib.test.ts New test cases for CJK, emoji, ZWJ clusters, and mixed ASCII+wide text wrapping, including the narrow-width edge case.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Build as build-bin.ts
    participant Plugin as opentuiStableNativeLibPlugin
    participant Shim as Generated Shim Module
    participant Mat as nativeLibMaterialize.ts
    participant Cache as ~/.cache/hunk/native/

    Build->>Plugin: "Bun.build({ plugins: [plugin] })"
    Plugin->>Plugin: "onResolve @opentui/core-* to SHIM_NAMESPACE"
    Plugin->>Plugin: onLoad - generate shim contents
    Plugin-->>Build: shim TS injected into bundle
    Note over Build: Binary compiled with shim
    rect rgb(230, 240, 255)
        Note over Shim,Cache: At binary startup (module load)
        Shim->>Mat: materializeStableNativeLibPath(embeddedPath, libFileName)
        Mat->>Mat: readEmbedded(embeddedPath) - bytes
        Mat->>Mat: SHA-256(bytes).slice(0,16) - hash
        Mat->>Cache: existsSync(dest)?
        alt file missing
            Mat->>Cache: mkdirSync(dir)
            Mat->>Cache: writeFileSync(tmpPath, bytes)
            Mat->>Cache: renameSync(tmpPath, dest)
        end
        Mat->>Cache: pruneStaleVersions (files older than 1h)
        Mat-->>Shim: return dest (stable cache path)
    end
    Shim-->>Build: "export default = dest"
    Note over Build: @opentui/core dlopens dest instead of Bun tmp path
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Build as build-bin.ts
    participant Plugin as opentuiStableNativeLibPlugin
    participant Shim as Generated Shim Module
    participant Mat as nativeLibMaterialize.ts
    participant Cache as ~/.cache/hunk/native/

    Build->>Plugin: "Bun.build({ plugins: [plugin] })"
    Plugin->>Plugin: "onResolve @opentui/core-* to SHIM_NAMESPACE"
    Plugin->>Plugin: onLoad - generate shim contents
    Plugin-->>Build: shim TS injected into bundle
    Note over Build: Binary compiled with shim
    rect rgb(230, 240, 255)
        Note over Shim,Cache: At binary startup (module load)
        Shim->>Mat: materializeStableNativeLibPath(embeddedPath, libFileName)
        Mat->>Mat: readEmbedded(embeddedPath) - bytes
        Mat->>Mat: SHA-256(bytes).slice(0,16) - hash
        Mat->>Cache: existsSync(dest)?
        alt file missing
            Mat->>Cache: mkdirSync(dir)
            Mat->>Cache: writeFileSync(tmpPath, bytes)
            Mat->>Cache: renameSync(tmpPath, dest)
        end
        Mat->>Cache: pruneStaleVersions (files older than 1h)
        Mat-->>Shim: return dest (stable cache path)
    end
    Shim-->>Build: "export default = dest"
    Note over Build: @opentui/core dlopens dest instead of Bun tmp path
Loading
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
src/core/nativeLibMaterialize.ts:136-140
**Prune failure silently discards a successfully materialized cache path**

`pruneStaleVersions` sits inside the same `try` block and is the last statement before `return dest`. If `readdirSync(dir)` inside it throws — the cache dir could be removed by a concurrent process or a system sweep between the `mkdirSync`/`renameSync` and the prune — the outer `catch` discards the already-correct `dest` and returns `embeddedPath`, causing the file leak we're trying to prevent. The pruning is explicitly best-effort; wrapping it in its own `try/catch` or moving the `return dest` before the prune call ensures the stable path is returned even when cleanup fails.

### Issue 2 of 3
src/core/nativeLibMaterialize.ts:126
**Existence check doesn't cover hash-but-same-size corruption**

The reuse guard checks `statSync(dest).size !== bytes.byteLength`. Because the filename already encodes the SHA-256 prefix, an intentional collision is negligible — but a truncated-then-padded or partially-overwritten cache file with the right byte count passes the check and is returned as a valid path. Additionally, between `existsSync` and `statSync` the file could be concurrently removed, throwing from inside the `try` and falling back unnecessarily. Dropping the size check and trusting the content-addressed name alone would be more robust.

```suggestion
    if (!existsSync(dest)) {
```

### Issue 3 of 3
src/core/nativeLibMaterialize.ts:132-133
**Orphaned `.tmp` files on `renameSync` failure (Windows in particular)**

If `writeFileSync(tmpPath, …)` succeeds but `renameSync(tmpPath, dest)` throws (on Windows this happens when another concurrent process has already placed `dest`), the outer `catch` fires and the `.tmp` file is left in the cache directory forever — `pruneStaleVersions` skips it because it starts with `.`. Consider adding an explicit `unlinkSync(tmpPath)` in a `catch` around the rename.

```suggestion
      writeFileSync(tmpPath, bytes, { mode: 0o755 });
      try {
        renameSync(tmpPath, dest);
      } catch (renameErr) {
        try { unlinkSync(tmpPath); } catch { /* ignore */ }
        throw renameErr;
      }
```

Reviews (1): Last reviewed commit: "fix(build): dlopen OpenTUI native lib fr..." | Re-trigger Greptile

Comment on lines +136 to +140
pruneStaleVersions(dir, base, ext, destName, now());
return dest;
} catch {
// Leaking via Bun's extraction is strictly better than failing to start.
return embeddedPath;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Prune failure silently discards a successfully materialized cache path

pruneStaleVersions sits inside the same try block and is the last statement before return dest. If readdirSync(dir) inside it throws — the cache dir could be removed by a concurrent process or a system sweep between the mkdirSync/renameSync and the prune — the outer catch discards the already-correct dest and returns embeddedPath, causing the file leak we're trying to prevent. The pruning is explicitly best-effort; wrapping it in its own try/catch or moving the return dest before the prune call ensures the stable path is returned even when cleanup fails.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/nativeLibMaterialize.ts
Line: 136-140

Comment:
**Prune failure silently discards a successfully materialized cache path**

`pruneStaleVersions` sits inside the same `try` block and is the last statement before `return dest`. If `readdirSync(dir)` inside it throws — the cache dir could be removed by a concurrent process or a system sweep between the `mkdirSync`/`renameSync` and the prune — the outer `catch` discards the already-correct `dest` and returns `embeddedPath`, causing the file leak we're trying to prevent. The pruning is explicitly best-effort; wrapping it in its own `try/catch` or moving the `return dest` before the prune call ensures the stable path is returned even when cleanup fails.

How can I resolve this? If you propose a fix, please make it concise.

const destName = `${base}-${hash}${ext}`;
const dest = join(dir, destName);

if (!existsSync(dest) || statSync(dest).size !== bytes.byteLength) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Existence check doesn't cover hash-but-same-size corruption

The reuse guard checks statSync(dest).size !== bytes.byteLength. Because the filename already encodes the SHA-256 prefix, an intentional collision is negligible — but a truncated-then-padded or partially-overwritten cache file with the right byte count passes the check and is returned as a valid path. Additionally, between existsSync and statSync the file could be concurrently removed, throwing from inside the try and falling back unnecessarily. Dropping the size check and trusting the content-addressed name alone would be more robust.

Suggested change
if (!existsSync(dest) || statSync(dest).size !== bytes.byteLength) {
if (!existsSync(dest)) {
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/nativeLibMaterialize.ts
Line: 126

Comment:
**Existence check doesn't cover hash-but-same-size corruption**

The reuse guard checks `statSync(dest).size !== bytes.byteLength`. Because the filename already encodes the SHA-256 prefix, an intentional collision is negligible — but a truncated-then-padded or partially-overwritten cache file with the right byte count passes the check and is returned as a valid path. Additionally, between `existsSync` and `statSync` the file could be concurrently removed, throwing from inside the `try` and falling back unnecessarily. Dropping the size check and trusting the content-addressed name alone would be more robust.

```suggestion
    if (!existsSync(dest)) {
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +132 to +133
writeFileSync(tmpPath, bytes, { mode: 0o755 });
renameSync(tmpPath, dest);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Orphaned .tmp files on renameSync failure (Windows in particular)

If writeFileSync(tmpPath, …) succeeds but renameSync(tmpPath, dest) throws (on Windows this happens when another concurrent process has already placed dest), the outer catch fires and the .tmp file is left in the cache directory forever — pruneStaleVersions skips it because it starts with .. Consider adding an explicit unlinkSync(tmpPath) in a catch around the rename.

Suggested change
writeFileSync(tmpPath, bytes, { mode: 0o755 });
renameSync(tmpPath, dest);
writeFileSync(tmpPath, bytes, { mode: 0o755 });
try {
renameSync(tmpPath, dest);
} catch (renameErr) {
try { unlinkSync(tmpPath); } catch { /* ignore */ }
throw renameErr;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/nativeLibMaterialize.ts
Line: 132-133

Comment:
**Orphaned `.tmp` files on `renameSync` failure (Windows in particular)**

If `writeFileSync(tmpPath, …)` succeeds but `renameSync(tmpPath, dest)` throws (on Windows this happens when another concurrent process has already placed `dest`), the outer `catch` fires and the `.tmp` file is left in the cache directory forever — `pruneStaleVersions` skips it because it starts with `.`. Consider adding an explicit `unlinkSync(tmpPath)` in a `catch` around the rename.

```suggestion
      writeFileSync(tmpPath, bytes, { mode: 0o755 });
      try {
        renameSync(tmpPath, dest);
      } catch (renameErr) {
        try { unlinkSync(tmpPath); } catch { /* ignore */ }
        throw renameErr;
      }
```

How can I resolve this? If you propose a fix, please make it concise.

Cache-root expectations must be built with path.join since the
implementation joins segments (CI runs them on Windows), and the exec
bit assertion is POSIX-only.
Two hardening fixes against silent regressions of the tmp-leak fix:

- The plugin now requires exactly one native library per OpenTUI
  platform package instead of picking the first match, so upstream
  restructuring fails the build instead of dlopening the wrong file.
- CI asserts the compiled binary leaves TMPDIR empty across runs, so if
  the platform-package import ever stops matching the plugin filter the
  leak resurfaces as a red job rather than silently returning.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants