Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/calm-workers-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-doctor": patch
---

Cap Oxlint subprocesses across concurrent workspace project scans so project-level parallelism cannot multiply the configured worker budget.
29 changes: 28 additions & 1 deletion packages/api/src/diagnose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as Layer from "effect/Layer";
import {
buildSkippedChecks,
Config,
createOxlintSpawnSlots,
DEFAULT_PROJECT_SCAN_CONCURRENCY,
DEFAULT_SHOW_WARNINGS,
DeadCode,
Expand All @@ -15,6 +16,8 @@ import {
LintPartialFailures,
mapWithConcurrency,
mergeReactDoctorConfigs,
OxlintConcurrency,
OxlintSpawnSlots,
Progress,
Project,
Reporter,
Expand All @@ -25,6 +28,7 @@ import {
SupplyChain,
type InspectOutput,
type ResolvedScanTarget,
type WorkerSlots,
} from "@react-doctor/core";
import type {
DiagnoseOptions,
Expand Down Expand Up @@ -53,6 +57,8 @@ interface DiagnoseLayerInput {
readonly config: ReactDoctorConfig | null;
readonly shouldRunLint: boolean;
readonly shouldRunDeadCode: boolean;
readonly oxlintConcurrency: number;
readonly oxlintSpawnSlots: WorkerSlots;
readonly configOverrideTarget?: Pick<
ResolvedScanTarget,
"resolvedDirectory" | "configSourceDirectory"
Expand Down Expand Up @@ -86,6 +92,8 @@ const buildDiagnoseLayer = (input: DiagnoseLayerInput) => {
Git.layerNode,
input.shouldRunLint ? Linter.layerOxlint : Linter.layerOf([]),
LintPartialFailures.layerLive,
Layer.succeed(OxlintConcurrency, input.oxlintConcurrency),
Layer.succeed(OxlintSpawnSlots, input.oxlintSpawnSlots),
Progress.layerNoop,
Reporter.layerNoop,
Score.layerHttp,
Expand Down Expand Up @@ -154,6 +162,8 @@ const diagnoseDirectory = async (
const program = buildInspectProgram(scanTarget, options);
const shouldRunLint = resolveShouldRunLint(options, scanTarget.userConfig);
const shouldRunDeadCode = resolveShouldRunDeadCode(options, scanTarget.userConfig);
const oxlintConcurrency = Effect.runSync(OxlintConcurrency);
const oxlintSpawnSlots = createOxlintSpawnSlots(oxlintConcurrency);

const output: InspectOutput = await Effect.runPromise(
restoreLegacyThrow(
Expand All @@ -163,6 +173,8 @@ const diagnoseDirectory = async (
config: scanTarget.userConfig,
shouldRunLint,
shouldRunDeadCode,
oxlintConcurrency,
oxlintSpawnSlots,
}),
),
Effect.provide(layerOtlp),
Expand Down Expand Up @@ -190,6 +202,8 @@ const diagnoseProject = async (
projectDefinition: ProjectDefinition,
baseOptions: DiagnoseOptions,
batchConfig: ReactDoctorConfig | undefined,
oxlintConcurrency: number,
oxlintSpawnSlots: WorkerSlots,
): Promise<ProjectResult> => {
const startTime = globalThis.performance.now();

Expand Down Expand Up @@ -220,6 +234,8 @@ const diagnoseProject = async (
config: effectiveConfig,
shouldRunLint,
shouldRunDeadCode,
oxlintConcurrency,
oxlintSpawnSlots,
configOverrideTarget: {
resolvedDirectory: scanTarget.resolvedDirectory,
configSourceDirectory: didOverridePlugins ? null : scanTarget.configSourceDirectory,
Expand All @@ -229,6 +245,8 @@ const diagnoseProject = async (
config: effectiveConfig,
shouldRunLint,
shouldRunDeadCode,
oxlintConcurrency,
oxlintSpawnSlots,
};
const layer = buildDiagnoseLayer(diagnoseLayerInput);

Expand Down Expand Up @@ -256,13 +274,22 @@ const diagnoseProjectBatch = async (
warnIfAiTrainingEnvironment();
const startTime = globalThis.performance.now();
const { projects, concurrency, config: batchConfig, ...baseOptions } = input;
const oxlintConcurrency = Effect.runSync(OxlintConcurrency);
const oxlintSpawnSlots = createOxlintSpawnSlots(oxlintConcurrency);

// `diagnoseProject` never rejects (failures come back as `ok: false`),
// so the pool always drains every project.
const projectResults = await mapWithConcurrency(
projects,
concurrency ?? DEFAULT_PROJECT_SCAN_CONCURRENCY,
(projectDefinition) => diagnoseProject(projectDefinition, baseOptions, batchConfig),
(projectDefinition) =>
diagnoseProject(
projectDefinition,
baseOptions,
batchConfig,
oxlintConcurrency,
oxlintSpawnSlots,
),
);

const succeededProjects = projectResults.filter((projectResult) => projectResult.ok);
Expand Down
67 changes: 8 additions & 59 deletions packages/core/src/dead-code/dead-code-worker-slots.ts
Original file line number Diff line number Diff line change
@@ -1,67 +1,16 @@
import { resolveDeadCodeConcurrency } from "../utils/resolve-dead-code-concurrency.js";
import { createWorkerSlots } from "../utils/create-worker-slots.js";
import type { WorkerSlots } from "../utils/create-worker-slots.js";

// A process-global counting semaphore bounding how many real deslop dead-code
// child processes run at once, to the memory budget (`resolveDeadCodeConcurrency`).
//
// It's process-global on purpose: the CLI scans the projects of a workspace in
// concurrent `runInspect` fibers within ONE process, and each spawns its own
// dead-code worker — without a shared cap, N concurrent projects could
// oversubscribe memory with N simultaneous children on a small runner. This
// gates only HOW MANY start; each worker still self-terminates via the proven
// one-shot lifecycle (spawn → analyze → exit), so the semaphore adds no
// process-lifecycle surface — it's plain in-process bookkeeping.
//
// `-1` is the un-initialized sentinel; the first acquirer reads the budget once
// (after which the cap is fixed for the process).
let availableSlots = -1;
const waiters: Array<() => void> = [];
let deadCodeWorkerSlots: WorkerSlots | null = null;

const releaseSlot = (): void => {
const nextWaiter = waiters.shift();
// Hand the slot straight to the next waiter (no increment); only return it to
// the pool when nobody is waiting. Keeps the count balanced either way.
if (nextWaiter !== undefined) nextWaiter();
else availableSlots += 1;
};

/**
* Runs `task` once a dead-code worker slot is free, releasing the slot when the
* task settles (success or failure). With a high cap (roomy machine) every
* caller proceeds immediately; with a low cap (constrained runner) callers
* queue and run as slots free.
*
* `abortSignal` short-circuits the WAIT: if it's already aborted, or fires while
* this caller is queued, the call rejects without acquiring a slot or running
* `task` — so a cancelled scan (e.g. lint failed) doesn't sit in the queue and
* then spawn a child only to tear it down. A queued caller that aborts removes
* its own waiter so a later release never hands a slot to a dead request.
*/
export const withDeadCodeWorkerSlot = async <Result>(
task: () => Promise<Result>,
abortSignal?: AbortSignal,
): Promise<Result> => {
if (abortSignal?.aborted) throw new Error("Dead-code worker aborted.");
if (availableSlots < 0) availableSlots = resolveDeadCodeConcurrency();
if (availableSlots > 0) {
availableSlots -= 1;
} else {
await new Promise<void>((resolve, reject) => {
const waiter = (): void => {
abortSignal?.removeEventListener("abort", onAbort);
resolve();
};
const onAbort = (): void => {
const queuedIndex = waiters.indexOf(waiter);
if (queuedIndex !== -1) waiters.splice(queuedIndex, 1);
reject(new Error("Dead-code worker aborted."));
};
waiters.push(waiter);
abortSignal?.addEventListener("abort", onAbort, { once: true });
});
}
try {
return await task();
} finally {
releaseSlot();
}
deadCodeWorkerSlots ??= createWorkerSlots({
slotCount: resolveDeadCodeConcurrency(),
createAbortError: () => new Error("Dead-code worker aborted."),
});
return deadCodeWorkerSlots.run(task, abortSignal);
};
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export * from "./utils/assign-fix-groups.js";
export * from "./utils/build-rule-docs-url.js";
export * from "./utils/classify-package-role.js";
export * from "./utils/compute-config-fingerprint.js";
export * from "./utils/create-oxlint-spawn-slots.js";
export * from "./utils/create-worker-slots.js";
export * from "./utils/dedupe-diagnostics.js";
export * from "./utils/define-config.js";
export * from "./utils/detect-ai-training-environment.js";
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/refs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { readPositiveEnvMs } from "./utils/read-positive-env-ms.js";
import { resolveAutoScanConcurrency } from "./utils/resolve-auto-scan-concurrency.js";
import { resolveLintBatchOrdering } from "./utils/resolve-lint-batch-ordering.js";
import { resolveScanConcurrency } from "./utils/resolve-scan-concurrency.js";
import type { WorkerSlots } from "./utils/create-worker-slots.js";

/**
* Per-batch oxlint wall-clock budget. Reads from the env var on
Expand Down Expand Up @@ -126,6 +127,13 @@ export class OxlintConcurrency extends Context.Reference<number>("react-doctor/O
},
}) {}

export class OxlintSpawnSlots extends Context.Reference<WorkerSlots | null>(
"react-doctor/OxlintSpawnSlots",
{
defaultValue: () => null,
},
) {}

/**
* Three-state control for overlapping the dead-code pass with the lint pass —
* forking dead-code as a child fiber that runs DURING lint instead of strictly
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/run-oxlint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
SidecarDependencyProbe,
SidecarLintCache,
} from "./runners/oxlint/sidecar-lint-cache.js";
import type { WorkerSlots } from "./utils/create-worker-slots.js";
import { resolveUserPlugins } from "./runners/oxlint/plugin-resolution.js";
import { resolveOxlintToolchainVersions } from "./runners/oxlint/resolve-toolchain-versions.js";
import {
Expand Down Expand Up @@ -133,6 +134,7 @@ interface RunOxlintOptions {
* exhaustion (see `spawnLintBatches`).
*/
concurrency?: number;
spawnSlots?: WorkerSlots;
/**
* Aborted when the orchestrator's lint-phase timeout fires; forwarded to
* `spawnLintBatches` so in-flight oxlint subprocesses are torn down instead
Expand Down Expand Up @@ -634,6 +636,7 @@ export const runOxlint = async (options: RunOxlintOptions): Promise<Diagnostic[]
spawnTimeoutMs,
outputMaxBytes,
concurrency: options.concurrency,
spawnSlots: options.spawnSlots,
signal: options.signal,
deadlineEpochMs: options.deadlineEpochMs,
});
Expand Down Expand Up @@ -1016,6 +1019,7 @@ export const runOxlint = async (options: RunOxlintOptions): Promise<Diagnostic[]
spawnTimeoutMs,
outputMaxBytes,
concurrency: options.concurrency,
spawnSlots: options.spawnSlots,
signal: options.signal,
deadlineEpochMs: options.deadlineEpochMs,
});
Expand Down
51 changes: 39 additions & 12 deletions packages/core/src/runners/oxlint/spawn-batches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { dedupeDiagnostics } from "../../utils/dedupe-diagnostics.js";
import { mapWithConcurrency } from "../../utils/map-with-concurrency.js";
import { remainingDeadlineBudgetMs } from "../../utils/remaining-deadline-budget-ms.js";
import { resolveScanConcurrency } from "../../utils/resolve-scan-concurrency.js";
import type { WorkerSlots } from "../../utils/create-worker-slots.js";
import { parseOxlintOutput } from "./parse-output.js";
import { spawnOxlint } from "./spawn-oxlint.js";

Expand Down Expand Up @@ -91,6 +92,7 @@ export interface SpawnLintBatchesInput {
* resource error replays once with a single worker.
*/
readonly concurrency?: number;
readonly spawnSlots?: WorkerSlots;
}

interface BatchPassOutcome {
Expand All @@ -109,6 +111,13 @@ interface BatchPassOutcome {
readonly firstNonOomDropReason: string | null;
}

interface BatchState {
deadlineMs: number | null;
deadlineSkippedFileCount: number;
didStart: boolean;
initialFileCount: number;
}

/**
* Runs every prebuilt file batch through oxlint, with binary-split
* retry on the splittable error classes (timeout / output-too-large /
Expand Down Expand Up @@ -216,7 +225,7 @@ export const spawnLintBatches = async (input: SpawnLintBatchesInput): Promise<Di
const spawnLintBatch = async (
batch: string[],
depth: number,
batchState: { deadlineMs: number | null; deadlineSkippedFileCount: number },
batchState: BatchState,
): Promise<Diagnostic[]> => {
// Past the --max-duration budget: skip instead of spawning, even inside a
// binary-split retry, so a batch that started just before the deadline
Expand All @@ -228,14 +237,31 @@ export const spawnLintBatches = async (input: SpawnLintBatchesInput): Promise<Di
}
const batchArgs = [...baseArgs, ...batch];
try {
const stdout = await spawnOxlint(
batchArgs,
rootDirectory,
nodeBinaryPath,
spawnTimeoutMs,
outputMaxBytes,
signal,
);
const spawnBatch = (): Promise<string | null> => {
if (isPastDeadline()) {
deadlineSkippedFiles.push(...batch);
batchState.deadlineSkippedFileCount += batch.length;
return Promise.resolve(null);
}
return spawnOxlint(
batchArgs,
rootDirectory,
nodeBinaryPath,
spawnTimeoutMs,
outputMaxBytes,
signal,
() => {
if (batchState.didStart) return;
batchState.didStart = true;
startedFileCount += batchState.initialFileCount;
},
);
};
const stdout =
input.spawnSlots === undefined
? await spawnBatch()
: await input.spawnSlots.run(spawnBatch, signal);
if (stdout === null) return [];
return parseOxlintOutput(stdout, project, rootDirectory, sourcePathByLintPath);
} catch (error) {
if (!isSplittableReactDoctorError(error)) throw error;
Expand Down Expand Up @@ -302,16 +328,17 @@ export const spawnLintBatches = async (input: SpawnLintBatchesInput): Promise<Di
deadlineSkippedFiles.push(...batch);
return [];
}
startedFileCount += batch.length;
const batchState: { deadlineMs: number | null; deadlineSkippedFileCount: number } = {
const batchState: BatchState = {
deadlineMs: null,
deadlineSkippedFileCount: 0,
didStart: false,
initialFileCount: batch.length,
};
const batchDiagnostics = await spawnLintBatch(batch, 0, batchState);
// A split retry can deadline-skip part of the batch, so count only the
// files actually linted — not the whole batch — as scanned.
scannedFileCount += batch.length - batchState.deadlineSkippedFileCount;
if (passOnFileProgress) {
if (passOnFileProgress && batchState.didStart) {
displayedFileCount = Math.min(
Math.max(displayedFileCount, scannedFileCount),
totalFileCount,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/runners/oxlint/spawn-oxlint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const spawnOxlint = (
// bounded lint phase actually stops work instead of leaving subprocesses
// running until their own per-batch spawn timeout.
abortSignal?: AbortSignal,
onSpawn?: () => void,
): Promise<string> =>
new Promise<string>((resolve, reject) => {
if (abortSignal?.aborted) {
Expand All @@ -52,6 +53,7 @@ export const spawnOxlint = (
);
return;
}
onSpawn?.();
const child = spawn(
nodeBinaryPath,
buildProfiledNodeArguments({
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/services/linter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
LintBatchOrdering,
OxlintConcurrency,
OxlintOutputMaxBytes,
OxlintSpawnSlots,
OxlintSpawnTimeoutMs,
PerFileLintCacheEnabled,
SidecarLintCacheEnabled,
Expand Down Expand Up @@ -129,6 +130,7 @@ export class Linter extends Context.Service<
const spawnTimeoutMs = yield* OxlintSpawnTimeoutMs;
const outputMaxBytes = yield* OxlintOutputMaxBytes;
const concurrency = yield* OxlintConcurrency;
const spawnSlots = yield* OxlintSpawnSlots;
const lintBatchOrdering = yield* LintBatchOrdering;
const perFileLintCacheEnabled = yield* PerFileLintCacheEnabled;
const sidecarLintCacheEnabled = yield* SidecarLintCacheEnabled;
Expand Down Expand Up @@ -162,6 +164,7 @@ export class Linter extends Context.Service<
onSidecarStats: input.onSidecarStats,
spawnTimeoutMs,
outputMaxBytes,
spawnSlots: spawnSlots ?? undefined,
concurrency,
signal,
lintBatchOrdering,
Expand Down
Loading
Loading