@@ -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 ( ) ;
0 commit comments