Skip to content

fix(cli): print the update-available notice once, not on every event-loop drain#1104

Merged
jrusso1020 merged 1 commit into
mainfrom
05-28-fix_cli_update_notice_double_print
May 28, 2026
Merged

fix(cli): print the update-available notice once, not on every event-loop drain#1104
jrusso1020 merged 1 commit into
mainfrom
05-28-fix_cli_update_notice_double_print

Conversation

@jrusso1020
Copy link
Copy Markdown
Collaborator

What

process.on("beforeExit", …) re-fires every time the event loop drains. The listener kicks off a fire-and-forget async telemetry flush, which schedules new work — so on a successful command the user sees the "Update available: …" notice twice (once after the initial drain, again after the flush settles, and so on).

Switched to process.once so the listener detaches after first invocation. Side benefit: prevents the double-flush of telemetry on the same path.

- // Async flush for normal exit (beforeExit fires when the event loop drains)
- process.on("beforeExit", () => {
+ // … `once` detaches after first invocation …
+ process.once("beforeExit", () => {
    _flush?.().catch(() => {});
    if (!hasJsonFlag) _printUpdateNotice?.();
  });

Why

Reported during local testing of the auth stack (#1081 / #1084), but the bug affects every command — any code path where _flush() schedules work (which is, by design, every non---json invocation when telemetry is enabled).

User-visible symptom:

✓ API key saved. Authenticated as james.russo@heygen.com.

  Update available: 0.6.46 → 0.6.52
  Run: npx hyperframes@latest


  Update available: 0.6.46 → 0.6.52
  Run: npx hyperframes@latest

Test plan

  • Ran the full CLI suite (bun run --cwd packages/cli test): 446/446 green.
  • bunx oxlint, bunx oxfmt --check, bunx tsc --noEmit -p packages/cli/tsconfig.json, bunx fallow audit --base origin/main all clean.
  • Manual repro: built the bundle and ran a command that exercises the post-exit notice — only one banner now.

Not adding a unit test for the listener wiring itself — beforeExit re-fire behavior is a Node-level contract that's awkward to mock cleanly and the once semantic is structural; future regressions would just bring the double-print back, which is visually obvious.

Out of scope

Other commands in the auth stack (#1081 / #1084) — unrelated; this is a separate, narrowly-scoped fix off main so it can ship/merge independently.

…loop drain

`process.on("beforeExit", ...)` re-fires every time the event loop
drains, and the handler kicks off a fire-and-forget async telemetry
flush — so on a successful command the user sees the
"Update available: …" notice twice (once after the initial drain, again
after the flush settles). Using `process.once` detaches the listener
after first invocation, fixing the double-print and also preventing a
double-flush of telemetry.

Reported during local testing of `auth login`, but the bug affects every
command (any path where `_flush()` schedules work).
Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen left a comment

Choose a reason for hiding this comment

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

Clean fix — process.once is exactly right for the re-entrant beforeExit drain loop. No issues.

Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls left a comment

Choose a reason for hiding this comment

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

Review

Design is correct. James's diagnosis matches Node's beforeExit semantics exactly: the event fires each time the event loop drains; _flush().catch(...) schedules a microtask continuation that is new event-loop work, so a plain on listener re-fires on every drain until the process actually terminates — causing the double (or infinite) update banner. process.once detaches after first invocation, which is the right primitive and the minimal fix.

Hyperframes pattern checklist:

  • Not in packages/engine, so the ESM require() trap is N/A.
  • beforeExit handler uses captured references (_flush?.(), _printUpdateNotice?.()) — not dynamic import() inside the handler. Pattern is correctly followed, matching the existing exit / uncaughtException handlers nearby. Clean.
  • No GSAP. N/A.

One nit (non-blocking):

With ononce, if the first beforeExit fires before the import("./utils/updateCheck.js") dynamic import has resolved and written _printUpdateNotice, the listener is permanently detached and the notice never prints for that run. With the old on listener a late drain after the import settled would have caught it. In practice this is only a concern for commands that drain the event loop so fast that no async work is in flight at all — extremely unlikely on any real command, and the auth-stack path that triggered this report is definitely safe. Not a blocker, just worth knowing.

No unit test: James's justification is solid — beforeExit re-fire behavior is a Node contract, the fix is structural, and a regression would be visually obvious. Agree, nothing to add here.

CI: A few checks (CLI smoke, Windows tests, global-install smoke) were still pending at review time — assuming they pass, this is good to merge.

LGTM. Ship it.

— Vai

@jrusso1020 jrusso1020 merged commit a0e6efb into main May 28, 2026
34 checks passed
@jrusso1020 jrusso1020 deleted the 05-28-fix_cli_update_notice_double_print branch May 28, 2026 05:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants