fix(cli): tolerate os.cpus() throwing when /proc/stat and /proc/cpuinfo disagree (Termux/proot) - #1377
fix(cli): tolerate os.cpus() throwing when /proc/stat and /proc/cpuinfo disagree (Termux/proot)#1377heavymio wants to merge 5 commits into
Conversation
… throw on ARM Linux (hotplug skew) — see CodebuffAI#1374 ### Patch refined and tested The patch in commit `1333802` is close, but the `cpus()` pre-check needs a fix: on Bun's ARM Linux runtime with CPU hotplug skew, `os.cpus()` can throw synchronously, so calling `cpus()` without a `try/catch` can crash before we ever reach the guarded `si.cpu()`. Updated patch: - `cpus()` wrapped in `try/catch` — if it throws, return empty values + `logger.warn` immediately. - `systeminformation.cpu()` in `try/catch` — catches the uncaught exception from the `nextTick` callback inside `lib/cpu.js:956`. - `Promise.all` now uses `getCpuInfoSafe()` (removed the duplicate `systeminformationModule.cpu()` call). - Fallback logging via the existing `logger` (`fingerprintType: cpu_pre_check_failed` / `cpu_info_failed`). Diagnosis confirmed via the mount-namespace workaround: - frozen CPU snapshot (8 CPUs consistent across /proc/stat, /proc/cpuinfo, /sys/devices/system/cpu/online) → CLI stays up. - live skewed state (9/8/9) → crash ~3s after start. Fallback behavior: fingerprint still generates (empty CPU fields or legacy path); process is stable, not ideal for fingerprinting uniqueness but no longer crashes.
|
Thanks for digging into #1374 - the instinct to guard fingerprinting so it can't take down the CLI is correct, and the Two things concern me before this is portable:
Given the crash you're describing is intermittent and hardware-dependent, this really needs a unit test that mocks |
|
Thanks for the careful review — both points were worth raising, and one of them turned out to be a real bug in my own patch. Addressing them in order. 2. Import verification — already present, nothing to add
1. Rejection vs uncaught exception — the crash output answers itBun reports A throw from a detached Worth flagging the limit of what a unit test can show here: mocking The review surfaced a real bug in my patch — fixed in the new commitThere are two cpuCount: cpus().length, // still unguardedThe consequence is that the fallback this PR advertises is unreachable on the affected platform. I verified this against the real
The new commit adds a Correction to the original description: this is not a hotplug skew
The failure is deterministic, not intermittent: exit 1 at ~3–4s on every run on the same hardware. Reproduced on 0.0.204 as well as 0.0.175. One-liner to confirm. With the fake corrected, the CLI stays up (survived 60s under Since the root cause is upstream, I've also filed it there: https://github.com/termux/proot-distro/issues Happy to rework this into whatever shape you'd prefer, and to add whatever test case you think is missing. |
calculateEnhancedFingerprint() calls cpus().length directly to build runtime.cpuCount, outside the guard added for getCpuInfoSafe(). On the platforms from CodebuffAI#1374 the first call returns empty CPU fields and then this one throws, which drops the whole enhanced fingerprint into the legacy fallback -- making the empty-fields path unreachable in practice. Add safeCpuCount() alongside getCpuInfoSafe() and use it here, so the enhanced fingerprint survives with cpuCount 0. Verified against the real fingerprint.ts on a standalone bun:test harness with os.cpus() mocked to throw: main (no guard) -> codebuff-cli- (legacy fallback) previous revision -> codebuff-cli- (legacy fallback) this revision -> enhanced- The happy path is unchanged: with a working os.cpus() the result is still enhanced and no guard logs. Tests included. Also corrects the comment above the pre-check: /proc/stat is not a stale snapshot and there is no hotplug window. proot-distro binds a hardcoded 8-core /proc/stat (sysdata.py:66-83) while /proc/cpuinfo stays live, so the two disagree permanently.
…try point Pins the behaviour codebuff-team asked for: calculateFingerprint() must resolve, not reject, when os.cpus() throws or systeminformation.cpu() rejects, and it must not silently degrade to a legacy fingerprint. Kept in its own file because mock.module for node:os is process-wide in bun, and a shared file would leak the mock into the existing fingerprint.test.ts cases. - os.cpus() throws -> enhanced- fingerprint, both call sites guarded - os.cpus() works -> enhanced- fingerprint, no guard logged Validated against this branch's fingerprint.ts on a standalone bun:test harness; not yet run in the full workspace. Happy path (9 real CPUs) checked too, so this is not a skew-only assertion.
|
Both fixes from the review are now pushed, in two commits on top of the previous revision.
On the import question: it is already there, unchanged from The test covers the two cases the review asked for — I validated the branch's |
…oes not leak mock.module() is not undone by mock.restore(), and bun runs every test file in a single process, so this file was leaking a throwing os.cpus() and a stub logger into whichever file ran after it. That included fingerprint.test.ts, which sorts immediately after this one. Capture the real exports before mocking and put every mocked module back in afterAll. Re-registering the namespace object alone is not enough, because the mock replaces entries in place. Confirmed with a follow-up file: without this, the next file sees cpus() throwing; with it, cpus() reports 9 and the real logger is back.
|
One correction to my previous comment, found while verifying the test locally.
Worth flagging for anyone else adding module mocks in this suite, since the failure is silent and shows up as an unrelated file failing. Local check, running the guard test followed by a probe file: With the restore removed, the probe fails with |
Verified in the real workspace and it was not robust there: - fingerprint.ts memoises systeminformation in a module-level variable, so a plain re-import reused whichever mocks the first case installed. The second and third cases saw a legacy fingerprint and an empty log. Fixed with a cache-busting import so each case gets a fresh module instance. - Mocking the local ./logger and ./analytics leaked into windows-terminal-health.test.ts, which then recorded no analytics events. Dropped both; the guard is asserted on calculateFingerprint()'s return value instead, which still separates the bug precisely -- a throw escaping the guard produces a legacy fingerprint. Only the three external packages are mocked now, and all are restored in afterAll. Full run of cli/src/utils/__tests__ in the workspace, 83 files: main 1393 pass 15 fail branch 1396 pass 15 fail Same 15 failures before and after (ensureSponsoredProjectIdentity, which needs a Git fixture), plus the 3 new tests. No regressions.
|
I ran the test in the real workspace rather than only on a standalone harness, and it needed rework — the first version was not order-independent. Now on What broke in the workspace:
Verification, full run of Same 15 failures before and after, plus the 3 new tests. They are all Unrelated, but you may want to know: |
|
One more root-cause data point, which changes where the real fix belongs. The throw is not in this repo. With vanilla Bun — no Codebuff, no const os = require('node:os')
os.cpus() // ok, length = 9
os.cpus()[0].model // throws: Failed to get CPU information
So Correcting my earlier framing: Filed upstream: oven-sh/bun#44125 and the proot-distro side here: termux/proot-distro#717 This does not change the case for the guard in this PR — |
Fingerprinting must not be able to take down the CLI. On ARM Linux under proot-distro,
os.cpus()throwsFailed to get CPU informationand the unhandled rejection fromsysteminformationkills the process ~3s after start. Closes #1374.Root cause
The two
/procviews disagree, permanently:/proc/cpuinfo— 9 processors (live, not masked)/proc/stat— 8 per-CPU lines (proot-distro binds a hardcoded 8-core file over it)online—0-8(live)This is not a CPU hotplug window and not a stale snapshot. proot-distro 5.9.0 ships a fixed
_FAKE_STATstring insysdata.pyand binds it over/proc/statinfake_sysdata_bindings(), because Android blocks the real one./proc/cpuinfostays live, so on any device whose core count isn't 8 the guest sees two different answers for the life of the container. The failure is deterministic — exit 1 at ~3–4s on every run, reproduced on 0.0.204 as well as 0.0.175.Bun's
os.cpus()throws on the disagreement; Node's returns 8 entries and never throws.Fix upstream, where the fake is generated: termux/proot-distro#717
This PR
getCpuInfoSafe()— pre-checkscpus(); if it throws, skipsi.cpu()and return empty CPU fields. Thesysteminformation.cpu()call is also wrapped, since it re-readsos.cpus()internally atlib/cpu.js:956.safeCpuCount()— guards the second call site,runtime.cpuCountincalculateEnhancedFingerprint(). Without this the first guard is defeated: the pre-check returns empty fields, thencpus().lengththrows again and the whole enhanced fingerprint falls back to legacy, making the empty-fields path unreachable.cli/src/utils/__tests__/fingerprint-cpu-guard.test.ts— separate file, becausemock.modulefornode:osis process-wide in bun and would leak into the existingfingerprint.test.tscases.cpu_pre_check_failedcpu_count_unavailablemainenhanced-Measured against this branch's
fingerprint.tson a standalonebun:testharness withos.cpus()mocked to throw. The happy path is covered too: with a workingos.cpus()the result is stillenhanced-and no guard logs.Note on test scope
A unit test can only mock a rejection, never a genuinely detached
nextTickthrow, so it pins the guard's fallback behaviour but does not by itself settle the detached-nextTickquestion raised in review. The end-to-end evidence is the CLI surviving 60s on the affected hardware versus exiting 1 at ~3s with the stock fake.