Skip to content

fix(cli): tolerate os.cpus() throwing when /proc/stat and /proc/cpuinfo disagree (Termux/proot) - #1377

Open
heavymio wants to merge 5 commits into
CodebuffAI:mainfrom
heavymio:patch-2
Open

heavymio wants to merge 5 commits into
CodebuffAI:mainfrom
heavymio:patch-2

Conversation

@heavymio

@heavymio heavymio commented Sep 17, 2026 •

Copy link
Copy Markdown

Fingerprinting must not be able to take down the CLI. On ARM Linux under proot-distro, os.cpus() throws Failed to get CPU information and the unhandled rejection from systeminformation kills the process ~3s after start. Closes #1374.

Root cause

The two /proc views 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_STAT string in sysdata.py and binds it over /proc/stat in fake_sysdata_bindings(), because Android blocks the real one. /proc/cpuinfo stays 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-checks cpus(); if it throws, skip si.cpu() and return empty CPU fields. The systeminformation.cpu() call is also wrapped, since it re-reads os.cpus() internally at lib/cpu.js:956.
  • safeCpuCount() — guards the second call site, runtime.cpuCount in calculateEnhancedFingerprint(). Without this the first guard is defeated: the pre-check returns empty fields, then cpus().length throws 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, because mock.module for node:os is process-wide in bun and would leak into the existing fingerprint.test.ts cases.
cpu_pre_check_failed cpu_count_unavailable result
main — — legacy fallback
first revision yes no legacy fallback
this revision yes yes enhanced-

Measured against this branch's fingerprint.ts on a standalone bun:test harness with os.cpus() mocked to throw. The happy path is covered too: with a working os.cpus() the result is still enhanced- and no guard logs.

Note on test scope

A unit test can only mock a rejection, never a genuinely detached nextTick throw, so it pins the guard's fallback behaviour but does not by itself settle the detached-nextTick question 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.

… 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.
@codebuff-team

Copy link
Copy Markdown
Contributor

Thanks for digging into #1374 - the instinct to guard fingerprinting so it can't take down the CLI is correct, and the cpus() pre-check plus structured logger.warn reporting is a reasonable pattern to follow in this file.

Two things concern me before this is portable:

  1. The core claim contradicts the fix. You state the crash comes from an exception thrown inside a nextTick callback deep in systeminformation's lib/cpu.js:956 - i.e. it's the classic "async callback throws after the call stack that invoked it has already returned" problem. A try/catch wrapped around await systeminformationModule.cpu() only catches synchronous throws or promise rejections. If the underlying library really does throw from a detached nextTick callback without going through the promise chain, it becomes an uncaughtException/unhandledRejection on the process, and your try/catch here won't see it at all. If that's really the mechanism, the fix needs a process.on('uncaughtException', ...) handler (scoped carefully) or a lower-level patch, not a try/catch around the call site. As written, I don't think this actually catches the case it claims to catch — only the os.cpus() pre-check (which is a real synchronous call) is guaranteed to work.

  2. Missing import verification. The diff calls cpus() directly but doesn't show adding import { cpus } from 'os' — if that import isn't already present elsewhere in fingerprint.ts, this won't compile.

Given the crash you're describing is intermittent and hardware-dependent, this really needs a unit test that mocks os.cpus() throwing and systeminformationModule.cpu() rejecting, to prove the guard actually holds. Please clarify with a stack trace from the actual crash showing whether it's a promise rejection or a true uncaught exception - that determines whether this fix is even the right layer.

@codebuff-team codebuff-team added bot:triaged Classified by the community triage bot pr:needs-work Right idea, not mergeable as written labels Sep 18, 2026
@heavymio

Copy link
Copy Markdown
Author

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

import { cpus, networkInterfaces } from 'node:os' is already in fingerprint.ts on main:

main, cli/src/utils/fingerprint.ts:11
  import { cpus, networkInterfaces } from 'node:os'
