fix(runtime): derive mount-guard skip from wired state, not a sticky flag (#153)

The instance-reuse guard set a per-app skippedMount flag that was never
reset, so one guarded mount() call permanently disabled the app's own
teardown. Three run-confirmed wedges (audit e18), all absent in Ink:

- an owner double-firing mount() on its own live stdout kept painting
  after unmount() and leaked its registry entry
- an app that once hit the guard could never unmount a later legitimate
  mount on a free stdout
- an app live on stream A that merely targeted another app's busy
  stream B became unkillable on A

Delete the flag; teardown()/resolveExit() now consult the actually
wired state (mountedAppContext / mountedAsOwner), so a guarded call is
inert for that call only. The blessed inert-no-op divergence from Ink's
reuse-and-rerender is unchanged; the ledger entry is reworded to the
call-scoped semantics.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-12 02:51:19 +08:00
committed by GitHub
parent 25114ff52a
commit c66cddb676
3 changed files with 214 additions and 22 deletions
+10 -4
View File
@@ -371,10 +371,16 @@ different runtime behavior, ownership rule, or out-of-contract handling.
- **Ink:** `render()` keeps one instance per stdout (`WeakMap<WriteStream, Ink>`); a second
`render(node, {stdout})` on a stream that already has a live instance warns on stderr but
**reuses** that instance and `rerender`s the new tree into it.
- **vue-tui:** a second `mount()` on a still-live stdout warns on stderr and returns an
**inert handle**. It wires no second renderer and renders nothing; the first app's tree
stays on screen. `unmount()`/`teardown()` on that handle never touch the owner's stream or
registry entry (`unmount()` only settles the inert handle's own exit promise).
- **vue-tui:** a second `mount()` on a still-live stdout warns on stderr, wires no second
renderer, renders nothing, and returns an empty placeholder object (the real controls —
`unmount()`, `waitUntilExit()` — live on the app, not on `mount()`'s return value). The
first app's tree stays on screen. The skip is scoped to that one guarded call — derived
from what the app actually wired, never sticky: a guarded _different_ app's `unmount()`
settles only its own exit promise and never touches the owner's stream or registry entry;
the _owner_ double-firing `mount()` on its own stdout keeps a fully working `unmount()`
(the warning's recovery path); an app that once hit the guard can later mount — and
cleanly unmount — on a free stdout; and a live app that merely targeted another app's
busy stream stays fully killable.
- **Why:** a second `mount()` on a live stdout is a misuse (forgot to `unmount()`, a
re-render glitch fired `mount()` twice, or expecting `mount()` to re-render — it doesn't;
update reactive state for that). Ink treats it as unsupported and warns too. vue-tui fails