Skip to content

Commit eda80fa

Browse files
committed
fix(vscode): address review findings on quiet classification, latches, color marker
Three review findings, each verified before fixing: - The missing-cwd spawn refusal threw a plain Error, so callers re-logged the already-warned stale-project state as an error with a stack. The guard now throws ReportedRstestResolutionError (which gained an optional message), and the four catch sites above RstestApi share one logUnlessReported helper next to the class instead of re-deciding. - The re-raise dedupe in versionMismatch/notInstalled short-circuited before the package-state restatement, so a crash latched between two identical verdicts survived a retry that aborted before spawning. Both observations now fold into one #observePackageState that restates first and skips only the repaint. - retractForceColorIfDisabled left RSTACK_FORCE_COLOR_INJECTED in the env on the no-NO_COLOR path; the marker is now removed once the decision is complete, so pool processes and user test code never observe it.
1 parent 3795cf6 commit eda80fa

9 files changed

Lines changed: 92 additions & 39 deletions

File tree

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

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
/**
2-
* Classifying and reporting failed Rstest resolutions — the host-side helpers
3-
* for a `@rstest/core` that cannot be resolved. (The worker-side classifier
4-
* for a config whose own import failed is `shared/missingDependency.ts`.)
2+
* Classifying and reporting failed Rstest worker setups — the host-side
3+
* helpers for a `@rstest/core` that cannot be resolved, and the
4+
* already-reported marker for any setup failure whose actionable state was
5+
* logged where it was observed. (The worker-side classifier for a config
6+
* whose own import failed is `shared/missingDependency.ts`.)
57
*
68
* Every message here replaces Node's own `MODULE_NOT_FOUND` text, which
79
* embeds a multi-line require stack and says nothing about what to do. An
@@ -11,18 +13,28 @@
1113
* has to fix, so it is notified.
1214
*/
1315

