Skip to content

Commit a53d7bc

Browse files
committed
fix(vscode): keep worker teardown races out of notifications
Two intermittent error notifications traced to the rstest worker's child-process 'error' handler, which treated every 'error' event as a spawn failure the user must fix: - "write EPIPE": a birpc message racing the worker's death. send() was called without a callback, so a lost race became an 'error' event and a notification. Pass a callback and log instead — the 'exit' handler already owns reporting an exit nobody asked for. - "spawn node ENOENT": Node blames the executable when it is the spawn cwd that is gone (a project directory deleted under a live master by a branch switch or a build wiping fixtures). That is a stale-project state detection will reconcile, not a broken runtime: log the real cause, skip the notification and the crashed status. A genuine spawn failure with the cwd intact — the wrong-nodeExecutable case the notification exists for — keeps notifying. Post-spawn 'error' events are absorbed, matching LanguageServerProcessOwner's shape in the lint/fmt stacks.
1 parent 82adc59 commit a53d7bc

2 files changed

Lines changed: 149 additions & 26 deletions

File tree

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

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { type ChildProcess, spawn } from 'node:child_process';
2+
import { existsSync } from 'node:fs';
23
import net from 'node:net';
34
import path, { dirname } from 'node:path';
45
import { type BirpcReturn, createBirpc } from 'birpc';
@@ -643,6 +644,12 @@ export class RstestApi {
643644
});
644645
}
645646

