Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/13094-deepseek-pow-slot-leak.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 12 additions & 1 deletion open-sse/lib/deepseek-pow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,18 @@ function solveInWorker(
activeWorkerCount += 1;

return new Promise<number>((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 = () => {
Expand Down
85 changes: 85 additions & 0 deletions tests/unit/deepseek-pow-slot-leak-13094.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});