16+
import { logger } from './logger';
17+
1418
/**
15-
* Resolution failed after the actionable error was already logged or shown.
16-
* Callers still reject so project initialization stops, but must not report the
17-
* same failure again.
19+
* The worker could not be set up, and the actionable state was already logged
20+
* or shown — a core that did not resolve, or a spawn refused because the
21+
* project directory is gone. Callers still reject so the operation stops, but
22+
* must not report the same failure again: catch sites log through
23+
* `logUnlessReported` below instead of re-deciding.
1824
*/
1925
export class ReportedRstestResolutionError extends Error {
20-
constructor() {
21-
super('Failed to resolve rstest path');
26+
constructor(message = 'Failed to resolve rstest path') {
27+
super(message);
2228
this.name = 'ReportedRstestResolutionError';
2329
}
2430
}
2531

32+
/** The catch-site half of the contract above. */
33+
export function logUnlessReported(message: string, error: unknown): void {
34+
if (error instanceof ReportedRstestResolutionError) return;
35+
logger.error(message, error);
36+
}
37+
2638
// Whether `specifier` itself is what could not be found. `MODULE_NOT_FOUND`
2739
// alone is too broad: a package that is installed but whose entry file is gone
2840
// (an interrupted install, or a workspace link that has not been built) throws

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
StackContext,
55
StackController,
66
} from '../../types';
7+
import { logUnlessReported } from './coreResolution';
78
import { RstestDiagnostics } from './diagnostics';
89
import { TestErrorStore, testMessageText } from './errorStore';
910
import { logger } from './logger';
@@ -486,7 +487,7 @@ class Rstest implements vscode.Disposable {
486487
request.include ?? gatherTestItems(this.ctrl.items, false),
487488
);
488489
} catch (error) {
489-
logger.error('Error running tests:', error);
490+
logUnlessReported('Error running tests:', error);
490491
} finally {
491492
run.end();
492493
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -685,7 +685,7 @@ export class RstestApi {
685685
if (this.cwdIsGone()) {
686686
const message = this.missingCwdMessage();
687687
logger.warn(message);
688-
throw new Error(message);
688+
throw new ReportedRstestResolutionError(message);
689689
}
690690
// Resolved once per spawn and handed back to the caller: the callers'
691691
// worker requests need the same path, and re-resolving would repeat the

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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
formatConfigDependencyMissingLog,
1111
formatConfigDependencyMissingStatus,
1212
} from '../../shared/notInstalled';
13-
import { ReportedRstestResolutionError } from './coreResolution';
13+
import { logUnlessReported } from './coreResolution';
1414
import { logger } from './logger';
1515
import { RstestApi } from './master';
1616
import { type ChildProjectRef, computeCoveredConfigs } from './projectCoverage';
@@ -606,9 +606,7 @@ export class Project implements vscode.Disposable {
606606
.catch((error) => {
607607
if (this.cancellationSource.token.isCancellationRequested) return;
608608
this.configLoadFailed = true;
609-
if (!(error instanceof ReportedRstestResolutionError)) {
610-
logger.error('Failed to initialize project config', error);
611-
}
609+
logUnlessReported('Failed to initialize project config', error);
612610
// Let the manager settle its tree even when a config fails to load.
613611
this.onConfigResolved?.();
614612
});
@@ -788,7 +786,10 @@ export class Project implements vscode.Disposable {
788786
})
789787
.catch((error) => {
790788
if (!token.isCancellationRequested) {
791-
logger.error('Failed to update runtime test list', error);
789+
logUnlessReported(
790+
'Failed to update runtime test list',
791+
error,
792+
);
792793
}
793794
});
794795
};
@@ -821,7 +822,7 @@ export class Project implements vscode.Disposable {
821822
});
822823
} catch (error) {
823824
if (!token.isCancellationRequested) {
824-
logger.error('Failed to collect test files', error);
825+
logUnlessReported('Failed to collect test files', error);
825826
}
826827
} finally {
827828
if (this.testItem) {

packages/vscode/src/stacks/test/shared/colorEnv.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,14 @@ export function injectForceColor(env: NodeJS.ProcessEnv): void {
4242

4343
/**
4444
* Retract an earlier injection if the loaded config set `NO_COLOR`. Call in
45-
* the worker, right after the config has been evaluated.
45+
* the worker, right after the config has been evaluated. The marker is
46+
* removed either way: the decision is complete, and pool processes, user
47+
* test code and their children must not observe an extension-only variable
48+
* the bare CLI never supplies.
4649
*/
4750
export function retractForceColorIfDisabled(env: NodeJS.ProcessEnv): void {
4851
if (env[INJECTED_MARKER] === '1' && env.NO_COLOR !== undefined) {
4952
delete env.FORCE_COLOR;
50-
delete env[INJECTED_MARKER];
5153
}
54+
delete env[INJECTED_MARKER];
5255
}

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

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -131,20 +131,29 @@ class StatusHolder implements StatusReporter {
131131
* exit, `workerSpawned`, cannot happen while the package is unusable.
132132
* `crashed` above supersedes nothing: it is a fact about the worker,
133133
* arriving while the package state stays whatever it was.
134+
*
135+
* Both observations are re-raised on every refresh pass with the same
136+
* words (the bridge re-resolves per pass), so an unchanged observation is
137+
* not repainted. The restatement still runs first: a re-raise means a
138+
* fresh resolution pass ran, so a crash latched in between — whose worker
139+
* is gone — is retired even when the verdict itself did not change.
134140
*/
135-
#restatePackageState(keep: Map<string, string>, source: string): void {
136-
for (const latch of [this.#crashes, this.#mismatches, this.#notInstalled]) {
137-
if (latch !== keep) latch.delete(source);
141+
#observePackageState(
142+
latch: Map<string, string>,
143+
detail: string,
144+
source: string,
145+
): void {
146+
let retired = false;
147+
for (const other of [this.#crashes, this.#mismatches, this.#notInstalled]) {
148+
if (other !== latch && other.delete(source)) retired = true;
138149
}
150+
if (!retired && latch.get(source) === detail) return;
151+
latch.set(source, detail);
152+
this.#paintOrRun();
139153
}
140154

141-
// Like `notInstalled`, re-raised on every refresh pass with the same words
142-
// (the bridge re-resolves per pass), so an unchanged entry is not restated.
143155
versionMismatch(detail: string, source = ''): void {
144-
if (this.#mismatches.get(source) === detail) return;
145-
this.#restatePackageState(this.#mismatches, source);
146-
this.#mismatches.set(source, detail);
147-
this.#paintOrRun();
156+
this.#observePackageState(this.#mismatches, detail, source);
148157
}
149158

150159
/** A worker process came up: that root's previous spawn failure is over. */
@@ -157,7 +166,7 @@ class StatusHolder implements StatusReporter {
157166
* A package version check passed. A version was read, so the package is
158167
* necessarily installed: this one observation ends both a previous
159168
* mismatch and a previous missing install — the recovery-side restatement
160-
* mirroring `#restatePackageState`, so a success site cannot forget half
169+
* mirroring `#observePackageState`, so a success site cannot forget half
161170
* the clearing (`crashed` stays: it is a fact about the worker, not the
162171
* package). `installed` below survives for the `config-deps:` namespace,
163172
* which has no version verdict.
@@ -168,17 +177,9 @@ class StatusHolder implements StatusReporter {
168177
if (hadMismatch || hadNotInstalled) this.#paintOrRun();
169178
}
170179

171-
/**
172-
* A root's dependencies are not installed: `disabled`, with the way out.
173-
* Unlike the crash and mismatch latches this one is re-raised on every
174-
* refresh pass and worker spawn with the same words, so an unchanged entry
175-
* is not repainted.
176-
*/
180+
/** A root's dependencies are not installed: `disabled`, with the way out. */
177181
notInstalled(reason: string, source = ''): void {
178-
if (this.#notInstalled.get(source) === reason) return;
179-
this.#restatePackageState(this.#notInstalled, source);
180-
this.#notInstalled.set(source, reason);
181-
this.#paintOrRun();
182+
this.#observePackageState(this.#notInstalled, reason, source);
182183
}
183184

184185
/** A resolution under that root succeeded: its missing install is over. */

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,20 +29,25 @@ describe('injectForceColor', () => {
2929
});
3030

3131
describe('retractForceColorIfDisabled', () => {
32+
// Both paths also assert the internal marker is gone: pool processes and
33+
// user test code must not observe an extension-only variable the bare CLI
34+
// never supplies.
3235
it('retracts the injection when the config set NO_COLOR', () => {
3336
const env: NodeJS.ProcessEnv = {};
3437
injectForceColor(env);
3538
env.NO_COLOR = '1'; // config load
3639
retractForceColorIfDisabled(env);
3740
expect(env.FORCE_COLOR).toBeUndefined();
3841
expect(env.NO_COLOR).toBe('1');
42+
expect(env.RSTACK_FORCE_COLOR_INJECTED).toBeUndefined();
3943
});
4044

4145
it('leaves the injection alone when the config set nothing', () => {
4246
const env: NodeJS.ProcessEnv = {};
4347
injectForceColor(env);
4448
retractForceColorIfDisabled(env);
4549
expect(env.FORCE_COLOR).toBe('1');
50+
expect(env.RSTACK_FORCE_COLOR_INJECTED).toBeUndefined();
4651
});
4752

4853
it('never touches a user-set FORCE_COLOR (that conflict warns in the bare CLI too)', () => {

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,12 @@ describe('RstestApi worker spawn failures', () => {
494494
api = createApi(cwd);
495495
fs.rmSync(cwd, { recursive: true, force: true });
496496

497-
await expect(api.createChildProcess()).rejects.toThrow('no longer exists');
497+
// The reported-error class keeps the quiet classification through the
498+
// callers — `logUnlessReported` must not re-log this as a failure.
499+
await expect(api.createChildProcess()).rejects.toMatchObject({
500+
name: 'ReportedRstestResolutionError',
501+
message: expect.stringContaining('no longer exists'),
502+
});
498503

499504
expect(shownMessages).toEqual([]);
500505
expect(reported).toEqual([]);

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,31 @@ describe('StatusHolder failure latches', () => {
6363
expect(calls.slice(2)).toEqual(['mismatch:rstack too old']);
6464
});
6565

66+
it('retires a stale crash even when the re-raised verdict is unchanged', () => {
67+
// A re-raise means a fresh resolution pass ran, so the crashed worker is
68+
// gone — e.g. a retry that aborts before spawning (occupied debug port)
69+
// never reaches `workerSpawned`, and only the restatement can clear the
70+
// old spawn failure. The dedupe must not short-circuit past it.
71+
const calls = bindRecorder();
72+
status.versionMismatch('core too old', '/a');
73+
status.crashed('spawn ENOENT', '/a');
74+
status.versionMismatch('core too old', '/a');
75+
expect(calls).toEqual([
76+
'mismatch:core too old',
77+
'crashed:spawn ENOENT',
78+
'mismatch:core too old',
79+
]);
80+
});
81+
82+
it('stays silent on an identical re-raise with nothing else latched', () => {
83+
const calls = bindRecorder();
84+
status.notInstalled('core missing', '/a');
85+
status.notInstalled('core missing', '/a');
86+
status.versionMismatch('core too old', '/b');
87+
status.versionMismatch('core too old', '/b');
88+
expect(calls).toEqual(['report:disabled', 'mismatch:core too old']);
89+
});
90+
6691
it('lets a package-state observation retire a stale crash', () => {
6792
// The crash's only other exit is `workerSpawned`, which cannot happen
6893
// while the package is unusable — a fresh resolution verdict restates

0 commit comments

Comments
 (0)