A vue-tui:request-reload arriving during the Ctrl+C shutdown window
spawned an orphan child the parent never kills. The reload path and the
shutdown path did not compose: handleReloadRequest only consulted the 3s
startup gate, never isShuttingDown, so a reload accepted during the
pm.shutdown() teardown window ran extractBundle then pm.restart() — which
schedules a fresh 100ms restartTimer. pm.shutdown() had already cleared
its own restartTimer at entry, so nothing cancels the new one; it fires
doSpawn() and creates a brand-new child after the parent has exited.
Fix mirrors respawnTick's existing two-check guard:
- Fast gate: compose shouldAccept as
`() => acceptReloads && !shutdown.isShuttingDown()` so reloads are
ignored once Ctrl+C teardown begins.
- Post-await guard (load-bearing): thread isShuttingDown into
ReloadRequestDeps and re-check it after `await extractBundle` and
before pm.setBundlePath/pm.restart, covering the case where shutdown
starts mid-extraction.
The hot handler registration is moved after createShutdown() so the
forward reference resolves (closures read at event time regardless).
Scope is only this reload-vs-shutdown guard; respawnTick, createShutdown
internals, process-manager.ts, and bundle-extractor.ts are unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dev-server shutdown path had two teardown bugs:
1. The crash-respawn setInterval was never cleared. During shutdown's
async window (await pm.shutdown(); await server.close()), the 500ms
interval kept firing; if the child had crashed it could call
pm.spawn() and start a BRAND-NEW child while the parent was tearing
down — orphaning that child when the parent exits.
2. shutdown was registered directly as the SIGINT/SIGTERM handler with
no re-entrancy guard. Pressing Ctrl+C twice (common when shutdown
feels slow) invoked shutdown() twice concurrently → double
pm.shutdown()/server.close() and two racing process.exit(0).
Fix: extract a testable createShutdown(deps) factory that owns a
shuttingDown flag (2nd+ invocation is a no-op) and clears the respawn
interval FIRST, before the async teardown window. The respawn-tick body
is extracted into respawnTick(deps), which re-checks isShuttingDown
both before and AFTER the extractBundle await — closing the in-flight
window where a tick already mid-extraction could still spawn after
clearInterval. Both helpers are module-scoped (not re-exported from the
package public index.ts).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The vue-tui:request-reload hot handler awaited extractBundle with no
try/catch. extractBundle throws ("No JS bundle found in Vite memoryFiles")
on a transient/broken build, and Vite's hot event emitter does not catch
async handler rejections — so the rejection escaped as an unhandledRejection
that could take down the whole dev process. The crash-respawn interval right
below already guards extractBundle for the same reason.
Extract the reload logic into a testable, module-scoped handleReloadRequest()
(also folding in the acceptReloads startup guard via a shouldAccept predicate)
and wrap the extract/restart work in try/catch: on failure, log via
logger.error and KEEP the previous bundle (no setBundlePath/restart), so a
momentarily broken build no longer kills the dev server. The inline
server.hot.on handler now delegates to it.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): serialize extractBundle to prevent concurrent outDir race
extractBundle() wipes (rm) then repopulates (mkdir + writeFile) the shared
outDir in place, and dev.ts calls it from two unsynchronized sources — the
vue-tui:request-reload hot handler and the 500ms crash-respawn interval. When
two calls overlap, a second call's rm() can delete the tree a first call is
mid-writeFile into (ENOENT/EINVAL/ENOTEMPTY), or leave a torn, partially
populated dir that the child then loads.
Serialize extractions through a single module-level in-flight promise chain so
concurrent callers queue instead of racing the shared outDir. The internal
chain swallows errors so one failed run can't permanently wedge the queue,
while each caller still receives the real result/rejection. dev.ts is left
untouched; the external contract (args, output layout) is unchanged.
Adds a test that reproduces the race with staggered concurrent calls: red on
origin/main (rejects with EINVAL/ENOENT/ENOTEMPTY), green with the fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(cli): slim extractBundle race test to fit CI timeout
The race test timed out at 5042ms on the 4-core ubuntu CI runner (vitest's
default 5s testTimeout) — it wrote 200 files x ~8KB across 20 staggered
concurrent calls, ~4000 serial ~8KB writes once extractBundle is serialized.
The race window is driven by the NUMBER of files in the write loop and the
staggered starts (more chances for an overlapping rm to interleave a
writeFile), not by file size. So shrink each file's payload from 8KB to 32
bytes to slash write time, keep FILE_COUNT=200, and trim CALLS 20->16 (the
minimum that still reds reliably). Also add an explicit 30s per-test timeout
for comfortable CI headroom.
Verified: green 5x consecutively (~410ms test time locally, ~1.4% of the
timeout); still red 3x on origin/main with the race signature
(EINVAL/ENOENT/ENOTEMPTY). Assertions unchanged in spirit (none reject; final
dir holds the complete file set). Production code untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
shutdown() force-killed the dev child with SIGKILL after a 2000ms wait but
never asked it to stop first. The dev (parent) process receives SIGINT/SIGTERM
directly, but the child is a plain spawn with no shared signal, so it never saw
the parent's signal — waitForExit blocked the full 2000ms, then SIGKILL
(uncatchable) skipped the child runtime's teardown (restore cursor, leave the
alternate screen, disable kitty keyboard, restore raw mode). Result: a 2s hang
on every clean shutdown plus a corrupted terminal afterward.
Send child.kill("SIGTERM") before awaiting exit, mirroring restart(), so a
well-behaved child exits gracefully and SIGKILL only escalates on a hang.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "test" script (vp test --passWithNoTests) to @vue-tui/cli and a
ci:test:cli branch (dependsOn ci:build) to the run.tasks graph. No CLI tests
exist yet, but wiring the branch now means future CLI tests are covered
automatically rather than silently skipped. #26 (item 3).
Each package gets check:type = `tsc --noEmit` (under its own tsconfig); root
check:type = `vp run -r check:type` fans out across the workspace. Unlike vp's
tsgolint-based type-aware path, this is the standard TypeScript compiler, so it
honors each tsconfig's real project semantics.
Additive only here — vp lint still carries typeCheck; the switch-over and ready
rewiring land in the next commit. Note: testing and runtime-tests resolve
@vue-tui/runtime types from its built dist, so check:type requires a prior
build.
The cli package had no tsconfig, so it was never type-checked under its own
config. Add a tsconfig (matching the other packages, minus JSX which cli does
not use) and the typescript dev dep so `tsc --noEmit` can run here — a
prerequisite for the upcoming check:type script.
Improve descriptions across all packages to include key differentiators
(Yoga flexbox, AI agent, React Ink lineage) and rename workspace root
from vue-tui to vue-tui-monorepo to avoid confusion with the taken npm name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace ugly `as unknown as EventEmitter` casts with a proper
type alias `Process = ChildProcess & NodeJS.EventEmitter`.
@types/node@25 removed .on() from ChildProcess class declaration
even though it extends EventEmitter at runtime.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fix unhandled promise in coding-agent example (void operator)
- Fix unhandled promise in cli/dev.ts (void operator)
- Fix ChildProcess.on() type error in cli/process-manager.ts
(@types/node v25 removed .on() from ChildProcess type)
- Change example build scripts from "vite build" to "vp build"
(vite resolves to vite-plus-core which has no CLI bin)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Bump all packages to 0.0.1
- Add publishConfig.access: public to @vue-tui/cli
- Disable tsgo for dts generation (missing native-preview dep)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Silence Vite server logs (logLevel: 'silent')
- Intercept console.log/info/warn/error/debug in child process to
suppress [vite] and [Vue warn] noise from HMR client
- Add 3s startup grace period to ignore initial reload requests
(prevents double-spawn on first WS connection)
- Clear screen before spawning child process
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prevents Vite startup logs and connection messages from mixing
with the TUI app's output.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- basic-jsx: Counter + Clock with defineComponent() + JSX
- basic-template: Counter + Clock with .vue SFC + <template>
- Both demonstrate HMR via vue-tui dev
- Fix vite-plugin: strip build.lib and external config for bundledDev,
add node builtins resolveId to prevent browser-external shimming
- Remove old hmr-demo example (superseded by basic-template)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Create dev.ts orchestrator wiring Vite dev server, bundle extractor, and
process manager together with HMR reload and crash-recovery logic.
Replace index.ts placeholder with full CLI entry supporting `vue-tui dev [entry]`.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>