From 549bfd8ed0910d31f50146b38014953c2d6e146c Mon Sep 17 00:00:00 2001 From: anhtahaylove <37265396+anhtahaylove@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:41:48 +0700 Subject: [PATCH] fix(deepseek): release the PoW worker slot when spawning fails `solveInWorker` increments `activeWorkerCount` before constructing the Worker, but the `cleanup()` that decrements it lives inside the promise executor and only runs once the worker exists. Anything that throws first -- most obviously `resolveWorkerPath()` when the worker script is missing, since it resolves against `process.cwd()` -- leaves the counter permanently incremented. With `MAX_CONCURRENT_WORKERS = 2`, two such failures wedge the solver for the lifetime of the process: every later call rejects with "capacity reached (2)" while no worker is actually running, and the real cause is hidden. Construct the Worker inside a try/catch and release the slot before rejecting. Fixes #13094 --- .../fixes/13094-deepseek-pow-slot-leak.md | 1 + open-sse/lib/deepseek-pow.ts | 13 ++- .../unit/deepseek-pow-slot-leak-13094.test.ts | 85 +++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/13094-deepseek-pow-slot-leak.md create mode 100644 tests/unit/deepseek-pow-slot-leak-13094.test.ts diff --git a/changelog.d/fixes/13094-deepseek-pow-slot-leak.md b/changelog.d/fixes/13094-deepseek-pow-slot-leak.md new file mode 100644 index 00000000000..6560854f8da --- /dev/null +++ b/changelog.d/fixes/13094-deepseek-pow-slot-leak.md @@ -0,0 +1 @@ +Fix a concurrency-slot leak in the DeepSeek PoW solver: a worker that failed to spawn (for example a missing worker script) never released its slot, so `MAX_CONCURRENT_WORKERS` failures disabled the solver until restart. diff --git a/open-sse/lib/deepseek-pow.ts b/open-sse/lib/deepseek-pow.ts index d70998276b3..3d373dcb138 100644 --- a/open-sse/lib/deepseek-pow.ts +++ b/open-sse/lib/deepseek-pow.ts @@ -106,7 +106,18 @@ function solveInWorker( activeWorkerCount += 1; return new Promise((resolve, reject) => { - const worker = new Worker(resolveWorkerPath(), { workerData: validated }); + // The slot is taken before this executor runs, so anything that throws here + // -- a missing worker script, a spawn failure -- has to hand it back. Without + // this, MAX_CONCURRENT_WORKERS spawn failures wedge the solver permanently + // and every later call reports "capacity reached" instead of the real cause. + let worker: Worker; + try { + worker = new Worker(resolveWorkerPath(), { workerData: validated }); + } catch (error) { + activeWorkerCount = Math.max(0, activeWorkerCount - 1); + reject(error instanceof Error ? error : new Error(String(error))); + return; + } let settled = false; const cleanup = () => { diff --git a/tests/unit/deepseek-pow-slot-leak-13094.test.ts b/tests/unit/deepseek-pow-slot-leak-13094.test.ts new file mode 100644 index 00000000000..e3d92397917 --- /dev/null +++ b/tests/unit/deepseek-pow-slot-leak-13094.test.ts @@ -0,0 +1,85 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { tmpdir } from "node:os"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; + +const { solveDeepSeekPowAsync } = await import("../../open-sse/lib/deepseek-pow.ts"); + +// A valid challenge: the failure under test happens at worker construction, +// long after validation, so these values just have to pass validateChallenge(). +const ALGORITHM = "DeepSeekHashV1"; +const CHALLENGE = "a".repeat(64); +const SALT = "test-salt"; +const DIFFICULTY = 1; + +function futureExpiry() { + return Date.now() + 60_000; +} + +test("a failed worker spawn does not consume a permanent concurrency slot (#13094)", async () => { + // resolveWorkerPath() resolves the worker script against process.cwd(), so + // running from a directory without it makes construction throw -- the same + // condition users hit when the process starts from an unexpected cwd. + const originalCwd = process.cwd(); + const emptyDir = mkdtempSync(join(tmpdir(), "deepseek-pow-nocwd-")); + process.chdir(emptyDir); + + try { + // MAX_CONCURRENT_WORKERS is 2, so two leaked slots exhaust the budget. + for (let i = 0; i < 2; i++) { + await assert.rejects( + () => solveDeepSeekPowAsync(ALGORITHM, CHALLENGE, SALT, DIFFICULTY, futureExpiry()), + /worker script not found/i, + `attempt ${i + 1} should fail on the missing worker script` + ); + } + + // The third attempt must still report the real cause. Before the fix the + // counter had been incremented twice without ever being released, so this + // rejected with "capacity reached" and the solver stayed dead until restart. + await assert.rejects( + () => solveDeepSeekPowAsync(ALGORITHM, CHALLENGE, SALT, DIFFICULTY, futureExpiry()), + (error: Error) => { + assert.doesNotMatch( + error.message, + /capacity reached/i, + "a spawn failure must not be reported as exhausted capacity" + ); + assert.match(error.message, /worker script not found/i); + return true; + } + ); + } finally { + process.chdir(originalCwd); + rmSync(emptyDir, { recursive: true, force: true }); + } +}); + +test("the slot is released again once spawning works (#13094)", async () => { + const originalCwd = process.cwd(); + const emptyDir = mkdtempSync(join(tmpdir(), "deepseek-pow-nocwd-")); + + try { + process.chdir(emptyDir); + await assert.rejects( + () => solveDeepSeekPowAsync(ALGORITHM, CHALLENGE, SALT, DIFFICULTY, futureExpiry()), + /worker script not found/i + ); + + // Back in a working directory the solver has to be usable again: a leaked + // slot would eventually surface here as a spurious capacity rejection. + process.chdir(originalCwd); + const answer = await solveDeepSeekPowAsync( + ALGORITHM, + CHALLENGE, + SALT, + DIFFICULTY, + futureExpiry() + ); + assert.equal(typeof answer, "number", "a real solve should still succeed"); + } finally { + process.chdir(originalCwd); + rmSync(emptyDir, { recursive: true, force: true }); + } +});