fix(tui): unhang dashboard TUI by breaking circular async-init cycle in @hermes/ink (#31227)#31243
Open
xxxigm wants to merge 3 commits into
Open
fix(tui): unhang dashboard TUI by breaking circular async-init cycle in @hermes/ink (#31227)#31243xxxigm wants to merge 3 commits into
xxxigm wants to merge 3 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
After the fix:
Related Issue
Fixes #31227
Type of Change
Changes Made
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
```
Checklist