Skip to content

Commit 1fff21f

Browse files
committed
fix(vscode): retire in-flight worker spawns and runtime verdicts on dispose
A settings-triggered restart can dispose an RstestApi while an await is still pending inside it; three sites could then act for a master that no longer exists: - createChildProcess spawned its worker after the Node preflight settled, producing the one process dispose() cannot kill (it was never added to childProcesses), running on the very resolution the restart exists to replace. - The configured-node verdict and the preflight failure both latched their version-mismatch into what is by then the replacement registration's status, with nothing left to clear it. Both now report through one disposed-aware helper. - resolveRstestPath re-latched a core version mismatch after the project's status was forgotten — sticky for a root that never comes back. A disposed master also fast-fails createChildProcess at entry, sparing it the package resolution and probe costs of a doomed spawn.
1 parent 76c337d commit 1fff21f

2 files changed

Lines changed: 89 additions & 19 deletions

File tree

packages/vscode/src/stacks/test/master.ts

Lines changed: 55 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export const runningWorkers = new Set<BirpcReturn<Worker, TestRunReporter>>();
4141
* The host-level inputs to the worker-node preflight. `notify` must not close
4242
* over `this`: the resolution is memoized for the extension host's lifetime, so
4343
* a callback capturing a `Project` would pin it and its whole test tree.
44+
* (Settle-bounded reactions, like the configured-node verdict's `.then`, may
45+
* capture `this` — the rule is about host-lifetime retention.)
4446
* `cwd` is the caller's standpoint for the shell probe — see
4547
* `probeShellNodePath`.
4648
*/
@@ -109,6 +111,10 @@ export class RstestApi {
109111
// Processes killed on purpose outside the `$close` → `off` path (dispose,
110112
// failed debugger attach). Their `exit` events are not crashes.
111113
private readonly expectedExits = new WeakSet<ChildProcess>();
114+
// Flipped by `dispose()` and checked wherever an await can outlive a
115+
// restart — see `reportNodeRuntimeIssue` and the spawn abort in
116+
// `createChildProcess`.
117+
private disposed = false;
112118

113119
constructor(
114120
private workspace: vscode.WorkspaceFolder,
@@ -191,6 +197,20 @@ export class RstestApi {
191197
* `vscode.env.shell` is read here so `nodeResolution.ts` needs no VS Code
192198
* import.
193199
*/
200+
/**
201+
* A Node-runtime verdict, latched under the host key (see
202+
* `NODE_RUNTIME_STATUS_SOURCE`) — dropped when this API was disposed while
203+
* the verdict was in flight: the memo is reset and `status` rebound by
204+
* then, so the report would land in the *replacement* registration with
205+
* nothing left to clear it.
206+
*/
207+
private reportNodeRuntimeIssue(message: string): void {
208+
if (this.disposed) {
209+
return;
210+
}
211+
status.versionMismatch(message, NODE_RUNTIME_STATUS_SOURCE);
212+
}
213+
194214
private async resolveWorkerNodeCommand(): Promise<{
195215
nodeExecutable: string;
196216
nodeExecArgs: string[];
@@ -204,7 +224,7 @@ export class RstestApi {
204224
// the preflight failure below; re-reporting on a later spawn is a no-op.
205225
void configuredNodeBelowFloor(nodeExecutable).then((message) => {
206226
if (message) {
207-
status.versionMismatch(message, NODE_RUNTIME_STATUS_SOURCE);
227+
this.reportNodeRuntimeIssue(message);
208228
}
209229
});
210230
return { nodeExecutable, nodeExecArgs };
@@ -218,9 +238,7 @@ export class RstestApi {
218238
if (error instanceof NodePreflightError) {
219239
// The status-aggregation adaptation: no usable runtime anywhere is the
220240
// same "fix your toolchain" state as an unsupported package version.
221-
// Latched under the host key, not `this.statusSource` — see
222-
// `NODE_RUNTIME_STATUS_SOURCE`.
223-
status.versionMismatch(error.message, NODE_RUNTIME_STATUS_SOURCE);
241+
this.reportNodeRuntimeIssue(error.message);
224242
}
225243
throw error;
226244
}
@@ -341,19 +359,24 @@ export class RstestApi {
341359
// The status-aggregation adaptation: the one-shot `showWarningMessage`
342360
// becomes the shared `version mismatch` status bar state with actual vs
343361
// required versions. The floor is the same `>= 0.6.0`.
344-
if (
345-
!reportVersionCheck(
346-
status,
347-
'@rstest/core',
348-
coreVersion,
349-
this.statusSource,
350-
)
351-
) {
352-
logger.error(
353-
`Unsupported @rstest/core version ${coreVersion ?? 'unknown'} resolved from ${this.cwd}`,
354-
);
355-
} else {
356-
status.versionOk(this.statusSource);
362+
// Skipped after dispose: the project's status was already forgotten,
363+
// and a mismatch re-latched now — for a root that may never come back —
364+
// would have nothing left to clear it.
365+
if (!this.disposed) {
366+
if (
367+
!reportVersionCheck(
368+
status,
369+
'@rstest/core',
370+
coreVersion,
371+
this.statusSource,
372+
)
373+
) {
374+
logger.error(
375+
`Unsupported @rstest/core version ${coreVersion ?? 'unknown'} resolved from ${this.cwd}`,
376+
);
377+
} else {
378+
status.versionOk(this.statusSource);
379+
}
357380
}
358381

359382
return nodeExport;
@@ -574,6 +597,12 @@ export class RstestApi {
574597
startDebugging?: boolean,
575598
testRun?: vscode.TestRun,
576599
) {
600+
// Cheap fast-fail; the load-bearing check is the one after the Node
601+
// preflight below, which covers a dispose landing mid-await. This one
602+
// just spares a retired master the package resolution and probe costs.
603+
if (this.disposed) {
604+
throw new Error('worker spawn aborted: this master is disposed');
605+
}
577606
const rstestPath = this.resolveRstestPath();
578607
if (!rstestPath) {
579608
throw new Error('Failed to resolve rstest path');
@@ -601,6 +630,14 @@ export class RstestApi {
601630
const workerPath = path.resolve(__dirname, 'worker.js');
602631
const { nodeExecutable, nodeExecArgs } =
603632
await this.resolveWorkerNodeCommand();
633+
// A restart may have retired this API while the preflight above was
634+
// pending. Spawning now would produce the one worker `dispose()` cannot
635+
// kill, running on the very resolution the restart exists to replace.
636+
if (this.disposed) {
637+
throw new Error(
638+
'worker spawn aborted: this master was disposed while its Node runtime was being resolved',
639+
);
640+
}
604641
const nodeEnv = getConfigValue('nodeEnv', this.workspace);
605642
const debugNodeEnv = startDebugging
606643
? getConfigValue('debugNodeEnv', this.workspace)
@@ -745,6 +782,7 @@ export class RstestApi {
745782
}
746783

747784
public dispose() {
785+
this.disposed = true;
748786
for (const child of this.childProcesses) {
749787
this.expectedExits.add(child);
750788
child.kill();

packages/vscode/tests/stacks/test/master.test.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,22 @@ rs.mock('vscode', () => {
115115
// the workspace `node_modules` and `@rstest/core` is genuinely missing.
116116
const noCoreDir = os.tmpdir();
117117

118+
// Settings are a module-level bag every suite writes into; clearing them per
119+
// test keeps one suite's configuration from leaking into the next.
120+
afterEach(() => {
121+
for (const key of Object.keys(settings)) delete settings[key];
122+
});
123+
118124
const createApi = (cwd = noCoreDir) => {
119125
const workspace = { uri: { fsPath: cwd } };
126+
// `sourceUri` backs the per-project status latch key, which the version
127+
// check on the spawn path reads before anything can fail.
128+
const project = { sourceUri: { toString: () => `test://${cwd}` } };
120129
return new RstestApi(
121130
workspace as any,
122131
cwd,
123132
`${cwd}/rstest.config.ts`,
124-
{} as any,
133+
project as any,
125134
);
126135
};
127136

@@ -267,7 +276,6 @@ describe('RstestApi with a configured nodeExecutable', () => {
267276
afterEach(() => {
268277
status.unbind();
269278
resetWorkerNodeCaches();
270-
delete settings.nodeExecutable;
271279
});
272280

273281
// The verdict is reported off the spawn path, so a spawn resolves before the
@@ -293,4 +301,28 @@ describe('RstestApi with a configured nodeExecutable', () => {
293301
expect(nodeExecutable).toBe(configuredNode);
294302
expect(mismatches).toHaveLength(1);
295303
});
304+
305+
// The two mid-flight races a settings-triggered restart makes reachable:
306+
// the verdict and the spawn each cross an await that can outlive `dispose()`.
307+
it('should drop a verdict that settles after dispose', async () => {
308+
await seedProbe({ kind: 'ok', version: '20.19.4' });
309+
const api = createApi();
310+
const pending = resolveWorkerNodeCommand(api);
311+
api.dispose();
312+
await pending;
313+
await settleVerdict();
314+
expect(mismatches).toEqual([]);
315+
});
316+
317+
it('should refuse to spawn a worker after dispose', async () => {
318+
// The seed keeps the configured executable's probe off the real spawn
319+
// path; the package dir (not `process.cwd()`) is a cwd where
320+
// `@rstest/core` resolves, so the abort observed is the disposed check
321+
// and not an earlier resolution failure.
322+
await seedProbe({ kind: 'ok', version: '24.0.0' });
323+
const api = createApi(path.resolve(__dirname, '../../..'));
324+
const spawning = api.createChildProcess();
325+
api.dispose();
326+
await expect(spawning).rejects.toThrow('disposed');
327+
});
296328
});

0 commit comments

Comments
 (0)