fix(bindings): size BLS thread pool by cgroup-aware CPU count - #386
Conversation
std.Thread.getCpuCount() only reads the CPU affinity mask, so under a
cgroup CPU quota (docker --cpus=N / k8s limits.cpu) it returns the host
core count rather than the quota. That over-sized the BLS thread pool at
NAPI init and caused CFS throttling in CPU-limited containers.
Add src/cpu_count.zig: getNumCpus(gpa, io) returns min(cgroup quota,
affinity), locating the cpu controller via /proc/self/{cgroup,mountinfo}
and reading cpu.max / cpu.cfs_quota_us (cgroup v1 + v2). Use it at NAPI
thread-pool init.
- /proc pseudo-files report size 0, so they are streamed to EOF
(readerStreaming + allocRemaining), not read by stat size.
- ceilDiv is overflow-safe; detection failure logs a warning and falls
back to the affinity count.
- Non-Linux falls back to the affinity count.
CPU-count logic ported from the num_cpus crate; test vectors adapted from
seanmonstar/num_cpus (MIT).
a4435e1 to
295ace4
Compare
|
@codex review |
|
Codex Review: Didn't find any major issues. Another round soon, please! ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
getNumCpus previously collapsed every failure into a silent fallback: any cgroup detection error returned null (sizing by affinity), and getCpuCount itself was `catch 1`. An unreadable or malformed quota was indistinguishable from a genuinely unlimited one, so a CPU-limited container could silently over-size the BLS thread pool. Make the contract explicit: - null (-> affinity) only when no quota can be located: non-Linux, no cpu controller, no cgroup mount, unresolvable path, quota file absent, or unlimited (cpu.max "max" / cfs_quota_us "-1"). - error otherwise: unreadable /proc or quota file, unopenable cgroup dir, malformed/zero content, or an unavailable affinity count -- now propagated to the NAPI init caller (via `try`) instead of masked. Also fixes a latent parse hazard: the v1 "-1" and v2 "max" unlimited sentinels are matched before parseUnsigned, so they can no longer be taken for malformed input now that parse errors propagate. Drop the redundant log.warn calls; a thrown error is its own signal.
A bare `8 << 20` next to the cgroup file's `1 << 20` reads as arbitrary and invites tightening; mountinfo scales with the mount count and can reach several MB under thousands of container mounts. Note it as a generous ceiling so the limit is not narrowed without thought.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8286a791c2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (options.thread_count == 0) { | ||
| cpu_count = @max((try std.Thread.getCpuCount()) - 1, 1); | ||
| std.debug.print("Note: no -Dthread-count set, will use runtime CPU count minus 1: {}\n", .{cpu_count}); | ||
| cpu_count = @max(try @import("cpu_count").getNumCpus(allocator, napi_io.get()), 2) - 1; |
There was a problem hiding this comment.
Fall back when cgroup probing fails
In Linux runtimes where /proc/self/cgroup or /proc/self/mountinfo is unavailable/unreadable (for example restricted chroots or sandboxed launches), getNumCpus returns an error and this try propagates out of NAPI initialization, so loading the bindings fails even though the previous std.Thread.getCpuCount() path would still size the pool. Since the cgroup lookup is only an optimization over the affinity count, catch probe failures here (or inside getNumCpus) and fall back to std.Thread.getCpuCount() instead of aborting module init.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
we already fall back to std.Thread.getCpuCount() inside getNumCpus @codex
splitScalar's next() truncated the /proc/self/cgroup path field at the next ':', so colon-named cgroup directories (containerd's ...slice:cri-containerd:<id> cgroupfs naming) resolved to a nonexistent path — silently falling back to the affinity count, or erroring NAPI init under host cgroupns. Take the remainder with rest() instead, the same way runc's ParseCgroupFile splits the line.
The kernel reports a relative path like 0::/../foo when the process sits outside its cgroupns root (cgroup_namespaces(7)). Joining '..' onto the mount point escapes the cgroupfs, so openDir failed and the error aborted NAPI init. The quota is simply not locatable through this mount: return null and fall back to the affinity count, per the module's documented unresolvable-path contract.
The kernel enforces the smallest CFS limit of the whole ancestor chain, but each level's quota file reports only its own limit — a constrained child still reads 'max'. Reading only the leaf therefore missed quotas set above it: LXC/Proxmox cpulimit (limit at the cgroupns root), systemd CPUQuota= on a parent slice, or any process-manager sub-cgroup inside a limited container, sizing the BLS pool to the host core count despite an enforceable quota. Walk from the leaf up to the cgroup mount point and take the minimum, matching Rust std::thread::available_parallelism. The leaf keeps the fail-fast contract; unopenable ancestors end the walk best-effort. Bounded by the component count of the leaf path.
The build-test job enumerates per-module test steps explicitly, so the new test:cpu_count step never ran in CI — the only Linux environment exercising the real /proc + cgroup path.
- Assert n != 0 in ceilDiv instead of silently returning 0: both callers reject a zero quota first, so n == 0 is violated-invariant space and should crash next to the existing d != 0 assert. - Drop pub from parseCpuMaxV2/parseCpuV1: tests live in-file and the module's public surface is getNumCpus alone. - Factor the triple-duplicated FileNotFound catch into readQuotaFile. - Remove the boxed test-section banner; the fixture comment orients. - Rename q/p to quota_s/period_s (matches parseCpuMaxV2), rc/bc/mp to full words per the styleguide's no-abbreviation rule. - Wrap everything to the 100-column hard limit; build the /proc mountinfo fixtures from per-line ++ continuations with a shared prefix instead of 770-column single lines.
…med lines - A hybrid fixture (cgroup2 mount ordered before the v1 cpu mount) exercises MountInfo.load's version filter in both directions; before this, no test fed the filter a parseable mount of the other version, so dropping the version check would have passed the whole suite. - translate()'s two out-buffer capacity guards were never executed (the helper always passed a max_path_bytes buffer); cover both branches plus the exact-fit boundary. - Cover MountInfo.parseLine structural failures: missing "-" separator (the while-else path), truncated line, line ending at "-".
- Use page_allocator for the two short-lived /proc reads instead of adding a third per-file DebugAllocator/c_allocator pair to the bindings; io.zig and blst.initThreadPool already use page_allocator for one-shot init-time allocation. - Wrap the init print to the 100-column limit. - Drop the duplicate blst.deinitThreadPool() in cleanup() (idempotent no-op left over from the zapi DSL rewrite).
getNumCpus reads /proc via allocRemaining, which grows an ArrayList incrementally — with page_allocator every grow is an mmap round trip at page granularity with no reuse. page_allocator fits the one-shot large/long-lived init allocations it backs elsewhere in the bindings (ThreadPool.init, the Threaded executor), not incremental small growth; c_allocator is the bindings' general-purpose allocator and malloc reuses buckets across the doubling steps. Leak coverage for these defer-freed buffers lives in the module's unit tests via std.testing.allocator.
…ount probe Match the bindings-wide pattern (blst.zig, stateTransition.zig, metrics.zig): DebugAllocator in Debug builds for double-free and use-after-free detection on this path, c_allocator in release.
…errors getNumCpus keeps its fail-fast contract (a broken read of an existing resource errors, so a readable quota is never silently masked), but propagating that error out of init() made require() of the whole native addon throw — a thread-pool sizing probe should not take down SSZ/state-transition/BLS in an exotic container setup (/proc-less chroot, seccomp-blocked reads, malformed quota content). Catch at the call site, warn, and size by the affinity count instead, matching the pre-detection behavior.
spiral-ladder
left a comment
There was a problem hiding this comment.
looks good, some comments
| pubkeys.state.deinit(); | ||
| pool.state.deinit(); | ||
| metrics.deinit(); | ||
| blst.deinitThreadPool(); |
There was a problem hiding this comment.
Yes — intentional. Both calls came in together with the #320 zapi rewrite (merge artifact), and the first position is the correct one to keep: ThreadPool.deinit drains the queue and joins all workers, which must happen before the pubkeys/pool caches (which in-flight jobs read) are freed, and before napi_io.deinit() (pool teardown uses the io). The removed second call was a guaranteed idempotent no-op.
| //! cgroup-aware logical CPU count for Linux. | ||
| //! | ||
| //! `std.Thread.getCpuCount()` only reads the CPU affinity mask and is blind to | ||
| //! the cgroup CFS quota (`cpu.max` / `cpu.cfs_quota_us`), so under | ||
| //! `docker --cpus=N` or k8s `limits.cpu` it reports the host core count. This | ||
| //! returns `min(quota, affinity)` instead, where the quota is the smallest one | ||
| //! along the cgroup ancestor chain — the kernel enforces every level, but each | ||
| //! level's quota file reports only its own limit. |
There was a problem hiding this comment.
It would be nice to also include where we are consuming this:
| //! cgroup-aware logical CPU count for Linux. | |
| //! | |
| //! `std.Thread.getCpuCount()` only reads the CPU affinity mask and is blind to | |
| //! the cgroup CFS quota (`cpu.max` / `cpu.cfs_quota_us`), so under | |
| //! `docker --cpus=N` or k8s `limits.cpu` it reports the host core count. This | |
| //! returns `min(quota, affinity)` instead, where the quota is the smallest one | |
| //! along the cgroup ancestor chain — the kernel enforces every level, but each | |
| //! level's quota file reports only its own limit. | |
| //! cgroup-aware logical CPU count for Linux. | |
| //! | |
| //! `std.Thread.getCpuCount()` only reads the CPU affinity mask and is blind to | |
| //! the cgroup CFS quota (`cpu.max` / `cpu.cfs_quota_us`), so under | |
| //! `docker --cpus=N` or k8s `limits.cpu` it reports the host core count. This | |
| //! returns `min(quota, affinity)` instead, where the quota is the smallest one | |
| //! along the cgroup ancestor chain — the kernel enforces every level, but each | |
| //! level's quota file reports only its own limit. | |
| //! | |
| //! This module is important when consumed by docker deployed applications using | |
| //! multithreaded operations within `lodestar-z`, eg. | |
| //! our threadpool implementation used for bls operations in `bls/ThreadPool.zig`. |
| /// Logical CPU count from the affinity mask. Propagates the error rather than | ||
| /// silently degrading — a failed affinity count would size the pool to 1 worker. | ||
| fn logicalCpus() !usize { | ||
| const n = try std.Thread.getCpuCount(); | ||
| assert(n >= 1); | ||
| return n; | ||
| } |
There was a problem hiding this comment.
we can do this inline and assert n >= 1 in getNumCpus and also get rid of the test, this is just doing an unnecessary abstraction on std.Thread.getCpuCount() imo
There was a problem hiding this comment.
Done in 62b1494 — inlined with the n >= 1 assert moved into getNumCpus, and dropped the "never exceeds logical" test since the result <= logical postcondition assert now checks it on every call (including from the remaining smoke test).
- Keep cgroup paths containing ':' intact (containerd's ...slice:cri-containerd:<id> cgroupfs naming): take the path with rest() instead of truncating at the next ':'. - Treat '..' cgroup paths (process outside its cgroupns root) as unresolvable -> affinity fallback instead of a hard error. - Take the minimum quota along the cgroup ancestor chain (leaf up to the mount point), matching Rust available_parallelism: the kernel enforces every level but each level's file reports only its own limit, so leaf-only reads missed LXC/Proxmox cpulimit and systemd CPUQuota= on a parent slice. - Polish per review: assert ceilDiv preconditions, dedup quota-file reads, drop unused pubs, 100-col wraps, hybrid-mount and buffer-limit test coverage (23 tests). Synced verbatim from ChainSafe/lodestar-z#386 (commits 7c84fab7..f504039f); verified there end-to-end in containers including the ancestor and colon-path scenarios.
- Document the consumer in the module doc: NAPI thread-pool init sizing the BLS verification pool (spiral-ladder). - Inline the logicalCpus wrapper — it was an unnecessary abstraction over std.Thread.getCpuCount(); the n >= 1 assert moves into getNumCpus. Drop the now-redundant 'never exceeds logical' test: the result <= logical postcondition assert checks it on every call, including from the remaining smoke test (spiral-ladder).
spiral-ladder
left a comment
There was a problem hiding this comment.
In general looks good but this should really be a library on its own, but we can leave that for a followup cleanup, let's merge this so we can cut a release and deploy bls updates
…afe#386) ## Motivation `std.Thread.getCpuCount()` only reads the CPU affinity mask, so under a cgroup CPU quota (`docker --cpus=N` / k8s `limits.cpu`) it reports the host core count rather than the quota. That over-sizes the BLS verification thread pool at NAPI init, which can cause thread oversubscription and CFS throttling in CPU-limited containers. ## Changes - Add `src/cpu_count.zig` — `getNumCpus(gpa, io)` returns `min(cgroup quota, affinity)`, locating the cpu controller via `/proc/self/{cgroup,mountinfo}` and reading `cpu.max` / `cpu.cfs_quota_us` (cgroup v1 + v2). Parsing logic ported from the `num_cpus` crate. - The quota is the **minimum along the cgroup ancestor chain** (leaf up to the mount point), matching Rust `std::thread::available_parallelism`: the kernel enforces every level but each level's file reports only its own limit, so leaf-only reads miss LXC/Proxmox `cpulimit`, systemd `CPUQuota=` on a parent slice, and sub-cgroups inside a limited container. - Hardened beyond `num_cpus`: cgroup paths containing `:` (containerd's `…slice:cri-containerd:<id>` cgroupfs naming) are kept intact, and `..` paths (process outside its cgroupns root, `cgroup_namespaces(7)`) fall back cleanly instead of erroring. - Use it at NAPI thread-pool init (`bindings/napi/root.zig`) instead of `std.Thread.getCpuCount()`. - Run `test:cpu_count` in CI (the build-test job enumerates module test steps explicitly). Notes: - `/proc` pseudo-files report size 0, so they are streamed to EOF (`readerStreaming` + `allocRemaining`), not read by stat size (`readFileAlloc` reads empty). - Error policy: `getNumCpus` itself is fail-fast — a genuinely **absent** quota (non-Linux, no cpu controller, no cgroup mount, unresolvable path, unlimited) is `null` → affinity fallback, while a **broken read** of an existing resource (unreadable `/proc` or quota file, unopenable cgroup dir, malformed content) is an error, so a readable quota is never silently masked. The NAPI call site catches detection errors, logs a warning, and sizes by the affinity count — a sizing probe must not prevent the module from loading. - `ceilDiv` is overflow-safe; the allocator is passed in by the caller (the module stays allocator-agnostic / libc-free). ## Testing - 22 unit tests covering the parse-and-resolve pipeline and the ancestor walk; test vectors adapted from `seanmonstar/num_cpus` (MIT). - Verified end-to-end in Linux containers (OrbStack): `--cpus=2` → 2, `--cpus=4` → 4, `--cpus=1.5` → 2 (ceil), unlimited → host count. The old `getCpuCount()` returned the host count in all cases. - Ancestor walk verified end-to-end: with `--cpus=2` and the process moved into an unconstrained child cgroup (`0::/child`, no `cpu.max` of its own), the walk finds the limit at the cgroupns root and returns 2 — leaf-only reads return the host count here. - Colon handling verified end-to-end: a process in `0::/x.slice:cri-containerd:y` resolves its cgroup dir and returns the quota. --- 🤖 This PR was developed with AI assistance (Claude Code).
🤖 I have created a release *beep* *boop* --- ## [1.0.0](v0.1.2...v1.0.0) (2026-08-19) ### Features * add `state.getBuildersLength()` binding ([#472](#472)) ([be2b5ab](be2b5ab)) * **beacon-node:** add block state cache and checkpoint datastore ([#452](#452)) ([2145faa](2145faa)) * bindings to `getExpectedWithdrawals` and native tweaks ([#350](#350)) ([f47bc66](f47bc66)) * **bindings:** add pubkey cache syncPubkeys ([#537](#537)) ([542779f](542779f)) * **bindings:** aggregate cached public keys by validator index ([#397](#397)) ([2f90603](2f90603)) * **bindings:** align `BeaconStateView` with `IBeaconStateView` ([#347](#347)) ([b8ec273](b8ec273)) * **bindings:** configurable pubkey cache growth step ([#481](#481)) ([133ef24](133ef24)) * **bindings:** expose more APIs for STF ([#444](#444)) ([7fe2609](7fe2609)) * **bls:** add small MSM for npoints < 32 ([#393](#393)) ([b430638](b430638)) * **blst:** use external buffers for blst operations ([#358](#358)) ([78e4678](78e4678)) * **ci:** conditionally publish bindings with tag ([#355](#355)) ([ea77919](ea77919)) * **clock:** add clock module for slot/epoch timing ([#354](#354)) ([385b077](385b077)) * **fork_choice:** add Prometheus metrics module ([#309](#309)) ([cbc9d8d](cbc9d8d)) * **forkchoice:** implement the forkchoice module ([#246](#246)) ([7c62a9b](7c62a9b)) * getSyncCommitteesWitness ([#367](#367)) ([ef77649](ef77649)) * implement `loadState` API and binding ([#165](#165)) ([f903519](f903519)), closes [#159](#159) * **metrics:** metrics bindings ([#455](#455)) ([dd41999](dd41999)) * migrate blst,pubkeys to use zapi js dsl ([#331](#331)) ([fcd26ca](fcd26ca)) * **pubkeys:** add getPubkeyBytes binding ([#555](#555)) ([4ca51cf](4ca51cf)) * publish ARM64 musl bindings ([#482](#482)) ([ac764c9](ac764c9)) * **shuffle:** add swap-or-not shuffling module and binding ([#559](#559)) ([c2db37c](c2db37c)) * split nextValue fn ([#464](#464)) ([b47faeb](b47faeb)) * support getLatestWeakSubjectivityCheckpointEpoch ([#366](#366)) ([dcf3883](dcf3883)) * update fulu deposit processing ([#442](#442)) ([064335c](064335c)) ### Bug Fixes * avoid set ([#484](#484)) ([2e25d97](2e25d97)) * better generation of rand scalar ([#388](#388)) ([74dce77](74dce77)) * **bindings:** accept `dontTransferCache` in processSlots for backward compatibility ([#460](#460)) ([65df5af](65df5af)) * **bindings:** check signature infinity by default ([#509](#509)) ([2f5f281](2f5f281)) * **bindings:** clean up failed async BLS work ([#527](#527)) ([1111b00](1111b00)) * **bindings:** free metrics writer on scrape failure ([#529](#529)) ([4c8d94a](4c8d94a)) * **bindings:** harden random aggregate scalars ([#528](#528)) ([8e89a63](8e89a63)) * **bindings:** log level for missing fields ([#435](#435)) ([08faf41](08faf41)) * **bindings:** misordering of print for cpu count ([#381](#381)) ([752a972](752a972)) * **bindings:** populate epoch participation for test fixtures ([#436](#436)) ([8dbdd2e](8dbdd2e)) * **bindings:** refcount Pool to fix teardown panic ([#352](#352)) ([23b2f68](23b2f68)) * **bindings:** roll back partial N-API initialization ([#491](#491)) ([31c5ebb](31c5ebb)) * **bindings:** size BLS thread pool by cgroup-aware CPU count ([#386](#386)) ([3ae9522](3ae9522)) * **bindings:** validate class types before unwrap ([#514](#514)) ([2fd2ad5](2fd2ad5)) * **bindings:** validate secret key hex length ([#517](#517)) ([136e415](136e415)) * **bls:** align PublicKey.uncompress validation with Signature.uncompress ([#508](#508)) ([5a8dbe9](5a8dbe9)) * **bls:** bound randomized aggregation inputs ([#548](#548)) ([779d0bf](779d0bf)), closes [#542](#542) * **bls:** clean up partial thread pool initialization ([#490](#490)) ([d55e598](d55e598)) * **bls:** convert pippenger scratch bytes to element counts ([#513](#513)) ([a12ca92](a12ca92)) * **bls:** enforce 32-byte signing roots ([#545](#545)) ([72fd308](72fd308)) * **bls:** make batch cardinality structural ([#547](#547)) ([a06d8b2](a06d8b2)) * **bls:** preserve aggregate outputs on failure ([#521](#521)) ([e0b6dd1](e0b6dd1)) * **bls:** reject empty keygen salts ([#524](#524)) ([d2a9c86](d2a9c86)) * **bls:** reject unknown BLST error codes ([#525](#525)) ([9e4a6ad](9e4a6ad)) * **bls:** size pairing buffers for 32-bit targets ([#531](#531)) ([dc64a27](dc64a27)) * **blst:** default signature infinity check to true if not provided ([#387](#387)) ([021cdcb](021cdcb)) * **build:** remove `zig-out` from `files` ([#360](#360)) ([c52af09](c52af09)) * **ci:** fix caching spec test version ([#439](#439)) ([96885a1](96885a1)) * dangling state pointer in loadOtherState ([#450](#450)) ([81cbd5f](81cbd5f)) * **epoch_cache:** compute missing `next_proposers` ([#447](#447)) ([0088a29](0088a29)) * **epoch_cache:** populate decision roots in afterProcessEpoch ([#453](#453)) ([4b70a5e](4b70a5e)) * export asyncAggregateWithRandomness through napi binding ([#371](#371)) ([1d04c2b](1d04c2b)) * harden memory safety across PMT, SSZ tree views, and state transition ([#377](#377)) ([d6f5897](d6f5897)) * improve atomic ordering in ThreadPool and NAPI init ([#310](#310)) ([4b0a1cc](4b0a1cc)) * interface compatbility with NativeBeaconStateView ([#445](#445)) ([89e13d1](89e13d1)) * missing deinits in loadOtherState ([#459](#459)) ([094d278](094d278)) * missing state commits ([#454](#454)) ([a432b55](a432b55)) * no-op when syncPubkeys run on a pk cache with shrinking validator set ([#432](#432)) ([ed05a99](ed05a99)) * param order in BeaconBlockBody ([#348](#348)) ([d8b9c06](d8b9c06)) * pendingConsolidations bindings ([#449](#449)) ([b9c497e](b9c497e)) * **pmt,ssz:** harden chunked-leaf and zero-copy tree-view memory safety ([#400](#400)) ([de50c53](de50c53)) * populate cache balances during rewards/penalties processing ([#474](#474)) ([5bf23dc](5bf23dc)) * re-expose sizes ([#369](#369)) ([64b81f3](64b81f3)) * remove `slashValidator` gating on active status ([#448](#448)) ([d319a0d](d319a0d)) * **ssz:** drop redundant default-init pass in fixed-list decode ([#468](#468)) ([0c757be](0c757be)) * **ssz:** publish child cache entries after lookup ([#565](#565)) ([21e78c9](21e78c9)) * state transition binding exports ([#456](#456)) ([895982c](895982c)) * **state-transition:** group-check signature sets ([#515](#515)) ([42774e9](42774e9)), closes [#502](#502) * **state-transition:** isolate epoch step cache mutations ([#535](#535)) ([a83741a](a83741a)) * **state-transition:** repair Pool.init call broken by [#346](https://github.com/ChainSafe/lodestar-z/issues/346)×[#367](https://github.com/ChainSafe/lodestar-z/issues/367) merge skew ([#394](#394)) ([b42944f](b42944f)) * various fixes around config ([#433](#433)) ([c4f082c](c4f082c)) ### Performance Improvements * **bindings:** drop TS BLS comparison benches and report benchmarks on PRs ([#552](#552)) ([c909c6f](c909c6f)) * **bls:** add cache-aware signature verifier ([#562](#562)) ([063857e](063857e)) * **bls:** bypass worker queue for small batches ([#553](#553)) ([3f8a6df](3f8a6df)) * **epoch:** replace AutoHashMap with array lookup in reward/penalty caches ([#286](#286)) ([e4e181b](e4e181b)), closes [#243](#243) * **pmt:** chunked-leaf packing for basic lists and container_struct ([#346](#346)) ([ba156c4](ba156c4)) ### Code Refactoring * allocate `AsyncAggRandData` in one obj ([#384](#384)) ([459750f](459750f)) * **bindings/pubkeys:** simplify allocation strategy for aggregate ([#518](#518)) ([b82750f](b82750f)) * **bindings:** rename blst Lifecycle to State ([#516](#516)) ([0a9c179](0a9c179)) * **bindings:** use zapi js.io() instead of local io module ([#469](#469)) ([2b34cc0](2b34cc0)) * **bindings:** wake only required number of workers ([#383](#383)) ([1db57f1](1db57f1)) * **bls:** allocations around VMAS ([#395](#395)) ([dfda58c](dfda58c)) * **bls:** clean up bls ([#398](#398)) ([e0f3b9b](e0f3b9b)) * **bls:** remove need for tracking results for verifyMultipleAggregateSignatures ([#389](#389)) ([6fe5c3f](6fe5c3f)) * **bls:** remove single-threaded fallback ([#390](#390)) ([e057713](e057713)) * **clock:** single public Clock; internalize SlotClock ([#463](#463)) ([fbab1fa](fbab1fa)) * make XXXDecisionRoot fns return `js.String` ([#342](#342)) ([aef4420](aef4420)) * move shuffle into swap_or_not_shuffle module ([#558](#558)) ([e56efb2](e56efb2)) * **pubkeys:** centralize the process-wide cache ([#522](#522)) ([dc9669d](dc9669d)) ### Miscellaneous Chores * avoid slow tests in AGENTS.md ([#546](#546)) ([c60f2a9](c60f2a9)) * bump zapi to include musl build ([#485](#485)) ([0b488cc](0b488cc)) * **ci:** pin github actions with sha hashes ([#507](#507)) ([167b8f5](167b8f5)) * deprecate unused blst APIs ([#575](#575)) ([7b547fa](7b547fa)) * **deps:** bump zapi v2.1.0 -> v2.2.0 ([#376](#376)) ([0c240d8](0c240d8)) * **deps:** bump zbuild ([#403](#403)) ([e2545de](e2545de)) * **deps:** compile blst with ReleaseFast ([#391](#391)) ([753a896](753a896)) * **deps:** update zapi to 3.1.0 ([#483](#483)) ([f3e5827](f3e5827)) * **deps:** use zapi v2.1.0 ([#372](#372)) ([88f403a](88f403a)) * disable gemini auto code review ([#382](#382)) ([63e42a4](63e42a4)), closes [#380](#380) * **docs:** add comments section in AGENTS.md ([#566](#566)) ([0c09750](0c09750)) * move state clones out of benchmark run functions ([#324](#324)) ([e4035de](e4035de)) * prepare 1.0.0 release ([#576](#576)) ([20b657b](20b657b)) * release v0.1.2-rc.3 ([#370](#370)) ([e4fc551](e4fc551)) * **release:** 0.1.2-rc.2 ([#365](#365)) ([7046128](7046128)) * **release:** v0.1.2-rc.10 ([#477](#477)) ([9a4fad5](9a4fad5)) * **release:** v0.1.2-rc.4 ([#373](#373)) ([09468f1](09468f1)) * **release:** v0.1.2-rc.5 ([#374](#374)) ([f344efa](f344efa)) * **release:** v0.1.2-rc.6 ([#375](#375)) ([bdf5b67](bdf5b67)) * **release:** v0.1.2-rc.8 ([#401](#401)) ([06f91c2](06f91c2)) * **release:** v0.1.2-rc.9 ([#404](#404)) ([6024800](6024800)) * remove merge transition code ([#359](#359)) ([09b175d](09b175d)) * remove stale epoch cache TODOs ([#534](#534)) ([27a547a](27a547a)) * rename era shortHistoricalRoot to shortEraRoot ([#473](#473)) ([c75a4d3](c75a4d3)) * **scripts:** build bindings with preset ([#434](#434)) ([a1b5ef7](a1b5ef7)) * silence debug log when used in release builds ([#486](#486)) ([c5377d7](c5377d7)) * support dev workflow ([#364](#364)) ([fcb9a78](fcb9a78)) * update gloas types to align with the latest specs ([#431](#431)) ([1f065b5](1f065b5)) * update spec test version to v1.7.0-alpha.11 ([#451](#451)) ([5875660](5875660)) * update spec-test-version: v1.6.0-beta.2 -> v1.7.0-alpha.10 ([#441](#441)) ([f932b1c](f932b1c)) * update zapi to 4.0.0 ([#571](#571)) ([de8e3fd](de8e3fd)) ### Documentation * document security threat model ([#557](#557)) ([e678b87](e678b87)) * more comprehensive AGENTS.md ([#520](#520)) ([c74b386](c74b386)) * **pkix:** document load provenance requirement ([#556](#556)) ([37e0aa2](37e0aa2)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Motivation
std.Thread.getCpuCount()only reads the CPU affinity mask, so under a cgroup CPU quota (docker --cpus=N/ k8slimits.cpu) it reports the host core count rather than the quota. That over-sizes the BLS verification thread pool at NAPI init, which can cause thread oversubscription and CFS throttling in CPU-limited containers.Changes
src/cpu_count.zig—getNumCpus(gpa, io)returnsmin(cgroup quota, affinity), locating the cpu controller via/proc/self/{cgroup,mountinfo}and readingcpu.max/cpu.cfs_quota_us(cgroup v1 + v2). Parsing logic ported from thenum_cpuscrate.std::thread::available_parallelism: the kernel enforces every level but each level's file reports only its own limit, so leaf-only reads miss LXC/Proxmoxcpulimit, systemdCPUQuota=on a parent slice, and sub-cgroups inside a limited container.num_cpus: cgroup paths containing:(containerd's…slice:cri-containerd:<id>cgroupfs naming) are kept intact, and..paths (process outside its cgroupns root,cgroup_namespaces(7)) fall back cleanly instead of erroring.bindings/napi/root.zig) instead ofstd.Thread.getCpuCount().test:cpu_countin CI (the build-test job enumerates module test steps explicitly).Notes:
/procpseudo-files report size 0, so they are streamed to EOF (readerStreaming+allocRemaining), not read by stat size (readFileAllocreads empty).getNumCpusitself is fail-fast — a genuinely absent quota (non-Linux, no cpu controller, no cgroup mount, unresolvable path, unlimited) isnull→ affinity fallback, while a broken read of an existing resource (unreadable/procor quota file, unopenable cgroup dir, malformed content) is an error, so a readable quota is never silently masked. The NAPI call site catches detection errors, logs a warning, and sizes by the affinity count — a sizing probe must not prevent the module from loading.ceilDivis overflow-safe; the allocator is passed in by the caller (the module stays allocator-agnostic / libc-free).Testing
seanmonstar/num_cpus(MIT).--cpus=2→ 2,--cpus=4→ 4,--cpus=1.5→ 2 (ceil), unlimited → host count. The oldgetCpuCount()returned the host count in all cases.--cpus=2and the process moved into an unconstrained child cgroup (0::/child, nocpu.maxof its own), the walk finds the limit at the cgroupns root and returns 2 — leaf-only reads return the host count here.0::/x.slice:cri-containerd:yresolves its cgroup dir and returns the quota.🤖 This PR was developed with AI assistance (Claude Code).