main, cli/src/utils/fingerprint.ts:113
      cpuCount: cpus().length,

cpus was already used on main, so fingerprint.ts compiles with the call and the diff doesn't need an import. Sorry that wasn't visible in the diff.

1. Rejection vs uncaught exception — the crash output answers it

Bun reports Unhandled rejection, not an uncaught error:

Unhandled rejection: Error: Failed to get CPU information
    at cpus (unknown)
    at populate (node:os:18:25)
    at model (node:os:27:21)
    at <anonymous> (../node_modules/systeminformation/lib/cpu.js:956:41)
    at processTicksAndRejections (unknown:7:39)

A throw from a detached nextTick surfaces as an uncaught exception. A rejection is what you get when the throw happens inside a callback already sitting on a promise chain, and the outermost frame here is processTicksAndRejections, which is a promise-stack frame. So this is a rejection, and try/catch around the awaited call is the right layer — I don't think a process.on('uncaughtException') handler is needed for this case.

Worth flagging the limit of what a unit test can show here: mocking os.cpus() can only produce a rejection, never a genuinely detached throw. So the unit test proves the guard returns a correct fallback; it can't by itself settle the detached-nextTick question. The evidence for that part is the end-to-end run on the affected hardware.

The review surfaced a real bug in my patch — fixed in the new commit

There are two cpus() call sites. My patch guarded one and missed the second at line 113, which sits inside calculateEnhancedFingerprint and is not covered:

cpuCount: cpus().length,   // still unguarded

The consequence is that the fallback this PR advertises is unreachable on the affected platform. getCpuInfoSafe() catches the pre-check throw and returns empty CPU fields, then a few lines later cpus().length throws again, propagating to the existing try/catch in calculateFingerprint() and dropping the whole thing into legacy fingerprinting.

I verified this against the real fingerprint.ts on a standalone bun:test harness, with os.cpus() mocked to throw:

variant cpu_pre_check_failed cpu_count_unavailable resulting fingerprint
main (no guard) no no codebuff-cli- (legacy fallback)
this PR as written yes no codebuff-cli- (legacy fallback)
this PR + safeCpuCount() yes yes enhanced- ✅

The new commit adds a safeCpuCount() helper alongside getCpuInfoSafe() and uses it at line 113, so the enhanced fingerprint survives with cpuCount: 0 instead of degrading to legacy. Tests are included in the same commit.

Correction to the original description: this is not a hotplug skew

/proc/stat is not a stale snapshot and there is no window to race. proot-distro 5.9.0 replaces it with a hardcoded eight-core file — sysdata.py:66-83, _FAKE_STAT — and binds it over /proc/stat in fake_sysdata_bindings() (sysdata.py:485-493) because Android blocks the real one. /proc/cpuinfo is not masked and stays live. So /proc/stat reports 8 while /proc/cpuinfo reports 9, permanently, for the life of the container.

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.

printf 'stat=%s cpuinfo=%s nproc=%s\n' \
  "$(grep -c '^cpu[0-9][0-9]* ' /proc/stat)" \
  "$(grep -c ^processor /proc/cpuinfo)" "$(nproc)"

One-liner to confirm. With the fake corrected, the CLI stays up (survived 60s under timeout); with the stock 8-core fake it exits 1 at ~3s.

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.

@heavymio heavymio changed the title fix(cli): guard systeminformation.cpu() against uncatchable os.cpus()… fix(cli): tolerate os.cpus() throwing when /proc/stat and /proc/cpuinfo disagree (Termux/proot) Sep 27, 2026
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.
@heavymio

Copy link
Copy Markdown
Author

Both fixes from the review are now pushed, in two commits on top of the previous revision.

  • 7becc45 — safeCpuCount() for the second cpus() call site at line 113, plus a correction to the comment above the pre-check (the cause is not a hotplug window)
  • 7001ef9 — cli/src/utils/__tests__/fingerprint-cpu-guard.test.ts

