Skip to content

fix(proxy): sample CPU via os.cpus() on hosts without /proc/stat - #53

Merged
TriDefender merged 1 commit into
TriDefender:masterfrom
2921323707:fix/windows-cpu-governor-sampling
Sep 27, 2026
Merged

TriDefender merged 1 commit into
TriDefender:masterfrom
2921323707:fix/windows-cpu-governor-sampling

Conversation

@2921323707

Copy link
Copy Markdown

Problem

On Windows the CPU governor never throttles captcha solving, which lets the
token pool run unattended until the proxy stops serving.

CaptchaCpuGovernor reads host load from /proc/stat and, when that file is
missing, falls back to os.loadavg():

const load = os.loadavg()[0] ?? 0;
const cpus = Math.max(1, os.cpus().length);
const pct = (load / cpus) * 100;
return { idle: 100 - pct, total: 100 };

os.loadavg() is hard-wired to [0,0,0] on Windows (documented in Node, and
Bun inherits it), so lastCpuPercent stays 0 forever. Every tick() then
takes the ramp branch:

} else if (cpu <= rampThreshold) {
  if (this.concurrency < this.cfg.maxSolveConcurrency) this.concurrency += 1;
  if (this.maxEffectiveTarget < this.cfg.poolSizeMax) this.maxEffectiveTarget += TARGET_STEP;
}

Concurrency and the pool target climb to their maxima (poolSizeMax, default
60) no matter how loaded the host actually is.

Observed behaviour (Windows 11, v4.7.0 exe)

  • one core pinned at ~100%
  • heap growing steadily, ~2.8 GB after tens of minutes
  • every HTTP route stops answering — including paths that should just 404
  • 0 outbound connections, so it is not upstream throttling or a network stall
  • 8–13 server-side CLOSE_WAIT sockets

Restarting clears it; it recurs whenever the pool is allowed to refill.

Fix

Replace the loadavg fallback with a delta over os.cpus() tick counters,
which are cumulative per-core milliseconds on every platform and produce the
same host-wide busy ratio /proc/stat does.

  • readProcStatCounters() — Linux fast path, returns null on failure
  • readOsCpusCounters() — new cross-platform fallback
  • readCpuCounters() = proc ?? os

Two smaller hardening changes while touching this path:

  • track which source produced a sample (source: "proc" | "os") so a source
    switch re-baselines instead of diffing two unrelated scales
  • reject non-positive or inverted deltas (counter reset, core-count change)
    and keep the last good reading, rather than feeding the throttle a bogus
    percentage

Linux keeps the /proc/stat path and is unaffected.

Verification

  • bun test — 885 pass, 0 fail (56 files), including the 6 existing
    CaptchaCpuGovernor cases, which stub sampleCpuPercent and so are
    unaffected by construction.
  • Behavioural check on win32 with cpuLimitPercent: 5, burning one core for
    3 s between ticks:
tick#1 (baseline)    cpu = 0
tick#2 (after burn)  cpu = 16.6   throttled = true
  -> concurrency = 1   maxEffectiveTarget = 15
  -> backgroundConcurrency(50) = 0   (pool warm: no more background solving)
  -> backgroundConcurrency(2)  = 1   (floor preserved when pool is short)

Before the change the same scenario reports cpu = 0 and ramps.

  • Sanity check that os.cpus() deltas are scaled correctly on win32: a 2 s
    sample on a 20-core machine advances total by ~40 s of core-time.

Notes

The default CAPTCHA_CPU_LIMIT_PCT is 100, so even with correct sampling the
governor only engages when the whole host is saturated. Sampling correctly is
still what makes the existing CAPTCHA_CPU_LIMIT_PCT knob usable at all on
Windows — today it does nothing there.

The CPU governor read host load from /proc/stat and fell back to
os.loadavg() when that file was missing. os.loadavg() is hard-wired to
[0,0,0] on Windows (documented in Node, inherited by Bun), so
lastCpuPercent stayed at 0 forever and tick() always took the ramp
branch: solve concurrency and the pool target climbed to their maxima
(poolSizeMax 60) even while the host was saturated.

Observed on Windows: a proxy that pinned a core, grew the heap to
~2.8 GB over tens of minutes, and eventually stopped answering every
HTTP route (including paths that should 404) while holding 0 outbound
connections.

Replace the loadavg fallback with a delta over os.cpus() tick counters,
which are cumulative per-core milliseconds on every platform and yield
the same host-wide busy ratio /proc/stat does. Also track which source
produced a sample so a source switch re-baselines instead of diffing
two unrelated scales, and reject non-positive or inverted deltas
(counter reset, core-count change) instead of feeding the throttle a
bogus percentage.

Linux keeps the /proc/stat fast path and is unaffected.
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.

2 participants