Commit e085620
authored
fix(core): resume() drops keystrokes due to deferred listener registration (#858)
## Problem
`resume()` calls `stdin.resume()` then defers listener registration into
a `setImmediate`:
```ts
this.stdin.resume()
this.addExitListeners()
setImmediate(() => {
while (this.stdin.read() !== null) {}
this.stdin.on("data", this.stdinListener)
})
```
The stream starts flowing immediately, but `stdinListener` isn't
attached until the next macrotask. Any keystrokes arriving in that gap
are silently lost — which is exactly the window where a user types after
`fg`.
`setupInput()` has a milder variant of the same bug: `resume()` before
`on("data")`.
The drain loop was introduced in #331 to discard stale input from child
process suspension. The placement made it ineffective: `read()` returns
null in flowing mode because data goes through events, not pull. Stale
bytes were only "discarded" by accident — emitted as `data` events with
no listener present.
**Repro:** in the test suite, `mockInput.pressKey()` right after
`renderer.resume()` is silently dropped. The existing tests masked this
by yielding a macrotask (`await new Promise(r => setImmediate(r))`)
before asserting.
## Fix
Reorder `resume()` to: drain → register listener → resume stream:
```ts
while (this.stdin.read() !== null) {}
this.stdin.on("data", this.stdinListener)
this.stdin.resume()
```
The ordering is load-bearing:
1. **Drain first** — `read()` pulls from the internal buffer in paused
mode, discarding stale input (#331's scenario).
2. **Register listener second** — must come after the drain. Adding a
`data` listener can auto-resume a paused Readable, flushing stale bytes
through the listener before the drain runs.
3. **Resume last** — starts flowing mode. New data hits the registered
listener with zero gap.
`setupInput()` just swaps the two lines (no drain needed at init).
## Tests
**Modified:** removed three `setImmediate` yield workarounds (2 in
`renderer.control`, 1 in `renderer.input`). The tests now assert
synchronous delivery — they'd fail if the fix were wrong.
**New (4):**
- "keystrokes received immediately after resume() without yielding" —
core invariant.
- "keystrokes survive multiple rapid suspend/resume cycles" — 5 cycles,
no accumulation.
- "input buffered during suspension is drained on resume" — `push()`
bytes into stdin during suspension, verifies they're discarded. Directly
tests the #331 scenario.
- "suspend/resume does not leak stdin listeners" — 10 cycles,
`listenerCount("data")` stays constant.
## Background
I hit this in 0.1.88 while building a TUI on OpenTUI and patched the
bundled JS. When 0.1.90 shipped, `setupInput()` had its `setImmediate`
removed but the ordering was still wrong, and `resume()` still defers
via `setImmediate`. I've been running a patched build since.
The `setImmediate` + drain pattern was introduced in #770 (building on
#331). The intent was sound — this fix keeps the drain but moves it to
where `read()` actually works.1 parent d7ce851 commit e085620
File tree
3 files changed
+74
-14
lines changed- packages/core/src
- tests
3 files changed
+74
-14
lines changed| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1311 | 1311 | | |
1312 | 1312 | | |
1313 | 1313 | | |
1314 | | - | |
1315 | 1314 | | |
| 1315 | + | |
1316 | 1316 | | |
1317 | 1317 | | |
1318 | 1318 | | |
| |||
1862 | 1862 | | |
1863 | 1863 | | |
1864 | 1864 | | |
| 1865 | + | |
| 1866 | + | |
| 1867 | + | |
| 1868 | + | |
| 1869 | + | |
| 1870 | + | |
1865 | 1871 | | |
1866 | 1872 | | |
1867 | 1873 | | |
1868 | | - | |
1869 | | - | |
1870 | | - | |
1871 | | - | |
1872 | | - | |
1873 | | - | |
1874 | 1874 | | |
1875 | 1875 | | |
1876 | 1876 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
277 | 277 | | |
278 | 278 | | |
279 | 279 | | |
280 | | - | |
| 280 | + | |
281 | 281 | | |
282 | 282 | | |
283 | 283 | | |
| |||
295 | 295 | | |
296 | 296 | | |
297 | 297 | | |
298 | | - | |
299 | | - | |
300 | 298 | | |
301 | 299 | | |
302 | 300 | | |
| |||
335 | 333 | | |
336 | 334 | | |
337 | 335 | | |
338 | | - | |
| 336 | + | |
339 | 337 | | |
340 | 338 | | |
341 | 339 | | |
| |||
354 | 352 | | |
355 | 353 | | |
356 | 354 | | |
357 | | - | |
358 | | - | |
359 | 355 | | |
360 | 356 | | |
361 | 357 | | |
362 | 358 | | |
363 | 359 | | |
364 | 360 | | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2104 | 2104 | | |
2105 | 2105 | | |
2106 | 2106 | | |
2107 | | - | |
2108 | 2107 | | |
2109 | 2108 | | |
2110 | 2109 | | |
| |||
0 commit comments