647+
// One wording for the pre-spawn guard and the 'error'-handler fallback it
648+
// leaves for the delete-after-check race.
649+
private missingCwdMessage(): string {
650+
return `worker spawn skipped: project directory ${this.cwd} no longer exists; the project will be re-detected if it comes back`;
651+
}
652+
646653
public async createChildProcess(
647654
testRunReporter = new TestRunReporter(),
648655
startDebugging?: boolean,
@@ -689,6 +696,18 @@ export class RstestApi {
689696
'worker spawn aborted: this master was disposed while its Node runtime was being resolved',
690697
);
691698
}
699+
// A project directory deleted under a live master (branch switch,
700+
// `git clean`, a build wiping fixtures) is a stale-project state, not a
701+
// runtime the user must fix: detection watches the config file and will
702+
// drop or re-add the project. Refusing here, before the spawn, spares
703+
// the caller a worker whose every call rejects with an opaque
704+
// "[birpc] rpc is closed" — and spares the user Node's misreading of a
705+
// missing cwd as "spawn node ENOENT".
706+
if (!existsSync(this.cwd)) {
707+
const message = this.missingCwdMessage();
708+
logger.warn(message);
709+
throw new Error(message);
710+
}
692711
const nodeEnv = getConfigValue('nodeEnv', this.workspace);
693712
const debugNodeEnv = startDebugging
694713
? getConfigValue('debugNodeEnv', this.workspace)
@@ -732,9 +751,15 @@ export class RstestApi {
732751

733752
const worker = createBirpc<Worker, TestRunReporter>(testRunReporter, {
734753
// Target the local process rather than the shared field, which is
735-
// reassigned on every spawn; skip once the IPC channel is gone.
754+
// reassigned on every spawn; skip once the IPC channel is gone. The
755+
// callback matters: without one, Node surfaces a failed write — a
756+
// message losing the race against the worker's death — as a process
757+
// 'error' event instead.
736758
post: (data) => {
737-
if (rstestProcess.connected) rstestProcess.send(data);
759+
if (rstestProcess.connected)
760+
rstestProcess.send(data, (error) => {
761+
if (error) logger.debug('IPC send to worker failed', error);
762+
});
738763
},
739764
on: (fn) => rstestProcess.on('message', fn),
740765
bind: 'functions',
@@ -754,23 +779,38 @@ export class RstestApi {
754779
configFilePath: this.configFilePath,
755780
});
756781

782+
let spawned = false;
757783
rstestProcess.on('spawn', () => {
784+
spawned = true;
758785
status.workerSpawned(this.statusSource);
759786
});
760787

761788
rstestProcess.on('error', (error) => {
762-
logger.error('Worker process error', error);
763-
// The status-aggregation adaptation: a worker that never came up is the
764-
// `crashed` state of the shared status bar. The notification is kept because
765-
// a failed spawn is almost always a wrong `nodeExecutable` the user has
766-
// to fix, and the status bar alone is easy to miss mid-run.
767-
status.crashed(
768-
`worker process failed: ${error.message}`,
769-
this.statusSource,
770-
);
771-
vscode.window.showErrorMessage(
772-
`Rstest worker process failed: ${error.message}`,
773-
);
789+
if (spawned) {
790+
// Post-spawn errors (a failed kill(), an IPC write losing the race
791+
// against the worker's death) are teardown noise with nothing for
792+
// the user to fix; the 'exit' handler already reports an exit nobody
793+
// asked for. Same shape as `LanguageServerProcessOwner`'s handler.
794+
logger.debug('Worker process error after spawn', error);
795+
} else if (!existsSync(this.cwd)) {
796+
// The cwd was deleted between the pre-spawn guard and the spawn —
797+
// Node blames the executable ("spawn node ENOENT") when it is the
798+
// cwd that is gone. Same stale-project state, same quiet report.
799+
logger.warn(this.missingCwdMessage());
800+
} else {
801+
logger.error('Worker process error', error);
802+
// The status-aggregation adaptation: a worker that never came up is the
803+
// `crashed` state of the shared status bar. The notification is kept because
804+
// a failed spawn is almost always a wrong `nodeExecutable` the user has
805+
// to fix, and the status bar alone is easy to miss mid-run.
806+
status.crashed(
807+
`worker process failed: ${error.message}`,
808+
this.statusSource,
809+
);
810+
vscode.window.showErrorMessage(
811+
`Rstest worker process failed: ${error.message}`,
812+
);
813+
}
774814
// Reject any in-flight birpc calls instead of letting them hang; $close
775815
// runs the `off` handler, which removes the process from the Set.
776816
if (!worker.$closed) worker.$close();

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

Lines changed: 95 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ChildProcess } from 'node:child_process';
12
import fs from 'node:fs';
23
import { createRequire } from 'node:module';
34
import os from 'node:os';
@@ -11,7 +12,7 @@ import {
1112
resetUserNodeCaches,
1213
} from '../../../src/shared/nodeResolution';
1314
import { status } from '../../../src/stacks/test/status';
14-
import type { StatusReporter } from '../../../src/types';
15+
import type { StackState, StatusReporter } from '../../../src/types';
1516
import { createStatusRecorder } from './statusRecorder';
1617

1718
// The Rstest runner injects its own `@rstest/core` into every resolution path so
@@ -117,6 +118,18 @@ rs.mock('vscode', () => {
117118
// the workspace `node_modules` and `@rstest/core` is genuinely missing.
118119
const noCoreDir = os.tmpdir();
119120

121+
// The opposite fixture: a cwd where `@rstest/core` resolves, for suites whose
122+
// case under test sits past the resolution step.
123+
const packageDir = path.resolve(__dirname, '../../..');
124+
125+
// Seeding the memo is how the probe is injected: `resolveWorkerNodeCommand`
126+
// takes no probe option (it is called from deep inside a spawn path), and the
127+
// memo is keyed by executable path, so a seeded entry is the answer it gets.
128+
const seedNodeProbe = (executable: string, probe: NodeProbe) =>
129+
configuredNodeBelowFloor(executable, {
130+
probe: () => Promise.resolve(probe),
131+
});
132+
120133
// Settings are a module-level bag every suite writes into; clearing them per
121134
// test keeps one suite's configuration from leaking into the next.
122135
afterEach(() => {
@@ -357,13 +370,7 @@ describe('RstestApi with a configured nodeExecutable', () => {
357370
versionMismatch: (detail) => mismatches.push(detail),
358371
};
359372

360-
// Seeding the memo is how the probe is injected: `resolveWorkerNodeCommand`
361-
// takes no probe option (it is called from deep inside a spawn path), and the
362-
// memo is keyed by executable path, so a seeded entry is the answer it gets.
363-
const seedProbe = (probe: NodeProbe) =>
364-
configuredNodeBelowFloor(configuredNode, {
365-
probe: () => Promise.resolve(probe),
366-
});
373+
const seedProbe = (probe: NodeProbe) => seedNodeProbe(configuredNode, probe);
367374

368375
// Reaching the private method keeps these cases on the decision under test
369376
// instead of spawning a real worker process for each one.
@@ -438,13 +445,89 @@ describe('RstestApi with a configured nodeExecutable', () => {
438445

439446
it('should refuse to spawn a worker after dispose', async () => {
440447
// The seed keeps the configured executable's probe off the real spawn
441-
// path; the package dir (not `process.cwd()`) is a cwd where
442-
// `@rstest/core` resolves, so the abort observed is the disposed check
443-
// and not an earlier resolution failure.
448+
// path, so the abort observed is the disposed check and not an earlier
449+
// resolution failure.
444450
await seedProbe({ kind: 'ok', version: '24.0.0' });
445-
const api = createApi(path.resolve(__dirname, '../../..'));
451+
const api = createApi(packageDir);
446452
const spawning = api.createChildProcess();
447453
api.dispose();
448454
await expect(spawning).rejects.toThrow('disposed');
449455
});
450456
});
457+
458+
// Worker spawn failures: only the wrong-executable case is the user's to fix
459+
// (and keeps its notification); the rest is absorbed — the rationale lives on
460+
// the guard and the 'error' handler in `master.ts`.
461+
describe('RstestApi worker spawn failures', () => {
462+
let api: RstestApi | undefined;
463+
let reported: StackState[];
464+
465+
const crashes = () => reported.filter((state) => state.kind === 'crashed');
466+
467+
const seedConfiguredNode = (executable: string) => {
468+
settings['rstack.nodeExecutable'] = executable;
469+
return seedNodeProbe(executable, { kind: 'ok', version: '24.0.0' });
470+
};
471+
472+
beforeEach(() => {
473+
shownMessages.length = 0;
474+
loggedWarnings.length = 0;
475+
resetUserNodeCaches();
476+
const recorder = createStatusRecorder();
477+
reported = recorder.reported;
478+
status.bind(recorder.reporter);
479+
});
480+
481+
afterEach(() => {
482+
api?.dispose();
483+
api = undefined;
484+
status.unbind();
485+
resetUserNodeCaches();
486+
});
487+
488+
it('should log, not notify, when the spawn cwd no longer exists', async () => {
489+
await seedConfiguredNode(process.execPath);
490+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'rstest-gone-'));
491+
api = createApi(cwd, packageDir);
492+
fs.rmSync(cwd, { recursive: true, force: true });
493+
494+
await expect(api.createChildProcess()).rejects.toThrow('no longer exists');
495+
496+
expect(shownMessages).toEqual([]);
497+
expect(crashes()).toEqual([]);
498+
expect(loggedWarnings.join('\n')).toContain(cwd);
499+
});
500+
501+
it('should keep notifying when the executable itself fails to spawn', async () => {
502+
await seedConfiguredNode(path.join(os.tmpdir(), 'no-such-node-xyz'));
503+
api = createApi(packageDir);
504+
505+
await api.createChildProcess();
506+
await expect
507+
.poll(() => shownMessages[0] ?? '', { timeout: 5000 })
508+
.toContain('Rstest worker process failed');
509+
510+
expect(crashes()).toHaveLength(1);
511+
});
512+
513+
it('should absorb a post-spawn error instead of notifying', async () => {
514+
await seedConfiguredNode(process.execPath);
515+
// `--eval` wins over the worker script path, so the child is a plain
516+
// long-lived node — the point is the handler, not the worker protocol.
517+
settings.nodeExecArgs = ['--eval', 'setInterval(() => {}, 1000)'];
518+
api = createApi(packageDir);
519+
520+
await api.createChildProcess();
521+
const child = [...((api as any).childProcesses as Set<ChildProcess>)][0]!;
522+
// The handler's spawned latch is set by the master's own 'spawn'
523+
// listener, which registered first and therefore runs first.
524+
await new Promise<void>((resolve) => {
525+
child.once('spawn', () => resolve());
526+
});
527+
528+
child.emit('error', new Error('write EPIPE'));
529+
530+
expect(shownMessages).toEqual([]);
531+
expect(crashes()).toEqual([]);
532+
});
533+
});

0 commit comments

Comments
 (0)