Skip to content

Clean up Terminal status update, and reset failed status write state - #2201

Draft
vzaidman wants to merge 1 commit into
mainfrom
vzaidman/terminal-update-promise-finally
Draft

vzaidman wants to merge 1 commit into
mainfrom
vzaidman/terminal-update-promise-finally

Conversation

@vzaidman

@vzaidman vzaidman commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Run each status update in #scheduleStatusUpdate() itself, with no helper: each iteration assigns #updatePromise inside the try block to the current #writeStatus() promise and awaits it, looping until the written status is the latest one. The loop exit check and the cleanup (the beforeExit listener removal, the #updatePromise reset, the flush-request reset) run in one synchronous step, with no microtask gap between them, so a status() call just after an update finishes can't slip between them and be lost. A rejected #writeStatus() no longer leaves the listener attached or a rejected promise in #updatePromise. Every later #scheduleStatusUpdate() then early-returned at the #updatePromise != null guard, permanently stopping status updates and leaving beforeExit wired to flush a broken terminal. waitForUpdates() loops on #updatePromise until it clears, so it waits for the whole update, not just the current write.

Status updates, writes, and the beforeExit flush are fire-and-forget, so their rejections are marked as handled: a failed update no longer crashes the process when no one awaits it, while waitForUpdates() and flush() still see the rejection.

#writeStatus() marks the status as written before awaiting the write's completion. If that write fails, the status was never drawn, but #writtenStatus still claims it was. The next update then erases a status that isn't on the screen, which in a TTY moves the cursor above the drawn output and erases earlier logs, and a retry of the same status writes nothing because it looks already written. Reset #writtenStatus and clear #lastWritePromise on a failed write, unless another write has replaced the awaited promise or #writtenStatus has changed since, as a log() in between redraws the status itself. Without the clear, flush() and waitForUpdates() keep rejecting with the stale error. A failed log() write leaves the same stale state: log() now applies the same reset to a redrawn status and clears the stale write promise.

Changelog: [Fix] Terminal status updates can resume after a failed write, without losing a status set just after an update finishes, erasing earlier output, or rejecting with a stale error

Test plan:

  • New tests does not lose a status set just after an update finishes (fails on the previous .finally() version, passes on base and with this change), does not crash on a failed status write with no waiter, does not crash on a failed write during the beforeExit flush, recovers after a failed status write, recovery after a failed status write does not erase earlier logs, retries the same status after a failed status write, flush does not reject with a stale error after a failed status write, flush does not reject with a stale error after a failed log write, and a failed log redraw does not erase earlier output on recovery fail without the fix and pass with it.
  • yarn jest packages/metro-core/src/__tests__/Terminal-test.js
  • yarn eslint packages/metro-core/src/Terminal.js packages/metro-core/src/__tests__/Terminal-test.js
  • yarn prettier --check on both files
  • yarn flow check

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Nov 9, 2026
@vzaidman
vzaidman requested review from huntie and robhogan November 9, 2026 13:30
@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

Metro review: Changes suggested

Risk: low · Commit: 9ecabbc · 6 commands run

The PR runs the status update loop inline with synchronous cleanup, marks fire-and-forget rejections as handled, and resets state after a failed status write. All four findings from the previous review are fixed, but the failure handling still has two gaps that contradict the description and changelog: a failed write during the beforeExit flush still crashes the process, and a failed log() write can still erase earlier output and leave flush() rejecting with a stale error. Both only occur when stream.write throws synchronously, so the blast radius is small.

Findings

Verified
  • ✅ yarn jest --no-watchman packages/metro-core/src/__tests__/Terminal-test.js — 51 tests pass on PR head, including all new tests.
  • ✅ yarn eslint packages/metro-core/src/Terminal.js packages/metro-core/src/__tests__/Terminal-test.js
  • ✅ yarn prettier --check packages/metro-core/src/Terminal.js packages/metro-core/src/__tests__/Terminal-test.js
  • ✅ node __review__/terminal-repro.js (failed as expected) — Real processes on PR head: noWaiter exits 0 (previous crash finding fixed); beforeExitFlush exits 1; logStale rejects on all three flushes.
  • ✅ node __review__/terminal-log-repro.js (failed as expected) — TTY: after a failed log redraw, status2 emits a second cursor-up and clear with no status on screen.
  • ✅ yarn jest --no-watchman packages/metro-core/src/__tests__/Terminal-test.js -t "does not crash on a failed status write" (failed as expected) — With the new catch handling temporarily removed from the update loop, the Jest process dies on write failed, so this test does guard the fix. Source restored afterward; git status shows only the untracked __review__/.

