Skip to content

fix(tui): unhang dashboard TUI by breaking circular async-init cycle in @hermes/ink (#31227)#31243

Open
xxxigm wants to merge 3 commits into
NousResearch:mainfrom
xxxigm:fix/31227-tui-esm-deadlock
Open

fix(tui): unhang dashboard TUI by breaking circular async-init cycle in @hermes/ink (#31227)#31243
xxxigm wants to merge 3 commits into
NousResearch:mainfrom
xxxigm:fix/31227-tui-esm-deadlock

Conversation

@xxxigm

@xxxigm xxxigm commented May 24, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes the dashboard TUI startup hang reported in #31227 — the bundle produced only 141 bytes of ANSI reset sequences and then sat on a blank screen forever. Both `hermes --tui` and the dashboard `/chat` PTY were affected; no errors, no crash, the Node process and the spawned `python -m tui_gateway.entry` child both stayed alive.

Root cause. esbuild's lightweight `__esm` helper (line 14 of `dist/entry.js`) does NOT await nested async init:

```js
var __esm = (fn, res) => function __init() {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
};
```

So if any `async __esm` module participates in a circular import graph, the helper hands out a Promise that nobody completes — the top-level `await Promise.all([init_entry_exports().then(...)])` in `src/entry.tsx` waits forever.

The cycle came from `packages/hermes-ink/src/entry-exports.ts`:

```ts
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'
```

`ink-text-input` depends on the upstream `ink` npm package, whose graph then loops back through React + our in-tree `@hermes/ink` ink fork. The result was `init_entry_exports` emitted as `async … await init_build4()` (where `build4` = `node_modules/ink-text-input/build/index.js`), and the deadlock above.

Nothing in `ui-tui/` actually imports `TextInput` from `@hermes/ink` — the composer uses the in-tree `src/components/textInput.tsx` widget. The re-export was dead weight that dragged a whole second copy of the `ink` graph into the bundle.

Fix. Three small things wired together:

  1. Drop the `from 'ink-text-input'` re-export in `packages/hermes-ink/src/entry-exports.ts` and `packages/hermes-ink/index.d.ts`. Callers that legitimately want the upstream widget can still import it via the dedicated `@hermes/ink/text-input` subpath, which sits outside `entry-exports` and so does not get inlined into consumers' bundles.
  2. Update `scripts/build.mjs` to mention the second reason `@hermes/ink` is aliased to source (keep the upstream ink graph out of the bundle, not just esbuild's lazy-init quirk).
  3. Add a vitest regression that builds `dist/entry.js` and pins two structural invariants — zero `async __esm` wrappers, and no upstream `ink` / `ink-text-input` modules in the bundle — so the next person who touches the re-export catches it locally before shipping.

After the fix:

  • `dist/entry.js` shrinks 2.9MB → 2.4MB (~11.5k fewer bundled lines).
  • Zero `async __esm` wrappers remain.
  • `init_entry_exports` is now a synchronous `__esm` module.
  • The bundle's top-level await chain resolves in ~30ms instead of hanging.

Related Issue

Fixes #31227

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • `ui-tui/packages/hermes-ink/src/entry-exports.ts` — drop the `'ink-text-input'` re-export; document why the cycle existed and where to import the widget instead.
  • `ui-tui/packages/hermes-ink/index.d.ts` — drop the matching type re-exports so the type surface matches the runtime surface.
  • `ui-tui/scripts/build.mjs` — expand the alias comment to cover both reasons `@hermes/ink` is aliased to source.
  • `ui-tui/src/tests/bundleNoAsyncEsmDeadlock.test.ts` — 3 new tests (rebuilds bundle on demand, asserts no async __esm wrappers, asserts no upstream ink modules, asserts `init_entry_exports` is synchronous).

Backwards compatible: the only public-API surface affected is `TextInput` / `UncontrolledTextInput` exports from the package root. No existing `ui-tui/` code uses them; external consumers (if any) must switch to `import TextInput from '@hermes/ink/text-input'`, which already worked.

How to Test

```bash
cd ui-tui

Build

npm run build

expected: dist/entry.js is now 2.4MB (was 2.9MB on main)

New regression suite (3 tests, ~100ms)

npx vitest run src/tests/bundleNoAsyncEsmDeadlock.test.ts

expected: 3 passed

Full ui-tui regression excluding 5 pre-existing failures unrelated

to this PR (cursorDriftRegression / textInputFastEcho /

textInputWrap / virtualHeights / createSlashHandler — all reproduce

unchanged on upstream/main):

npx vitest run \
--exclude '/cursorDriftRegression.test.ts' \
--exclude '
/textInputFastEcho.test.ts' \
--exclude '/textInputWrap.test.ts' \
--exclude '
/virtualHeights.test.ts' \
--exclude '**/createSlashHandler.test.ts'

expected: 736 passed, 1 skipped

```

End-to-end behavior on the issue's exact repro:

```bash
HERMES_PYTHON=/path/to/venv/bin/python TERM=xterm-256color \
node --expose-gc ui-tui/dist/entry.js
```

  • Before: ANSI reset bytes (141 of them), then permanent blank screen.
  • After: TUI renders the chat layout normally.

Checklist

  • Conventional Commits (`fix(tui):`, `docs(tui):`, `test(tui):`)
  • 3 focused commits, single author (xxxigm)
  • 3 new tests pass; 736 existing ui-tui tests pass; 28 unrelated pre-existing failures confirmed identical on upstream/main
  • Tested on macOS 15.6 (darwin 24.6.0), Node 22.15.0
  • No new config keys, no platform-specific code paths, no schema migration

xxxigm added 3 commits May 24, 2026 08:45
NousResearch#31227)

The dashboard TUI bundle hung at startup with only 141 bytes of ANSI
reset sequences and a blank screen forever. Root cause: esbuild's
lightweight `__esm` helper at the top of `dist/entry.js` does not
await nested async init, so a circular async cycle in the module
graph never resolves. The cycle came from re-exporting
``TextInput`/`UncontrolledTextInput`` from `'ink-text-input'` here —
that npm package depends on the upstream `ink` package, whose graph
loops back through React + our in-tree `@hermes/ink` ink fork. The
result: `init_entry_exports` was emitted as `async … await
init_build4()` (where `build4` is `node_modules/ink-text-input/build`),
and the top-level `await Promise.all([init_entry_exports().then(...)])`
in `src/entry.tsx` deadlocked waiting on the dangling Promise.

Nobody in `ui-tui/` actually imports `TextInput` from `@hermes/ink` —
the composer uses the in-tree `src/components/textInput.tsx` widget
instead. Drop the re-export from the source so the bundle no longer
inlines the upstream ink graph at all. Callers that legitimately want
the upstream widget can still import it from the dedicated
`@hermes/ink/text-input` subpath, which sits outside `entry-exports`
and so does not get inlined into consumers' bundles.

After the fix:
* `dist/entry.js` shrinks from 2.9MB → 2.4MB (~11.5k fewer bundled
  lines) with zero `async __esm` wrappers remaining.
* `init_entry_exports` is now a synchronous `__esm` module.
* The bundle's top-level await chain resolves in ~30ms instead of
  hanging.
Update the comment on the `alias` entry to mention the second reason
the source-inline is needed: keeping the upstream `ink` /
`ink-text-input` graph out of the bundle (which fixed the startup
deadlock in NousResearch#31227). Code path is unchanged.
…sing

Vitest regression that builds `dist/entry.js` and checks two
structural invariants required for startup to not hang:

  1. Zero `async "<path>"() { … }` keys inside any `__esm` definition.
     esbuild only emits the `async` form when a module body contains
     top-level await; the `__esm` helper at the top of the bundle
     does not await nested inits, so any async wrapper participating
     in a circular module graph would deadlock the boot
     `await Promise.all([…])` in `src/entry.tsx`.
  2. No `node_modules/ink/build/index.js` or
     `node_modules/ink-text-input/build/index.js` modules. Their
     absence is what makes invariant 1 hold today; if a future commit
     re-introduces the `ink-text-input` re-export, this test catches
     it before the bundle ships.

The test rebuilds the bundle on demand when the source is newer than
`dist/entry.js`, runs in <100ms with no TTY needed, and is hermetic
on a clean checkout.
@alt-glitch alt-glitch added type/bug Something isn't working P1 High — major feature broken, no workaround comp/tui Terminal UI (ui-tui/ + tui_gateway/) labels May 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/tui Terminal UI (ui-tui/ + tui_gateway/) P1 High — major feature broken, no workaround type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TUI hangs silently at startup — esbuild __esm helper deadlocks on @hermes/ink async init

2 participants