perf(javascript): reuse the source-analysis worker within a process (#5819)
* perf(javascript): reuse the source-analysis worker within a process Each analyze-javascript-application call started a new Node worker. That startup, not fixture IO or the shared CLI session, dominated the slow loader-review and factory-completion files. Park one healthy worker on the artifact resource scope and on the CLI process. Stop it when the scope, session, or one-shot process ends. Do not reuse after failure, cancellation, or a different heap or timeout. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(cli): close the JavaScript worker when dispatch throws Minimal dispatcher runtimes now export a no-op closeCliJavaScriptResources, and CLI dispatch plus output rendering always await that cleanup. A dispatch failure and a cleanup failure are both reported. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(artifacts): report close-hook failures with retained cleanup When retained cleanup and the scope close hook both fail, the close error keeps both causes and both resource labels. The pending owners and the hook stay registered so the next close retries each path. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(javascript): stop a replaced analysis worker before the next one starts A heap or timeout change now closes the parked worker before constructing its replacement. The scope retains that process only when the close fails. Co-authored-by: morluto <morluto@users.noreply.github.com> * style: format worker-reuse close and launch helpers Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(cli): report a failed JavaScript worker shutdown before exit Share one in-flight close and drop the CLI scope only after it succeeds, so a later caller retries a worker that is still owned. The one-shot exit callback writes that failure and exits non-zero, while a closed stdout pipe still exits 141. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(javascript): keep a worker parked during an overlapping limit change Claim the scope slot only when it is still empty after the replaced worker closes. A concurrent analysis that parked another healthy worker keeps that process, and the extra worker is closed through its own port. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(artifacts): run later close hooks when an earlier hook fails Run every registered close hook on each close. One failure is returned unchanged, and several failures are aggregated so a later hook is not skipped. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(javascript): fail a limit change when the previous worker stays alive A failed close used to be retained and then ignored, so MCP could return a successful analysis while the old process and its replacement were both owned. The request now fails before the replacement is constructed, and the still-live worker stays owned for a later close. Co-authored-by: morluto <morluto@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: morluto <morluto@users.noreply.github.com>
This commit is contained in:
15 files changed
+925
-77
No files matched your search
@@ -5,6 +5,8 @@ import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { expect, it } from "vitest";
|
||||
import { JavaScriptAnalysisWorker } from "../../../src/javascript/analysis/JavaScriptAnalysisWorker.js";
|
||||
import { createJavaScriptSourceAnalysis } from "../../../src/artifacts/javascript/JavaScriptSourceAnalysis.js";
|
||||
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
|
||||
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
|
||||
import type { OwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
@@ -90,6 +92,278 @@ it("reuses one real owned analyzer while retaining distinct source identities an
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses one owned worker across analyses on the same resource scope", async () => {
|
||||
const scope = new ArtifactResourceScope();
|
||||
const launches: number[] = [];
|
||||
const exits = new Map<number, Promise<void>>();
|
||||
const launcher: typeof spawnOwnedProviderProcess = async (request) => {
|
||||
const previous = launches.at(-1);
|
||||
if (previous !== undefined) await exits.get(previous);
|
||||
const spawned = await spawnOwnedProviderProcess(request);
|
||||
const pid = spawned.process.pid;
|
||||
if (pid === undefined)
|
||||
throw new Error("Owned analyzer has no observed PID");
|
||||
exits.set(
|
||||
pid,
|
||||
new Promise((resolve) => {
|
||||
spawned.process.once("exit", () => {
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
);
|
||||
launches.push(pid);
|
||||
return spawned;
|
||||
};
|
||||
const limits = { heapMb: 128, timeoutMs: 30_000 };
|
||||
const first = source("first.mjs", "export const observed = 1;");
|
||||
const second = source("second.mjs", "export const observed = 2;");
|
||||
const files = [first, second];
|
||||
try {
|
||||
for (const file of files) {
|
||||
const worker = await createJavaScriptSourceAnalysis(limits, 1, {
|
||||
scope,
|
||||
launcher,
|
||||
});
|
||||
try {
|
||||
const result = await worker.analyze(file, {
|
||||
nodes: 100,
|
||||
unknowns: 100,
|
||||
});
|
||||
if (!result.ok) throw result.error.error;
|
||||
expect(result.value.javascript.parse_status).toBe("complete");
|
||||
} finally {
|
||||
await worker.close();
|
||||
}
|
||||
}
|
||||
expect(launches).toHaveLength(1);
|
||||
const replaced = await createJavaScriptSourceAnalysis(
|
||||
{ heapMb: 256, timeoutMs: 30_000 },
|
||||
1,
|
||||
{ scope, launcher },
|
||||
);
|
||||
try {
|
||||
const result = await replaced.analyze(first, {
|
||||
nodes: 100,
|
||||
unknowns: 100,
|
||||
});
|
||||
if (!result.ok) throw result.error.error;
|
||||
expect(result.value.javascript.parse_status).toBe("complete");
|
||||
} finally {
|
||||
await replaced.close();
|
||||
}
|
||||
expect(launches).toHaveLength(2);
|
||||
await scope.close();
|
||||
const reopened = await createJavaScriptSourceAnalysis(limits, 1, {
|
||||
scope,
|
||||
launcher,
|
||||
});
|
||||
try {
|
||||
const result = await reopened.analyze(first, {
|
||||
nodes: 100,
|
||||
unknowns: 100,
|
||||
});
|
||||
if (!result.ok) throw result.error.error;
|
||||
} finally {
|
||||
await reopened.close();
|
||||
}
|
||||
expect(launches).toHaveLength(3);
|
||||
} finally {
|
||||
await scope.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a worker parked while a limit change closes the previous process", async () => {
|
||||
const scope = new ArtifactResourceScope();
|
||||
const launches: number[] = [];
|
||||
const exited = new Set<number>();
|
||||
const exits = new Map<number, Promise<void>>();
|
||||
const launcher: typeof spawnOwnedProviderProcess = async (request) => {
|
||||
const spawned = await spawnOwnedProviderProcess(request);
|
||||
const pid = spawned.process.pid;
|
||||
if (pid === undefined)
|
||||
throw new Error("Owned analyzer has no observed PID");
|
||||
exits.set(
|
||||
pid,
|
||||
new Promise((resolve) => {
|
||||
spawned.process.once("exit", () => {
|
||||
exited.add(pid);
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
);
|
||||
launches.push(pid);
|
||||
return spawned;
|
||||
};
|
||||
const limits = { heapMb: 128, timeoutMs: 30_000 };
|
||||
const file = source("parked.mjs", "export const observed = 1;");
|
||||
const originalClose = JavaScriptAnalysisWorker.prototype.close;
|
||||
let releaseClose = (): void => {};
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
releaseClose = resolve;
|
||||
});
|
||||
let closeCalls = 0;
|
||||
let markCloseStarted = (): void => {};
|
||||
const closeStarted = new Promise<void>((resolve) => {
|
||||
markCloseStarted = resolve;
|
||||
});
|
||||
let replacement:
|
||||
| ReturnType<typeof createJavaScriptSourceAnalysis>
|
||||
| undefined;
|
||||
try {
|
||||
const initial = await createJavaScriptSourceAnalysis(limits, 1, {
|
||||
scope,
|
||||
launcher,
|
||||
});
|
||||
const first = await initial.analyze(file, { nodes: 100, unknowns: 100 });
|
||||
if (!first.ok) throw first.error.error;
|
||||
await initial.close();
|
||||
const replacedPid = launches[0];
|
||||
if (replacedPid === undefined)
|
||||
throw new Error("Replaced analyzer has no observed PID");
|
||||
|
||||
JavaScriptAnalysisWorker.prototype.close = async function (
|
||||
this: JavaScriptAnalysisWorker,
|
||||
) {
|
||||
closeCalls += 1;
|
||||
if (closeCalls === 1) {
|
||||
markCloseStarted();
|
||||
await gate;
|
||||
}
|
||||
return originalClose.call(this);
|
||||
};
|
||||
replacement = createJavaScriptSourceAnalysis(
|
||||
{ heapMb: 256, timeoutMs: 30_000 },
|
||||
1,
|
||||
{ scope, launcher },
|
||||
);
|
||||
await closeStarted;
|
||||
|
||||
const concurrent = await createJavaScriptSourceAnalysis(limits, 1, {
|
||||
scope,
|
||||
launcher,
|
||||
});
|
||||
const concurrentResult = await concurrent.analyze(file, {
|
||||
nodes: 100,
|
||||
unknowns: 100,
|
||||
});
|
||||
if (!concurrentResult.ok) throw concurrentResult.error.error;
|
||||
await concurrent.close();
|
||||
const parkedPid = launches[1];
|
||||
if (parkedPid === undefined)
|
||||
throw new Error("Parked analyzer has no observed PID");
|
||||
|
||||
releaseClose();
|
||||
const extra = await replacement;
|
||||
const extraResult = await extra.analyze(file, {
|
||||
nodes: 100,
|
||||
unknowns: 100,
|
||||
});
|
||||
if (!extraResult.ok) throw extraResult.error.error;
|
||||
const extraPid = launches[2];
|
||||
if (extraPid === undefined)
|
||||
throw new Error("Replacement analyzer has no observed PID");
|
||||
await extra.close();
|
||||
await exits.get(replacedPid);
|
||||
await exits.get(extraPid);
|
||||
expect(exited.has(replacedPid)).toBe(true);
|
||||
expect(exited.has(extraPid)).toBe(true);
|
||||
expect(exited.has(parkedPid)).toBe(false);
|
||||
|
||||
await scope.close();
|
||||
await exits.get(parkedPid);
|
||||
expect(exited.has(parkedPid)).toBe(true);
|
||||
} finally {
|
||||
JavaScriptAnalysisWorker.prototype.close = originalClose;
|
||||
releaseClose();
|
||||
const port = await replacement?.catch(() => undefined);
|
||||
await port?.close().catch(() => undefined);
|
||||
await scope.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not start a replacement when closing the previous worker fails", async () => {
|
||||
const scope = new ArtifactResourceScope();
|
||||
const launches: number[] = [];
|
||||
const exited = new Set<number>();
|
||||
const exits = new Map<number, Promise<void>>();
|
||||
const launcher: typeof spawnOwnedProviderProcess = async (request) => {
|
||||
const previous = launches.at(-1);
|
||||
if (previous !== undefined) await exits.get(previous);
|
||||
const spawned = await spawnOwnedProviderProcess(request);
|
||||
const pid = spawned.process.pid;
|
||||
if (pid === undefined)
|
||||
throw new Error("Owned analyzer has no observed PID");
|
||||
exits.set(
|
||||
pid,
|
||||
new Promise((resolve) => {
|
||||
spawned.process.once("exit", () => {
|
||||
exited.add(pid);
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
);
|
||||
launches.push(pid);
|
||||
return spawned;
|
||||
};
|
||||
const limits = { heapMb: 128, timeoutMs: 30_000 };
|
||||
const file = source("replaced.mjs", "export const observed = 1;");
|
||||
const originalClose = JavaScriptAnalysisWorker.prototype.close;
|
||||
let closeCalls = 0;
|
||||
try {
|
||||
const initial = await createJavaScriptSourceAnalysis(limits, 1, {
|
||||
scope,
|
||||
launcher,
|
||||
});
|
||||
const first = await initial.analyze(file, { nodes: 100, unknowns: 100 });
|
||||
if (!first.ok) throw first.error.error;
|
||||
await initial.close();
|
||||
const replacedPid = launches[0];
|
||||
if (replacedPid === undefined)
|
||||
throw new Error("Replaced analyzer has no observed PID");
|
||||
|
||||
JavaScriptAnalysisWorker.prototype.close = async function (
|
||||
this: JavaScriptAnalysisWorker,
|
||||
) {
|
||||
closeCalls += 1;
|
||||
if (closeCalls === 1) throw new Error("worker still owned");
|
||||
return originalClose.call(this);
|
||||
};
|
||||
await expect(
|
||||
createJavaScriptSourceAnalysis({ heapMb: 256, timeoutMs: 30_000 }, 1, {
|
||||
scope,
|
||||
launcher,
|
||||
}),
|
||||
).rejects.toThrow("worker still owned");
|
||||
expect(launches).toEqual([replacedPid]);
|
||||
expect(exited.has(replacedPid)).toBe(false);
|
||||
|
||||
JavaScriptAnalysisWorker.prototype.close = originalClose;
|
||||
const replacement = await createJavaScriptSourceAnalysis(
|
||||
{ heapMb: 256, timeoutMs: 30_000 },
|
||||
1,
|
||||
{ scope, launcher },
|
||||
);
|
||||
try {
|
||||
const result = await replacement.analyze(file, {
|
||||
nodes: 100,
|
||||
unknowns: 100,
|
||||
});
|
||||
if (!result.ok) throw result.error.error;
|
||||
} finally {
|
||||
await replacement.close();
|
||||
}
|
||||
expect(launches).toHaveLength(2);
|
||||
expect(exited.has(replacedPid)).toBe(true);
|
||||
const replacementPid = launches[1];
|
||||
if (replacementPid === undefined)
|
||||
throw new Error("Replacement analyzer has no observed PID");
|
||||
expect(exited.has(replacementPid)).toBe(false);
|
||||
} finally {
|
||||
JavaScriptAnalysisWorker.prototype.close = originalClose;
|
||||
await scope.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports an actual startup deadline and closes its owned resources before subsequent analysis", async () => {
|
||||
const file = source("app.mjs", "export const observed = 7;");
|
||||
const expired = new JavaScriptAnalysisWorker({ heapMb: 128, timeoutMs: 1 });
|
||||
|
||||
Reference in new issue
Block a user