SIGN IN SIGN UP

fix(studio): prevent preview hang on burst external file rewrites (#3648)

* fix(studio): prevent preview hang on burst external file rewrites

Two interacting bugs caused Studio to freeze when multiple processes
(generator, check, snapshot) burst-wrote index.html within seconds:

1. SSE listener leak: the /api/events handler added a watcher listener
   per client connection but never removed it on disconnect. Reconnects
   accumulated dead listeners, each triggering readFileSync on every
   file change and writing to closed streams.

2. Generation starvation: processChange incremented generationRef and
   awaited drainPendingChanges. A second event arriving mid-drain bumped
   the generation, causing the first drain to bail at the generation
   check. With rapid writes, no drain ever completed and Studio stayed
   frozen on stale content.

Fix 1: use stream.onAbort() to remove the watcher listener when the
SSE connection closes.

Fix 2: gate processChange with a draining ref. While a drain is in
progress, stash the latest event. On completion, process the stashed
event — the last write in a burst always completes its reload.

Closes #3646

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(studio): align coordinator tests with drain serialization

Update existing test to expect the new behavior: when two events
fire in quick succession, the first drain completes and triggers a
reload (previously it was silently discarded). The stashed event
then starts a second drain.

Also fix the burst-write test to use the drains array pattern and
explicit act() flushes for stashed event processing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(studio): stash events with allowDuplicate and harden listener cleanup

Address Rames's review findings:

- Stash with allowDuplicate: true so re-dispatched events are not
  swallowed by the duplicate guard (the identity was already written
  on the way in, so the stashed event matched itself on re-entry).
- Wrap SSE keepalive loop in try/finally so the listener is removed on
  both abort and throw, not just abort.
- Restore stale-completion guard test coverage lost in the rename.
- Use await act(async () => {...}) for burst dispatches so assertions
  depend on the stash guard rather than scheduling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(studio): simplify drain serialization and restore SSE cleanup

Restructure processChange into intake + drain loop:

- processChange is now synchronous — validates, dedupes, checks own
  echoes, enqueues the accepted payload, and starts the drain loop
- startDrainLoop runs while the pending slot is non-null, draining
  one event per iteration via drainOnePending
- No recursive void processChange(...) from finally, so no
  allowDuplicate escape hatch needed — stashed events never re-enter
  intake guards

SSE listener: restore stream.onAbort alongside try/finally. Hono's
sleep() never throws, so finally alone doesn't fire on disconnect.
Both paths call removeListener (Set.delete is idempotent).

Tests: remove stale-drain test that contaminated subsequent tests by
emptying the shared roots array mid-test. Use sync act() for burst
dispatches — the stash decision is synchronous.

All 10 coordinator tests pass locally (NODE_ENV=test).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
M
miga-heygen committed
00c575d23b15d1da39eb37355a4760562270b4d0
Parent: 64ce9fd
Committed by GitHub <noreply@github.com> on 9/5/2026, 1:44:25 AM