Not run: yarn typecheck and running the new tests on base were denied by this session's permission check, so the Flow claim is unverified. From the base source, does not lose a status set just after an update finishes should already pass on base, contrary to the test plan's claim; not run. No benchmark: only a few promise allocations per status update. Windows not run: no path or filesystem behavior changed.

Automated review by Claude. Advisory only: it never approves or blocks, and a maintainer makes the call.

Comment thread packages/metro-core/src/Terminal.js Resolved
Comment thread packages/metro-core/src/Terminal.js Resolved
Comment thread packages/metro-core/src/Terminal.js Resolved
Comment thread packages/metro-core/src/Terminal.js Resolved
@vzaidman
vzaidman marked this pull request as draft November 9, 2026 13:50
@vzaidman
vzaidman force-pushed the vzaidman/terminal-update-promise-finally branch from 87fcdbe to 77e8372 Compare November 9, 2026 13:55
@vzaidman

vzaidman commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the review findings:

  • Race: replaced the .then()/.finally() chain with #runStatusUpdate(), where the exit check and cleanup run in one synchronous step in finally. Added regression test does not lose a status set just after an update finishes (fails on the old chain, passes now).
  • Unhandled rejection: status updates and writes are fire-and-forget, so their rejections are now marked handled with a no-op .catch(); waitForUpdates()/flush() still see the rejection. Added test does not crash on a failed status write with no waiter. This also covers the log() write path.
  • Failed-write state: the remaining state (undrawn #writtenStatus, stale #lastWritePromise) is now fully addressed in the follow-up Reset #writtenStatus when a status write fails #2202.
  • Changelog: added Changelog: [Fix] Terminal status updates can resume after a failed write, without losing a status set just after an update finishes.

Verified: Terminal-test.js (45 tests), ESLint, Prettier, Flow.

@vzaidman
vzaidman marked this pull request as ready for review November 9, 2026 14:23
@vzaidman
vzaidman marked this pull request as draft November 9, 2026 14:24
@vzaidman
vzaidman force-pushed the vzaidman/terminal-update-promise-finally branch 4 times, most recently from deecf49 to 505c4bb Compare November 9, 2026 14:53
@vzaidman vzaidman changed the title Clean up Terminal status update in finally Clean up Terminal status update in finally, and reset failed status write state Nov 9, 2026
@vzaidman
vzaidman force-pushed the vzaidman/terminal-update-promise-finally branch 2 times, most recently from 803a5f3 to 3a037ba Compare November 9, 2026 15:38
@vzaidman vzaidman changed the title Clean up Terminal status update in finally, and reset failed status write state Clean up Terminal status update, and reset failed status write state Nov 9, 2026
@vzaidman
vzaidman force-pushed the vzaidman/terminal-update-promise-finally branch from 3a037ba to 9ecabbc Compare November 9, 2026 15:41
@vzaidman
vzaidman marked this pull request as ready for review November 9, 2026 15:51
// An update is fire-and-forget: mark its rejection as handled, so
// a failed update doesn't crash the process when no one awaits it.
// Waiters still see the rejection through #updatePromise.
void this.#updatePromise.catch(() => {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should fix · A failed write during the beforeExit flush still crashes the process

The PR marks #updatePromise and #lastWritePromise as handled, but the beforeExit listener in the constructor (Terminal.js:108) still calls void this.flush(). flush() returns a new promise that rejects when the write fails, and nothing handles that one.

This contradicts the description's claim that a failed update no longer crashes the process when no one awaits it. The exit flush is the one internal caller of flush() and has no waiter of its own.

Failure case: a plain new Terminal(stream), with stream.write throwing on the pending write:

t.status('a'); // written
t.status('b'); // pending behind the unref'd status delay
s.failNext = 1; // the next write throws
// the event loop drains, beforeExit flushes, the write throws

The process exits with code 1 on an unhandled rejection, even though no code ever awaited the update.

Fix: handle the rejection in the listener: this.flush().catch(() => {});. Add a test that runs this in a child process or invokes the listener directly.

Reproduced: node __review__/terminal-repro.js, scenario beforeExitFlush, on PR head: exit=1, exit handler code=1 writes=["a\n"], with the stderr showing the thrown write failed. Scenario noWaiter (failure with the update's own promise only) exits 0, so the gap is specific to the flush path. · id b39da856

// A write is fire-and-forget: mark its rejection as handled, so a
// failed write doesn't crash the process when no one awaits it.
// Waiters still see the rejection through #lastWritePromise.
void this.#lastWritePromise.catch(() => {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should fix · A failed log() write keeps the stale state this PR fixes for status writes

The reset added to #writeStatus() only covers writes that function issues itself. log() calls #write() directly, then starts an update that returns early when the status looks already written, so the new catch never runs. The changelog says recovery happens without erasing earlier output or rejecting with a stale error, but both still happen on this path:

  • Stale rejection: with stream.write throwing once, t.log('x') leaves #lastWritePromise rejected. Every later flush() rejects with the old write failed, even though the stream has recovered, until some other write replaces the promise.
  • Erases earlier output (TTY): with foo logged and status drawn, log('bar') erases the status successfully, then its line + status redraw throws. log() still sets #writtenStatus = writtenStatus, so the status is recorded as drawn although it was just erased. The next status('status2') emits cursor-up and clear again, which moves over the line above and erases foo.

Fix: settle failed writes in one place. For example, have log() track its write promise and, if it rejects and is still the latest write, reset #writtenStatus and clear #lastWritePromise, as #writeStatus() does. Add tests for a failed log() next to the new status tests.

Reproduced: node __review__/terminal-repro.js, scenario logStale: flush 1, 2 and 3 all report rejected: write failed. node __review__/terminal-log-repro.js: the failed log's only successful write is \r\x1b[1A\x1b[J, then status2 writes ["\r\x1b[1A\x1b[J","status2\n"], a second cursor-up with no status on screen. · id 4523774c

@vzaidman
vzaidman marked this pull request as draft November 9, 2026 17:02
Run each status update in #scheduleStatusUpdate() itself, with no helper: each iteration assigns #updatePromise inside the try block to the current #writeStatus() promise and awaits it, looping until the written status is the latest one. The loop exit check and the cleanup (beforeExit listener removal, #updatePromise reset, flush-request reset) run in one synchronous step, with no microtask gap between them, so a status() call just after an update finishes can't slip between them and be lost. A rejected #writeStatus() no longer leaves the listener attached or a rejected promise in #updatePromise, which made every later #scheduleStatusUpdate() early-return and permanently stopped status updates. waitForUpdates() loops on #updatePromise until it clears, so it waits for the whole update, not just the current write.

Status updates, writes, and the beforeExit flush are fire-and-forget, so mark their rejections as handled: a failed update no longer crashes the process when no one awaits it, while waitForUpdates() and flush() still see the rejection.

#writeStatus() marks the status as written before awaiting the write's completion. If that write fails, the status was never drawn, but #writtenStatus still claims it was. The next update then erases a status that isn't on the screen, which in a TTY moves the cursor above the drawn output and erases earlier logs, and a retry of the same status writes nothing because it looks already written. Reset #writtenStatus and clear #lastWritePromise on a failed write, unless another write has replaced the awaited promise or #writtenStatus has changed since, as a log() in between redraws the status itself. Without the clear, flush() and waitForUpdates() keep rejecting with the stale error. A failed log() write leaves the same stale state: log() now applies the same reset to a redrawn status and clears the stale write promise.

Test plan: New tests 'does not lose a status set just after an update finishes' (fails on the previous .finally() version, passes on base and with this change), 'does not crash on a failed status write with no waiter', 'does not crash on a failed write during the beforeExit flush', 'recovers after a failed status write', 'recovery after a failed status write does not erase earlier logs', 'retries the same status after a failed status write', 'flush does not reject with a stale error after a failed status write', 'flush does not reject with a stale error after a failed log write', and 'a failed log redraw does not erase earlier output on recovery' fail without the fix and pass with it. yarn jest packages/metro-core/src/__tests__/Terminal-test.js, yarn eslint, yarn prettier, yarn flow check.
@vzaidman
vzaidman force-pushed the vzaidman/terminal-update-promise-finally branch from 9ecabbc to 31e08d5 Compare November 9, 2026 17:14
@vzaidman

vzaidman commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the latest review findings:

  • beforeExit flush: #flushBeforeExit now marks flush()'s rejection as handled (void this.flush().catch(() => {})), like the update and write promises. Added test does not crash on a failed write during the beforeExit flush (fails without the fix).
  • Failed log() write: log() now applies the same reset as #writeStatus() — if its write rejects and is still the latest write, it clears a redrawn status recorded as drawn and clears the stale #lastWritePromise. Added tests flush does not reject with a stale error after a failed log write and a failed log redraw does not erase earlier output on recovery (both fail without the fix; the redraw case is TTY-specific).
  • Test plan wording: clarified that the race test fails on the previous .finally() version and passes on base and with this change.

Verified: Terminal-test.js (57 tests), ESLint, Prettier, yarn flow check.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant