Repository navigation
fix(captcha): move happy-dom solve off the proxy event loop into workers - #55
Merged
TriDefender merged 5 commits intoSep 27, 2026
Merged
Conversation
- syncFetchBlocking timeout 30s -> 12s (CAPTCHA_SYNC_FETCH_TIMEOUT_MS) - solveTraceless overall timeout 30s -> 20s (CAPTCHA_SOLVE_TIMEOUT_MS) Mitigation only: these bounds reduce how long a single solve can stall the proxy event loop, they do not remove the stall.
- fetchCaptchaConfig: 5s abort timeout (CAPTCHA_CONFIG_TIMEOUT_MS) - 15s negative cache after a failed fetch so a dead configs endpoint cannot repeatedly wedge request handling
Root cause of idle stalls: solveTraceless ran happy-dom + sync XHR (Atomics.wait) on the proxy main thread. After deep idle emptied the token pool, the next burst of requests spawned up to 8 concurrent solves x 6 retries, freezing the event loop for tens of seconds each time. Every connection hung until restart.
Now each solve runs in a dedicated worker:
- captcha-worker-entry.ts: worker entry running solveTraceless over postMessage (ESM)
- build-fork-worker.ts: pre-bundles the entry into a self-contained captcha-worker-entry.bundle.js (bun --target=bun --bundle), embedded into the compiled exe via with { type: file }
- captcha-solver.ts: worker-per-solve scheduler with 20s hard terminate, spawn/exit/error fallbacks
A hung solve can no longer wedge anything outside its own worker.
Verified: real request 200 with worker decode path, 3 concurrent requests all 200, error log clean; upstream tsc --noEmit clean, captcha unit tests 28/28.
State explicitly that this fork adds no telemetry, analytics, or outbound reporting of any kind, and that debug/dump logs auto-redact secrets (inherited from upstream).
The bundled worker (captcha-worker-entry.bundle.js, ~1.7MB / ~50k lines minified-free) was committed as a build artifact. Upstream convention keeps generated bundles out of git (.gitignore: Android server.cjs -- 'CI/local builds regenerate it'), and PR CI only runs tsc + tests, so the committed artifact added 50k lines of review noise without helping any automated check. - .gitignore: ignore src/proxy/captcha-worker-entry.bundle.js - package.json: prebuild hook runs scripts/build-fork-worker.ts before every bun run build, so the artifact always exists at compile time - captcha-solver.ts: comment points contributors to the regen path Verified: fresh checkout without the bundle -> prebuild regenerates it (1,683,850 bytes) -> tsc clean, full suite 885 pass / 0 fail.
TriDefender
added a commit
that referenced
this pull request
Sep 27, 2026
…ess fallback The worker move (#55) embedded the entry via a static `with { type: "file" }` asset import in captcha-solver.ts, which broke every non-Bun-compile path: - esbuild (Android server bundle) rejects the import attribute at parse time — build:android-bundle and the release build-android job failed - a fresh checkout has no gitignored worker bundle, so dev mode / the Docker TS-source image failed to even LOAD captcha-solver on start-plan - release.yml invokes `bun build --compile` directly (no npm lifecycle hooks), so the prebuild hook never ran and all five release binaries failed to resolve the asset Restructure so the attribute syntax lives only in captcha-worker-asset.ts, reached via dynamic import from captcha-worker-dispatch.ts with an in-process solveTraceless fallback when the asset is unavailable (dev, Docker, Android) or the worker entry fails to load (module-not-found only; runtime crashes stay hard failures so an OOM never migrates to the main thread). Mode transitions are announced once on stderr. - package.json: every compile script self-generates the worker bundle; android esbuild marks the asset module --external (falls back on device) - release.yml build-binaries generates the bundle before compiling - Dockerfile bundles the entry at image build time - tsconfig excludes the generated bundle from typechecking - README privacy section reworded for upstream voice + README_EN parity Verified: tsc clean; 888 tests (3 new dispatch tests, incl. a real worker_threads round trip); android bundle builds; compiled exe embeds the asset via the dynamic-import chain and solves through a worker (6.7s live); no-bundle dev run degrades and solves in-process.
TriDefender
approved these changes
Sep 27, 2026
TriDefender
left a comment
Owner
There was a problem hiding this comment.
Verified and merged, with a follow-up fix (88ac212) on master.
Verified locally on your branch (Bun 1.4.0, Windows x64):
- tsc clean, 885/885 tests (CI-equivalent, no bundle present)
- Real solve through a worker: 6.4s, 280-char param — the mechanism is solid
Gaps found and fixed in 88ac212 (none detectable by ci.yml, which is why they slipped through):
- esbuild rejects
with { type: "file" }at parse time →build:android-bundleand the releasebuild-androidjob failed even with the bundle present. Fix: the attribute now lives only incaptcha-worker-asset.ts, dynamically imported; android esbuild marks it--externaland the device falls back to in-process solving. - Fresh checkout (no gitignored bundle) → captcha-solver failed to LOAD in dev mode and the Docker TS-source image on start-plan. Fix: dynamic asset resolution + in-process fallback (restores the lazy-load discipline too).
- release.yml invokes
bun build --compiledirectly (no lifecycle hooks) → the prebuild hook never ran in the release pipeline and all 5 binaries failed to resolve the asset. Fix: generation chained into every compile script + an explicit release.yml step. - Dockerfile now bundles the worker entry at image build time so containers get worker solving, not just the fallback.
Also reworded the README privacy section for upstream voice and added the README_EN counterpart (bilingual convention). Your timeout caps and the config-fetch negative cache are in as-is — good defensive work. The em-dash→-- comment churn was left alone.
Thanks for the excellent root-cause analysis in #54 — it made review straightforward.
4 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #54
PR draft: fix(captcha): move happy-dom solve off the proxy event loop
What this PR does
On the start-plan path, captcha solving (happy-dom + sync XHR via
Atomics.wait) runs on the proxy's main thread. After the token pool drains during idle, the refill burst freezes the event loop repeatedly for tens of seconds; every connection hangs until restart (reproduction steps and log evidence in the companion issue).This PR moves each solve into a dedicated worker thread. The main thread only dispatches the job and waits on the result with a hard timeout. A hung solve can no longer affect anything outside its own worker.
Changes (3 commits, mitigation and structural fix separated for easy review)
1.
fix(captcha): cap main-thread blocking windows in happy solver(mitigation)captcha-happy.ts: syncFetchBlocking timeout 30s -> 12s (envCAPTCHA_SYNC_FETCH_TIMEOUT_MS)captcha-happy.ts: solveTraceless overall deadline 30s -> 20s (envCAPTCHA_SOLVE_TIMEOUT_MS)2.
fix(captcha): bound config fetch with AbortSignal and negative cache(mitigation)captcha.ts: fetchCaptchaConfig gets a 5s AbortSignal timeout (envCAPTCHA_CONFIG_TIMEOUT_MS)3.
fix(captcha): move happy-dom solve off the proxy event loop into workers(structural fix)src/proxy/captcha-worker-entry.ts: worker entry running happy-domsolveTracelessover postMessage (ESM)scripts/build-fork-worker.ts: pre-bundles the entry into a self-containedcaptcha-worker-entry.bundle.js(bun build --target=bun --bundle --format=esm), embedded into the compiled binary viaimport ... with { type: "file" }. Underbun build --compile,new Worker(new URL())with a file path does not work; this asset-based approach is the verified alternative.src/proxy/captcha-solver.ts: worker-per-solve scheduler. Each solve gets its own worker with a 20s hard-terminate deadline; spawn/exit/error all have fallback paths. The existing concurrency-control API is kept as no-op stubs for compatibility.Compatibility notes
captcha-pool.tsor the token acquisition flow;setCaptchaSolverConcurrency/shutdownCaptchaSolversignatures unchanged.Verification
bun x tsc --noEmitcleanbun test: 885 pass / 0 fail (includes 28 captcha-related tests)serve debugerror log empty (the same scenario always produced main-thread stall records before the fix)scripts/; runbun run scripts/build-fork-worker.tsbeforebun run buildto reproduce the artifact.Known trade-offs
(Chinese translation attached for reference: upstream-pr-draft.zh.md)
Update (bundle no longer committed)
The worker bundle is now a gitignored build artifact, matching the upstream convention used for the Android server bundle: package.json wires scripts/build-fork-worker.ts as a prebuild hook, so �un run build always regenerates it before compiling. The diff is now ~250 lines of real code instead of ~50k lines of generated output (commit bd07d6e).