On the import question: it is already there, unchanged from main.

main, cli/src/utils/fingerprint.ts:11
  import { cpus, networkInterfaces } from 'node:os'

The test covers the two cases the review asked for — os.cpus() throwing and systeminformation.cpu() rejecting — asserted at the public entry point rather than on an internal, so it does not need getSystemInfo exported. One caveat I want to be upfront about: it lives in its own file because mock.module for node:os is process-wide in bun and would otherwise leak into the existing fingerprint.test.ts cases.

I validated the branch's fingerprint.ts against a standalone bun:test harness rather than the full workspace, so CI is the real check on the test file. The behaviour it pins is verified: skew gives enhanced- with both guards firing, and the happy path is unchanged.

…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.
@heavymio

Copy link
Copy Markdown
Author

One correction to my previous comment, found while verifying the test locally.

mock.module() is not undone by mock.restore(), and bun runs every test file in a single process. My test was therefore leaking a throwing os.cpus() and a stub logger into whichever file ran next — including fingerprint.test.ts, which sorts immediately after it. Pushed as e57a30: the real exports are captured before mocking and every mocked module is restored in afterAll.

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:

  4 pass  fingerprint CPU guard (#1374)
  1 pass  probe: cpus() reports 9, real logger restored

With the restore removed, the probe fails with Failed to get CPU information.

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.
@heavymio

Copy link
Copy Markdown
Author

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 e8787a0.

What broke in the workspace:

  • fingerprint.ts memoises systeminformation in a module-level variable, so a plain re-import reused whichever mocks the first case installed. Cases two and three got 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 at all. Both mocks are gone; the guard is asserted on calculateFingerprint()'s return value instead. That still separates the bug precisely — a throw escaping the guard is exactly what a legacy fingerprint looks like. Only node:os, node-machine-id and systeminformation are mocked now, all external, all restored in afterAll.

Verification, full run of cli/src/utils/__tests__ in the workspace, 83 files, 1419 tests:

  main    1393 pass  15 fail
  branch  1396 pass  15 fail

Same 15 failures before and after, plus the 3 new tests. They are all ensureSponsoredProjectIdentity, which needs a Git fixture (could not initialize Git fixture) and has nothing to do with this change. No regressions, and the isolated pair fingerprint-cpu-guard.test.ts + fingerprint.test.ts is 24/24.

Unrelated, but you may want to know: cli/bunfig.toml preloads ../test/setup-scm-loader.ts, and that path 404s on every branch of the public repo, so bun test for cli cannot run as configured from here. I removed that one preload locally to run the suite and did not push the change. Happy to file it separately if it is meant to be tracked somewhere.

@heavymio

Copy link
Copy Markdown
Author

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 systeminformation, four lines:

const os = require('node:os')
os.cpus()            // ok, length = 9
os.cpus()[0].model   // throws: Failed to get CPU information
os.cpus() c[0].model JSON.stringify(c)
bun 1.2.21 9 throws throws
bun 1.4.2 9 throws throws
bun 1.4.2, compiled binary 9 throws throws
node 24 8 "unknown" ok

So os.cpus() itself succeeds and the failure is in reading the per-CPU fields — and it reproduces in the runtime, in a file, and in a compiled binary, on both 1.2.21 and 1.4.2, so it isn't a bundling artifact or a fixed regression. Node returns "unknown" for the same field on the same machine.

Correcting my earlier framing: os.cpus() does not throw, accessing .model does. That is what lib/cpu.js:956 (os.cpus()[0].model) hits.

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 — /proc skew has more sources than proot-distro, and fingerprinting should not be able to take down the CLI. But it means the permanent fix is likely to land in Bun rather than here.

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

bot:triaged Classified by the community triage bot pr:needs-work Right idea, not mergeable as written

Projects

None yet

2 participants