From bc60e7be85251413c3023bb391371c4080a6571c Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 4 Sep 2026 16:52:41 +0800 Subject: [PATCH 01/44] feat(vscode): classify lint config dependency failures --- .../vscode/src/shared/missingDependency.ts | 47 +++-- .../lint/worker/ConfigTransactionAdapter.ts | 49 ++++- .../vscode/src/stacks/lint/worker/index.ts | 56 ++++- .../tests/shared/missingDependency.test.ts | 33 ++- .../vscode/tests/stacks/lint/worker.test.ts | 192 +++++++++++++++++- 5 files changed, 345 insertions(+), 32 deletions(-) diff --git a/packages/vscode/src/shared/missingDependency.ts b/packages/vscode/src/shared/missingDependency.ts index 141314c..43c202a 100644 --- a/packages/vscode/src/shared/missingDependency.ts +++ b/packages/vscode/src/shared/missingDependency.ts @@ -9,29 +9,17 @@ import { findPackageJsonUncached } from './packageResolve'; * `shared/` beside the walk-up it uses rather than in one stack. * * Returns the one-line cause when a config evaluation failed on a package - * that is not installed, or `undefined` for a real error. Gated on the - * error's `code` — Node's own classification (`ERR_MODULE_NOT_FOUND` for - * ESM, `MODULE_NOT_FOUND` for CJS) — but the code alone is too broad: a - * typo'd relative import fails with the same codes, and installing - * dependencies cannot fix it, so only a bare specifier — a package name, - * read from the message since CJS carries no structured one — counts, and - * anything unrecognized fails towards the full error report. The check has - * to run in the process where the error is thrown: an IPC channel back to - * the extension host (`serialization: 'advanced'`) drops the `code`, so the - * verdict travels as data (e.g. `NormalizedConfigResult`). Only the first - * line comes back: the rest of a CJS message is the require stack, and the - * not-installed state is one warn line without one. + * that is not installed, or `undefined` for a real error. Only a bare + * specifier — a package name, read from the message since CJS carries no + * structured one — counts, and anything unrecognized fails towards the full + * error report. Only the first line comes back: the rest of a CJS message is + * the require stack, and the not-installed state is one warn line without one. */ -export function missingDependencyCauseOf( - error: unknown, +export function classifyMissingDependencyMessage( + message: string, resolveFrom: string, ): string | undefined { - if (!(error instanceof Error)) return undefined; - const { code } = error as NodeJS.ErrnoException; - if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'MODULE_NOT_FOUND') { - return undefined; - } - const [firstLine] = error.message.split('\n', 1); + const [firstLine] = message.split('\n', 1); const specifier = /^Cannot find (?:package|module) '([^']+)'/.exec( firstLine, )?.[1]; @@ -58,3 +46,22 @@ export function missingDependencyCauseOf( } return firstLine; } + +/** + * Error-object entry point used where Node's loader code survives. The code is + * still required there: arbitrary user errors may contain loader-like prose. + * Worker/protocol boundaries that already carry a separately checked code use + * `classifyMissingDependencyMessage` directly because serialization can drop + * custom Error fields. + */ +export function missingDependencyCauseOf( + error: unknown, + resolveFrom: string, +): string | undefined { + if (!(error instanceof Error)) return undefined; + const { code } = error as NodeJS.ErrnoException; + if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'MODULE_NOT_FOUND') { + return undefined; + } + return classifyMissingDependencyMessage(error.message, resolveFrom); +} diff --git a/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts b/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts index 4e456c3..1e8bb82 100644 --- a/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts +++ b/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts @@ -2,11 +2,23 @@ import type { ActivateConfigsRequest, ActivateConfigsResponse, ConfigModuleActivationPlan, + ConfigModuleCandidate, ConfigModuleEslintPluginEntry, ConfigModulePluginDescriptor, LoadConfigsRequest, LoadConfigsResponse, } from '@rslint/core/config-loader'; +import { classifyMissingDependencyMessage } from '../../../shared/missingDependency'; + +export interface ConfigDependencyFailure { + readonly configPath: string; + readonly cause: string; +} + +interface ConfigDependencyObserver { + resolveFrom(candidate: ConfigModuleCandidate): string; + report(failure: ConfigDependencyFailure): void; +} interface ConfigActivationWireResponse { transactionId: string; @@ -88,6 +100,7 @@ export class LspConfigTransactionAdapter { private readonly pluginLintPool: PluginLintPoolAdapter, private readonly fingerprint: (plan: ConfigModuleActivationPlan) => string, private readonly protocolVersion: number, + private readonly configDependencyObserver?: ConfigDependencyObserver, ) {} async loadConfigs( @@ -106,7 +119,41 @@ export class LspConfigTransactionAdapter { ); this.assertActive(); throwIfAborted(signal); - return response; + let classified = false; + return { + ...response, + results: response.results.map((result, index) => { + if (classified || result.status !== 'failed') return result; + const candidate = request.candidates[index]; + if ( + candidate === undefined || + (result.error.code !== 'ERR_MODULE_NOT_FOUND' && + result.error.code !== 'MODULE_NOT_FOUND') + ) { + return result; + } + const cause = classifyMissingDependencyMessage( + result.error.message, + this.configDependencyObserver?.resolveFrom(candidate) ?? + candidate.configDirectory, + ); + if (cause === undefined) return result; + classified = true; + this.configDependencyObserver?.report({ + configPath: candidate.configPath, + cause, + }); + return { + ...result, + error: { + ...result.error, + // Keep the classified result to one line so Go cannot echo a + // CJS require stack beside the policy's one-warn-line report. + message: cause, + }, + }; + }), + }; } catch (error) { this.cleanup(transactionId); throw error; diff --git a/packages/vscode/src/stacks/lint/worker/index.ts b/packages/vscode/src/stacks/lint/worker/index.ts index 830278d..3475bc4 100644 --- a/packages/vscode/src/stacks/lint/worker/index.ts +++ b/packages/vscode/src/stacks/lint/worker/index.ts @@ -11,6 +11,7 @@ import { } from 'vscode-jsonrpc/node'; import { LspConfigTransactionAdapter, + type ConfigDependencyFailure, type ConfigTransactionControlRequest, } from './ConfigTransactionAdapter'; import { PluginLintPool } from './PluginLintPool'; @@ -26,6 +27,13 @@ import { logger } from './logger'; const GRACEFUL_EXIT_TIMEOUT_MS = 500; const FORCED_EXIT_TIMEOUT_MS = 1_500; +export const CONFIG_DEPENDENCY_STATUS_NOTIFICATION = + 'rstack/rslintConfigDependency'; + +export interface ConfigDependencyStatusNotification { + readonly failure: ConfigDependencyFailure | null; +} + interface StopRequest { readonly exitCode: number; readonly reason: string; @@ -136,6 +144,8 @@ function forwardRequest( interface EditorProxyOptions { readonly protocolVersion: number; readonly configPath?: string; + beginConfigRefresh(): void; + takeConfigDependencyFailure(): ConfigDependencyFailure | undefined; observeRefresh(reason: unknown): void; requestStop(request: StopRequest): void; } @@ -148,16 +158,30 @@ export function registerEditorProxy( editorConnection.onRequest(async (method, params, token) => { if (method === 'rslint/configRefresh') { const refresh = params as ConfigRefreshParams; + options.beginConfigRefresh(); options.observeRefresh(refresh?.reason); - return goConnection.sendRequest( - method, - stampConfigRefresh( - refresh, - options.protocolVersion, - options.configPath, - ), - token, - ); + try { + const result = await goConnection.sendRequest( + method, + stampConfigRefresh( + refresh, + options.protocolVersion, + options.configPath, + ), + token, + ); + await editorConnection.sendNotification( + CONFIG_DEPENDENCY_STATUS_NOTIFICATION, + { failure: options.takeConfigDependencyFailure() ?? null }, + ); + return result; + } catch (error) { + await editorConnection.sendNotification( + CONFIG_DEPENDENCY_STATUS_NOTIFICATION, + { failure: options.takeConfigDependencyFailure() ?? null }, + ); + throw error; + } } return forwardRequest(goConnection, method, params, token); }); @@ -193,11 +217,21 @@ export async function runLintWorker( logger, installation.createPluginLintHost, ); + let configDependencyFailure: ConfigDependencyFailure | undefined; const adapter = new LspConfigTransactionAdapter( installation.createConfigModuleHost(), pluginLintPool, (activation) => fingerprinter.compute(activation), installation.protocolVersion, + { + resolveFrom: (candidate) => + candidate.configPath === options.configPath + ? process.cwd() + : candidate.configDirectory, + report: (failure) => { + configDependencyFailure ??= failure; + }, + }, ); const stop = deferred(); @@ -212,6 +246,10 @@ export async function runLintWorker( registerEditorProxy(editorConnection, goConnection, { protocolVersion: installation.protocolVersion, configPath: options.configPath, + beginConfigRefresh: () => { + configDependencyFailure = undefined; + }, + takeConfigDependencyFailure: () => configDependencyFailure, observeRefresh: (reason) => fingerprinter.observeRefresh(reason), requestStop, }); diff --git a/packages/vscode/tests/shared/missingDependency.test.ts b/packages/vscode/tests/shared/missingDependency.test.ts index 51e4ba7..7036aac 100644 --- a/packages/vscode/tests/shared/missingDependency.test.ts +++ b/packages/vscode/tests/shared/missingDependency.test.ts @@ -2,7 +2,10 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { describe, expect, it } from '@rstest/core'; -import { missingDependencyCauseOf } from '../../src/shared/missingDependency'; +import { + classifyMissingDependencyMessage, + missingDependencyCauseOf, +} from '../../src/shared/missingDependency'; // Resolve for real rather than hand-building an error object: the classifier // reads a code and a message Node owns, so a fake error would only assert @@ -101,3 +104,31 @@ describe('missingDependencyCauseOf', () => { expect(classify(undefined)).toBe(undefined); }); }); + +describe('classifyMissingDependencyMessage', () => { + it('classifies loader messages without requiring an Error code', () => { + expect( + classifyMissingDependencyMessage( + "Cannot find package '@scope/missing' imported from /project/config.mjs", + __dirname, + ), + ).toBe( + "Cannot find package '@scope/missing' imported from /project/config.mjs", + ); + expect( + classifyMissingDependencyMessage( + "Cannot find module 'missing-package'\nRequire stack:\n- /project/config.cjs", + __dirname, + ), + ).toBe("Cannot find module 'missing-package'"); + }); + + it('rejects non-loader messages even without the Error-code gate', () => { + expect( + classifyMissingDependencyMessage( + "Configuration says Cannot find package 'missing'", + __dirname, + ), + ).toBe(undefined); + }); +}); diff --git a/packages/vscode/tests/stacks/lint/worker.test.ts b/packages/vscode/tests/stacks/lint/worker.test.ts index 85128ac..6ef8826 100644 --- a/packages/vscode/tests/stacks/lint/worker.test.ts +++ b/packages/vscode/tests/stacks/lint/worker.test.ts @@ -4,13 +4,23 @@ import os from 'node:os'; import path from 'node:path'; import { PassThrough } from 'node:stream'; import { describe, expect, it } from '@rstest/core'; +import type { + ConfigModuleActivationPlan, + LoadConfigsRequest, + LoadConfigsResponse, +} from '@rslint/core/config-loader'; import { createMessageConnection, NullLogger } from 'vscode-jsonrpc/node'; import { LINT_WORKER_USAGE, parseWorkerArgs, stampConfigRefresh, } from '../../../src/stacks/lint/worker/cli'; -import { registerEditorProxy } from '../../../src/stacks/lint/worker/index'; +import { LspConfigTransactionAdapter } from '../../../src/stacks/lint/worker/ConfigTransactionAdapter'; +import { + CONFIG_DEPENDENCY_STATUS_NOTIFICATION, + type ConfigDependencyStatusNotification, + registerEditorProxy, +} from '../../../src/stacks/lint/worker/index'; const fakeGoSource = String.raw` let buffer = Buffer.alloc(0); @@ -28,6 +38,17 @@ function handle(message) { } if (message.id === undefined) return; const hasParams = Object.prototype.hasOwnProperty.call(message, 'params'); + if ( + message.method === 'rslint/configRefresh' && + message.params?.reason === 'reject' + ) { + send({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32603, message: 'refresh rejected' }, + }); + return; + } send({ jsonrpc: '2.0', id: message.id, @@ -135,14 +156,30 @@ describe('lint worker config refresh', () => { ); const configPath = path.resolve('/project/rslintConfig.js'); const observedReasons: unknown[] = []; + const notificationFailure = { + configPath: '/project/rslint.config.mjs', + cause: "Cannot find package 'missing'", + }; + const notifications: ConfigDependencyStatusNotification[] = []; + let activeFailure: + { readonly configPath: string; readonly cause: string } | undefined = + notificationFailure; try { registerEditorProxy(workerConnection, goConnection, { protocolVersion: 2, configPath, + beginConfigRefresh: () => undefined, + takeConfigDependencyFailure: () => activeFailure, observeRefresh: (reason) => observedReasons.push(reason), requestStop: () => undefined, }); + editorConnection.onNotification( + CONFIG_DEPENDENCY_STATUS_NOTIFICATION, + (notification: ConfigDependencyStatusNotification) => { + notifications.push(notification); + }, + ); goConnection.listen(); workerConnection.listen(); editorConnection.listen(); @@ -162,6 +199,19 @@ describe('lint worker config refresh', () => { }, }); expect(observedReasons).toEqual(['config-change']); + expect(notifications).toEqual([{ failure: notificationFailure }]); + + activeFailure = undefined; + await expect( + editorConnection.sendRequest('rslint/configRefresh', { + reason: 'reject', + }), + ).rejects.toThrow('refresh rejected'); + expect(observedReasons).toEqual(['config-change', 'reject']); + expect(notifications).toEqual([ + { failure: notificationFailure }, + { failure: null }, + ]); const shutdown = await editorConnection.sendRequest<{ readonly method: string; @@ -185,3 +235,143 @@ describe('lint worker config refresh', () => { } }); }); + +describe('lint worker config dependency classification', () => { + it('reports and truncates only the first classified failed candidate', async () => { + const firstMessage = + "Cannot find module 'first-missing'\nRequire stack:\n- /project/first.config.cjs"; + const secondMessage = + "Cannot find package 'second-missing' imported from /project/second.config.mjs"; + const host = { + loadConfigs: async (): Promise => ({ + transactionId: 'transaction', + results: [ + { + id: 'first', + status: 'failed', + error: { code: 'MODULE_NOT_FOUND', message: firstMessage }, + }, + { + id: 'second', + status: 'failed', + error: { code: 'ERR_MODULE_NOT_FOUND', message: secondMessage }, + }, + ], + }), + activateConfigs: async () => { + throw new Error('not used'); + }, + deleteSession: () => true, + }; + const pluginLintPool = { + prepare: async () => true, + commit: async () => true, + abort: async () => undefined, + }; + const failures: Array<{ configPath: string; cause: string }> = []; + const adapter = new LspConfigTransactionAdapter( + host, + pluginLintPool, + (_plan: ConfigModuleActivationPlan) => 'fingerprint', + 3, + { + resolveFrom: (candidate) => candidate.configDirectory, + report: (failure) => failures.push(failure), + }, + ); + const request: LoadConfigsRequest = { + protocolVersion: 3, + transactionId: 'transaction', + loadMode: 'cached', + candidates: [ + { + id: 'first', + configPath: '/project/first.config.cjs', + configDirectory: '/project', + }, + { + id: 'second', + configPath: '/project/second.config.mjs', + configDirectory: '/project', + }, + ], + }; + + const response = await adapter.loadConfigs(request); + + expect(failures).toEqual([ + { + configPath: '/project/first.config.cjs', + cause: "Cannot find module 'first-missing'", + }, + ]); + expect(response.results).toEqual([ + { + id: 'first', + status: 'failed', + error: { + code: 'MODULE_NOT_FOUND', + message: "Cannot find module 'first-missing'", + }, + }, + { + id: 'second', + status: 'failed', + error: { code: 'ERR_MODULE_NOT_FOUND', message: secondMessage }, + }, + ]); + }); + + it('leaves an unclassified failed result untouched', async () => { + const response: LoadConfigsResponse = { + transactionId: 'transaction', + results: [ + { + id: 'config', + status: 'failed', + error: { + code: 'ERR_MODULE_NOT_FOUND', + message: "Cannot find package './relative.js'", + }, + }, + ], + }; + const failures: Array<{ configPath: string; cause: string }> = []; + const adapter = new LspConfigTransactionAdapter( + { + loadConfigs: async () => response, + activateConfigs: async () => { + throw new Error('not used'); + }, + deleteSession: () => true, + }, + { + prepare: async () => true, + commit: async () => true, + abort: async () => undefined, + }, + () => 'fingerprint', + 3, + { + resolveFrom: () => '/project', + report: (failure) => failures.push(failure), + }, + ); + + const result = await adapter.loadConfigs({ + protocolVersion: 3, + transactionId: 'transaction', + loadMode: 'cached', + candidates: [ + { + id: 'config', + configPath: '/project/rslint.config.mjs', + configDirectory: '/project', + }, + ], + }); + + expect(result).toEqual(response); + expect(failures).toEqual([]); + }); +}); From c127b19173e4db1188e7d5c7e0728f885c9aecf2 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 4 Sep 2026 17:01:54 +0800 Subject: [PATCH 02/44] feat(vscode): report lint config dependencies as not installed --- .../rslint.config.mjs | 10 ++ .../missing-config-dependency/src/index.ts | 1 + .../missing-config-dependency/tsconfig.json | 6 ++ packages/vscode/e2e/lint/runTest.ts | 5 + .../suite-missing-config-dependency/index.ts | 3 + .../missing-config-dependency.test.ts | 76 +++++++++++++ packages/vscode/src/stacks/lint/Rslint.ts | 101 +++++++++++++++++- packages/vscode/src/stacks/lint/index.ts | 34 +++++- .../lint/worker/ConfigTransactionAdapter.ts | 6 +- .../lint/worker/configDependencyProtocol.ts | 11 ++ .../vscode/src/stacks/lint/worker/index.ts | 17 +-- 11 files changed, 252 insertions(+), 18 deletions(-) create mode 100644 packages/vscode/e2e/lint/fixtures/missing-config-dependency/rslint.config.mjs create mode 100644 packages/vscode/e2e/lint/fixtures/missing-config-dependency/src/index.ts create mode 100644 packages/vscode/e2e/lint/fixtures/missing-config-dependency/tsconfig.json create mode 100644 packages/vscode/e2e/lint/suite-missing-config-dependency/index.ts create mode 100644 packages/vscode/e2e/lint/suite-missing-config-dependency/missing-config-dependency.test.ts create mode 100644 packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts diff --git a/packages/vscode/e2e/lint/fixtures/missing-config-dependency/rslint.config.mjs b/packages/vscode/e2e/lint/fixtures/missing-config-dependency/rslint.config.mjs new file mode 100644 index 0000000..83b50bc --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/missing-config-dependency/rslint.config.mjs @@ -0,0 +1,10 @@ +import 'missing-rslint-config-dependency'; + +export default [ + { + files: ['src/**/*.ts'], + rules: { + 'no-debugger': 'error', + }, + }, +]; diff --git a/packages/vscode/e2e/lint/fixtures/missing-config-dependency/src/index.ts b/packages/vscode/e2e/lint/fixtures/missing-config-dependency/src/index.ts new file mode 100644 index 0000000..eab7469 --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/missing-config-dependency/src/index.ts @@ -0,0 +1 @@ +debugger; diff --git a/packages/vscode/e2e/lint/fixtures/missing-config-dependency/tsconfig.json b/packages/vscode/e2e/lint/fixtures/missing-config-dependency/tsconfig.json new file mode 100644 index 0000000..f798b5c --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/missing-config-dependency/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/vscode/e2e/lint/runTest.ts b/packages/vscode/e2e/lint/runTest.ts index 7ca02b5..c466725 100644 --- a/packages/vscode/e2e/lint/runTest.ts +++ b/packages/vscode/e2e/lint/runTest.ts @@ -309,6 +309,11 @@ async function main(): Promise { workspace: sharedFixture('rstack'), tests: suiteDir('suite-bridge'), }, + { + name: 'Missing config dependency tests', + workspace: fixture('missing-config-dependency'), + tests: suiteDir('suite-missing-config-dependency'), + }, ]; // Optional development filter: `RSTACK_LINT_E2E_SUITES="No config,Monorepo"` diff --git a/packages/vscode/e2e/lint/suite-missing-config-dependency/index.ts b/packages/vscode/e2e/lint/suite-missing-config-dependency/index.ts new file mode 100644 index 0000000..e8a41f6 --- /dev/null +++ b/packages/vscode/e2e/lint/suite-missing-config-dependency/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../runSuite'; + +export const run = createRun(); diff --git a/packages/vscode/e2e/lint/suite-missing-config-dependency/missing-config-dependency.test.ts b/packages/vscode/e2e/lint/suite-missing-config-dependency/missing-config-dependency.test.ts new file mode 100644 index 0000000..4d732b7 --- /dev/null +++ b/packages/vscode/e2e/lint/suite-missing-config-dependency/missing-config-dependency.test.ts @@ -0,0 +1,76 @@ +import * as assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import * as vscode from 'vscode'; +import type { StackState } from '../../../src/types'; +import { + getRslintDiagnostics, + waitForRslintDiagnostics, +} from '../utils/diagnostics'; +import { extensionExports } from '../utils/extension'; + +function lintExports(): { + getFolderStates(): ReadonlyMap; + getRuntimeStates(): ReadonlyMap; + getConfigDependencyWarnings(): readonly string[]; +} { + const exports = extensionExports().getStackExports('rslint'); + assert.ok(exports, 'lint stack exports are unavailable'); + return exports as ReturnType; +} + +async function waitForRuntimeKind( + kind: StackState['kind'], + timeoutMs = 60_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const states = [...lintExports().getRuntimeStates().values()]; + if (states.some((state) => state.kind === kind)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for the Rslint runtime to become ${kind}`); +} + +suite('Rslint missing config dependency', function () { + this.timeout(120_000); + + test('reports not installed across initial and live config refreshes', async () => { + const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + assert.ok(root, 'VS Code test workspace is unavailable'); + const document = await vscode.workspace.openTextDocument( + path.join(root, 'src', 'index.ts'), + ); + await vscode.window.showTextDocument(document); + + await waitForRuntimeKind('disabled'); + + const folderStates = [...lintExports().getFolderStates().values()]; + const runtimeStates = [...lintExports().getRuntimeStates().values()]; + assert.ok(folderStates.every((state) => state.kind === 'disabled')); + assert.ok(runtimeStates.every((state) => state.kind === 'disabled')); + assert.deepStrictEqual(getRslintDiagnostics(document), []); + + const warnings = lintExports().getConfigDependencyWarnings(); + assert.strictEqual(warnings.length, 1); + assert.match(warnings[0], /missing-rslint-config-dependency/); + assert.ok(!warnings[0].includes('\n'), 'warning must remain one line'); + + const configPath = path.join(root, 'rslint.config.mjs'); + fs.writeFileSync( + configPath, + "export default [{ files: ['src/**/*.ts'], rules: { 'no-debugger': 'error' } }];\n", + ); + await waitForRslintDiagnostics(document); + await waitForRuntimeKind('running'); + + fs.writeFileSync( + configPath, + "import 'missing-rslint-config-dependency';\nexport default [];\n", + ); + await waitForRuntimeKind('disabled'); + assert.strictEqual(lintExports().getConfigDependencyWarnings().length, 2); + }); +}); diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index a41ad9e..0e45719 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -26,6 +26,10 @@ import { type ServerOptions, State, } from 'vscode-languageclient/node'; +import { + formatConfigDependencyMissingLog, + formatConfigDependencyMissingStatus, +} from '../../shared/notInstalled'; import { configuredNodeBelowFloor, NodePreflightError, @@ -37,6 +41,10 @@ import type { CoreInstallation } from './CoreResolver'; import { LanguageServerProcessOwner } from './LanguageServerProcessOwner'; import type { Logger } from './logger'; import type { RslintMode } from './resolution'; +import { + CONFIG_DEPENDENCY_STATUS_NOTIFICATION, + type ConfigDependencyStatusNotification, +} from './worker/configDependencyProtocol'; import { RslintVersionMismatchError, runningRslintStatus, @@ -304,9 +312,17 @@ export interface RslintOptions { readonly router: WorkspaceDocumentRouter; readonly logger: Logger; readonly reportStatus: RslintStatusSink; + /** Root Rstack config represented by the worker's physical bridge shim. */ + readonly bridgeConfigPath?: string; readonly onClosed?: () => void; } +interface ReportedConfigDependencyFailure { + readonly fingerprint: string; + readonly displayPath: string; + readonly cause: string; +} + export class Rslint implements Disposable { private client: LanguageClient | undefined; private readonly logger: Logger; @@ -314,10 +330,12 @@ export class Rslint implements Disposable { public readonly workspaceFolder: WorkspaceFolder; private readonly router: WorkspaceDocumentRouter; private readonly reportStatus: RslintStatusSink; + private readonly bridgeConfigPath: string | undefined; private readonly installation: CoreInstallation; private readonly lspOutputChannel: OutputChannel; private readonly outputChannel: OutputChannel; private readonly onClosed: (() => void) | undefined; + private readonly configDependencyWarnings: string[] = []; private readonly configWatchers: FileSystemWatcher[] = []; private configReloadTimer: ReturnType | undefined; private configReloadChain: Promise = Promise.resolve(); @@ -326,6 +344,7 @@ export class Rslint implements Disposable { private stateWatcher: Disposable | undefined; private lifecycleEpoch = 0; private advisory: string | undefined; + private configDependencyFailure: ReportedConfigDependencyFailure | undefined; private startPromise: Promise | undefined; private startOperation: Promise | undefined; private clientStartPromise: Promise | undefined; @@ -337,6 +356,7 @@ export class Rslint implements Disposable { this.workspaceFolder = options.workspaceFolder; this.router = options.router; this.reportStatus = options.reportStatus; + this.bridgeConfigPath = options.bridgeConfigPath; this.installation = options.installation; this.logger = options.logger; this.lspOutputChannel = options.lspOutputChannel; @@ -352,6 +372,52 @@ export class Rslint implements Disposable { this.report(runningRslintStatus(this.advisory)); } + private displayConfigPath(configPath: string): string { + const physicalPath = + configPath === this.installation.shimPath && this.bridgeConfigPath + ? this.bridgeConfigPath + : configPath; + const relative = path.relative( + this.workspaceFolder.uri.fsPath, + physicalPath, + ); + return relative.length > 0 && !relative.startsWith('..') + ? relative + : path.basename(physicalPath); + } + + private handleConfigDependencyStatus( + notification: ConfigDependencyStatusNotification, + ): void { + const failure = notification.failure; + if (failure === null) { + const wasMissing = this.configDependencyFailure !== undefined; + this.configDependencyFailure = undefined; + if (wasMissing && this.isRunning()) this.reportRunning(); + return; + } + const displayPath = this.displayConfigPath(failure.configPath); + const fingerprint = `${displayPath}\0${failure.cause}`; + if (this.configDependencyFailure?.fingerprint !== fingerprint) { + const warning = formatConfigDependencyMissingLog( + 'rslint', + displayPath, + failure.cause, + ); + this.logger.warn(warning); + this.configDependencyWarnings.push(warning); + } + this.configDependencyFailure = { + fingerprint, + displayPath, + cause: failure.cause, + }; + this.report({ + kind: 'disabled', + reason: formatConfigDependencyMissingStatus('rslint', displayPath), + }); + } + public async start(signal: AbortSignal): Promise { if (this.startPromise) { await this.startPromise; @@ -441,6 +507,12 @@ export class Rslint implements Disposable { serverOptions, clientOptions, ); + client.onNotification( + CONFIG_DEPENDENCY_STATUS_NOTIFICATION, + (notification: ConfigDependencyStatusNotification) => { + this.handleConfigDependencyStatus(notification); + }, + ); errorHandlerHolder.current = client.createDefaultErrorHandler(); this.client = client; this.stateWatcher = client.onDidChangeState((event) => { @@ -454,7 +526,7 @@ export class Rslint implements Disposable { detail: 'the Rslint language server stopped', }); } else if (event.newState === State.Running) { - this.reportRunning(); + if (!this.hasConfigDependencyFailure()) this.reportRunning(); } }); @@ -492,7 +564,12 @@ export class Rslint implements Disposable { ); }, (error: unknown) => { - this.logger.error('Failed to recover after server restart', error); + if (!this.hasConfigDependencyFailure()) { + this.logger.error( + 'Failed to recover after server restart', + error, + ); + } }, ); }); @@ -509,7 +586,7 @@ export class Rslint implements Disposable { ); } this.logger.info('Rslint language client started successfully'); - this.reportRunning(); + if (!this.hasConfigDependencyFailure()) this.reportRunning(); } catch (error: unknown) { // A close or supersede during start is a planned abort, not a failure; // logging it as an error made every teardown race look like a crash. @@ -575,7 +652,9 @@ export class Rslint implements Disposable { this.configReloadTimer = setTimeout(() => { this.configReloadTimer = undefined; void this.requestConfigRefresh(reason).catch((error: unknown) => { - this.logger.error('Failed to refresh config discovery', error); + if (!this.hasConfigDependencyFailure()) { + this.logger.error('Failed to refresh config discovery', error); + } }); }, 300); }; @@ -604,6 +683,20 @@ export class Rslint implements Disposable { await refresh; } + public hasConfigDependencyFailure(): boolean { + return this.configDependencyFailure !== undefined; + } + + public retryConfigDependency(): Promise | undefined { + if (!this.hasConfigDependencyFailure()) return undefined; + return this.requestConfigRefresh('dependency-change'); + } + + /** E2E-only observation surfaced through the controller's activation exports. */ + public getConfigDependencyWarnings(): readonly string[] { + return this.configDependencyWarnings; + } + private isLifecycleCurrent(epoch: number, client: LanguageClient): boolean { return ( epoch === this.lifecycleEpoch && client === this.client && !this.closing diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index 74b4862..fa17f36 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -108,7 +108,7 @@ class RslintController implements StackController { // A detection pass fires on config topology and lockfile changes — // exactly the moments a document's core may have appeared, moved or // changed ownership. This replaces the coordinator's `retryFailedRoots`. - this.reconcileOpenDocuments('detection change'); + this.retryConfigDependenciesThenReconcile(); }), vscode.workspace.onDidChangeWorkspaceFolders(() => { this.pruneDepartedFolders(); @@ -178,6 +178,10 @@ class RslintController implements StackController { ...states.runtimes, ]), ), + getConfigDependencyWarnings: (): readonly string[] => + [...this.#runtimes.values()].flatMap((runtime) => + runtime.getConfigDependencyWarnings(), + ), }; } @@ -277,6 +281,11 @@ class RslintController implements StackController { attributeToCore(state, installation.packageDirectory), ); }, + bridgeConfigPath: + installation.mode === 'bridged' + ? this.#snapshot?.forFolder(workspaceFolder)?.stacks.rslint + .rstackConfigFiles[0]?.fsPath + : undefined, onClosed: () => { if (this.#runtimes.get(resolved.key) === runtime) { this.#runtimes.delete(resolved.key); @@ -334,6 +343,29 @@ class RslintController implements StackController { }); } + private retryConfigDependenciesThenReconcile(): void { + const retries = [...this.#runtimes.values()].flatMap((runtime) => { + const retry = runtime.retryConfigDependency(); + return retry ? [{ runtime, retry }] : []; + }); + void Promise.allSettled(retries.map(({ retry }) => retry)).then( + (results) => { + results.forEach((result, index) => { + if ( + result.status === 'rejected' && + !retries[index]?.runtime.hasConfigDependencyFailure() + ) { + this.#logger?.error( + 'Failed to retry Rslint config dependency discovery', + result.reason, + ); + } + }); + this.reconcileOpenDocuments('detection change'); + }, + ); + } + private setState( folderKey: string, bucket: keyof FolderStates, diff --git a/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts b/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts index 1e8bb82..b020387 100644 --- a/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts +++ b/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts @@ -9,11 +9,7 @@ import type { LoadConfigsResponse, } from '@rslint/core/config-loader'; import { classifyMissingDependencyMessage } from '../../../shared/missingDependency'; - -export interface ConfigDependencyFailure { - readonly configPath: string; - readonly cause: string; -} +import type { ConfigDependencyFailure } from './configDependencyProtocol'; interface ConfigDependencyObserver { resolveFrom(candidate: ConfigModuleCandidate): string; diff --git a/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts b/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts new file mode 100644 index 0000000..4a143d3 --- /dev/null +++ b/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts @@ -0,0 +1,11 @@ +export const CONFIG_DEPENDENCY_STATUS_NOTIFICATION = + 'rstack/rslintConfigDependency'; + +export interface ConfigDependencyFailure { + readonly configPath: string; + readonly cause: string; +} + +export interface ConfigDependencyStatusNotification { + readonly failure: ConfigDependencyFailure | null; +} diff --git a/packages/vscode/src/stacks/lint/worker/index.ts b/packages/vscode/src/stacks/lint/worker/index.ts index 3475bc4..df29c39 100644 --- a/packages/vscode/src/stacks/lint/worker/index.ts +++ b/packages/vscode/src/stacks/lint/worker/index.ts @@ -11,7 +11,6 @@ import { } from 'vscode-jsonrpc/node'; import { LspConfigTransactionAdapter, - type ConfigDependencyFailure, type ConfigTransactionControlRequest, } from './ConfigTransactionAdapter'; import { PluginLintPool } from './PluginLintPool'; @@ -23,17 +22,19 @@ import { import { loadCoreInstallation } from './core'; import { ActivationFingerprinter } from './fingerprint'; import { logger } from './logger'; +import { + CONFIG_DEPENDENCY_STATUS_NOTIFICATION, + type ConfigDependencyFailure, +} from './configDependencyProtocol'; + +export { + CONFIG_DEPENDENCY_STATUS_NOTIFICATION, + type ConfigDependencyStatusNotification, +} from './configDependencyProtocol'; const GRACEFUL_EXIT_TIMEOUT_MS = 500; const FORCED_EXIT_TIMEOUT_MS = 1_500; -export const CONFIG_DEPENDENCY_STATUS_NOTIFICATION = - 'rstack/rslintConfigDependency'; - -export interface ConfigDependencyStatusNotification { - readonly failure: ConfigDependencyFailure | null; -} - interface StopRequest { readonly exitCode: number; readonly reason: string; From 1677311286c056026356a2935d1748c20a1676d7 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 4 Sep 2026 17:08:26 +0800 Subject: [PATCH 03/44] feat(vscode): suppress fmt config dependency toasts --- .../fmt-missing-config-dependency/.nvmrc | 1 + .../package.json | 10 ++ .../rstack.config.ts | 1 + .../src/needs-format.ts | 1 + packages/vscode/e2e/run.mjs | 2 +- packages/vscode/e2e/runTest.ts | 99 +++++++++------- packages/vscode/e2e/setupFixtures.mjs | 4 + .../fmt-missing-config-dependency.test.ts | 42 +++++++ .../index.ts | 33 ++++++ packages/vscode/src/stacks/fmt/index.ts | 108 ++++++++++++++++-- .../vscode/src/stacks/fmt/sessionError.ts | 51 +++++++++ .../tests/stacks/fmt/sessionError.test.ts | 70 ++++++++++++ 12 files changed, 373 insertions(+), 49 deletions(-) create mode 100644 packages/vscode/e2e/fixtures/fmt-missing-config-dependency/.nvmrc create mode 100644 packages/vscode/e2e/fixtures/fmt-missing-config-dependency/package.json create mode 100644 packages/vscode/e2e/fixtures/fmt-missing-config-dependency/rstack.config.ts create mode 100644 packages/vscode/e2e/fixtures/fmt-missing-config-dependency/src/needs-format.ts create mode 100644 packages/vscode/e2e/suite-fmt-missing-config-dependency/fmt-missing-config-dependency.test.ts create mode 100644 packages/vscode/e2e/suite-fmt-missing-config-dependency/index.ts create mode 100644 packages/vscode/src/stacks/fmt/sessionError.ts create mode 100644 packages/vscode/tests/stacks/fmt/sessionError.test.ts diff --git a/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/.nvmrc b/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/.nvmrc new file mode 100644 index 0000000..6f4247a --- /dev/null +++ b/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/.nvmrc @@ -0,0 +1 @@ +26 diff --git a/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/package.json b/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/package.json new file mode 100644 index 0000000..46d49ae --- /dev/null +++ b/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/package.json @@ -0,0 +1,10 @@ +{ + "name": "rstack-editor-fixture-fmt-missing-config-dependency", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "E2E fixture: rs fmt config imports a package that is not installed.", + "dependencies": { + "rstack": "0.7.2" + } +} diff --git a/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/rstack.config.ts b/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/rstack.config.ts new file mode 100644 index 0000000..ffdc3df --- /dev/null +++ b/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/rstack.config.ts @@ -0,0 +1 @@ +import 'missing-fmt-config-dependency'; diff --git a/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/src/needs-format.ts b/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/src/needs-format.ts new file mode 100644 index 0000000..01bb788 --- /dev/null +++ b/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/src/needs-format.ts @@ -0,0 +1 @@ +const answer={value:'42'}; diff --git a/packages/vscode/e2e/run.mjs b/packages/vscode/e2e/run.mjs index 7b4d36c..77a6c31 100644 --- a/packages/vscode/e2e/run.mjs +++ b/packages/vscode/e2e/run.mjs @@ -26,7 +26,7 @@ const SLICES = [ // The shell/detection/fmt suites (`e2e/suite/`) over the multi-root // workspace of the three shared fixtures. name: 'vscode', - fixtures: ['rslint', 'rstest', 'rstack'], + fixtures: ['rslint', 'rstest', 'rstack', 'fmt-missing-config-dependency'], entry: 'tests-dist/e2e/runTest.js', compile: true, }, diff --git a/packages/vscode/e2e/runTest.ts b/packages/vscode/e2e/runTest.ts index 8c473c7..3e277d2 100644 --- a/packages/vscode/e2e/runTest.ts +++ b/packages/vscode/e2e/runTest.ts @@ -11,63 +11,42 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { runTests } from '@vscode/test-electron'; -const FIXTURE_NAMES = ['rslint', 'rstest', 'rstack'] as const; +const FIXTURE_NAMES = [ + 'rslint', + 'rstest', + 'rstack', + 'fmt-missing-config-dependency', +] as const; -async function main() { - // `__dirname` is `/tests-dist/e2e` (see tsconfig.e2e.json). - const extensionDevelopmentPath = path.resolve(__dirname, '../..'); - const extensionTestsPath = path.resolve(__dirname, './suite/index'); - const fixturesDir = path.join(extensionDevelopmentPath, 'e2e/fixtures'); - const workspaceFile = path.join(fixturesDir, 'e2e.code-workspace'); - - // The extension host loads `main` from `package.json`; an unbuilt repo would - // otherwise fail deep inside VS Code with an unhelpful activation error. - if (!existsSync(path.join(extensionDevelopmentPath, 'dist/extension.js'))) { - throw new Error( - 'dist/extension.js is missing — run `pnpm build` before `pnpm test:e2e`.', - ); - } - for (const name of FIXTURE_NAMES) { - if (!existsSync(path.join(fixturesDir, name, 'node_modules'))) { - throw new Error( - `the ${name} E2E fixture is not installed — run \`pnpm test:e2e:fixtures\`.`, - ); - } - } +interface LaunchOptions { + readonly extensionDevelopmentPath: string; + readonly extensionTestsPath: string; + readonly workspace: string; + readonly profileSuffix: string; +} - // A short user-data dir keeps the Unix socket paths below the macOS limit. +async function launch({ + extensionDevelopmentPath, + extensionTestsPath, + workspace, + profileSuffix, +}: LaunchOptions): Promise { const hash = createHash('sha1') - .update(extensionDevelopmentPath) + .update(`${extensionDevelopmentPath}\0${profileSuffix}`) .digest('hex') .slice(0, 8); const userDataDir = mkdtempSync(path.join(tmpdir(), `rstack-${hash}-`)); await runTests({ - // Pinnable for CI; `stable` locally. `runTests` forwards the whole options - // object to the downloader, so `version`/`timeout`/`vscodeExecutablePath` - // all apply to it. version: process.env.VSCODE_TEST_VERSION ?? 'stable', - // The default per-request timeout is 15s, which a 300 MB download on a slow - // or proxied link loses to before it ever starts making progress. timeout: 60_000, - // Escape hatch for offline / restricted environments: point at an existing - // VS Code (`.../Visual Studio Code.app/Contents/MacOS/Electron`, `Code.exe`, - // `code`) and nothing is downloaded at all. vscodeExecutablePath: process.env.VSCODE_TEST_EXECUTABLE || undefined, extensionDevelopmentPath, extensionTestsPath, launchArgs: [ - workspaceFile, - // Keep VS Code's CI-only extension inventory and AgentHost info logs out - // of test output while preserving an opt-in for verbose diagnosis. + workspace, `--log=${process.env.VSCODE_TEST_LOG_LEVEL ?? 'warn'}`, - // Only the extension under development runs: no user extension may - // register a competing formatter, test controller or language client. '--disable-extensions', - // The fixtures spawn project-local binaries, which Restricted Mode - // forbids by design. Trust is granted up front so the - // suite tests the trusted path; the Restricted Mode path needs its own - // launch and is not covered in phase 1. '--disable-workspace-trust', '--disable-updates', '--skip-welcome', @@ -78,6 +57,44 @@ async function main() { }); } +async function main() { + // `__dirname` is `/tests-dist/e2e` (see tsconfig.e2e.json). + const extensionDevelopmentPath = path.resolve(__dirname, '../..'); + const fixturesDir = path.join(extensionDevelopmentPath, 'e2e/fixtures'); + const workspaceFile = path.join(fixturesDir, 'e2e.code-workspace'); + + // The extension host loads `main` from `package.json`; an unbuilt repo would + // otherwise fail deep inside VS Code with an unhelpful activation error. + if (!existsSync(path.join(extensionDevelopmentPath, 'dist/extension.js'))) { + throw new Error( + 'dist/extension.js is missing — run `pnpm build` before `pnpm test:e2e`.', + ); + } + for (const name of FIXTURE_NAMES) { + if (!existsSync(path.join(fixturesDir, name, 'node_modules'))) { + throw new Error( + `the ${name} E2E fixture is not installed — run \`pnpm test:e2e:fixtures\`.`, + ); + } + } + + await launch({ + extensionDevelopmentPath, + extensionTestsPath: path.resolve(__dirname, './suite/index'), + workspace: workspaceFile, + profileSuffix: 'main', + }); + await launch({ + extensionDevelopmentPath, + extensionTestsPath: path.resolve( + __dirname, + './suite-fmt-missing-config-dependency/index', + ), + workspace: path.join(fixturesDir, 'fmt-missing-config-dependency'), + profileSuffix: 'fmt-missing-config-dependency', + }); +} + main().catch((error) => { console.error('Failed to run E2E tests'); console.error(error); diff --git a/packages/vscode/e2e/setupFixtures.mjs b/packages/vscode/e2e/setupFixtures.mjs index 8a95cef..a488824 100644 --- a/packages/vscode/e2e/setupFixtures.mjs +++ b/packages/vscode/e2e/setupFixtures.mjs @@ -32,6 +32,10 @@ export const FIXTURES = { rslint: path.join(FIXTURES_DIR, 'rslint'), rstest: path.join(FIXTURES_DIR, 'rstest'), rstack: path.join(FIXTURES_DIR, 'rstack'), + 'fmt-missing-config-dependency': path.join( + FIXTURES_DIR, + 'fmt-missing-config-dependency', + ), 'rstest-workspace-1': path.join(here, 'rstest', 'fixtures', 'workspace-1'), 'rstest-workspace-2': path.join(here, 'rstest', 'fixtures', 'workspace-2'), lint: path.join(here, 'lint', 'fixtures'), diff --git a/packages/vscode/e2e/suite-fmt-missing-config-dependency/fmt-missing-config-dependency.test.ts b/packages/vscode/e2e/suite-fmt-missing-config-dependency/fmt-missing-config-dependency.test.ts new file mode 100644 index 0000000..0adf5d8 --- /dev/null +++ b/packages/vscode/e2e/suite-fmt-missing-config-dependency/fmt-missing-config-dependency.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import type { RstackExtensionExports } from '../../src/types'; +import { eventually } from '../suite/helpers'; + +suite('fmt missing config dependency', () => { + test('suppresses the server toast and reports disabled', async () => { + const extension = + vscode.extensions.getExtension('rstack.rstack'); + assert.ok(extension, 'rstack.rstack is not installed in the test host'); + const api = await extension.activate(); + const exports = await api.whenStackActive('fmt'); + const folderStates = exports.folderStates as () => Record; + const suppressedConfigDependencyMessages = + exports.suppressedConfigDependencyMessages as () => number; + const configDependencyWarnings = + exports.configDependencyWarnings as () => readonly string[]; + const folder = vscode.workspace.workspaceFolders?.[0]; + assert.ok(folder, 'fmt fixture workspace is unavailable'); + + await eventually(() => { + assert.equal(folderStates()[folder.uri.fsPath], 'running'); + }, 'the rs fmt server to start'); + + const uri = vscode.Uri.joinPath(folder.uri, 'src', 'needs-format.ts'); + await vscode.workspace.openTextDocument(uri); + await vscode.commands.executeCommand( + 'vscode.executeFormatDocumentProvider', + uri, + { tabSize: 2, insertSpaces: true }, + ); + + await eventually(() => { + assert.equal(folderStates()[folder.uri.fsPath], 'disabled'); + }, 'the fmt config dependency failure to become disabled'); + assert.equal(suppressedConfigDependencyMessages(), 1); + const warnings = configDependencyWarnings(); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /missing-fmt-config-dependency/); + assert.ok(!warnings[0].includes('\n'), 'warning must remain one line'); + }); +}); diff --git a/packages/vscode/e2e/suite-fmt-missing-config-dependency/index.ts b/packages/vscode/e2e/suite-fmt-missing-config-dependency/index.ts new file mode 100644 index 0000000..021bf76 --- /dev/null +++ b/packages/vscode/e2e/suite-fmt-missing-config-dependency/index.ts @@ -0,0 +1,33 @@ +import { readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; +import Mocha from 'mocha'; + +const collectTests = (dir: string): string[] => + readdirSync(dir).flatMap((entry) => { + const full = path.join(dir, entry); + return statSync(full).isDirectory() + ? collectTests(full) + : full.endsWith('.test.js') + ? [full] + : []; + }); + +export function run(): Promise { + const mocha = new Mocha({ ui: 'tdd', color: true, timeout: 120_000 }); + collectTests(__dirname).forEach((file) => mocha.addFile(file)); + return new Promise((resolve, reject) => { + const failed: string[] = []; + const runner = mocha.run((failures) => { + if (failures === 0) resolve(); + else + reject( + new Error(`${failures} E2E test(s) failed:\n${failed.join('\n')}`), + ); + }); + runner.on('fail', (test, error) => { + failed.push( + `- ${test.fullTitle()}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + }); +} diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index f05e46c..421f805 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -4,6 +4,7 @@ import { CloseAction, ErrorAction, LanguageClient, + ShowMessageNotification, State, type ErrorHandler, type LanguageClientOptions, @@ -12,6 +13,8 @@ import { import { RSTACK_CONFIG_GLOB } from '../../detection'; import { getConfiguredNodeExecutable } from '../../shared/nodeExecutableSetting'; import { + formatConfigDependencyMissingLog, + formatConfigDependencyMissingStatus, formatNotInstalledLog, formatNotInstalledStatus, } from '../../shared/notInstalled'; @@ -41,6 +44,10 @@ import type { // no lint behaviour is shared, and the file has no lint imports. import { LanguageServerProcessOwner } from '../lint/LanguageServerProcessOwner'; import { pickBinEntry } from './binEntry'; +import { + classifyFmtSessionError, + showMessagePresentation, +} from './sessionError'; import { foldFolderStatus, type FmtFolderStatus, @@ -154,6 +161,10 @@ class FmtFolderRuntime { #client: LanguageClient | undefined; #defaultErrorHandler: ErrorHandler | undefined; #stateWatcher: vscode.Disposable | undefined; + #configPath: string | undefined; + #configDependencyFingerprint: string | undefined; + readonly #configDependencyWarnings: string[] = []; + #suppressedShowMessages = 0; #closing = false; #disposed = false; /** True only across `startImpl`'s `client.start()` await — the window `interruptInFlightStart` exists for. */ @@ -173,7 +184,10 @@ class FmtFolderRuntime { * stack's one report (`foldFolderStatus`). */ private readonly onDidChangeStatus: () => void, - ) {} + configPath: string | undefined, + ) { + this.#configPath = configPath; + } get state(): FmtRuntimeState { return this.#state; @@ -193,6 +207,18 @@ class FmtFolderRuntime { return this.folder.uri.fsPath; } + setConfigPath(configPath: string | undefined): void { + this.#configPath = configPath; + } + + get configDependencyWarnings(): readonly string[] { + return this.#configDependencyWarnings; + } + + get suppressedShowMessages(): number { + return this.#suppressedShowMessages; + } + private setState(state: FmtRuntimeState, detail = ''): void { this.#state = state; this.#detail = detail; @@ -204,6 +230,48 @@ class FmtFolderRuntime { this.onDidChangeStatus(); } + private handleShowMessage(message: { + readonly type: number; + readonly message: string; + }): void { + const configPath = this.#configPath; + const failure = + configPath === undefined + ? undefined + : classifyFmtSessionError(message, this.folderPath, configPath); + if (failure !== undefined) { + const fingerprint = `${failure.configPath}\0${failure.cause}`; + if (this.#configDependencyFingerprint !== fingerprint) { + const warning = formatConfigDependencyMissingLog( + 'fmt', + failure.configPath, + failure.cause, + ); + this.context.output.warn(warning); + this.#configDependencyWarnings.push(warning); + } + this.#configDependencyFingerprint = fingerprint; + this.#suppressedShowMessages++; + this.setState( + 'disabled', + formatConfigDependencyMissingStatus('fmt', failure.configPath), + ); + return; + } + + switch (showMessagePresentation(message.type)) { + case 'error': + void vscode.window.showErrorMessage(message.message); + break; + case 'warning': + void vscode.window.showWarningMessage(message.message); + break; + case 'information': + void vscode.window.showInformationMessage(message.message); + break; + } + } + /** * `waitFor` is the previous runtime's retirement (see the controller's * `#retiring`): awaited *inside* the queue, so a config-event `restart()` @@ -349,6 +417,13 @@ class FmtFolderRuntime { serverOptions, this.createClientOptions(), ); + // vscode-languageclient installs pending handlers after initialize with + // method-keyed replacement semantics. Registering before start therefore + // replaces its default toast handler while leaving unrelated messages on + // the same Error/Warning/Info UI path below. + client.onNotification(ShowMessageNotification.type, (message) => { + this.handleShowMessage(message); + }); // Created once per client, not per callback: the default handler carries // the restart budget (N crashes in K minutes), and it can only be created // from the client the options were built for. @@ -632,6 +707,17 @@ class FmtController implements StackController { runtime.state, ]), ), + /** E2E only: classified config failures suppressed from showMessage. */ + suppressedConfigDependencyMessages: (): number => + [...this.#runtimes.values()].reduce( + (count, runtime) => count + runtime.suppressedShowMessages, + 0, + ), + /** E2E only: one-line warnings emitted for classified config failures. */ + configDependencyWarnings: (): readonly string[] => + [...this.#runtimes.values()].flatMap( + (runtime) => runtime.configDependencyWarnings, + ), }); } @@ -649,9 +735,13 @@ class FmtController implements StackController { return; } const detected = new Map( - snapshot - .foldersFor('fmt') - .map((entry) => [entry.folder.uri.fsPath, entry.folder] as const), + snapshot.foldersFor('fmt').map((entry) => { + const folderPath = entry.folder.uri.fsPath; + const configPath = entry.stacks.fmt.rstackConfigFiles.find( + (uri) => path.dirname(uri.fsPath) === folderPath, + )?.fsPath; + return [folderPath, { folder: entry.folder, configPath }] as const; + }), ); for (const [folderPath, runtime] of [...this.#runtimes]) { if (!detected.has(folderPath)) { @@ -672,9 +762,10 @@ class FmtController implements StackController { this.#retiring.set(folderPath, retirement); } } - for (const [folderPath, folder] of detected) { + for (const [folderPath, { folder, configPath }] of detected) { const existing = this.#runtimes.get(folderPath); if (existing) { + existing.setConfigPath(configPath); if (isFailedFmtState(existing.state)) { // A failed runtime is retried in place, on the same path a config // change uses: restart re-runs package resolution, the version @@ -686,8 +777,11 @@ class FmtController implements StackController { // The callback re-reads `#snapshot`, so a server that finishes starting // after a detection change reports from the freshest snapshot — and the // closure captures nothing beyond `this`. - const runtime = new FmtFolderRuntime(folder, context, () => - this.reportStatus(), + const runtime = new FmtFolderRuntime( + folder, + context, + () => this.reportStatus(), + configPath, ); this.#runtimes.set(folderPath, runtime); void runtime.start(this.#retiring.get(folderPath)); diff --git a/packages/vscode/src/stacks/fmt/sessionError.ts b/packages/vscode/src/stacks/fmt/sessionError.ts new file mode 100644 index 0000000..076792e --- /dev/null +++ b/packages/vscode/src/stacks/fmt/sessionError.ts @@ -0,0 +1,51 @@ +import path from 'node:path'; +import { classifyMissingDependencyMessage } from '../../shared/missingDependency'; + +export const FMT_SESSION_ERROR_PREFIX = 'rs fmt cannot format this workspace: '; + +export interface FmtConfigDependencyFailure { + readonly configPath: string; + readonly cause: string; +} + +interface ShowMessageParams { + readonly type: number; + readonly message: string; +} + +export function classifyFmtSessionError( + message: ShowMessageParams, + workspaceRoot: string, + configPath: string, +): FmtConfigDependencyFailure | undefined { + if ( + message.type !== 1 || + !message.message.startsWith(FMT_SESSION_ERROR_PREFIX) + ) { + return undefined; + } + const cause = message.message + .slice(FMT_SESSION_ERROR_PREFIX.length) + .replace(/^Error(?: \[[A-Z_]+\])?: /, ''); + const firstLine = cause.split('\n', 1)[0]; + const classified = classifyMissingDependencyMessage(firstLine, workspaceRoot); + if (classified === undefined) return undefined; + const relative = path.relative(workspaceRoot, configPath); + return { + configPath: relative.length > 0 ? relative : path.basename(configPath), + cause: classified, + }; +} + +export const showMessagePresentation = ( + type: number, +): 'error' | 'warning' | 'information' => { + switch (type) { + case 1: + return 'error'; + case 2: + return 'warning'; + default: + return 'information'; + } +}; diff --git a/packages/vscode/tests/stacks/fmt/sessionError.test.ts b/packages/vscode/tests/stacks/fmt/sessionError.test.ts new file mode 100644 index 0000000..0794d84 --- /dev/null +++ b/packages/vscode/tests/stacks/fmt/sessionError.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from '@rstest/core'; +import { + classifyFmtSessionError, + FMT_SESSION_ERROR_PREFIX, + showMessagePresentation, +} from '../../../src/stacks/fmt/sessionError'; + +describe('classifyFmtSessionError', () => { + const root = '/project'; + const configPath = '/project/rstack.config.ts'; + + it('classifies the first line of the rs fmt config-loading error', () => { + for (const errorPrefix of ['Error: ', 'Error [ERR_MODULE_NOT_FOUND]: ']) { + expect( + classifyFmtSessionError( + { + type: 1, + message: `${FMT_SESSION_ERROR_PREFIX}${errorPrefix}Cannot find package 'missing' imported from /project/rstack.config.ts\nmore detail`, + }, + root, + configPath, + ), + ).toEqual({ + configPath: 'rstack.config.ts', + cause: + "Cannot find package 'missing' imported from /project/rstack.config.ts", + }); + } + }); + + it('leaves unrelated messages and loader failures to the default UI', () => { + expect( + classifyFmtSessionError( + { type: 2, message: 'warning' }, + root, + configPath, + ), + ).toBe(undefined); + expect( + classifyFmtSessionError( + { + type: 1, + message: `${FMT_SESSION_ERROR_PREFIX}SyntaxError: Unexpected token`, + }, + root, + configPath, + ), + ).toBe(undefined); + expect( + classifyFmtSessionError( + { + type: 1, + message: "Cannot find package 'missing'", + }, + root, + configPath, + ), + ).toBe(undefined); + }); +}); + +describe('showMessagePresentation', () => { + it('matches vscode-languageclient default show-message routing', () => { + expect(showMessagePresentation(1)).toBe('error'); + expect(showMessagePresentation(2)).toBe('warning'); + expect(showMessagePresentation(3)).toBe('information'); + expect(showMessagePresentation(4)).toBe('information'); + expect(showMessagePresentation(5)).toBe('information'); + }); +}); From e21a62ca4d4d5dd4fef8011dd078dd492759f5f2 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 4 Sep 2026 17:21:11 +0800 Subject: [PATCH 04/44] feat(vscode): poll for installed dependencies --- .gitignore | 1 + packages/vscode/AGENTS.md | 2 +- .../lint/fixtures/dependency-recovery/.nvmrc | 1 + .../fixtures/dependency-recovery/package.json | 10 ++ .../dependency-recovery/rslint.config.mjs | 8 ++ .../fixtures/dependency-recovery/src/index.ts | 1 + packages/vscode/e2e/lint/runTest.ts | 36 ++++--- .../dependency-recovery.test.ts | 83 ++++++++++++++++ .../lint/suite-dependency-recovery/index.ts | 3 + packages/vscode/e2e/run.mjs | 2 +- packages/vscode/e2e/setupFixtures.mjs | 6 ++ packages/vscode/src/detection.ts | 10 ++ packages/vscode/src/extension.ts | 97 ++++++++++++++++++- packages/vscode/src/stacks/fmt/index.ts | 17 +++- packages/vscode/src/stacks/lint/index.ts | 35 +++++-- packages/vscode/src/stacks/test/index.ts | 4 + packages/vscode/src/stacks/test/master.ts | 22 +++-- packages/vscode/src/stacks/test/project.ts | 61 +++++++++--- packages/vscode/src/stacks/test/status.ts | 4 + packages/vscode/src/types.ts | 6 ++ packages/vscode/tests/detection.test.ts | 9 ++ packages/vscode/tests/extension.test.ts | 57 ++++++++++- .../vscode/tests/stacks/test/project.test.ts | 38 ++++++++ .../vscode/tests/stacks/test/status.test.ts | 9 ++ 24 files changed, 469 insertions(+), 53 deletions(-) create mode 100644 packages/vscode/e2e/lint/fixtures/dependency-recovery/.nvmrc create mode 100644 packages/vscode/e2e/lint/fixtures/dependency-recovery/package.json create mode 100644 packages/vscode/e2e/lint/fixtures/dependency-recovery/rslint.config.mjs create mode 100644 packages/vscode/e2e/lint/fixtures/dependency-recovery/src/index.ts create mode 100644 packages/vscode/e2e/lint/suite-dependency-recovery/dependency-recovery.test.ts create mode 100644 packages/vscode/e2e/lint/suite-dependency-recovery/index.ts diff --git a/.gitignore b/.gitignore index 0c3f7bf..b2f67e3 100644 --- a/.gitignore +++ b/.gitignore @@ -155,6 +155,7 @@ packages/vscode/.playground/ packages/vscode/e2e/fixtures/*/node_modules/ packages/vscode/e2e/fixtures/*/pnpm-lock.yaml packages/vscode/e2e/lint/fixtures/pnpm-lock.yaml +packages/vscode/e2e/lint/fixtures/*/pnpm-lock.yaml packages/vscode/e2e/rstest/fixtures/*/pnpm-lock.yaml # Build-time copy of the workspace root LICENSE (see rslib.config.mts) diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 0df73ad..7854c6f 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -7,7 +7,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - `stacks/lint` and `stacks/test` are deliberate near-verbatim copies of the upstream extensions, kept close to upstream so changes can be synced by diffing. Do NOT deduplicate or refactor across the two stacks — the duplication is the point; consolidation is a later, explicit phase. - The copies diverge from upstream in exactly nine ways (the "adaptations" below). When syncing upstream, preserve them. A tenth divergence is either a bug or must be added to this list. - **Tracked upstream state.** `stacks/lint` is synced to web-infra-dev/rslint `packages/vscode-extension` at **39536fd6** (#1617 — per-document core resolution, `CoreResolver` + `RuntimeManager`, `corePath`, PnP removed) and **892482e0** (#1630 — `configPath` on `rslint/configRefresh`). Targeted later ports are **84f9c9b5** (#1967 — languageclient-owned live LSP tracing) and **b7176723** (#1951 — remove legacy JSON config watching); the Unicode BOM E2E comes from **5fc197a5** (#1560), with its native-config fixture shape from **b7176723**. `CoreResolver.ts` / `RuntimeManager.ts` / `WorkspaceDocumentRouter.ts` / `Rslint.ts` are the files to diff when syncing further; record the new commits here when you do. -- **Ahead of upstream — offer these back when syncing** (bug fixes, not adaptations): (1) `RuntimeManager.reconcile` resolves the document's core **before** sweeping pending uses (`planDocumentCore`), so a reconcile landing on the key a pending start is already producing adopts that start instead of tearing it down mid-`initialize` — the teardown made vscode-languageclient force-notify ("couldn't create connection to server") whenever the register-time pass, a detection change and `didOpen` landed inside one worker startup window (`tests/stacks/lint/runtimeManager.test.ts`). (2) `Rslint.close()` gives a still-Starting language client a bounded chance to settle before tearing down its transport, so a legitimate mid-start close (document closed during start, core key changed) stops cleanly instead of triggering the same force-notified toasts. (3) The registry-harness E2E gives its never-settling startup operation 500ms to begin and accepts only the in-flight timeout message, so a stalled runner cannot satisfy the assertion through the already-expired path (`e2e/lint/suite/registry-harness.test.ts`). +- **Ahead of upstream — offer these back when syncing** (bug fixes, not adaptations): (1) `RuntimeManager.reconcile` resolves the document's core **before** sweeping pending uses (`planDocumentCore`), so a reconcile landing on the key a pending start is already producing adopts that start instead of tearing it down mid-`initialize` — the teardown made vscode-languageclient force-notify ("couldn't create connection to server") whenever the register-time pass, a detection change and `didOpen` landed inside one worker startup window (`tests/stacks/lint/runtimeManager.test.ts`). (2) `Rslint.close()` gives a still-Starting language client a bounded chance to settle before tearing down its transport, so a legitimate mid-start close (document closed during start, core key changed) stops cleanly instead of triggering the same force-notified toasts. (3) The registry-harness E2E gives its never-settling startup operation 500ms to begin and accepts only the in-flight timeout message, so a stalled runner cannot satisfy the assertion through the already-expired path (`e2e/lint/suite/registry-harness.test.ts`). (4) `Project.retryFailedConfig()` keeps a failed Rstest project and retries its config evaluation in place with one single-flight promise, so repeated dependency-change passes neither overlap workers nor repeat an unchanged not-installed warning. ## The nine adaptations diff --git a/packages/vscode/e2e/lint/fixtures/dependency-recovery/.nvmrc b/packages/vscode/e2e/lint/fixtures/dependency-recovery/.nvmrc new file mode 100644 index 0000000..6f4247a --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/dependency-recovery/.nvmrc @@ -0,0 +1 @@ +26 diff --git a/packages/vscode/e2e/lint/fixtures/dependency-recovery/package.json b/packages/vscode/e2e/lint/fixtures/dependency-recovery/package.json new file mode 100644 index 0000000..b3516ec --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/dependency-recovery/package.json @@ -0,0 +1,10 @@ +{ + "name": "rstack-editor-fixture-lint-dependency-recovery", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "E2E fixture: Rslint dependencies are installed while VS Code stays open.", + "dependencies": { + "@rslint/core": "0.9.0" + } +} diff --git a/packages/vscode/e2e/lint/fixtures/dependency-recovery/rslint.config.mjs b/packages/vscode/e2e/lint/fixtures/dependency-recovery/rslint.config.mjs new file mode 100644 index 0000000..2f07926 --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/dependency-recovery/rslint.config.mjs @@ -0,0 +1,8 @@ +export default [ + { + files: ['src/**/*.ts'], + rules: { + 'no-debugger': 'error', + }, + }, +]; diff --git a/packages/vscode/e2e/lint/fixtures/dependency-recovery/src/index.ts b/packages/vscode/e2e/lint/fixtures/dependency-recovery/src/index.ts new file mode 100644 index 0000000..eab7469 --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/dependency-recovery/src/index.ts @@ -0,0 +1 @@ +debugger; diff --git a/packages/vscode/e2e/lint/runTest.ts b/packages/vscode/e2e/lint/runTest.ts index c466725..443bcd6 100644 --- a/packages/vscode/e2e/lint/runTest.ts +++ b/packages/vscode/e2e/lint/runTest.ts @@ -39,6 +39,7 @@ interface TestSuite { tests: string; workspaceEntry?: string; workspaceFolders?: string[]; + inheritDependencies?: boolean; } const workspaceMarkerFile = '.rstack-vscode-test-sandbox.json'; @@ -118,19 +119,22 @@ async function runIsolatedSuite( { encoding: 'utf8', flag: 'wx', mode: 0o600 }, ); - // Preserve the fixture install root's package boundary and dependency - // lookup (the project-resolved `@rslint/core`) without - // placing a writable node_modules link inside the test workspace. - const packageRoot = await findPackageRoot(suite.workspace); - await fs.promises.copyFile( - path.join(packageRoot, 'package.json'), - path.join(profileRoot, 'package.json'), - ); - await fs.promises.symlink( - path.join(packageRoot, 'node_modules'), - path.join(profileRoot, 'node_modules'), - process.platform === 'win32' ? 'junction' : 'dir', - ); + if (suite.inheritDependencies !== false) { + // Preserve the fixture install root's package boundary and dependency + // lookup (the project-resolved `@rslint/core`) without placing a + // writable node_modules link inside the test workspace. The dependency + // recovery suite opts out: absence at startup is what it tests. + const packageRoot = await findPackageRoot(suite.workspace); + await fs.promises.copyFile( + path.join(packageRoot, 'package.json'), + path.join(profileRoot, 'package.json'), + ); + await fs.promises.symlink( + path.join(packageRoot, 'node_modules'), + path.join(profileRoot, 'node_modules'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + } await runTests({ extensionDevelopmentPath, @@ -314,6 +318,12 @@ async function main(): Promise { workspace: fixture('missing-config-dependency'), tests: suiteDir('suite-missing-config-dependency'), }, + { + name: 'Dependency polling recovery tests', + workspace: fixture('dependency-recovery'), + tests: suiteDir('suite-dependency-recovery'), + inheritDependencies: false, + }, ]; // Optional development filter: `RSTACK_LINT_E2E_SUITES="No config,Monorepo"` diff --git a/packages/vscode/e2e/lint/suite-dependency-recovery/dependency-recovery.test.ts b/packages/vscode/e2e/lint/suite-dependency-recovery/dependency-recovery.test.ts new file mode 100644 index 0000000..22c37f6 --- /dev/null +++ b/packages/vscode/e2e/lint/suite-dependency-recovery/dependency-recovery.test.ts @@ -0,0 +1,83 @@ +import * as assert from 'node:assert'; +import { execFile as execFileCallback } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import * as vscode from 'vscode'; +import type { StackState } from '../../../src/types'; +import { waitForRslintDiagnostics } from '../utils/diagnostics'; +import { extensionExports } from '../utils/extension'; + +const execFile = promisify(execFileCallback); + +function lintExports(): { + getFolderStates(): ReadonlyMap; +} { + const exports = extensionExports().getStackExports('rslint'); + assert.ok(exports, 'lint stack exports are unavailable'); + return exports as ReturnType; +} + +async function waitForFolderKind( + kind: StackState['kind'], + timeoutMs = 90_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if ( + [...lintExports().getFolderStates().values()].some( + (state) => state.kind === kind, + ) + ) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for the Rslint folder to become ${kind}`); +} + +suite('Rslint dependency polling recovery', function () { + this.timeout(180_000); + + test('recovers after pnpm install without a restart command', async () => { + const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + assert.ok(root, 'VS Code test workspace is unavailable'); + const api = extensionExports(); + api.setDependencyPollIntervalForTest(250); + + const document = await vscode.workspace.openTextDocument( + path.join(root, 'src', 'index.ts'), + ); + await vscode.window.showTextDocument(document); + await waitForFolderKind('disabled'); + + const lockfile = path.join(root, 'pnpm-lock.yaml'); + const beforeContents = fs.readFileSync(lockfile); + const beforeMtime = fs.statSync(lockfile).mtimeMs; + const pollCountBeforeInstall = api.getDependencyPollCountForTest(); + + await execFile( + 'pnpm', + ['install', '--frozen-lockfile', '--ignore-scripts'], + { cwd: root, timeout: 90_000 }, + ); + + assert.deepStrictEqual( + fs.readFileSync(lockfile), + beforeContents, + 'pnpm install changed the lockfile contents', + ); + assert.strictEqual( + fs.statSync(lockfile).mtimeMs, + beforeMtime, + 'pnpm install changed the lockfile mtime', + ); + + await waitForRslintDiagnostics(document, undefined, 90_000); + await waitForFolderKind('running'); + assert.ok( + api.getDependencyPollCountForTest() > pollCountBeforeInstall, + 'the folder recovered without a dependency polling pass', + ); + }); +}); diff --git a/packages/vscode/e2e/lint/suite-dependency-recovery/index.ts b/packages/vscode/e2e/lint/suite-dependency-recovery/index.ts new file mode 100644 index 0000000..e8a41f6 --- /dev/null +++ b/packages/vscode/e2e/lint/suite-dependency-recovery/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../runSuite'; + +export const run = createRun(); diff --git a/packages/vscode/e2e/run.mjs b/packages/vscode/e2e/run.mjs index 77a6c31..ed20e57 100644 --- a/packages/vscode/e2e/run.mjs +++ b/packages/vscode/e2e/run.mjs @@ -43,7 +43,7 @@ const SLICES = [ // shared `rstack` fixture. `RSTACK_LINT_E2E_SUITES=` filters // which suites run. name: 'lint', - fixtures: ['lint', 'rstack'], + fixtures: ['lint', 'lint-dependency-recovery', 'rstack'], entry: 'tests-dist/e2e/lint/runTest.js', compile: true, }, diff --git a/packages/vscode/e2e/setupFixtures.mjs b/packages/vscode/e2e/setupFixtures.mjs index a488824..fb4cd6d 100644 --- a/packages/vscode/e2e/setupFixtures.mjs +++ b/packages/vscode/e2e/setupFixtures.mjs @@ -39,6 +39,12 @@ export const FIXTURES = { 'rstest-workspace-1': path.join(here, 'rstest', 'fixtures', 'workspace-1'), 'rstest-workspace-2': path.join(here, 'rstest', 'fixtures', 'workspace-2'), lint: path.join(here, 'lint', 'fixtures'), + 'lint-dependency-recovery': path.join( + here, + 'lint', + 'fixtures', + 'dependency-recovery', + ), }; export const FIXTURE_NAMES = Object.keys(FIXTURES); diff --git a/packages/vscode/src/detection.ts b/packages/vscode/src/detection.ts index dd906cc..e9fecc3 100644 --- a/packages/vscode/src/detection.ts +++ b/packages/vscode/src/detection.ts @@ -283,6 +283,16 @@ export class DetectionService implements vscode.Disposable { return this.refresh(); } + /** + * Re-runs package probes and notifies live stacks even when detection's file + * signature stays unchanged. This is the same signal a lockfile event sends: + * dependencies may now resolve from a newly populated `node_modules`. + */ + refreshForDependencyChange(): Promise { + this.#notifyUnchanged = true; + return this.refresh(); + } + async refresh(): Promise { if (this.#running) { // Coalesce concurrent refreshes: one extra pass covers every caller that diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index e250577..4f73458 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -10,6 +10,7 @@ import { type StackControllerFactory, type StackId, type StackState, + type StatusReporter, STACK_IDS, STACK_LABELS, stackCommand, @@ -27,6 +28,8 @@ const STACK_FACTORIES: Readonly> = { /** Stacks that run project-loading children on the shared User Node runtime. */ const USER_NODE_STACKS: readonly StackId[] = ['rslint', 'rstest', 'fmt']; +const DEFAULT_DEPENDENCY_POLL_INTERVAL_MS = 10_000; + const errorMessage = (error: unknown): string => error instanceof Error ? (error.stack ?? error.message) : String(error); @@ -57,6 +60,10 @@ class ExtensionShell { >(); #reconciling: Promise = Promise.resolve(); + #dependencyPollIntervalMs = DEFAULT_DEPENDENCY_POLL_INTERVAL_MS; + #dependencyPollTimer: ReturnType | undefined; + #dependencyPollInFlight = false; + #dependencyPollCount = 0; #disposed = false; constructor(private readonly context: vscode.ExtensionContext) { @@ -219,6 +226,76 @@ class ExtensionShell { void this.reconcile(); } + /** + * Starts one recursive timer only while a live controller owns a + * not-installed state. The timer enters the same shell queue as every + * reconcile/restart, then sends the same forced detection event as a + * lockfile change; each stack therefore reuses its existing retry path. + */ + private syncDependencyPoll(): void { + const needed = + !this.#disposed && + [...this.#controllers.values()].some((controller) => + controller.hasNotInstalledState(), + ); + if (!needed) { + if (this.#dependencyPollTimer !== undefined) { + clearTimeout(this.#dependencyPollTimer); + this.#dependencyPollTimer = undefined; + } + return; + } + if ( + this.#dependencyPollTimer !== undefined || + this.#dependencyPollInFlight + ) { + return; + } + this.#dependencyPollTimer = setTimeout(() => { + this.#dependencyPollTimer = undefined; + this.#dependencyPollInFlight = true; + void this.enqueue(async () => { + if ( + this.#disposed || + ![...this.#controllers.values()].some((controller) => + controller.hasNotInstalledState(), + ) + ) { + return; + } + try { + await this.#detection.refreshForDependencyChange(); + this.#dependencyPollCount++; + } catch (error) { + if (!this.#disposed) { + this.#channels.shell.error( + `Dependency recovery detection failed: ${errorMessage(error)}`, + ); + } + } + }).finally(() => { + this.#dependencyPollInFlight = false; + this.syncDependencyPoll(); + }); + }, this.#dependencyPollIntervalMs); + } + + private stackStatusReporter(stack: StackId): StatusReporter { + const reporter = this.#statusBar.reporterFor(stack); + const report = (state: StackState): void => { + reporter.report(state); + this.syncDependencyPoll(); + }; + return { + stack, + report, + starting: (detail) => report({ kind: 'starting', detail }), + running: (detail) => report({ kind: 'running', detail }), + crashed: (detail) => report({ kind: 'crashed', detail }), + versionMismatch: (detail) => report({ kind: 'version-mismatch', detail }), + }; + } + /** * `rstack.restart` (every stack) and `rstack..restart` (one) — a full * reset, not a "retry whatever looks broken". @@ -324,6 +401,7 @@ class ExtensionShell { next?: StackState, ): Promise { this.#controllers.delete(stack); + this.syncDependencyPoll(); await this.disposeController(stack, controller); if (!next || this.#disposed) { return; @@ -387,7 +465,7 @@ class ExtensionShell { stack, extensionContext: this.context, output: this.#channels.forStack(stack), - status: this.#statusBar.reporterFor(stack), + status: this.stackStatusReporter(stack), detection: snapshot, onDidChangeDetection: this.#detectionEmitter.event, }); @@ -403,6 +481,7 @@ class ExtensionShell { } await this.setContextKey(`rstack.${stack}.active`, true); this.#statusBar.setActive(stack, true); + this.syncDependencyPoll(); this.#channels.shell.info(`${STACK_LABELS[stack]} registered`); } catch (error) { await this.retire(stack, controller, { @@ -468,6 +547,18 @@ class ExtensionShell { this.#stackExportWaiters.set(stack, waiters); }); }, + setDependencyPollIntervalForTest: (intervalMs) => { + if (!Number.isFinite(intervalMs) || intervalMs < 1) { + throw new Error('dependency poll interval must be a positive number'); + } + this.#dependencyPollIntervalMs = intervalMs; + if (this.#dependencyPollTimer !== undefined) { + clearTimeout(this.#dependencyPollTimer); + this.#dependencyPollTimer = undefined; + } + this.syncDependencyPoll(); + }, + getDependencyPollCountForTest: () => this.#dependencyPollCount, }; } @@ -483,6 +574,10 @@ class ExtensionShell { async dispose(): Promise { this.#disposed = true; + if (this.#dependencyPollTimer !== undefined) { + clearTimeout(this.#dependencyPollTimer); + this.#dependencyPollTimer = undefined; + } // Before the wait below, not after it: the service holds a debounce timer // and its own watchers, so leaving it live means a file touched during // shutdown can start a fresh detection pass behind us. diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index 421f805..23157c7 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -162,6 +162,7 @@ class FmtFolderRuntime { #defaultErrorHandler: ErrorHandler | undefined; #stateWatcher: vscode.Disposable | undefined; #configPath: string | undefined; + #missingPackage: string | undefined; #configDependencyFingerprint: string | undefined; readonly #configDependencyWarnings: string[] = []; #suppressedShowMessages = 0; @@ -372,11 +373,15 @@ class FmtFolderRuntime { // already current) fires no file event, so nothing rebuilds this // runtime — the status message is where the way out has to live. this.setState('disabled', formatNotInstalledStatus('fmt', 'rstack')); - context.output.warn( - formatNotInstalledLog('rstack', this.folder.name, folderRoot), - ); + if (this.#missingPackage !== 'rstack') { + context.output.warn( + formatNotInstalledLog('rstack', this.folder.name, folderRoot), + ); + } + this.#missingPackage = 'rstack'; return; } + this.#missingPackage = undefined; // One read for the version and the bin entry; `readPackageJson` re-reads // from disk by design, so a reinstall is picked up on the next start. @@ -816,6 +821,12 @@ class FmtController implements StackController { ); } + hasNotInstalledState(): boolean { + return [...this.#runtimes.values()].some( + (runtime) => runtime.state === 'disabled', + ); + } + /** * Covers `rstack.fmt.restart` (the shell rebuilds the controller), a folder * losing detection and a workspace losing its trust: none of them may leave a diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index fa17f36..0380475 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -213,15 +213,25 @@ class RslintController implements StackController { // upstream's error. A document with a last-good runtime still // lints, so its consequence says what it keeps, not "will not". const missing = missingPackageOf(error); + const status = statusForRslintStartFailure(error); if (missing !== undefined) { - logger.warn( - formatNotInstalledLog( - missing, - workspaceFolder.name, - workspaceFolder.uri.fsPath, - `${document.uri} ${keeping ? `keeps ${keeping}` : 'will not lint'} until it is installed`, - ), - ); + const previous = this.#folderStates + .get(folderKeyOf(workspaceFolder)) + ?.failures.get(document.uri.toString()); + if ( + previous?.kind !== 'disabled' || + previous.reason !== + (status.kind === 'disabled' ? status.reason : undefined) + ) { + logger.warn( + formatNotInstalledLog( + missing, + workspaceFolder.name, + workspaceFolder.uri.fsPath, + `${document.uri} ${keeping ? `keeps ${keeping}` : 'will not lint'} until it is installed`, + ), + ); + } } else { logger.error( formatCoreSelectionFailure(document.uri.toString(), keeping), @@ -232,7 +242,6 @@ class RslintController implements StackController { // The failure is still the folder's worst news, so it is folded in // beside the runtimes rather than shown as a toast. A start failure // outlives its (already closed) runtime here, so it names the core. - const status = statusForRslintStartFailure(error); this.setState( folderKeyOf(workspaceFolder), 'failures', @@ -414,6 +423,14 @@ class RslintController implements StackController { ); } + hasNotInstalledState(): boolean { + return [...this.#folderStates.values()].some((states) => + [...states.runtimes.values(), ...states.failures.values()].some( + (state) => state.kind === 'disabled', + ), + ); + } + private async closeRuntimeManager(): Promise { const manager = this.#runtimeManager; this.#runtimeManager = undefined; diff --git a/packages/vscode/src/stacks/test/index.ts b/packages/vscode/src/stacks/test/index.ts index 831fdab..a2d6ce1 100644 --- a/packages/vscode/src/stacks/test/index.ts +++ b/packages/vscode/src/stacks/test/index.ts @@ -555,6 +555,10 @@ class RstestController implements StackController { return this.#rstest.buildExports(); } + hasNotInstalledState(): boolean { + return status.hasNotInstalled(); + } + dispose(): void { this.#rstest?.dispose(); this.#rstest = undefined; diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index 7ec58e4..399a770 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -133,6 +133,7 @@ export class RstestApi { // `createChildProcess`. private disposed = false; private lastResolvedRstestPath?: string; + private reportedCoreMissingFrom?: string; constructor( private workspace: vscode.WorkspaceFolder, @@ -336,14 +337,17 @@ export class RstestApi { // out plus one warn line — the normal state of a repository whose // dependencies are not installed yet, never a notification. private reportCoreNotInstalled(searchedFrom: string): void { - logger.warn( - formatNotInstalledLog( - '@rstest/core', - this.workspace.name, - searchedFrom, - CORE_NOT_INSTALLED_CONSEQUENCE, - ), - ); + if (this.reportedCoreMissingFrom !== searchedFrom) { + logger.warn( + formatNotInstalledLog( + '@rstest/core', + this.workspace.name, + searchedFrom, + CORE_NOT_INSTALLED_CONSEQUENCE, + ), + ); + } + this.reportedCoreMissingFrom = searchedFrom; status.notInstalled(CORE_NOT_INSTALLED_STATUS, this.statusSource); } @@ -397,6 +401,8 @@ export class RstestApi { if (!nodeExport) return ''; } + this.reportedCoreMissingFrom = undefined; + const coreVersion = readPackageVersion(corePackageJsonPath); // Upstream also compared the core version against the extension's own diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts index 9e3e981..bfefc97 100644 --- a/packages/vscode/src/stacks/test/project.ts +++ b/packages/vscode/src/stacks/test/project.ts @@ -235,17 +235,15 @@ export class WorkspaceManager implements vscode.Disposable { ); } /** - * Recreates projects whose one-shot config evaluation failed — dependencies - * may have been installed since (observed as a lockfile-driven detection - * pass). Only ever called from a detection event, never from a project - * callback, so a persistently failing config cannot recreate itself in a - * loop; it is simply re-attempted once per detection pass. + * Retries projects whose config evaluation failed — dependencies may have + * been installed since. The project keeps its identity and the retry is + * single-flight, so repeated dependency signals cannot overlap workers or + * repeat an unchanged not-installed warning. */ public retryFailedProjects() { - for (const [key, project] of [...this.projects]) { + for (const project of this.projects.values()) { if (!project.configLoadFailed) continue; - project.dispose(); - this.projects.set(key, this.createProject(project.source)); + void project.retryFailedConfig(); } } @@ -550,10 +548,9 @@ export class Project implements vscode.Disposable { // the same tests are not shown twice. suppressed = false; // The one-shot config evaluation in the constructor rejected (typically: - // dependencies not installed yet). `retryFailedProjects` recreates such - // projects on the next detection pass. + // dependencies not installed yet). A dependency-change pass retries it. configLoadFailed = false; - /** What this project was built from; `retryFailedProjects` rebuilds from it. */ + /** What this project was built from. */ readonly source: ProjectSource; // See `ProjectSource`. readonly sourceUri: vscode.Uri; @@ -562,6 +559,8 @@ export class Project implements vscode.Disposable { readonly rstestResolutionDir: string; readonly isBridge: boolean; #watch?: vscode.Disposable; + #configLoad: Promise | undefined; + #configDependencyCause: string | undefined; constructor( private workspaceFolder: vscode.WorkspaceFolder, source: ProjectSource, @@ -587,7 +586,12 @@ export class Project implements vscode.Disposable { ); this.cancellationSource = new vscode.CancellationTokenSource(); - void this.api + void this.loadConfig(); + } + + private loadConfig(): Promise { + if (this.#configLoad !== undefined) return this.#configLoad; + const pending = this.api .getNormalizedConfig() .then((result) => { if (this.cancellationSource.token.isCancellationRequested) return; @@ -595,6 +599,8 @@ export class Project implements vscode.Disposable { this.reportMissingDependency(result.message); return; } + this.configLoadFailed = false; + this.#configDependencyCause = undefined; status.installed(this.configDependencyStatusSource); this.root = vscode.Uri.file(result.root); this.include = result.include; @@ -606,10 +612,28 @@ export class Project implements vscode.Disposable { .catch((error) => { if (this.cancellationSource.token.isCancellationRequested) return; this.configLoadFailed = true; + this.#configDependencyCause = undefined; + status.installed(this.configDependencyStatusSource); logUnlessReported('Failed to initialize project config', error); // Let the manager settle its tree even when a config fails to load. this.onConfigResolved?.(); }); + this.#configLoad = pending; + void pending.then( + () => { + if (this.#configLoad === pending) this.#configLoad = undefined; + }, + () => { + if (this.#configLoad === pending) this.#configLoad = undefined; + }, + ); + return pending; + } + + /** Re-evaluates a failed config without replacing this project. */ + public retryFailedConfig(): Promise | undefined { + if (!this.configLoadFailed) return undefined; + return this.loadConfig(); } /** @@ -629,9 +653,16 @@ export class Project implements vscode.Disposable { // Latched under this project's key, which `dispose` forgets. private reportMissingDependency(cause: string): void { this.configLoadFailed = true; - logger.warn( - formatConfigDependencyMissingLog('rstest', this.sourceUri.fsPath, cause), - ); + if (cause !== this.#configDependencyCause) { + logger.warn( + formatConfigDependencyMissingLog( + 'rstest', + this.sourceUri.fsPath, + cause, + ), + ); + } + this.#configDependencyCause = cause; status.notInstalled( formatConfigDependencyMissingStatus( 'rstest', diff --git a/packages/vscode/src/stacks/test/status.ts b/packages/vscode/src/stacks/test/status.ts index 731c514..72e54d1 100644 --- a/packages/vscode/src/stacks/test/status.ts +++ b/packages/vscode/src/stacks/test/status.ts @@ -70,6 +70,10 @@ class StatusHolder implements StatusReporter { this.#reporter = undefined; } + public hasNotInstalled(): boolean { + return this.#notInstalled.size > 0; + } + get #latched(): boolean { return ( this.#crashes.size > 0 || diff --git a/packages/vscode/src/types.ts b/packages/vscode/src/types.ts index 639e3cc..d33f28d 100644 --- a/packages/vscode/src/types.ts +++ b/packages/vscode/src/types.ts @@ -157,6 +157,8 @@ export interface StackController { */ readonly restartOnSettings?: readonly string[]; register(context: StackContext): Promise | void>; + /** True while at least one owned folder/project needs dependencies installed. */ + hasNotInstalledState(): boolean; /** Teardown may be asynchronous (stopping a language server, workers). */ dispose(): void | Promise; } @@ -173,6 +175,10 @@ export interface RstackExtensionExports { * already did). Rejects nothing: a stack that never activates never settles. */ whenStackActive(stack: StackId): Promise>; + /** E2E only: shorten the shell's dependency-recovery polling interval. */ + setDependencyPollIntervalForTest(intervalMs: number): void; + /** E2E only: completed dependency-recovery detection passes. */ + getDependencyPollCountForTest(): number; } export type StackControllerFactory = () => StackController; diff --git a/packages/vscode/tests/detection.test.ts b/packages/vscode/tests/detection.test.ts index 27558be..c7e852b 100644 --- a/packages/vscode/tests/detection.test.ts +++ b/packages/vscode/tests/detection.test.ts @@ -224,6 +224,15 @@ describe('DetectionService — notification rules', () => { service.dispose(); }); + it('force-notifies an unchanged signature for dependency recovery', async () => { + const service = new DetectionService(fakeOutput()); + const seen = listen(service); + await service.initialize(); + await service.refreshForDependencyChange(); + expect(seen).toHaveLength(1); + service.dispose(); + }); + it('does not notify after disposal', async () => { const service = new DetectionService(fakeOutput()); const seen = listen(service); diff --git a/packages/vscode/tests/extension.test.ts b/packages/vscode/tests/extension.test.ts index d5b0c26..6d67814 100644 --- a/packages/vscode/tests/extension.test.ts +++ b/packages/vscode/tests/extension.test.ts @@ -13,11 +13,15 @@ import { STACK_IDS, stackCommand, stackCommandTitle, + type StatusReporter, } from '../src/types'; interface FakeController { readonly restartOnSettings?: readonly string[]; - register(): Promise>; + register(context: { + status: StatusReporter; + }): Promise>; + hasNotInstalledState(): boolean; dispose(): Promise; } @@ -56,6 +60,8 @@ const harness = rs.hoisted(() => { events: [] as string[], /** One entry per detection pass the shell asked for. */ refreshes: 0, + /** Forced unchanged passes issued by the dependency-recovery timer. */ + dependencyRefreshes: 0, /** Everything the shell wrote to its own output channel. */ shellLog: [] as string[], commands: new Map unknown>(), @@ -70,6 +76,10 @@ const harness = rs.hoisted(() => { settings: new Map(), /** How often `runRestart` reset the host-scoped User Node memo. */ nodeResets: 0, + /** Stacks whose raw controller state currently says not installed. */ + notInstalled: new Set(), + /** Shell-wrapped reporters handed to the fake controllers. */ + reporters: new Map(), /** Every configuration listener the shell installed. */ configListeners: [] as ((event: { affectsConfiguration(section: string): boolean; @@ -83,8 +93,9 @@ const harness = rs.hoisted(() => { controller(stack: string): FakeController { return { restartOnSettings: state.restartOnSettings.get(stack), - register: async () => { + register: async ({ status }) => { state.events.push(`register:${stack}`); + state.reporters.set(stack, status); const block = state.blockRegister.get(stack); if (block) { state.registering.add(stack); @@ -96,8 +107,10 @@ const harness = rs.hoisted(() => { } return { stack }; }, + hasNotInstalledState: () => state.notInstalled.has(stack), dispose: async () => { state.events.push(`dispose:${stack}`); + state.reporters.delete(stack); if (state.registering.has(stack)) { state.overlaps.push(stack); } @@ -242,6 +255,10 @@ rs.mock('../src/detection', () => { harness.refreshes += 1; return this.snapshot; } + async refreshForDependencyChange() { + harness.dependencyRefreshes += 1; + return this.snapshot; + } dispose() {} } return { DetectionService }; @@ -305,6 +322,15 @@ const changeSetting = (...sections: string[]): void => { const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); +const waitFor = async (predicate: () => boolean): Promise => { + const deadline = Date.now() + 1_000; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error('timed out waiting for the shell condition'); +}; + /** * Restart is a shell concern, so *which settings trigger one* is too: a stack * declares `restartOnSettings` as data and the shell owns the listener and the @@ -435,6 +461,33 @@ describe('restart-triggering settings', () => { }); }); +describe('dependency recovery polling', () => { + beforeEach(() => { + harness.reset(); + harness.detected = new Set(['rslint']); + }); + + afterEach(async () => { + await deactivate(); + }); + + it('polls through the forced detection path only while not installed', async () => { + const exports = await activate(context); + exports.setDependencyPollIntervalForTest(5); + harness.notInstalled.add('rslint'); + harness.reporters.get('rslint')?.report({ kind: 'disabled' }); + + await waitFor(() => harness.dependencyRefreshes > 0); + expect(harness.refreshes).toBe(0); + + harness.notInstalled.delete('rslint'); + harness.reporters.get('rslint')?.running(); + const completed = harness.dependencyRefreshes; + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(harness.dependencyRefreshes).toBe(completed); + }); +}); + describe('the extension manifest', () => { it('scopes language-client tracing to the window', () => { const manifest = require('../package.json') as { diff --git a/packages/vscode/tests/stacks/test/project.test.ts b/packages/vscode/tests/stacks/test/project.test.ts index 01c39f4..f32324c 100644 --- a/packages/vscode/tests/stacks/test/project.test.ts +++ b/packages/vscode/tests/stacks/test/project.test.ts @@ -20,6 +20,7 @@ const apiCalls: { }[] = []; let normalizedConfigFailure: unknown; let normalizedConfigResult: NormalizedConfigResult | undefined; +let normalizedConfigCalls = 0; rs.mock('../../../src/stacks/test/master', () => { class RstestApi { @@ -35,6 +36,7 @@ rs.mock('../../../src/stacks/test/master', () => { // Never settles: the constructor's config-resolution continuation would // otherwise start watchers this test has no filesystem for. getNormalizedConfig() { + normalizedConfigCalls += 1; if (normalizedConfigFailure) { return Promise.reject(normalizedConfigFailure); } @@ -133,6 +135,7 @@ const collection = { beforeEach(() => { normalizedConfigFailure = undefined; normalizedConfigResult = undefined; + normalizedConfigCalls = 0; loggedErrors.length = 0; loggedWarnings.length = 0; logger.bind(channel as never); @@ -263,4 +266,39 @@ describe('Project config/cwd/package-resolution decoupling', () => { expect(reported.at(-1)).toEqual({ kind: 'running', detail: undefined }); status.unbind(); }); + + it('retries a missing config dependency in place with one flight and one warning', async () => { + const rstackConfig = uri('/repo/templates/app/rstack.config.ts'); + normalizedConfigResult = { + ok: false, + message: "Cannot find package '@rsbuild/plugin-react'", + }; + const { reporter, reported } = createStatusRecorder(); + status.bind(reporter); + const { project } = await createProject({ sourceUri: rstackConfig }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const firstRetry = project.retryFailedConfig(); + const sameRetry = project.retryFailedConfig(); + expect(firstRetry).toBe(sameRetry); + await firstRetry; + expect(normalizedConfigCalls).toBe(2); + expect(loggedWarnings).toHaveLength(1); + expect(project.configLoadFailed).toBe(true); + + normalizedConfigResult = { + ok: true, + root: '/repo/templates/app', + include: ['**/*.test.ts'], + exclude: [], + childProjects: [], + }; + await project.retryFailedConfig(); + expect(normalizedConfigCalls).toBe(3); + expect(project.configLoadFailed).toBe(false); + expect(reported.at(-1)).toEqual({ kind: 'running', detail: undefined }); + + project.dispose(); + status.unbind(); + }); }); diff --git a/packages/vscode/tests/stacks/test/status.test.ts b/packages/vscode/tests/stacks/test/status.test.ts index 85aa55f..275dc19 100644 --- a/packages/vscode/tests/stacks/test/status.test.ts +++ b/packages/vscode/tests/stacks/test/status.test.ts @@ -155,6 +155,15 @@ describe('StatusHolder failure latches', () => { expect(calls).toEqual(['report:disabled']); }); + it('exposes a missing install even when a crash outranks it', () => { + bindRecorder(); + status.notInstalled('core missing', '/a'); + status.crashed('worker stopped', '/b'); + expect(status.hasNotInstalled()).toBe(true); + status.installed('/a'); + expect(status.hasNotInstalled()).toBe(false); + }); + it('ranks a missing install below a mismatch and a crash', () => { const calls = bindRecorder(); status.notInstalled('core missing', '/a'); From 824ce6b4f8bb96f50677fb8ec9d31ecb99bd77eb Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 4 Sep 2026 17:29:45 +0800 Subject: [PATCH 05/44] docs(vscode): record not-installed recovery decision --- docs/adr/0002-fmt-lsp-on-user-node-runtime.md | 2 +- docs/adr/0003-lint-through-editor-worker.md | 2 +- .../0005-not-installed-recovery-by-polling.md | 32 +++++++++++++++++++ packages/vscode/AGENTS.md | 4 +-- packages/vscode/src/detection.ts | 11 ++++--- packages/vscode/src/shared/notInstalled.ts | 7 ++-- packages/vscode/src/stacks/fmt/index.ts | 6 ++-- packages/vscode/src/stacks/lint/index.ts | 22 ------------- 8 files changed, 47 insertions(+), 39 deletions(-) create mode 100644 docs/adr/0005-not-installed-recovery-by-polling.md diff --git a/docs/adr/0002-fmt-lsp-on-user-node-runtime.md b/docs/adr/0002-fmt-lsp-on-user-node-runtime.md index 04d1704..cb42a91 100644 --- a/docs/adr/0002-fmt-lsp-on-user-node-runtime.md +++ b/docs/adr/0002-fmt-lsp-on-user-node-runtime.md @@ -37,6 +37,6 @@ Falling back to the VS Code Node runtime stays rejected — ADR 0001's load-bear - There is no cold path any more, so formatting is briefly unavailable after activation, after a restart and after a config change, while that folder's server starts. A format requested before the client has registered the server's capability finds no formatter for the document; nothing falls back to a fresh process. - The server advertises document formatting only: no range or selection formatting, no format-on-type, no diagnostics. It also ignores the editor's `FormattingOptions` (tab size, spaces) and the client's language id — the file path picks the parser and the project's config decides the style. An editor setting that disagrees with the project config loses, which is the same answer `rs fmt` gives in a terminal. - Failures stay per folder and stay statuses: no `rstack` installed is `disabled`, an `rstack` below `0.5.2` is `version mismatch`, no Node clearing the floor is `version mismatch` with fmt's own consequence appended to the shared preflight message ("until then rs fmt will not format"), and only a server that fails to launch or stops on its own is `crashed`. One folder in any of those states does not affect another folder's server, and the stack's single status report is the folder set folded by severity (`crashed` > `version mismatch`, which is also where a running folder's pin advisory ranks > `disabled` > `starting` > `running`; the pure fold lives in `stacks/fmt/status.ts`), so a healthy sibling starting or recovering never overwrites another folder's failure — and the status says `starting`, not `running`, until a server actually formats. -- An `rstack.config.*` create, change or delete restarts the server of the folder that contains it, and only that one; a detection change reconciles the folder set, leaves healthy servers alone — a healthy server's cached config is worth keeping — and restarts a folder whose runtime already failed (`disabled`, `version mismatch`, `crashed`) in place, on the same path a config change uses, which re-runs package resolution and the version check. Detection notifies on lockfile events even when the folder set is unchanged precisely so that the install or upgrade that fixes a failed resolution is picked up without a manual restart. The remaining blind spot is an install that changes no lockfile (a fresh clone whose lockfile is already current): no file event fires, so the `disabled` status names the restart command as the way out. Watching `node_modules` for that case was rejected (unreliable under pnpm's layout and excluded by VS Code's default watcher excludes), and a bundled fallback formatter — the usual way editor extensions mask this blind spot — is ruled out by resolve-from-project. +- An `rstack.config.*` create, change or delete restarts the server of the folder that contains it, and only that one; a detection change reconciles the folder set, leaves healthy servers alone — a healthy server's cached config is worth keeping — and restarts a folder whose runtime already failed (`disabled`, `version mismatch`, `crashed`) in place, on the same path a config change uses, which re-runs package resolution and the version check. Dependency installation recovery, including the unchanged-lockfile blind spot and the rejected watcher alternatives, is governed by ADR 0005. - "A subproject becomes its own workspace folder" means a _sibling_ folder (or opening only the subproject). Keeping the parent **and** the nested subdirectory as workspace folders with fmt detected in both is a documented limitation: the parent's selector also matches the nested files, and which of the two servers VS Code asks is not defined. Per-document routing to the deepest folder was considered (lint carries a `WorkspaceDocumentRouter` for exactly this) and deferred — complexity the scenario does not yet justify. - The extension now holds one long-lived Node process per detected folder for fmt. Each is owned by the same process owner the lint client uses, so a stop is bounded (SIGTERM, then SIGKILL) and the automatic restart vscode-languageclient performs cannot leave an orphan behind. diff --git a/docs/adr/0003-lint-through-editor-worker.md b/docs/adr/0003-lint-through-editor-worker.md index 98f88b8..27a6033 100644 --- a/docs/adr/0003-lint-through-editor-worker.md +++ b/docs/adr/0003-lint-through-editor-worker.md @@ -27,5 +27,5 @@ Rslint's language server is two halves: the Go process (`rslint --lsp`) lints na - **One override, and it names a core, not a binary.** `rstack.rslint.binPath` / `customBinPath` are removed in favour of `rstack.rslint.corePath` — the setting upstream introduced in rslint #1617: a path to an `@rslint/core` package directory, resource-scoped, from which the binary, config host, protocol version and plugin host all derive. In a bridged folder it overrides the rstack → `@rslint/core` hop only; the shim stays rstack's. A binary chosen independently of its core cannot be supported: the two must speak the same protocol. The rest of #1617 — per-document core resolution, one runtime per physical installation — has since been synced (issue #13): a **Lint runtime** is now one Rslint core inside one workspace folder, resolved per open document and refcounted by it, so a folder runs as many workers as its files have distinct cores (a bridged folder always exactly one, rstack's) and none at all while nothing is open. The worker never noticed: it still takes explicit `--core` / `--config` paths, which is precisely why that change did not touch it. - **Ownership is per folder, native wins.** One server holds one config choice for its lifetime (the supported config protocols lock `configPath` per process), and explicit and automatic modes cannot mix, so a folder is bridged only when no `rslint.config.*` exists anywhere in it and a `rstack.config.*` sits at its root; a subdirectory `rstack.config.*` lights nothing (`rs lint` in a terminal reads its cwd only — the same reason ADR 0002 rejected deepest-config-wins for fmt). Detection lights a bridged folder on the file's presence and never reads it: a `rstack.config.*` without `define.lint()` runs an empty config, as `rs lint` does. - **Config changes refresh, mode changes restart.** Rslint has a live refresh (`rslint/configRefresh` with the same `configPath`), unlike `rs fmt --lsp`, so the extension keeps its watcher-driven refresh — extended, for a bridged folder, with the root `rstack.config.*` — and the worker re-stamps `protocolVersion` and its `configPath` on every refresh (the extension does not know either). Only a native ↔ bridged flip, or a dependency change the refresh cannot absorb, restarts the server. This is the "diverge only when the tool forces it" rule: rslint can refresh, fmt cannot. -- **Failure states mirror fmt.** Bridged folder: no `rstack` → `disabled`; `rstack` or the chained `@rslint/core` below floor, or no Node clearing the floor → `version mismatch`; worker or Go dying → `crashed`. Native folder missing `@rslint/core` stays `crashed` — the user asked for Rslint by name. +- **Failure states mirror fmt.** No `rstack`, no `@rslint/core`, or a config importing an absent package → `disabled`; `rstack` or the chained `@rslint/core` below floor, or no Node clearing the floor → `version mismatch`; worker or Go dying → `crashed`. Config-import failures are classified where the worker still has the loader's structured error and carried to the editor as data; the live Go server remains available for a later refresh. - The lint copy diverges further from upstream: the reverse-request adapter and plugin pool move into the worker unchanged in logic, and the extension-side `Rslint.ts` keeps only the language-client half. Recorded as an adaptation in `packages/vscode/AGENTS.md`. diff --git a/docs/adr/0005-not-installed-recovery-by-polling.md b/docs/adr/0005-not-installed-recovery-by-polling.md new file mode 100644 index 0000000..ba22c20 --- /dev/null +++ b/docs/adr/0005-not-installed-recovery-by-polling.md @@ -0,0 +1,32 @@ +--- +status: accepted +--- + +# Recover not-installed stacks by polling only while recovery is needed + +Installing an already-locked project can populate `node_modules` without changing any config or lockfile. Detection's config and lockfile watchers then have no event to send, even though every stack deliberately resolves its toolchain from the project and needs another resolution pass. The status names the restart command as a fallback, but a fresh clone should recover without requiring it. + +The lint stack previously added a direct watcher for `**/node_modules/@rslint/core/package.json`. A controlled VS Code Extension Host experiment opened a project with no `node_modules`, waited for `disabled`, installed with a frozen lockfile, and observed for 90 seconds without invoking a restart command. Lockfile bytes and nanosecond mtime were verified unchanged in every run: + +| Install layout | Watcher event | Automatic recovery | +| -------------- | ------------- | ------------------ | +| npm flat | 2/2 | 2/2 | +| pnpm isolated | 0/2 | 0/2 | +| pnpm hoisted | 0/2 | 0/2 | + +The result is not explained by pnpm symlinks: the hoisted core was an ordinary directory and still produced no matching event. Versions were VS Code 1.136.1, pnpm 11.20.0, and npm 11.17.0. + +The watcher's original rationale was also factually wrong. At Microsoft VS Code commit [`008427a`](https://github.com/microsoft/vscode/commit/008427a901bf4aa79b47f175ccc8da1731750f78), the default `files.watcherExclude` contains only `.git/objects`, `.git/subtree-cache`, and `.hg/store`, each at the root and one directory below; it does not exclude `node_modules` ([`files.contribution.ts:294-310`](https://github.com/microsoft/vscode/blob/008427a901bf4aa79b47f175ccc8da1731750f78/src/vs/workbench/contrib/files/browser/files.contribution.ts#L294-L310)). The failure is the absent pnpm per-file event observed above, not a VS Code default exclude. + +**Decision.** The extension shell owns one recursive 10-second timer. It exists only while any live controller's raw folder/project state says dependencies are not installed, enters the shell's existing serialized queue, and forces the same detection notification as a lockfile event even when the detection signature is unchanged. The three stacks reuse their existing dependency-change paths: lint reconciles open documents and refreshes config dependencies, fmt restarts failed folder runtimes in place, and Rstest re-resolves shims and retries failed config evaluation. The timer stops as soon as no not-installed state remains. Lockfile watchers stay as the lower-latency path. + +The aggregate status is deliberately not the predicate: a crash or version mismatch can outrank an unrelated missing folder. Polling must continue until the raw not-installed state itself clears. Warnings are deduplicated per unresolved episode so a persistent missing install does not add a line every ten seconds. The restart hint remains in the status as an explicit fallback. + +fmt has one tool-forced limitation. Restarting `rs fmt --lsp` re-runs package resolution, but the server loads project config lazily on the next formatting request. A poll can therefore move the folder to `running` before config loading has been proved; the next format either succeeds or reports the same config failure and returns the folder to `disabled`, which restarts polling. + +## Rejected alternatives + +- **Direct `node_modules` watchers** — rejected by the experiment: they recovered npm but missed both pnpm layouts. +- **Package-manager marker files** such as `node_modules/.modules.yaml`, `node_modules/.package-lock.json`, or `.yarn-integrity` — rejected because each covers one installer/layout and makes recovery depend on private install artifacts rather than the state being recovered. +- **Retry on window focus** — rejected because an install can finish while focus never leaves VS Code, and unrelated focus changes would cause unbounded retries. +- **Bundled tool fallbacks** — rejected by the resolve-from-project contract: editor and CLI must run the same installed versions. diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 7854c6f..bee1133 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -25,7 +25,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - **Pre-1.0.0 the extension breaks freely.** No compatibility is owed with earlier unpublished states of this extension — settings, command ids and behavior may change without deprecation paths, and dead compat code for them is removed, not kept. No settings migration exists either — not for earlier states of this extension, and not for the two retired standalone extensions (removed in #15; users re-enter their settings under `rstack.*`). Testing and fixtures track only the latest published releases, pinned exactly and bumped by Renovate; a green E2E run speaks only for those releases. `SUPPORT_MATRIX` floors are the minimum versions the extension accepts: each entry is the lowest release evidence shows works with the current code, and its comment records that evidence. Move a floor only when a change makes older releases stop working, never because a devDependency or fixture moved. Raising a floor needs no transition story; the status names the required version. - **The three tools are treated uniformly by default.** Detection, dependency-change retry, restart semantics, version gating and status reporting follow one shared pattern across the lint/test/fmt stacks; a stack diverges only when its tool forces it, and the divergence is recorded here as a gotcha. When adding behavior to one stack, first ask whether it belongs to all three. This is about behavior, not code — the upstream copies still must not be deduplicated. -- **Not installed is a state, not an error — uniformly.** A folder or project whose dependencies are not installed (no `rstack`, no `@rstest/core`, no `@rslint/core`, a config importing a package that is not there) is the normal state of a fresh clone and of scaffolded templates beside their generator (`create-rstack`'s `template-*`, which declare their own dependencies and are never installed). Every stack reports it the same way: a `disabled` status whose reason names the restart command as the way out (ADR 0002: an install that changes no lockfile fires no detection pass), one `warn` line in the output channel without a stack trace, never a `crashed` status and never a notification. The words come from one place, `shared/notInstalled.ts` (the `formatVersionMismatch` precedent) — each stack keeps its own status machinery, none its own wording; the restart hint is derived from `stackCommandTitle`, which `tests/extension.test.ts` checks against the manifest. Lint's report lives in the `onDocumentFailure` hook (`stacks/lint/index.ts`), which owns the log line too, so the upstream-tracked `RuntimeManager` only defers to it. Rstest classifies the config-import case in the worker (`missingDependencyCauseOf`: Node's `code`, a bare — package-name — specifier, and for a subpath a walk-up proving the package really is absent, so a typo'd relative import or a missing subpath of an installed package stays a real error) because the IPC channel drops the `code` — `NormalizedConfigResult` carries the verdict as data end to end, and `Project` branches on it. The config-import case is implemented for Rstest only today — lint and fmt load configs inside their own servers and cannot classify there yet (#30). +- **Not installed is a state, not an error — uniformly.** A folder or project whose dependencies are not installed (no `rstack`, no `@rstest/core`, no `@rslint/core`, a config importing a package that is not there) is the normal state of a fresh clone and of scaffolded templates beside their generator (`create-rstack`'s `template-*`, which declare their own dependencies and are never installed). Every stack reports it the same way: a `disabled` status whose reason keeps the restart command as an explicit fallback, one `warn` line per unresolved episode in the output channel without a stack trace, never a `crashed` status and never a notification. The shell owns one 10-second recursive poll while any controller's raw folder/project state is not installed; it enters the existing serialized queue, forces the same detection notification as a lockfile event, and stops when no such state remains (ADR 0005). The words come from one place, `shared/notInstalled.ts` (the `formatVersionMismatch` precedent) — each stack keeps its own status machinery, none its own wording; the restart hint is derived from `stackCommandTitle`, which `tests/extension.test.ts` checks against the manifest. Rstest classifies config-import failures in its worker (`missingDependencyCauseOf`: Node's `code`, a bare package specifier, and for a subpath a walk-up proving the package really is absent) because IPC drops the `code`; Rslint makes the same code-gated decision where its worker still has structured loader results and sends a dedicated verdict to the editor; fmt intercepts only the exact `rs fmt cannot format this workspace:` Error notification and applies the shared message classifier. A typo'd relative import or a missing subpath of an installed package stays a real error in all three. - One stack failing to register or crashing must never take another stack (or the shell) down. - The shell always activates; per-folder config detection decides which stacks start, and re-runs on config/lockfile changes without a window reload. The per-stack enable settings are coarse kill switches only. - Reconciles and restarts share one serialized queue (`enqueue`); a reconcile leaves a live stack alone, so the restart path — the commands, and the full pass any relevant settings change triggers — is the only thing that rebuilds one. Do not add a second queue. @@ -41,7 +41,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - The lint × `rstack.config.*` bridge stays thin on purpose: only a root Rstack config can claim a bridged folder, any native config anywhere in the folder wins ownership, and the worker evaluates rstack's published shim from the folder root. Never generate a shim, load the Rstack config in the extension host, or interpret `define.lint()` ourselves. - **Yarn Plug'n'Play is unsupported by decision, extension-wide.** Every stack resolves through physical `node_modules` (`shared/packageResolve.ts`, `resolution.ts`'s rstack → `@rslint/core` chain, the fmt bin probe, the rstest package lookup) and the lint worker's own `createRequire` from the core directory does too. Lint once carried a `.pnp.cjs` branch for the find-`@rslint/core` hop only; nothing after that hop (config evaluation, plugin resolution, the other stacks) had PnP hooks, so it never produced a working folder, and upstream removed its own PnP path in the same refactor that introduced `corePath`. Real support would be a PnP editor-SDK-shaped project across all three stacks, not a resolver branch — do not reintroduce one. -- **A Lint runtime lives as long as a document needs it, and a folder with none is `running: idle`.** Since the #1617 sync, `RuntimeManager` refcounts each runtime by open document: the first document to resolve a core starts one, the last to release it closes it, so a detected folder with nothing open holds zero workers and zero Go processes. That folder still reports `running` — with the detail `idle` — because it is live and will start a runtime on the next `didOpen`; do **not** add a `StackState` kind for it (the shell's status bar and `when` clauses read the kinds, and idle is not a kind of health). A folder's state is the **worst of** its runtimes plus any document whose core resolution currently fails (last-good: that document keeps the runtime it already had), so one failing core is never masked by a healthy sibling — the same invariant fmt pins across folders, applied inside one and across them alike (lint's rank table matches fmt's: `disabled` there means "a package is not installed" — no `rstack`, or no `@rslint/core` — not the kill switch). Triggers: the shell's detection pass (which already covers lockfiles) plus one lint-owned watcher on `node_modules/@rslint/core/package.json` — upstream's glob minus the lockfiles detection owns. Failures report through the status only: upstream's `window.showWarningMessage` is dropped, since stacks own no UI chrome. Consequently `whenStackActive('rslint')` means "the controller registered its folders", not "a server is up" — E2E suites open a document and await diagnostics. +- **A Lint runtime lives as long as a document needs it, and a folder with none is `running: idle`.** Since the #1617 sync, `RuntimeManager` refcounts each runtime by open document: the first document to resolve a core starts one, the last to release it closes it, so a detected folder with nothing open holds zero workers and zero Go processes. That folder still reports `running` — with the detail `idle` — because it is live and will start a runtime on the next `didOpen`; do **not** add a `StackState` kind for it (the shell's status bar and `when` clauses read the kinds, and idle is not a kind of health). A folder's state is the **worst of** its runtimes plus any document whose core resolution currently fails (last-good: that document keeps the runtime it already had), so one failing core is never masked by a healthy sibling — the same invariant fmt pins across folders, applied inside one and across them alike (lint's rank table matches fmt's: `disabled` there means "a package is not installed" — no `rstack`, or no `@rslint/core` — not the kill switch). Dependency retries come only through the shell's detection pass: lockfile events are the low-latency path and ADR 0005's conditional poll covers unchanged lockfiles. The former lint-owned `node_modules/@rslint/core/package.json` watcher was removed because pnpm produced no event in either isolated or hoisted layout. Failures report through the status only: upstream's `window.showWarningMessage` is dropped, since stacks own no UI chrome. Consequently `whenStackActive('rslint')` means "the controller registered its folders", not "a server is up" — E2E suites open a document and await diagnostics. - The lint worker is deliberately vscode-free so it can move upstream whole. It takes explicit `--core` / `--config` native paths, writes logs only to stderr because stdout is LSP, and owns the Go child plus config/plugin lifecycles. Config edits use `rslint/configRefresh` with the same pinned path; a native ↔ bridged ownership change replaces the whole folder runtime because the supported config protocols lock that choice for the process lifetime. - The test × `rstack.config.*` bridge stays thin on purpose: it points the upstream machinery at rstack's shipped shim and lets the shim interpret the config inside the worker, same as the CLI. Bridged projects resolve `@rstest/core` from the resolved rstack package directory, mirroring lint, so rstack's dependency remains visible under isolated installs. Never re-implement rstack config semantics in the extension. - The fmt stack is an LSP client: one `rs fmt --lsp` server per detected workspace folder, spawned at the **folder root** even when a deeper `rstack.config.*` exists. Deepest-config-wins was removed deliberately — `rs fmt` loads one config from its cwd with no upward walk, so anchoring deeper made the editor disagree with `rs fmt` in a terminal; a subproject that needs its own fmt config becomes its own workspace folder. The stack registers **no** `DocumentFormattingEditProvider`: the client registers the provider from the server's `documentFormattingProvider` capability, and adding one by hand would double-register. A config create/change/delete **restarts** the owning folder's server (the server caches its config for its process lifetime and has no config-change message), which is also why the stack watches `RSTACK_CONFIG_GLOB` itself instead of relying on detection — a detection signature records which config files exist, not their contents. A detection pass keeps healthy servers and restarts failed ones in place (`isFailedFmtState`) — lockfile events notify even when the folder set is unchanged, precisely so a completed install or upgrade is retried without a manual restart. There is no stdin fallback below `SUPPORT_MATRIX.rstack`; that is a version gate, not an omission. **Nested workspace folders are a documented limitation, by decision**: when a folder and its subdirectory are both workspace folders and both detect fmt, the parent's per-folder selector also matches the nested folder's files, and which server VS Code hands the request to is not defined — the supported shape is subprojects as _sibling_ workspace folders (or only the subproject opened), not parent-plus-child. Routing (lint's `WorkspaceDocumentRouter` shape) was considered and deferred. Why all of it: `docs/adr/0002-fmt-lsp-on-user-node-runtime.md`. diff --git a/packages/vscode/src/detection.ts b/packages/vscode/src/detection.ts index e9fecc3..897625f 100644 --- a/packages/vscode/src/detection.ts +++ b/packages/vscode/src/detection.ts @@ -34,8 +34,9 @@ export const DEFAULT_RSTEST_CONFIG_GLOBS = [ /** * Lockfiles are watched as a proxy for dependency changes — the pattern Rslint - * already uses. Watching `node_modules` directly is unreliable (pnpm symlinks) - * and is not attempted. + * already uses — and remain the low-latency path. A direct `node_modules` + * watcher is not attempted: pnpm installs produced no matching per-file event + * in either isolated or hoisted layout (ADR 0005). */ export const LOCKFILE_NAMES = [ 'package-lock.json', @@ -244,9 +245,9 @@ export class DetectionService implements vscode.Disposable { // out identical while every project-resolved package (Rslint binary, Rstest // core, the rstack shim) may now resolve differently. Such a pass must // notify subscribers even when the signature is unchanged, or failed - // resolutions are never retried until a window reload. Set by the lockfile - // watcher only — a caller that drives the rebuild itself does not need the - // event, it already has the fresh snapshot. + // resolutions would wait for the polling fallback. Set by the lockfile + // watcher and by `refreshForDependencyChange`; a caller that drives the + // rebuild itself does not need the event, it already has the fresh snapshot. #notifyUnchanged = false; #watchers: vscode.Disposable[] = []; #debounce: ReturnType | undefined; diff --git a/packages/vscode/src/shared/notInstalled.ts b/packages/vscode/src/shared/notInstalled.ts index 42f0bea..4338e31 100644 --- a/packages/vscode/src/shared/notInstalled.ts +++ b/packages/vscode/src/shared/notInstalled.ts @@ -13,10 +13,9 @@ import { * `formatVersionMismatch` — each keeps its own status machinery, but what * the user reads is one sentence, not three near-copies. * - * The trailing hint covers the recovery no watcher sees: an install that - * changes no lockfile (a fresh clone whose lockfile is already current) fires - * no detection pass, so the restart command is the way out and the status is - * where it has to be named (ADR 0002). + * A shell-owned poll now covers installs that change no lockfile. The trailing + * restart hint remains the explicit fallback when recovery is delayed or the + * project stays broken for another reason (ADR 0005). */ const restartHint = (stack: StackId): string => `then run "${COMMAND_CATEGORY}: ${stackCommandTitle(stack)}" if this status stays`; diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index 23157c7..1a06d64 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -368,10 +368,8 @@ class FmtFolderRuntime { const pkgJsonPath = findPackageJsonUncached('rstack', folderRoot); if (!pkgJsonPath) { - // The trailing hint covers the one recovery path no watcher sees: an - // install that changes no lockfile (a fresh clone whose lockfile is - // already current) fires no file event, so nothing rebuilds this - // runtime — the status message is where the way out has to live. + // The shell polls while this state remains disabled. The trailing restart + // hint stays as the explicit fallback if recovery is delayed. this.setState('disabled', formatNotInstalledStatus('fmt', 'rstack')); if (this.#missingPackage !== 'rstack') { context.output.warn( diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index 0380475..863aea0 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -39,16 +39,6 @@ import { WorkspaceDocumentRouter } from './WorkspaceDocumentRouter'; * the per-folder status fold the shell requires. */ -/** - * The one core-topology signal the shell's detection watcher does not carry: - * a core swapped in place. Lockfiles — upstream's other half of this glob — - * are already detection's business, and a detection pass notifies this stack - * even when the folder set is unchanged. `files.watcherExclude` hides - * `node_modules` by default, so in practice the lockfile path is the one that - * fires; this watcher costs nothing and covers the rest. - */ -const CORE_TOPOLOGY_GLOB = '**/node_modules/@rslint/core/package.json'; - /** Everything one detected folder contributes to its status fold. */ interface FolderStates { /** One entry per live Lint runtime, keyed by its runtime key. */ @@ -129,18 +119,6 @@ class RslintController implements StackController { }), ); - const topologyWatcher = - vscode.workspace.createFileSystemWatcher(CORE_TOPOLOGY_GLOB); - const onTopologyChange = () => { - this.reconcileOpenDocuments('dependency change'); - }; - this.#subscriptions.push( - topologyWatcher, - topologyWatcher.onDidCreate(onTopologyChange), - topologyWatcher.onDidChange(onTopologyChange), - topologyWatcher.onDidDelete(onTopologyChange), - ); - this.publishStatus(); // Adaptation #1: activation must not wait for a language server. Documents // already open are reconciled in the background; failures surface per From bc6768b3da8b5e5456f34473c25a56263619b509 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 4 Sep 2026 17:58:12 +0800 Subject: [PATCH 06/44] fix(vscode): preserve config dependency failure states --- packages/vscode/src/shared/notInstalled.ts | 41 ++++++++ packages/vscode/src/stacks/fmt/index.ts | 94 ++++++++++--------- .../vscode/src/stacks/fmt/sessionError.ts | 81 ++++++++++++++-- packages/vscode/src/stacks/fmt/status.ts | 5 + packages/vscode/src/stacks/lint/Rslint.ts | 56 ++++++----- packages/vscode/src/stacks/lint/index.ts | 8 +- packages/vscode/src/stacks/lint/status.ts | 17 ++++ packages/vscode/src/stacks/test/project.ts | 19 +++- .../vscode/tests/shared/notInstalled.test.ts | 41 ++++++++ .../tests/stacks/fmt/sessionError.test.ts | 54 +++++++++++ .../vscode/tests/stacks/fmt/status.test.ts | 12 +++ .../vscode/tests/stacks/lint/status.test.ts | 28 ++++++ .../vscode/tests/stacks/test/project.test.ts | 31 ++++++ 13 files changed, 405 insertions(+), 82 deletions(-) diff --git a/packages/vscode/src/shared/notInstalled.ts b/packages/vscode/src/shared/notInstalled.ts index 4338e31..32e391b 100644 --- a/packages/vscode/src/shared/notInstalled.ts +++ b/packages/vscode/src/shared/notInstalled.ts @@ -50,6 +50,47 @@ export const formatConfigDependencyMissingLog = ( ): string => `Cannot load ${configPath}: ${cause}. Install the project dependencies to enable ${STACK_LABELS[stack]} for this config.`; +export interface ConfigDependencyEpisodeReport { + readonly reason: string; + readonly warning: string | undefined; +} + +/** + * Deduplicates one config-dependency warning until a successful load ends the + * episode. Lint and fmt receive their failures over different protocols, but + * the latch semantics and the user-facing words are the same. + */ +export class ConfigDependencyEpisode { + #fingerprint: string | undefined; + + get active(): boolean { + return this.#fingerprint !== undefined; + } + + observe( + stack: StackId, + configPath: string, + cause: string, + ): ConfigDependencyEpisodeReport { + const fingerprint = `${configPath}\0${cause}`; + const warning = + fingerprint === this.#fingerprint + ? undefined + : formatConfigDependencyMissingLog(stack, configPath, cause); + this.#fingerprint = fingerprint; + return { + reason: formatConfigDependencyMissingStatus(stack, configPath), + warning, + }; + } + + clear(): boolean { + const wasActive = this.active; + this.#fingerprint = undefined; + return wasActive; + } +} + /** * The output-channel line: where the stack looked, plus the stack's own * consequence — the same shape as the shared Node preflight message diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index 1a06d64..b8da641 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -13,8 +13,7 @@ import { import { RSTACK_CONFIG_GLOB } from '../../detection'; import { getConfiguredNodeExecutable } from '../../shared/nodeExecutableSetting'; import { - formatConfigDependencyMissingLog, - formatConfigDependencyMissingStatus, + ConfigDependencyEpisode, formatNotInstalledLog, formatNotInstalledStatus, } from '../../shared/notInstalled'; @@ -45,11 +44,13 @@ import type { import { LanguageServerProcessOwner } from '../lint/LanguageServerProcessOwner'; import { pickBinEntry } from './binEntry'; import { - classifyFmtSessionError, - showMessagePresentation, + finishSuccessfulFormatting, + handleFmtShowMessage, + type ShowMessageParams, } from './sessionError'; import { foldFolderStatus, + hasNotInstalledFmtState, type FmtFolderStatus, type FmtRuntimeState, isFailedFmtState, @@ -163,7 +164,7 @@ class FmtFolderRuntime { #stateWatcher: vscode.Disposable | undefined; #configPath: string | undefined; #missingPackage: string | undefined; - #configDependencyFingerprint: string | undefined; + readonly #configDependencyEpisode = new ConfigDependencyEpisode(); readonly #configDependencyWarnings: string[] = []; #suppressedShowMessages = 0; #closing = false; @@ -231,46 +232,31 @@ class FmtFolderRuntime { this.onDidChangeStatus(); } - private handleShowMessage(message: { - readonly type: number; - readonly message: string; - }): void { - const configPath = this.#configPath; - const failure = - configPath === undefined - ? undefined - : classifyFmtSessionError(message, this.folderPath, configPath); - if (failure !== undefined) { - const fingerprint = `${failure.configPath}\0${failure.cause}`; - if (this.#configDependencyFingerprint !== fingerprint) { - const warning = formatConfigDependencyMissingLog( + private handleShowMessage(message: ShowMessageParams): void { + handleFmtShowMessage(message, this.folderPath, this.#configPath, { + onConfigDependency: (failure) => { + const report = this.#configDependencyEpisode.observe( 'fmt', failure.configPath, failure.cause, ); - this.context.output.warn(warning); - this.#configDependencyWarnings.push(warning); - } - this.#configDependencyFingerprint = fingerprint; - this.#suppressedShowMessages++; - this.setState( - 'disabled', - formatConfigDependencyMissingStatus('fmt', failure.configPath), - ); - return; - } - - switch (showMessagePresentation(message.type)) { - case 'error': - void vscode.window.showErrorMessage(message.message); - break; - case 'warning': - void vscode.window.showWarningMessage(message.message); - break; - case 'information': - void vscode.window.showInformationMessage(message.message); - break; - } + if (report.warning !== undefined) { + this.context.output.warn(report.warning); + this.#configDependencyWarnings.push(report.warning); + } + this.#suppressedShowMessages++; + this.setState('disabled', report.reason); + }, + showErrorMessage: (text) => { + void vscode.window.showErrorMessage(text); + }, + showWarningMessage: (text) => { + void vscode.window.showWarningMessage(text); + }, + showInformationMessage: (text) => { + void vscode.window.showInformationMessage(text); + }, + }); } /** @@ -568,6 +554,28 @@ class FmtFolderRuntime { // instead of a separate "... Trace" channel per folder. traceOutputChannel: this.context.output, errorHandler, + middleware: { + provideDocumentFormattingEdits: async ( + document, + options, + token, + next, + ) => { + const suppressedBeforeRequest = this.#suppressedShowMessages; + const edits = await next(document, options, token); + if ( + finishSuccessfulFormatting( + this.#configDependencyEpisode, + suppressedBeforeRequest, + this.#suppressedShowMessages, + ) && + this.#state === 'disabled' + ) { + this.setState('running'); + } + return edits; + }, + }, }; } @@ -820,8 +828,8 @@ class FmtController implements StackController { } hasNotInstalledState(): boolean { - return [...this.#runtimes.values()].some( - (runtime) => runtime.state === 'disabled', + return hasNotInstalledFmtState( + [...this.#runtimes.values()].map((runtime) => runtime.state), ); } diff --git a/packages/vscode/src/stacks/fmt/sessionError.ts b/packages/vscode/src/stacks/fmt/sessionError.ts index 076792e..d915752 100644 --- a/packages/vscode/src/stacks/fmt/sessionError.ts +++ b/packages/vscode/src/stacks/fmt/sessionError.ts @@ -1,25 +1,46 @@ import path from 'node:path'; +import type { MessageType as LspMessageType } from 'vscode-languageclient/node'; import { classifyMissingDependencyMessage } from '../../shared/missingDependency'; +import type { ConfigDependencyEpisode } from '../../shared/notInstalled'; export const FMT_SESSION_ERROR_PREFIX = 'rs fmt cannot format this workspace: '; +// Importing the runtime value from vscode-languageclient also evaluates its +// `vscode` dependency, which would make this otherwise pure module unusable in +// Node unit tests. These are the LSP MessageType values it re-exports. +const MessageType = { + Error: 1 as LspMessageType, + Warning: 2 as LspMessageType, + Info: 3 as LspMessageType, +}; + export interface FmtConfigDependencyFailure { readonly configPath: string; readonly cause: string; } -interface ShowMessageParams { - readonly type: number; +export interface ShowMessageParams { + readonly type: LspMessageType; readonly message: string; } +export interface ShowMessagePresenter { + showErrorMessage(message: string): void; + showWarningMessage(message: string): void; + showInformationMessage(message: string): void; +} + +export interface FmtShowMessageHandler extends ShowMessagePresenter { + onConfigDependency(failure: FmtConfigDependencyFailure): void; +} + export function classifyFmtSessionError( message: ShowMessageParams, workspaceRoot: string, configPath: string, ): FmtConfigDependencyFailure | undefined { if ( - message.type !== 1 || + message.type !== MessageType.Error || !message.message.startsWith(FMT_SESSION_ERROR_PREFIX) ) { return undefined; @@ -38,14 +59,62 @@ export function classifyFmtSessionError( } export const showMessagePresentation = ( - type: number, + type: LspMessageType, ): 'error' | 'warning' | 'information' => { switch (type) { - case 1: + case MessageType.Error: return 'error'; - case 2: + case MessageType.Warning: return 'warning'; default: return 'information'; } }; + +/** Reproduces vscode-languageclient's default show-message UI routing. */ +export const presentShowMessage = ( + message: ShowMessageParams, + presenter: ShowMessagePresenter, +): void => { + switch (showMessagePresentation(message.type)) { + case 'error': + presenter.showErrorMessage(message.message); + break; + case 'warning': + presenter.showWarningMessage(message.message); + break; + case 'information': + presenter.showInformationMessage(message.message); + break; + } +}; + +/** Filters the one stack-owned state transition and passes every other server UI request through. */ +export const handleFmtShowMessage = ( + message: ShowMessageParams, + workspaceRoot: string, + configPath: string | undefined, + handler: FmtShowMessageHandler, +): void => { + const failure = + configPath === undefined + ? undefined + : classifyFmtSessionError(message, workspaceRoot, configPath); + if (failure !== undefined) { + handler.onConfigDependency(failure); + return; + } + presentShowMessage(message, handler); +}; + +/** + * Ends the warning episode only when this formatting request completed + * without another classified show-message notification. A failed config load + * also resolves with empty edits, so the response alone is not success. + */ +export const finishSuccessfulFormatting = ( + episode: ConfigDependencyEpisode, + suppressedBeforeRequest: number, + suppressedAfterRequest: number, +): boolean => + suppressedBeforeRequest === suppressedAfterRequest && episode.clear(); diff --git a/packages/vscode/src/stacks/fmt/status.ts b/packages/vscode/src/stacks/fmt/status.ts index 09109a4..36a91a8 100644 --- a/packages/vscode/src/stacks/fmt/status.ts +++ b/packages/vscode/src/stacks/fmt/status.ts @@ -71,6 +71,11 @@ const STATE_RANK: Readonly> = { export const isFailedFmtState = (state: FmtRuntimeState): boolean => state === 'disabled' || state === 'version-mismatch' || state === 'crashed'; +/** The raw not-installed predicate used by the shell's conditional poll. */ +export const hasNotInstalledFmtState = ( + states: Iterable, +): boolean => [...states].some((state) => state === 'disabled'); + /** * Folds every folder runtime's state into the one report the shell shows for * the fmt stack. The worst folder wins, and with multiple folders the detail diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 0e45719..130eccf 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -26,10 +26,7 @@ import { type ServerOptions, State, } from 'vscode-languageclient/node'; -import { - formatConfigDependencyMissingLog, - formatConfigDependencyMissingStatus, -} from '../../shared/notInstalled'; +import { ConfigDependencyEpisode } from '../../shared/notInstalled'; import { configuredNodeBelowFloor, NodePreflightError, @@ -48,6 +45,7 @@ import { import { RslintVersionMismatchError, runningRslintStatus, + shouldReportRslintStartFailure, statusForRslintStartFailure, } from './status'; import { @@ -317,12 +315,6 @@ export interface RslintOptions { readonly onClosed?: () => void; } -interface ReportedConfigDependencyFailure { - readonly fingerprint: string; - readonly displayPath: string; - readonly cause: string; -} - export class Rslint implements Disposable { private client: LanguageClient | undefined; private readonly logger: Logger; @@ -344,7 +336,7 @@ export class Rslint implements Disposable { private stateWatcher: Disposable | undefined; private lifecycleEpoch = 0; private advisory: string | undefined; - private configDependencyFailure: ReportedConfigDependencyFailure | undefined; + private readonly configDependencyEpisode = new ConfigDependencyEpisode(); private startPromise: Promise | undefined; private startOperation: Promise | undefined; private clientStartPromise: Promise | undefined; @@ -391,30 +383,24 @@ export class Rslint implements Disposable { ): void { const failure = notification.failure; if (failure === null) { - const wasMissing = this.configDependencyFailure !== undefined; - this.configDependencyFailure = undefined; + const wasMissing = this.configDependencyEpisode.clear(); if (wasMissing && this.isRunning()) this.reportRunning(); return; } const displayPath = this.displayConfigPath(failure.configPath); - const fingerprint = `${displayPath}\0${failure.cause}`; - if (this.configDependencyFailure?.fingerprint !== fingerprint) { - const warning = formatConfigDependencyMissingLog( - 'rslint', - displayPath, - failure.cause, - ); + const report = this.configDependencyEpisode.observe( + 'rslint', + displayPath, + failure.cause, + ); + if (report.warning !== undefined) { + const warning = report.warning; this.logger.warn(warning); this.configDependencyWarnings.push(warning); } - this.configDependencyFailure = { - fingerprint, - displayPath, - cause: failure.cause, - }; this.report({ kind: 'disabled', - reason: formatConfigDependencyMissingStatus('rslint', displayPath), + reason: report.reason, }); } @@ -440,7 +426,14 @@ export class Rslint implements Disposable { } private reportStartFailure(error: unknown): void { - if (this.isPlannedStartAbort(error)) return; + if ( + !shouldReportRslintStartFailure( + this.isPlannedStartAbort(error), + this.hasConfigDependencyFailure(), + ) + ) { + return; + } this.report(statusForRslintStartFailure(error)); } @@ -590,7 +583,12 @@ export class Rslint implements Disposable { } catch (error: unknown) { // A close or supersede during start is a planned abort, not a failure; // logging it as an error made every teardown race look like a crash. - if (!this.isPlannedStartAbort(error)) { + if ( + shouldReportRslintStartFailure( + this.isPlannedStartAbort(error), + this.hasConfigDependencyFailure(), + ) + ) { this.logger.error('Failed to start Rslint language client', error); } throw error; @@ -684,7 +682,7 @@ export class Rslint implements Disposable { } public hasConfigDependencyFailure(): boolean { - return this.configDependencyFailure !== undefined; + return this.configDependencyEpisode.active; } public retryConfigDependency(): Promise | undefined { diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index 863aea0..e419239 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -17,6 +17,7 @@ import { aggregateFolderStates, attributeToCore, foldRslintFolderState, + hasNotInstalledRslintState, statusForRslintStartFailure, missingPackageOf, } from './status'; @@ -403,9 +404,10 @@ class RslintController implements StackController { hasNotInstalledState(): boolean { return [...this.#folderStates.values()].some((states) => - [...states.runtimes.values(), ...states.failures.values()].some( - (state) => state.kind === 'disabled', - ), + hasNotInstalledRslintState([ + ...states.runtimes.values(), + ...states.failures.values(), + ]), ); } diff --git a/packages/vscode/src/stacks/lint/status.ts b/packages/vscode/src/stacks/lint/status.ts index cb11602..c99f6a7 100644 --- a/packages/vscode/src/stacks/lint/status.ts +++ b/packages/vscode/src/stacks/lint/status.ts @@ -40,6 +40,18 @@ export const statusForRslintStartFailure = (error: unknown): StackState => { }; }; +/** + * A rejected start is already represented by the worker's config-dependency + * notification when that verdict arrived first. In that case the catch must + * preserve `disabled` and its one-line warning instead of replacing it with a + * `crashed` state and a stack trace. Planned aborts are silent for the same + * reason they were before config-dependency reporting existed. + */ +export const shouldReportRslintStartFailure = ( + plannedAbort: boolean, + hasConfigDependencyFailure: boolean, +): boolean => !plannedAbort && !hasConfigDependencyFailure; + /** * Names the Rslint core a runtime's failure came from. With several runtimes * in one folder, "the language server stopped" alone does not say which core @@ -139,6 +151,11 @@ export interface RslintFolderStatus { readonly state: StackState; } +/** The raw not-installed predicate used by the shell's conditional poll. */ +export const hasNotInstalledRslintState = ( + states: Iterable, +): boolean => [...states].some((state) => state.kind === 'disabled'); + /** * Folds every workspace folder's state into the one state the status bar shows * for the Rslint stack. The worst state wins, and the detail names the folders diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts index bfefc97..22d9d75 100644 --- a/packages/vscode/src/stacks/test/project.ts +++ b/packages/vscode/src/stacks/test/project.ts @@ -10,7 +10,10 @@ import { formatConfigDependencyMissingLog, formatConfigDependencyMissingStatus, } from '../../shared/notInstalled'; -import { logUnlessReported } from './coreResolution'; +import { + logUnlessReported, + ReportedRstestResolutionError, +} from './coreResolution'; import { logger } from './logger'; import { RstestApi } from './master'; import { type ChildProjectRef, computeCoveredConfigs } from './projectCoverage'; @@ -613,6 +616,20 @@ export class Project implements vscode.Disposable { if (this.cancellationSource.token.isCancellationRequested) return; this.configLoadFailed = true; this.#configDependencyCause = undefined; + if (!(error instanceof ReportedRstestResolutionError)) { + const cause = + error instanceof Error + ? error.message.split('\n', 1)[0] + : String(error); + // Replace the previous not-installed verdict with the real config + // error before clearing that latch. Crash outranks disabled, so the + // synchronous transition never paints a healthy intermediate state; + // clearing the raw latch stops dependency polling as intended. + status.crashed( + `Cannot load ${relativeTo(this.workspaceFolder, this.sourceUri)}: ${cause}`, + this.configDependencyStatusSource, + ); + } status.installed(this.configDependencyStatusSource); logUnlessReported('Failed to initialize project config', error); // Let the manager settle its tree even when a config fails to load. diff --git a/packages/vscode/tests/shared/notInstalled.test.ts b/packages/vscode/tests/shared/notInstalled.test.ts index 0c81480..2758800 100644 --- a/packages/vscode/tests/shared/notInstalled.test.ts +++ b/packages/vscode/tests/shared/notInstalled.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from '@rstest/core'; import { + ConfigDependencyEpisode, formatConfigDependencyMissingLog, formatConfigDependencyMissingStatus, formatNotInstalledLog, @@ -51,3 +52,43 @@ describe('not-installed wording', () => { ); }); }); + +describe('ConfigDependencyEpisode', () => { + it('warns once until success clears the episode', () => { + const episode = new ConfigDependencyEpisode(); + const first = episode.observe( + 'fmt', + 'rstack.config.ts', + "Cannot find package 'missing'", + ); + expect(first.warning).toContain("Cannot find package 'missing'"); + expect(episode.active).toBe(true); + + expect( + episode.observe( + 'fmt', + 'rstack.config.ts', + "Cannot find package 'missing'", + ).warning, + ).toBe(undefined); + + expect(episode.clear()).toBe(true); + expect(episode.active).toBe(false); + expect( + episode.observe( + 'fmt', + 'rstack.config.ts', + "Cannot find package 'missing'", + ).warning, + ).toContain("Cannot find package 'missing'"); + }); + + it('starts a new warning when the missing dependency changes', () => { + const episode = new ConfigDependencyEpisode(); + episode.observe('rslint', 'rslint.config.ts', "Cannot find package 'a'"); + expect( + episode.observe('rslint', 'rslint.config.ts', "Cannot find package 'b'") + .warning, + ).toContain("Cannot find package 'b'"); + }); +}); diff --git a/packages/vscode/tests/stacks/fmt/sessionError.test.ts b/packages/vscode/tests/stacks/fmt/sessionError.test.ts index 0794d84..b685288 100644 --- a/packages/vscode/tests/stacks/fmt/sessionError.test.ts +++ b/packages/vscode/tests/stacks/fmt/sessionError.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from '@rstest/core'; +import { ConfigDependencyEpisode } from '../../../src/shared/notInstalled'; import { classifyFmtSessionError, + finishSuccessfulFormatting, FMT_SESSION_ERROR_PREFIX, + handleFmtShowMessage, showMessagePresentation, } from '../../../src/stacks/fmt/sessionError'; @@ -68,3 +71,54 @@ describe('showMessagePresentation', () => { expect(showMessagePresentation(5)).toBe('information'); }); }); + +describe('handleFmtShowMessage', () => { + it('re-presents non-classified Error, Warning and Info messages without state changes', () => { + const shown: string[] = []; + let stateChanges = 0; + const handler = { + onConfigDependency: () => { + stateChanges += 1; + }, + showErrorMessage: (message: string) => shown.push(`error:${message}`), + showWarningMessage: (message: string) => shown.push(`warning:${message}`), + showInformationMessage: (message: string) => + shown.push(`information:${message}`), + }; + + for (const message of [ + { type: 1 as const, message: 'bad config syntax' }, + { type: 2 as const, message: 'deprecated option' }, + { type: 3 as const, message: 'formatter ready' }, + ]) { + handleFmtShowMessage(message, '/project', undefined, handler); + } + + expect(shown).toEqual([ + 'error:bad config syntax', + 'warning:deprecated option', + 'information:formatter ready', + ]); + expect(stateChanges).toBe(0); + }); +}); + +describe('finishSuccessfulFormatting', () => { + it('clears the warning latch only after a request without a config failure', () => { + const episode = new ConfigDependencyEpisode(); + episode.observe('fmt', 'rstack.config.ts', "Cannot find package 'missing'"); + + expect(finishSuccessfulFormatting(episode, 0, 1)).toBe(false); + expect(episode.active).toBe(true); + + expect(finishSuccessfulFormatting(episode, 1, 1)).toBe(true); + expect(episode.active).toBe(false); + expect( + episode.observe( + 'fmt', + 'rstack.config.ts', + "Cannot find package 'missing'", + ).warning, + ).toContain("Cannot find package 'missing'"); + }); +}); diff --git a/packages/vscode/tests/stacks/fmt/status.test.ts b/packages/vscode/tests/stacks/fmt/status.test.ts index f47bd92..c15d526 100644 --- a/packages/vscode/tests/stacks/fmt/status.test.ts +++ b/packages/vscode/tests/stacks/fmt/status.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from '@rstest/core'; import { foldFolderStatus, + hasNotInstalledFmtState, type FmtFolderStatus, type FmtRuntimeState, isFailedFmtState, @@ -153,3 +154,14 @@ describe('isFailedFmtState', () => { } }); }); + +describe('hasNotInstalledFmtState', () => { + it('reads raw folder states even when a crash would outrank disabled', () => { + expect(hasNotInstalledFmtState(['running', 'crashed', 'disabled'])).toBe( + true, + ); + expect( + hasNotInstalledFmtState(['running', 'crashed', 'version-mismatch']), + ).toBe(false); + }); +}); diff --git a/packages/vscode/tests/stacks/lint/status.test.ts b/packages/vscode/tests/stacks/lint/status.test.ts index 6c54021..a29f615 100644 --- a/packages/vscode/tests/stacks/lint/status.test.ts +++ b/packages/vscode/tests/stacks/lint/status.test.ts @@ -4,9 +4,11 @@ import { aggregateFolderStates, attributeToCore, foldRslintFolderState, + hasNotInstalledRslintState, missingPackageOf, RslintVersionMismatchError, runningRslintStatus, + shouldReportRslintStartFailure, statusForRslintStartFailure, } from '../../../src/stacks/lint/status'; @@ -89,6 +91,32 @@ describe('Rslint status classification', () => { detail: 'Node 22.17 is below the floor', }); }); + + it('preserves a classified config dependency when initial refresh rejects', () => { + // The worker notification is delivered before the rejected + // rslint/configRefresh response. Both the start catch's logger and its + // outer status catch use this verdict, so neither may replace `disabled`. + expect(shouldReportRslintStartFailure(false, true)).toBe(false); + expect(shouldReportRslintStartFailure(true, false)).toBe(false); + expect(shouldReportRslintStartFailure(false, false)).toBe(true); + }); +}); + +describe('hasNotInstalledRslintState', () => { + it('reads the raw runtime and resolution states rather than the aggregate', () => { + expect( + hasNotInstalledRslintState([ + { kind: 'crashed', detail: 'worker stopped' }, + { kind: 'disabled', reason: 'dependencies missing' }, + ]), + ).toBe(true); + expect( + hasNotInstalledRslintState([ + { kind: 'running' }, + { kind: 'version-mismatch', detail: 'core too old' }, + ]), + ).toBe(false); + }); }); describe('attributeToCore', () => { diff --git a/packages/vscode/tests/stacks/test/project.test.ts b/packages/vscode/tests/stacks/test/project.test.ts index f32324c..c68de41 100644 --- a/packages/vscode/tests/stacks/test/project.test.ts +++ b/packages/vscode/tests/stacks/test/project.test.ts @@ -301,4 +301,35 @@ describe('Project config/cwd/package-resolution decoupling', () => { project.dispose(); status.unbind(); }); + + it('replaces not installed with a logged config error when a retry rejects', async () => { + const config = uri('/repo/templates/app/rstest.config.ts'); + normalizedConfigResult = { + ok: false, + message: "Cannot find package '@rstest/plugin-missing'", + }; + const { reporter, reported } = createStatusRecorder(); + status.bind(reporter); + const { project } = await createProject({ sourceUri: config }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(status.hasNotInstalled()).toBe(true); + normalizedConfigResult = undefined; + normalizedConfigFailure = new SyntaxError('Unexpected token export'); + await project.retryFailedConfig(); + + expect(project.configLoadFailed).toBe(true); + expect(status.hasNotInstalled()).toBe(false); + expect(loggedWarnings).toHaveLength(1); + expect(loggedErrors).toHaveLength(1); + expect(loggedErrors[0]).toContain('Failed to initialize project config'); + expect(reported.at(-1)).toEqual({ + kind: 'crashed', + detail: + 'Cannot load templates/app/rstest.config.ts: Unexpected token export', + }); + + project.dispose(); + status.unbind(); + }); }); From dc3825c4ffe7194f3e4bc08876a9b920a49f48ab Mon Sep 17 00:00:00 2001 From: fi3ework Date: Fri, 4 Sep 2026 18:00:15 +0800 Subject: [PATCH 07/44] refactor(vscode): tighten recovery integration seams --- packages/vscode/e2e/runSuite.ts | 53 +++++++++++++++++++ packages/vscode/e2e/runTest.ts | 17 ++++++ .../index.ts | 34 +----------- packages/vscode/e2e/suite/index.ts | 50 +---------------- packages/vscode/src/extension.ts | 37 +++++-------- packages/vscode/src/statusBar.ts | 20 ++++--- packages/vscode/tests/extension.test.ts | 35 +++++++++++- packages/vscode/tests/statusBar.test.ts | 24 +++++++++ 8 files changed, 157 insertions(+), 113 deletions(-) create mode 100644 packages/vscode/e2e/runSuite.ts diff --git a/packages/vscode/e2e/runSuite.ts b/packages/vscode/e2e/runSuite.ts new file mode 100644 index 0000000..969004b --- /dev/null +++ b/packages/vscode/e2e/runSuite.ts @@ -0,0 +1,53 @@ +import { readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; +import Mocha from 'mocha'; + +const collectTests = (dir: string): string[] => + readdirSync(dir).flatMap((entry) => { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) { + return collectTests(full); + } + return full.endsWith('.test.js') ? [full] : []; + }); + +/** + * The extension host's entry point into a VS Code slice suite. VS Code calls + * the returned function once the window has started, so tests observe the real + * `onStartupFinished` activation instead of forcing it. + */ +export const createRun = (testPath: string): (() => Promise) => { + return () => { + const mocha = new Mocha({ ui: 'tdd', color: true, timeout: 120_000 }); + for (const file of collectTests(testPath)) { + mocha.addFile(file); + } + + return new Promise((resolve, reject) => { + try { + // Mocha's reporter writes to the extension host's stdout, which never + // reaches the harness log — the rejection message is the only channel + // that does, so it must name the failures itself. + const failed: string[] = []; + const runner = mocha.run((failures) => { + if (failures > 0) { + reject( + new Error( + `${failures} E2E test(s) failed:\n${failed.join('\n')}`, + ), + ); + } else { + resolve(); + } + }); + runner.on('fail', (test, error) => { + failed.push( + `- ${test.fullTitle()}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + }; +}; diff --git a/packages/vscode/e2e/runTest.ts b/packages/vscode/e2e/runTest.ts index 3e277d2..dad4d53 100644 --- a/packages/vscode/e2e/runTest.ts +++ b/packages/vscode/e2e/runTest.ts @@ -31,6 +31,7 @@ async function launch({ workspace, profileSuffix, }: LaunchOptions): Promise { + // A short user-data dir keeps the Unix socket paths below the macOS limit. const hash = createHash('sha1') .update(`${extensionDevelopmentPath}\0${profileSuffix}`) .digest('hex') @@ -38,15 +39,31 @@ async function launch({ const userDataDir = mkdtempSync(path.join(tmpdir(), `rstack-${hash}-`)); await runTests({ + // Pinnable for CI; `stable` locally. `runTests` forwards the whole options + // object to the downloader, so `version`/`timeout`/`vscodeExecutablePath` + // all apply to it. version: process.env.VSCODE_TEST_VERSION ?? 'stable', + // The default per-request timeout is 15s, which a 300 MB download on a slow + // or proxied link loses to before it ever starts making progress. timeout: 60_000, + // Escape hatch for offline / restricted environments: point at an existing + // VS Code (`.../Visual Studio Code.app/Contents/MacOS/Electron`, `Code.exe`, + // `code`) and nothing is downloaded at all. vscodeExecutablePath: process.env.VSCODE_TEST_EXECUTABLE || undefined, extensionDevelopmentPath, extensionTestsPath, launchArgs: [ workspace, + // Keep VS Code's CI-only extension inventory and AgentHost info logs out + // of test output while preserving an opt-in for verbose diagnosis. `--log=${process.env.VSCODE_TEST_LOG_LEVEL ?? 'warn'}`, + // Only the extension under development runs: no user extension may + // register a competing formatter, test controller or language client. '--disable-extensions', + // The fixtures spawn project-local binaries, which Restricted Mode + // forbids by design. Trust is granted up front so the + // suite tests the trusted path; the Restricted Mode path needs its own + // launch and is not covered in phase 1. '--disable-workspace-trust', '--disable-updates', '--skip-welcome', diff --git a/packages/vscode/e2e/suite-fmt-missing-config-dependency/index.ts b/packages/vscode/e2e/suite-fmt-missing-config-dependency/index.ts index 021bf76..e86c367 100644 --- a/packages/vscode/e2e/suite-fmt-missing-config-dependency/index.ts +++ b/packages/vscode/e2e/suite-fmt-missing-config-dependency/index.ts @@ -1,33 +1,3 @@ -import { readdirSync, statSync } from 'node:fs'; -import path from 'node:path'; -import Mocha from 'mocha'; +import { createRun } from '../runSuite'; -const collectTests = (dir: string): string[] => - readdirSync(dir).flatMap((entry) => { - const full = path.join(dir, entry); - return statSync(full).isDirectory() - ? collectTests(full) - : full.endsWith('.test.js') - ? [full] - : []; - }); - -export function run(): Promise { - const mocha = new Mocha({ ui: 'tdd', color: true, timeout: 120_000 }); - collectTests(__dirname).forEach((file) => mocha.addFile(file)); - return new Promise((resolve, reject) => { - const failed: string[] = []; - const runner = mocha.run((failures) => { - if (failures === 0) resolve(); - else - reject( - new Error(`${failures} E2E test(s) failed:\n${failed.join('\n')}`), - ); - }); - runner.on('fail', (test, error) => { - failed.push( - `- ${test.fullTitle()}: ${error instanceof Error ? error.message : String(error)}`, - ); - }); - }); -} +export const run = createRun(__dirname); diff --git a/packages/vscode/e2e/suite/index.ts b/packages/vscode/e2e/suite/index.ts index dfd8ada..e86c367 100644 --- a/packages/vscode/e2e/suite/index.ts +++ b/packages/vscode/e2e/suite/index.ts @@ -1,49 +1,3 @@ -import { readdirSync, statSync } from 'node:fs'; -import path from 'node:path'; -import Mocha from 'mocha'; +import { createRun } from '../runSuite'; -const collectTests = (dir: string): string[] => - readdirSync(dir).flatMap((entry) => { - const full = path.join(dir, entry); - if (statSync(full).isDirectory()) { - return collectTests(full); - } - return full.endsWith('.test.js') ? [full] : []; - }); - -/** - * The extension host's entry point into the suite. VS Code calls `run()` once - * the window has started, so the tests observe the real `onStartupFinished` - * activation instead of forcing it. - */ -export function run(): Promise { - const mocha = new Mocha({ ui: 'tdd', color: true, timeout: 120_000 }); - for (const file of collectTests(__dirname)) { - mocha.addFile(file); - } - - return new Promise((resolve, reject) => { - try { - // Mocha's reporter writes to the extension host's stdout, which never - // reaches the harness log — the rejection message is the only channel - // that does, so it must name the failures itself. - const failed: string[] = []; - const runner = mocha.run((failures) => { - if (failures > 0) { - reject( - new Error(`${failures} E2E test(s) failed:\n${failed.join('\n')}`), - ); - } else { - resolve(); - } - }); - runner.on('fail', (test, error) => { - failed.push( - `- ${test.fullTitle()}: ${error instanceof Error ? error.message : String(error)}`, - ); - }); - } catch (error) { - reject(error instanceof Error ? error : new Error(String(error))); - } - }); -} +export const run = createRun(__dirname); diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 4f73458..792a959 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -226,6 +226,15 @@ class ExtensionShell { void this.reconcile(); } + private get dependencyPollNeeded(): boolean { + return ( + !this.#disposed && + [...this.#controllers.values()].some((controller) => + controller.hasNotInstalledState(), + ) + ); + } + /** * Starts one recursive timer only while a live controller owns a * not-installed state. The timer enters the same shell queue as every @@ -233,12 +242,7 @@ class ExtensionShell { * lockfile change; each stack therefore reuses its existing retry path. */ private syncDependencyPoll(): void { - const needed = - !this.#disposed && - [...this.#controllers.values()].some((controller) => - controller.hasNotInstalledState(), - ); - if (!needed) { + if (!this.dependencyPollNeeded) { if (this.#dependencyPollTimer !== undefined) { clearTimeout(this.#dependencyPollTimer); this.#dependencyPollTimer = undefined; @@ -255,12 +259,7 @@ class ExtensionShell { this.#dependencyPollTimer = undefined; this.#dependencyPollInFlight = true; void this.enqueue(async () => { - if ( - this.#disposed || - ![...this.#controllers.values()].some((controller) => - controller.hasNotInstalledState(), - ) - ) { + if (!this.dependencyPollNeeded) { return; } try { @@ -281,19 +280,7 @@ class ExtensionShell { } private stackStatusReporter(stack: StackId): StatusReporter { - const reporter = this.#statusBar.reporterFor(stack); - const report = (state: StackState): void => { - reporter.report(state); - this.syncDependencyPoll(); - }; - return { - stack, - report, - starting: (detail) => report({ kind: 'starting', detail }), - running: (detail) => report({ kind: 'running', detail }), - crashed: (detail) => report({ kind: 'crashed', detail }), - versionMismatch: (detail) => report({ kind: 'version-mismatch', detail }), - }; + return this.#statusBar.reporterFor(stack, () => this.syncDependencyPoll()); } /** diff --git a/packages/vscode/src/statusBar.ts b/packages/vscode/src/statusBar.ts index f66bd99..d53e129 100644 --- a/packages/vscode/src/statusBar.ts +++ b/packages/vscode/src/statusBar.ts @@ -276,15 +276,21 @@ export class StatusBar implements vscode.Disposable { this.#item.show(); } - reporterFor(stack: StackId): StatusReporter { + reporterFor( + stack: StackId, + onReport?: (state: StackState) => void, + ): StatusReporter { + const report = (state: StackState): void => { + this.setState(stack, state); + onReport?.(state); + }; return { stack, - report: (state) => this.setState(stack, state), - starting: (detail) => this.setState(stack, { kind: 'starting', detail }), - running: (detail) => this.setState(stack, { kind: 'running', detail }), - crashed: (detail) => this.setState(stack, { kind: 'crashed', detail }), - versionMismatch: (detail) => - this.setState(stack, { kind: 'version-mismatch', detail }), + report, + starting: (detail) => report({ kind: 'starting', detail }), + running: (detail) => report({ kind: 'running', detail }), + crashed: (detail) => report({ kind: 'crashed', detail }), + versionMismatch: (detail) => report({ kind: 'version-mismatch', detail }), }; } diff --git a/packages/vscode/tests/extension.test.ts b/packages/vscode/tests/extension.test.ts index 6d67814..7ccfbed 100644 --- a/packages/vscode/tests/extension.test.ts +++ b/packages/vscode/tests/extension.test.ts @@ -84,6 +84,8 @@ const harness = rs.hoisted(() => { configListeners: [] as ((event: { affectsConfiguration(section: string): boolean; }) => void)[], + /** Detection change callbacks, wrapped so tests can publish a fresh snapshot. */ + detectionListeners: [] as Array<() => void>, }); const state = { ...defaults(), @@ -244,7 +246,18 @@ rs.mock('../src/detection', () => { forFolder: () => undefined, }); class DetectionService { - readonly onDidChange = () => ({ dispose: () => undefined }); + readonly onDidChange = ( + listener: (value: ReturnType) => void, + ) => { + const emit = () => listener(snapshot()); + harness.detectionListeners.push(emit); + return { + dispose: () => { + const index = harness.detectionListeners.indexOf(emit); + if (index >= 0) harness.detectionListeners.splice(index, 1); + }, + }; + }; get snapshot() { return snapshot(); } @@ -486,6 +499,26 @@ describe('dependency recovery polling', () => { await new Promise((resolve) => setTimeout(resolve, 25)); expect(harness.dependencyRefreshes).toBe(completed); }); + + it('queues a poll tick behind an in-flight reconcile', async () => { + const exports = await activate(context); + exports.setDependencyPollIntervalForTest(5); + + const blockedRegister = Promise.withResolvers(); + harness.blockRegister.set('fmt', blockedRegister.promise); + harness.detected.add('fmt'); + for (const emit of harness.detectionListeners) emit(); + await waitFor(() => harness.registering.has('fmt')); + + harness.notInstalled.add('rslint'); + harness.reporters.get('rslint')?.report({ kind: 'disabled' }); + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(harness.dependencyRefreshes).toBe(0); + + blockedRegister.resolve(); + await waitFor(() => harness.dependencyRefreshes > 0); + expect(harness.overlaps).toEqual([]); + }); }); describe('the extension manifest', () => { diff --git a/packages/vscode/tests/statusBar.test.ts b/packages/vscode/tests/statusBar.test.ts index 3b5d518..0e3d51f 100644 --- a/packages/vscode/tests/statusBar.test.ts +++ b/packages/vscode/tests/statusBar.test.ts @@ -343,3 +343,27 @@ describe('StatusBar item', () => { expect(itemOf().backgroundColor).toBeUndefined(); }); }); + +describe('StatusBar reporter', () => { + it('runs the report hook for direct and convenience reports', () => { + const { bar } = build(); + const reports: string[] = []; + const reporter = bar.reporterFor('fmt', (state) => + reports.push(state.kind), + ); + + reporter.report({ kind: 'disabled', reason: 'missing' }); + reporter.starting(); + reporter.running(); + reporter.crashed('stopped'); + reporter.versionMismatch('old version'); + + expect(reports).toEqual([ + 'disabled', + 'starting', + 'running', + 'crashed', + 'version-mismatch', + ]); + }); +}); From f7d32541293921034c4274f5abb27fc8947a9ab5 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 10:59:18 +0800 Subject: [PATCH 08/44] test(vscode): assert dependency recovery episodes --- .../dependency-recovery.test.ts | 22 ++++++++++++---- .../missing-config-dependency.test.ts | 18 +++++++++++-- .../fmt-missing-config-dependency.test.ts | 25 +++++++++++++++++-- packages/vscode/src/stacks/lint/index.ts | 19 ++++++++------ 4 files changed, 68 insertions(+), 16 deletions(-) diff --git a/packages/vscode/e2e/lint/suite-dependency-recovery/dependency-recovery.test.ts b/packages/vscode/e2e/lint/suite-dependency-recovery/dependency-recovery.test.ts index 22c37f6..7d56e02 100644 --- a/packages/vscode/e2e/lint/suite-dependency-recovery/dependency-recovery.test.ts +++ b/packages/vscode/e2e/lint/suite-dependency-recovery/dependency-recovery.test.ts @@ -12,6 +12,7 @@ const execFile = promisify(execFileCallback); function lintExports(): { getFolderStates(): ReadonlyMap; + getNotInstalledWarnings(): readonly string[]; } { const exports = extensionExports().getStackExports('rslint'); assert.ok(exports, 'lint stack exports are unavailable'); @@ -24,11 +25,14 @@ async function waitForFolderKind( ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - if ( - [...lintExports().getFolderStates().values()].some( - (state) => state.kind === kind, - ) - ) { + const states = [...lintExports().getFolderStates().values()]; + const crashed = states.find((state) => state.kind === 'crashed'); + assert.equal( + crashed, + undefined, + `Rslint became crashed while waiting for ${kind}: ${crashed?.detail}`, + ); + if (states.some((state) => state.kind === kind)) { return; } await new Promise((resolve) => setTimeout(resolve, 100)); @@ -50,6 +54,9 @@ suite('Rslint dependency polling recovery', function () { ); await vscode.window.showTextDocument(document); await waitForFolderKind('disabled'); + const warnings = lintExports().getNotInstalledWarnings(); + assert.strictEqual(warnings.length, 1); + assert.match(warnings[0], /@rslint\/core is not installed/); const lockfile = path.join(root, 'pnpm-lock.yaml'); const beforeContents = fs.readFileSync(lockfile); @@ -79,5 +86,10 @@ suite('Rslint dependency polling recovery', function () { api.getDependencyPollCountForTest() > pollCountBeforeInstall, 'the folder recovered without a dependency polling pass', ); + assert.strictEqual( + lintExports().getNotInstalledWarnings().length, + 1, + 'poll retries must not repeat the unresolved episode warning', + ); }); }); diff --git a/packages/vscode/e2e/lint/suite-missing-config-dependency/missing-config-dependency.test.ts b/packages/vscode/e2e/lint/suite-missing-config-dependency/missing-config-dependency.test.ts index 4d732b7..1840d31 100644 --- a/packages/vscode/e2e/lint/suite-missing-config-dependency/missing-config-dependency.test.ts +++ b/packages/vscode/e2e/lint/suite-missing-config-dependency/missing-config-dependency.test.ts @@ -25,7 +25,17 @@ async function waitForRuntimeKind( ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - const states = [...lintExports().getRuntimeStates().values()]; + const exports = lintExports(); + const states = [ + ...exports.getFolderStates().values(), + ...exports.getRuntimeStates().values(), + ]; + const crashed = states.find((state) => state.kind === 'crashed'); + assert.equal( + crashed, + undefined, + `Rslint became crashed while waiting for ${kind}: ${crashed?.detail}`, + ); if (states.some((state) => state.kind === kind)) { return; } @@ -71,6 +81,10 @@ suite('Rslint missing config dependency', function () { "import 'missing-rslint-config-dependency';\nexport default [];\n", ); await waitForRuntimeKind('disabled'); - assert.strictEqual(lintExports().getConfigDependencyWarnings().length, 2); + assert.strictEqual( + lintExports().getConfigDependencyWarnings().length, + warnings.length + 1, + 'the new missing-dependency episode must add exactly one warning', + ); }); }); diff --git a/packages/vscode/e2e/suite-fmt-missing-config-dependency/fmt-missing-config-dependency.test.ts b/packages/vscode/e2e/suite-fmt-missing-config-dependency/fmt-missing-config-dependency.test.ts index 0adf5d8..01dc982 100644 --- a/packages/vscode/e2e/suite-fmt-missing-config-dependency/fmt-missing-config-dependency.test.ts +++ b/packages/vscode/e2e/suite-fmt-missing-config-dependency/fmt-missing-config-dependency.test.ts @@ -17,9 +17,21 @@ suite('fmt missing config dependency', () => { exports.configDependencyWarnings as () => readonly string[]; const folder = vscode.workspace.workspaceFolders?.[0]; assert.ok(folder, 'fmt fixture workspace is unavailable'); + const observedStates: string[] = []; + const sampleState = (): string => { + const state = folderStates()[folder.uri.fsPath]; + observedStates.push(state); + return state; + }; await eventually(() => { - assert.equal(folderStates()[folder.uri.fsPath], 'running'); + const state = sampleState(); + assert.notEqual( + state, + 'crashed', + 'rs fmt became crashed while waiting for running', + ); + assert.equal(state, 'running'); }, 'the rs fmt server to start'); const uri = vscode.Uri.joinPath(folder.uri, 'src', 'needs-format.ts'); @@ -31,8 +43,17 @@ suite('fmt missing config dependency', () => { ); await eventually(() => { - assert.equal(folderStates()[folder.uri.fsPath], 'disabled'); + const state = sampleState(); + assert.notEqual( + state, + 'crashed', + 'rs fmt became crashed while waiting for disabled', + ); + assert.equal(state, 'disabled'); }, 'the fmt config dependency failure to become disabled'); + // eventually retries thrown assertions, so retain every sample and check + // outside it: a transient crash must not disappear behind later recovery. + assert.ok(!observedStates.includes('crashed'), observedStates.join(' -> ')); assert.equal(suppressedConfigDependencyMessages(), 1); const warnings = configDependencyWarnings(); assert.equal(warnings.length, 1); diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index e419239..8d1e3d2 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -69,6 +69,8 @@ class RslintController implements StackController { #snapshot: DetectionSnapshot | undefined; readonly #subscriptions: vscode.Disposable[] = []; readonly #folderStates = new Map(); + /** E2E-only record of the controller-level not-installed warning episodes. */ + readonly #notInstalledWarnings: string[] = []; // Mirror of the live runtimes, kept here (not on the router) so answering // "does this document's server advertise hover?" needs no new surface on the // upstream-copied WorkspaceDocumentRouter. Reachability is still gated by @@ -161,6 +163,9 @@ class RslintController implements StackController { [...this.#runtimes.values()].flatMap((runtime) => runtime.getConfigDependencyWarnings(), ), + getNotInstalledWarnings: (): readonly string[] => [ + ...this.#notInstalledWarnings, + ], }; } @@ -202,14 +207,14 @@ class RslintController implements StackController { previous.reason !== (status.kind === 'disabled' ? status.reason : undefined) ) { - logger.warn( - formatNotInstalledLog( - missing, - workspaceFolder.name, - workspaceFolder.uri.fsPath, - `${document.uri} ${keeping ? `keeps ${keeping}` : 'will not lint'} until it is installed`, - ), + const warning = formatNotInstalledLog( + missing, + workspaceFolder.name, + workspaceFolder.uri.fsPath, + `${document.uri} ${keeping ? `keeps ${keeping}` : 'will not lint'} until it is installed`, ); + logger.warn(warning); + this.#notInstalledWarnings.push(warning); } } else { logger.error( From 4699bf7062250092e263791750cb33c39cc4f193 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 11:06:35 +0800 Subject: [PATCH 09/44] fix(vscode): complete recovery episode contracts --- .../0005-not-installed-recovery-by-polling.md | 11 ++- packages/vscode/AGENTS.md | 3 +- .../fmt-missing-config-dependency.test.ts | 10 +++ packages/vscode/src/stacks/fmt/index.ts | 1 + .../vscode/src/stacks/fmt/sessionError.ts | 11 ++- packages/vscode/src/stacks/lint/Rslint.ts | 10 +++ packages/vscode/src/stacks/test/project.ts | 2 +- .../tests/stacks/fmt/sessionError.test.ts | 39 +++++++++- .../vscode/tests/stacks/lint/start.test.ts | 78 +++++++++++++++++++ .../vscode/tests/stacks/test/project.test.ts | 11 +++ 10 files changed, 164 insertions(+), 12 deletions(-) create mode 100644 packages/vscode/tests/stacks/lint/start.test.ts diff --git a/docs/adr/0005-not-installed-recovery-by-polling.md b/docs/adr/0005-not-installed-recovery-by-polling.md index ba22c20..2850fd8 100644 --- a/docs/adr/0005-not-installed-recovery-by-polling.md +++ b/docs/adr/0005-not-installed-recovery-by-polling.md @@ -20,13 +20,20 @@ The watcher's original rationale was also factually wrong. At Microsoft VS Code **Decision.** The extension shell owns one recursive 10-second timer. It exists only while any live controller's raw folder/project state says dependencies are not installed, enters the shell's existing serialized queue, and forces the same detection notification as a lockfile event even when the detection signature is unchanged. The three stacks reuse their existing dependency-change paths: lint reconciles open documents and refreshes config dependencies, fmt restarts failed folder runtimes in place, and Rstest re-resolves shims and retries failed config evaluation. The timer stops as soon as no not-installed state remains. Lockfile watchers stay as the lower-latency path. -The aggregate status is deliberately not the predicate: a crash or version mismatch can outrank an unrelated missing folder. Polling must continue until the raw not-installed state itself clears. Warnings are deduplicated per unresolved episode so a persistent missing install does not add a line every ten seconds. The restart hint remains in the status as an explicit fallback. +The aggregate status is deliberately not the predicate: a crash or version mismatch can outrank an unrelated missing folder. Polling continues while any raw state is not installed and stops when none is, including when a retry replaces not-installed with a real config error surfaced in status and Output for the user to fix. Warnings are deduplicated per unresolved episode so a persistent missing install does not add a line every ten seconds. The restart hint remains in the status as an explicit fallback. fmt has one tool-forced limitation. Restarting `rs fmt --lsp` re-runs package resolution, but the server loads project config lazily on the next formatting request. A poll can therefore move the folder to `running` before config loading has been proved; the next format either succeeds or reports the same config failure and returns the folder to `disabled`, which restarts polling. -## Rejected alternatives +## Considered options - **Direct `node_modules` watchers** — rejected by the experiment: they recovered npm but missed both pnpm layouts. - **Package-manager marker files** such as `node_modules/.modules.yaml`, `node_modules/.package-lock.json`, or `.yarn-integrity` — rejected because each covers one installer/layout and makes recovery depend on private install artifacts rather than the state being recovered. - **Retry on window focus** — rejected because an install can finish while focus never leaves VS Code, and unrelated focus changes would cause unbounded retries. - **Bundled tool fallbacks** — rejected by the resolve-from-project contract: editor and CLI must run the same installed versions. + +## Consequences + +- Healthy workspaces incur no polling work. An unresolved workspace retries at most once per timer interval, through the existing serialized shell queue. +- Recovery no longer depends on installer-specific file events; lockfile watchers remain the faster path when they do fire. +- A new, real config error replaces not-installed and stops its poll. Fixing that error still uses config events or the explicit restart command. +- fmt cannot prove config recovery at initialize time. Only a later format producing edits ends its warning episode; empty edits are ambiguous because the server uses them for both no-op formatting and failures whose showMessage may already have been sent. diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index bee1133..5ff2abf 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -17,7 +17,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten 4. **Status aggregation** — stacks own no UI chrome; they report to the shell's single status bar item, which always exists. In CI the test stack's `MasterLogger` also mirrors every entry to stderr (`RSTACK_E2E_MIRROR_LOGS=1`, set by `e2e/rstest/runTest.ts`) — the output channel is unreadable there; rationale in `stacks/test/logger.ts`. 5. **Worker-cwd decoupling** (test) — a project's cwd is explicit, not derived from the config file path; for native configs behavior stays byte-identical to upstream. 6. **Node runtime selection** (lint, test, fmt) — the Node a project-loading child process runs on is a **User Node runtime** chosen by the extension against one uniform floor, never assumed from PATH; the recovery path is the user's own shell, and the dividing line is the **load bound** (terms in CONTEXT.md; the full rule and rationale in `docs/adr/0001-node-runtime-selection.md`). All three callers — the lint worker, the rstest worker and the `rs fmt --lsp` server — take the decision from the one shared module (`shared/nodeResolution.ts`) and share one escape hatch, the resource-scoped `rstack.nodeExecutable` (`shared/nodeExecutableSetting.ts`); each appends its own consequence to the shared preflight message. -7. **Lint worker and Rstack bridge** — the extension host is only Rslint's language client. One vscode-free, editor-shipped lint worker per **Lint runtime** (one Rslint core inside one workspace folder — CONTEXT.md) runs on the User Node runtime, owns the Go LSP plus all five reverse requests, and derives the binary/config/plugin pieces from one explicit `@rslint/core` directory. Upstream's `CoreResolver` loads that core in the extension host; ours only walks to the directory (`fs.stat` + `package.json` + semver) and hands the path to the worker, and its `CoreInstallation` therefore carries paths, not module factories; upstream's installation cache goes with the module loading it memoized (`clear()` is a no-op kept for the `RuntimeManager` contract). A bridged folder passes only rstack's published `dist/rslintConfig.js` shim; neither the extension nor the worker re-implements Rstack config semantics. Because every supported config protocol locks `configPath` per process, the shim is part of the runtime key (`folder + core identity + shim`), which upstream — having no bridge — keys on the core alone. Why: `docs/adr/0003-lint-through-editor-worker.md`. +7. **Lint worker and Rstack bridge** — the extension host is only Rslint's language client. One vscode-free, editor-shipped lint worker per **Lint runtime** (one Rslint core inside one workspace folder — CONTEXT.md) runs on the User Node runtime, owns the Go LSP plus all five reverse requests, and derives the binary/config/plugin pieces from one explicit `@rslint/core` directory. Upstream's `CoreResolver` loads that core in the extension host; ours only walks to the directory (`fs.stat` + `package.json` + semver) and hands the path to the worker, and its `CoreInstallation` therefore carries paths, not module factories; upstream's installation cache goes with the module loading it memoized (`clear()` is a no-op kept for the `RuntimeManager` contract). A bridged folder passes only rstack's published `dist/rslintConfig.js` shim; neither the extension nor the worker re-implements Rstack config semantics. Because every supported config protocol locks `configPath` per process, the shim is part of the runtime key (`folder + core identity + shim`), which upstream — having no bridge — keys on the core alone. Why: `docs/adr/0003-lint-through-editor-worker.md`. The worker also sends the editor-only `rstack/rslintConfigDependency` notification (`stacks/lint/worker/configDependencyProtocol.ts`) when config loading finds a missing package. `ConfigTransactionAdapter` rewrites only that classified `rslint/loadConfigs` candidate's error message to its first line, so Go cannot echo a require stack beside the single warning. An initialized client whose initial configRefresh rejects with that verdict stays available for retry, rather than propagating a generic startup crash through RuntimeManager. 8. **Self-documenting Rslint diagnostics** — client-side providers parse Inline directives into per-rule hover, DocumentLink and underline-decoration affordances (the hover renders `Rslint(rule-id)`, the shape VS Code gives the published diagnostics), and the router enriches today's `[rule-id] message` diagnostics with a derived Rule docs link. No rule metadata or network lookup is bundled (ADR 0004). The hover provider yields whenever the owning language client's resolved capabilities advertise `hoverProvider`; an optional `Rslint.onClosed` hook identity-safely prunes the controller's capability mirror; the diagnostic synthesis is removed once upstream publishes `code` / `codeDescription` natively. 9. **Color env parity with the CLI** (test) — upstream hard-codes `FORCE_COLOR: '1'` into the worker's spawn env; ours mirrors the CLI's `getForceColorEnv` (rstest `packages/core/src/utils/logger.ts`) instead (`stacks/test/shared/colorEnv.ts`): the master injects `FORCE_COLOR=1` into the composed spawn env only when neither `FORCE_COLOR` nor `NO_COLOR` is already set (marking the injection with `RSTACK_FORCE_COLOR_INJECTED`), and the worker retracts the marked injection right after config load if the config set `NO_COLOR` — the CLI's own decision point. Otherwise a project whose config sets `process.env.NO_COLOR` (rstack-cli does) hits Node's "'NO_COLOR' env is ignored" warning in every pool process. A user-set `FORCE_COLOR` beside a config-set `NO_COLOR` still warns, exactly as the bare CLI does. @@ -39,6 +39,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten ## Gotchas — decisions that look wrong but aren't +- fmt's `handleShowMessage` suppresses only the classified config-dependency Error. Non-classified `window/showMessage` notifications are re-presented exactly as vscode-languageclient's default handler would (Error/Warning/Info toast). This passes through the server's protocol UI request unchanged; "stacks own no UI chrome" constrains UI the stack originates, not protocol UI it relays. `stacks/fmt/sessionError.ts`, like `binEntry.ts` and `status.ts`, stays pure and vscode-free for unit testing; it mirrors LSP MessageType constants because the languageclient runtime import loads `vscode`. Warning episodes end on nonempty formatting edits, not merely a response with no new notification: the server returns empty edits on failure and deduplicates showMessage. - The lint × `rstack.config.*` bridge stays thin on purpose: only a root Rstack config can claim a bridged folder, any native config anywhere in the folder wins ownership, and the worker evaluates rstack's published shim from the folder root. Never generate a shim, load the Rstack config in the extension host, or interpret `define.lint()` ourselves. - **Yarn Plug'n'Play is unsupported by decision, extension-wide.** Every stack resolves through physical `node_modules` (`shared/packageResolve.ts`, `resolution.ts`'s rstack → `@rslint/core` chain, the fmt bin probe, the rstest package lookup) and the lint worker's own `createRequire` from the core directory does too. Lint once carried a `.pnp.cjs` branch for the find-`@rslint/core` hop only; nothing after that hop (config evaluation, plugin resolution, the other stacks) had PnP hooks, so it never produced a working folder, and upstream removed its own PnP path in the same refactor that introduced `corePath`. Real support would be a PnP editor-SDK-shaped project across all three stacks, not a resolver branch — do not reintroduce one. - **A Lint runtime lives as long as a document needs it, and a folder with none is `running: idle`.** Since the #1617 sync, `RuntimeManager` refcounts each runtime by open document: the first document to resolve a core starts one, the last to release it closes it, so a detected folder with nothing open holds zero workers and zero Go processes. That folder still reports `running` — with the detail `idle` — because it is live and will start a runtime on the next `didOpen`; do **not** add a `StackState` kind for it (the shell's status bar and `when` clauses read the kinds, and idle is not a kind of health). A folder's state is the **worst of** its runtimes plus any document whose core resolution currently fails (last-good: that document keeps the runtime it already had), so one failing core is never masked by a healthy sibling — the same invariant fmt pins across folders, applied inside one and across them alike (lint's rank table matches fmt's: `disabled` there means "a package is not installed" — no `rstack`, or no `@rslint/core` — not the kill switch). Dependency retries come only through the shell's detection pass: lockfile events are the low-latency path and ADR 0005's conditional poll covers unchanged lockfiles. The former lint-owned `node_modules/@rslint/core/package.json` watcher was removed because pnpm produced no event in either isolated or hoisted layout. Failures report through the status only: upstream's `window.showWarningMessage` is dropped, since stacks own no UI chrome. Consequently `whenStackActive('rslint')` means "the controller registered its folders", not "a server is up" — E2E suites open a document and await diagnostics. diff --git a/packages/vscode/e2e/suite-fmt-missing-config-dependency/fmt-missing-config-dependency.test.ts b/packages/vscode/e2e/suite-fmt-missing-config-dependency/fmt-missing-config-dependency.test.ts index 01dc982..73d1737 100644 --- a/packages/vscode/e2e/suite-fmt-missing-config-dependency/fmt-missing-config-dependency.test.ts +++ b/packages/vscode/e2e/suite-fmt-missing-config-dependency/fmt-missing-config-dependency.test.ts @@ -59,5 +59,15 @@ suite('fmt missing config dependency', () => { assert.equal(warnings.length, 1); assert.match(warnings[0], /missing-fmt-config-dependency/); assert.ok(!warnings[0].includes('\n'), 'warning must remain one line'); + + // The server deduplicates identical showMessage errors, but still returns + // empty edits. A silent second response must not clear the warning latch. + await vscode.commands.executeCommand( + 'vscode.executeFormatDocumentProvider', + uri, + { tabSize: 2, insertSpaces: true }, + ); + assert.equal(sampleState(), 'disabled'); + assert.equal(configDependencyWarnings().length, 1); }); }); diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index b8da641..5c72e6a 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -568,6 +568,7 @@ class FmtFolderRuntime { this.#configDependencyEpisode, suppressedBeforeRequest, this.#suppressedShowMessages, + edits?.length ?? 0, ) && this.#state === 'disabled' ) { diff --git a/packages/vscode/src/stacks/fmt/sessionError.ts b/packages/vscode/src/stacks/fmt/sessionError.ts index d915752..54f2869 100644 --- a/packages/vscode/src/stacks/fmt/sessionError.ts +++ b/packages/vscode/src/stacks/fmt/sessionError.ts @@ -108,13 +108,16 @@ export const handleFmtShowMessage = ( }; /** - * Ends the warning episode only when this formatting request completed - * without another classified show-message notification. A failed config load - * also resolves with empty edits, so the response alone is not success. + * Nonempty edits prove formatting succeeded. Empty edits are ambiguous: the + * server also returns them on failure and deduplicates showMessage, so absence + * of a new notification cannot prove recovery on a repeated request. */ export const finishSuccessfulFormatting = ( episode: ConfigDependencyEpisode, suppressedBeforeRequest: number, suppressedAfterRequest: number, + editCount: number, ): boolean => - suppressedBeforeRequest === suppressedAfterRequest && episode.clear(); + editCount > 0 && + suppressedBeforeRequest === suppressedAfterRequest && + episode.clear(); diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 130eccf..9891863 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -581,6 +581,16 @@ export class Rslint implements Disposable { this.logger.info('Rslint language client started successfully'); if (!this.hasConfigDependencyFailure()) this.reportRunning(); } catch (error: unknown) { + // Keep the initialized runtime available for configRefresh retries. + // Rethrowing this classified rejection would make RuntimeManager close + // it and onDocumentFailure replace disabled with a generic crash. + if ( + !this.isPlannedStartAbort(error) && + this.hasConfigDependencyFailure() && + client.state === State.Running + ) { + return; + } // A close or supersede during start is a planned abort, not a failure; // logging it as an error made every teardown race look like a crash. if ( diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts index 22d9d75..7d87eb5 100644 --- a/packages/vscode/src/stacks/test/project.ts +++ b/packages/vscode/src/stacks/test/project.ts @@ -604,7 +604,7 @@ export class Project implements vscode.Disposable { } this.configLoadFailed = false; this.#configDependencyCause = undefined; - status.installed(this.configDependencyStatusSource); + status.forget(this.configDependencyStatusSource); this.root = vscode.Uri.file(result.root); this.include = result.include; this.exclude = result.exclude; diff --git a/packages/vscode/tests/stacks/fmt/sessionError.test.ts b/packages/vscode/tests/stacks/fmt/sessionError.test.ts index b685288..d701fe6 100644 --- a/packages/vscode/tests/stacks/fmt/sessionError.test.ts +++ b/packages/vscode/tests/stacks/fmt/sessionError.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from '@rstest/core'; +import { describe, expect, it, rs } from '@rstest/core'; +import vscode from 'vscode'; import { ConfigDependencyEpisode } from '../../../src/shared/notInstalled'; import { classifyFmtSessionError, @@ -8,6 +9,16 @@ import { showMessagePresentation, } from '../../../src/stacks/fmt/sessionError'; +rs.mock('vscode', () => ({ + default: { + window: { + showErrorMessage: rs.fn(), + showWarningMessage: rs.fn(), + showInformationMessage: rs.fn(), + }, + }, +})); + describe('classifyFmtSessionError', () => { const root = '/project'; const configPath = '/project/rstack.config.ts'; @@ -91,7 +102,16 @@ describe('handleFmtShowMessage', () => { { type: 2 as const, message: 'deprecated option' }, { type: 3 as const, message: 'formatter ready' }, ]) { - handleFmtShowMessage(message, '/project', undefined, handler); + handleFmtShowMessage(message, '/project', '/project/rstack.config.ts', { + ...vscode.window, + onConfigDependency: handler.onConfigDependency, + }); + handleFmtShowMessage( + message, + '/project', + '/project/rstack.config.ts', + handler, + ); } expect(shown).toEqual([ @@ -100,6 +120,15 @@ describe('handleFmtShowMessage', () => { 'information:formatter ready', ]); expect(stateChanges).toBe(0); + expect(vscode.window.showErrorMessage).toHaveBeenCalledExactlyOnceWith( + 'bad config syntax', + ); + expect(vscode.window.showWarningMessage).toHaveBeenCalledExactlyOnceWith( + 'deprecated option', + ); + expect( + vscode.window.showInformationMessage, + ).toHaveBeenCalledExactlyOnceWith('formatter ready'); }); }); @@ -108,10 +137,12 @@ describe('finishSuccessfulFormatting', () => { const episode = new ConfigDependencyEpisode(); episode.observe('fmt', 'rstack.config.ts', "Cannot find package 'missing'"); - expect(finishSuccessfulFormatting(episode, 0, 1)).toBe(false); + expect(finishSuccessfulFormatting(episode, 0, 1, 0)).toBe(false); + expect(episode.active).toBe(true); + expect(finishSuccessfulFormatting(episode, 1, 1, 0)).toBe(false); expect(episode.active).toBe(true); - expect(finishSuccessfulFormatting(episode, 1, 1)).toBe(true); + expect(finishSuccessfulFormatting(episode, 1, 1, 1)).toBe(true); expect(episode.active).toBe(false); expect( episode.observe( diff --git a/packages/vscode/tests/stacks/lint/start.test.ts b/packages/vscode/tests/stacks/lint/start.test.ts new file mode 100644 index 0000000..abc4fa7 --- /dev/null +++ b/packages/vscode/tests/stacks/lint/start.test.ts @@ -0,0 +1,78 @@ +import { expect, it, rs } from '@rstest/core'; +import type { StackState } from '../../../src/types'; +import type { RslintOptions } from '../../../src/stacks/lint/Rslint'; + +rs.mock('vscode', () => ({ + RelativePattern: class {}, + workspace: { + createFileSystemWatcher: () => ({ + onDidCreate() {}, + onDidChange() {}, + onDidDelete() {}, + }), + }, + env: {}, +})); +rs.mock('../../../src/shared/nodeExecutableSetting', () => ({ + getConfiguredNodeExecutable: () => undefined, +})); +rs.mock('../../../src/shared/nodeResolution', () => ({ + resolveUserNodeOnce: async () => ({ executable: 'node' }), +})); +rs.mock('vscode-languageclient/node', () => ({ + State: { Running: 2, Stopped: 1 }, + LanguageClient: class { + state = 2; + private notification: ((value: unknown) => void) | undefined; + onNotification(_method: unknown, callback: (value: unknown) => void) { + this.notification = callback; + } + onDidChangeState() { + return { dispose() {} }; + } + createDefaultErrorHandler() { + return {}; + } + async start() {} + async sendRequest() { + this.notification?.({ + failure: { + configPath: '/project/rslint.config.mjs', + cause: "Cannot find package 'missing'", + }, + }); + throw new Error('configRefresh rejected'); + } + }, +})); + +import { Rslint } from '../../../src/stacks/lint/Rslint'; + +it('keeps an initialized runtime disabled when initial configRefresh rejects', async () => { + const states: StackState[] = []; + const warnings: string[] = []; + const errors: unknown[] = []; + const runtime = new Rslint({ + rootKey: '/project/core', + workspaceFolder: { name: 'project', uri: { fsPath: '/project' } }, + installation: { mode: 'native', packageDirectory: '/project/core' }, + router: { createMiddleware: () => ({}) }, + logger: { + info() {}, + debug() {}, + warn: (message: string) => warnings.push(message), + error: (...args: unknown[]) => errors.push(args), + }, + reportStatus: (state: StackState) => states.push(state), + } as unknown as RslintOptions); + + await runtime.start(new AbortController().signal); + expect(states.at(-1)?.kind).toBe('disabled'); + expect(states.some((state) => state.kind === 'crashed')).toBe(false); + expect(warnings).toHaveLength(1); + expect(errors).toEqual([]); + await expect(runtime.retryConfigDependency()).rejects.toThrow( + 'configRefresh rejected', + ); + expect(warnings).toHaveLength(1); +}); diff --git a/packages/vscode/tests/stacks/test/project.test.ts b/packages/vscode/tests/stacks/test/project.test.ts index c68de41..d2d1453 100644 --- a/packages/vscode/tests/stacks/test/project.test.ts +++ b/packages/vscode/tests/stacks/test/project.test.ts @@ -329,6 +329,17 @@ describe('Project config/cwd/package-resolution decoupling', () => { 'Cannot load templates/app/rstest.config.ts: Unexpected token export', }); + normalizedConfigFailure = undefined; + normalizedConfigResult = { + ok: true, + root: '/repo/templates/app', + include: ['**/*.test.ts'], + exclude: [], + childProjects: [], + }; + await project.retryFailedConfig(); + expect(reported.at(-1)).toEqual({ kind: 'running', detail: undefined }); + project.dispose(); status.unbind(); }); From 48fad68015e4258f74da63cf26b6d19841676c8b Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 13:15:25 +0800 Subject: [PATCH 10/44] test(vscode): spawn pnpm portably in recovery E2E --- .../suite-dependency-recovery/dependency-recovery.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/vscode/e2e/lint/suite-dependency-recovery/dependency-recovery.test.ts b/packages/vscode/e2e/lint/suite-dependency-recovery/dependency-recovery.test.ts index 7d56e02..ea0672a 100644 --- a/packages/vscode/e2e/lint/suite-dependency-recovery/dependency-recovery.test.ts +++ b/packages/vscode/e2e/lint/suite-dependency-recovery/dependency-recovery.test.ts @@ -66,7 +66,13 @@ suite('Rslint dependency polling recovery', function () { await execFile( 'pnpm', ['install', '--frozen-lockfile', '--ignore-scripts'], - { cwd: root, timeout: 90_000 }, + { + cwd: root, + timeout: 90_000, + // Match setupFixtures.mjs/run.mjs: Windows needs a shell for pnpm's + // .cmd shim. All arguments are fixed safe tokens; cwd is not interpolated. + shell: process.platform === 'win32', + }, ); assert.deepStrictEqual( From 876a02df748f6eb5da72247ce06dc255e20c59f7 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 13:29:07 +0800 Subject: [PATCH 11/44] fix(vscode): preserve rejected config refresh errors --- packages/vscode/src/stacks/lint/Rslint.ts | 27 ++++++++++++++-- .../lint/worker/configDependencyProtocol.ts | 2 ++ .../vscode/src/stacks/lint/worker/index.ts | 13 +++++++- .../vscode/tests/stacks/lint/start.test.ts | 31 +++++++++++++++++++ .../vscode/tests/stacks/lint/worker.test.ts | 2 +- 5 files changed, 71 insertions(+), 4 deletions(-) diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 9891863..142064d 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -337,6 +337,8 @@ export class Rslint implements Disposable { private lifecycleEpoch = 0; private advisory: string | undefined; private readonly configDependencyEpisode = new ConfigDependencyEpisode(); + private configRefreshFailed = false; + private reportedConfigErrors = 0; private startPromise: Promise | undefined; private startOperation: Promise | undefined; private clientStartPromise: Promise | undefined; @@ -361,6 +363,7 @@ export class Rslint implements Disposable { } private reportRunning(): void { + if (this.configRefreshFailed) return; this.report(runningRslintStatus(this.advisory)); } @@ -381,10 +384,22 @@ export class Rslint implements Disposable { private handleConfigDependencyStatus( notification: ConfigDependencyStatusNotification, ): void { + if (notification.error !== undefined) { + this.configRefreshFailed = true; + this.reportedConfigErrors++; + this.report({ kind: 'crashed', detail: notification.error }); + this.configDependencyEpisode.clear(); + this.logger.error( + `Failed to refresh config discovery: ${notification.error}`, + ); + return; + } + const wasFailed = this.configRefreshFailed; + this.configRefreshFailed = false; const failure = notification.failure; if (failure === null) { const wasMissing = this.configDependencyEpisode.clear(); - if (wasMissing && this.isRunning()) this.reportRunning(); + if ((wasMissing || wasFailed) && this.isRunning()) this.reportRunning(); return; } const displayPath = this.displayConfigPath(failure.configPath); @@ -685,7 +700,15 @@ export class Rslint implements Disposable { if (!client) return; const refresh = this.configReloadChain.then(async () => { if (!this.isLifecycleCurrent(epoch, client)) return; - await client.sendRequest('rslint/configRefresh', { reason }); + const reportedBefore = this.reportedConfigErrors; + try { + await client.sendRequest('rslint/configRefresh', { reason }); + } catch (error) { + // The worker verdict already surfaced this rejection as a real config + // error. Keep the live runtime for config edits without duplicate logs + // or a generic startup failure replacing its precise status. + if (this.reportedConfigErrors === reportedBefore) throw error; + } }); this.configReloadChain = refresh.catch(() => undefined); await refresh; diff --git a/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts b/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts index 4a143d3..5c12850 100644 --- a/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts +++ b/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts @@ -8,4 +8,6 @@ export interface ConfigDependencyFailure { export interface ConfigDependencyStatusNotification { readonly failure: ConfigDependencyFailure | null; + /** Present only when refresh rejected without a classified dependency cause. */ + readonly error?: string; } diff --git a/packages/vscode/src/stacks/lint/worker/index.ts b/packages/vscode/src/stacks/lint/worker/index.ts index df29c39..aa3583e 100644 --- a/packages/vscode/src/stacks/lint/worker/index.ts +++ b/packages/vscode/src/stacks/lint/worker/index.ts @@ -177,9 +177,20 @@ export function registerEditorProxy( ); return result; } catch (error) { + const failure = options.takeConfigDependencyFailure() ?? null; await editorConnection.sendNotification( CONFIG_DEPENDENCY_STATUS_NOTIFICATION, - { failure: options.takeConfigDependencyFailure() ?? null }, + { + failure, + ...(failure === null + ? { + error: (error instanceof Error + ? error.message + : String(error) + ).split('\n', 1)[0], + } + : {}), + }, ); throw error; } diff --git a/packages/vscode/tests/stacks/lint/start.test.ts b/packages/vscode/tests/stacks/lint/start.test.ts index abc4fa7..08a7e38 100644 --- a/packages/vscode/tests/stacks/lint/start.test.ts +++ b/packages/vscode/tests/stacks/lint/start.test.ts @@ -2,6 +2,8 @@ import { expect, it, rs } from '@rstest/core'; import type { StackState } from '../../../src/types'; import type { RslintOptions } from '../../../src/stacks/lint/Rslint'; +let refreshOutcome: 'missing' | 'broken' | 'fixed' = 'missing'; + rs.mock('vscode', () => ({ RelativePattern: class {}, workspace: { @@ -35,6 +37,14 @@ rs.mock('vscode-languageclient/node', () => ({ } async start() {} async sendRequest() { + if (refreshOutcome !== 'missing') { + this.notification?.({ + failure: null, + ...(refreshOutcome === 'broken' ? { error: 'Invalid config' } : {}), + }); + if (refreshOutcome === 'broken') throw new Error('Invalid config'); + return; + } this.notification?.({ failure: { configPath: '/project/rslint.config.mjs', @@ -75,4 +85,25 @@ it('keeps an initialized runtime disabled when initial configRefresh rejects', a 'configRefresh rejected', ); expect(warnings).toHaveLength(1); + + refreshOutcome = 'broken'; + const beforeBroken = states.length; + await runtime.retryConfigDependency(); + expect(states.slice(beforeBroken).map((state) => state.kind)).toEqual([ + 'crashed', + ]); + expect(runtime.hasConfigDependencyFailure()).toBe(false); + expect(errors).toEqual([ + ['Failed to refresh config discovery: Invalid config'], + ]); + + refreshOutcome = 'fixed'; + // Config-file events use this same refresh path after dependency polling stops. + await ( + runtime as unknown as { + requestConfigRefresh(reason: string): Promise; + } + ).requestConfigRefresh('config-change'); + expect(states.at(-1)?.kind).toBe('running'); + expect(errors).toHaveLength(1); }); diff --git a/packages/vscode/tests/stacks/lint/worker.test.ts b/packages/vscode/tests/stacks/lint/worker.test.ts index 6ef8826..350ea22 100644 --- a/packages/vscode/tests/stacks/lint/worker.test.ts +++ b/packages/vscode/tests/stacks/lint/worker.test.ts @@ -210,7 +210,7 @@ describe('lint worker config refresh', () => { expect(observedReasons).toEqual(['config-change', 'reject']); expect(notifications).toEqual([ { failure: notificationFailure }, - { failure: null }, + { failure: null, error: 'refresh rejected' }, ]); const shutdown = await editorConnection.sendRequest<{ From e129dbf09d2e38b478d04bc33ca66716d08b687f Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 13:31:35 +0800 Subject: [PATCH 12/44] fix(vscode): limit config retries and close failed workers --- packages/vscode/AGENTS.md | 2 ++ packages/vscode/src/stacks/test/master.ts | 14 +++++++------ packages/vscode/src/stacks/test/project.ts | 8 ++++++-- .../vscode/tests/stacks/test/master.test.ts | 20 +++++++++++++++++++ .../vscode/tests/stacks/test/project.test.ts | 10 ++++++++++ 5 files changed, 46 insertions(+), 8 deletions(-) diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 5ff2abf..17b8667 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -9,6 +9,8 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - **Tracked upstream state.** `stacks/lint` is synced to web-infra-dev/rslint `packages/vscode-extension` at **39536fd6** (#1617 — per-document core resolution, `CoreResolver` + `RuntimeManager`, `corePath`, PnP removed) and **892482e0** (#1630 — `configPath` on `rslint/configRefresh`). Targeted later ports are **84f9c9b5** (#1967 — languageclient-owned live LSP tracing) and **b7176723** (#1951 — remove legacy JSON config watching); the Unicode BOM E2E comes from **5fc197a5** (#1560), with its native-config fixture shape from **b7176723**. `CoreResolver.ts` / `RuntimeManager.ts` / `WorkspaceDocumentRouter.ts` / `Rslint.ts` are the files to diff when syncing further; record the new commits here when you do. - **Ahead of upstream — offer these back when syncing** (bug fixes, not adaptations): (1) `RuntimeManager.reconcile` resolves the document's core **before** sweeping pending uses (`planDocumentCore`), so a reconcile landing on the key a pending start is already producing adopts that start instead of tearing it down mid-`initialize` — the teardown made vscode-languageclient force-notify ("couldn't create connection to server") whenever the register-time pass, a detection change and `didOpen` landed inside one worker startup window (`tests/stacks/lint/runtimeManager.test.ts`). (2) `Rslint.close()` gives a still-Starting language client a bounded chance to settle before tearing down its transport, so a legitimate mid-start close (document closed during start, core key changed) stops cleanly instead of triggering the same force-notified toasts. (3) The registry-harness E2E gives its never-settling startup operation 500ms to begin and accepts only the in-flight timeout message, so a stalled runner cannot satisfy the assertion through the already-expired path (`e2e/lint/suite/registry-harness.test.ts`). (4) `Project.retryFailedConfig()` keeps a failed Rstest project and retries its config evaluation in place with one single-flight promise, so repeated dependency-change passes neither overlap workers nor repeat an unchanged not-installed warning. +- **Targeted Rstest lifecycle port:** `RstestApi.getNormalizedConfig()` closes its worker in `finally`, including rejected config evaluation, matching web-infra-dev/rstest `packages/vscode/src/master.ts` at `d82db4fc31a61ee74b2a74917f14a458e1bca419`. This fixes a leak in our older copy; it is already fixed upstream. Dependency passes retry only projects with a classified missing-config-dependency verdict; real config errors retain config-edit/restart recovery. + ## The nine adaptations 1. **Shell activation** — stacks never self-activate; `register()` returns fast and never blocks on starting a server/worker. diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index 399a770..b873069 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -472,12 +472,14 @@ export class RstestApi { public async getNormalizedConfig() { const { worker, rstestPath } = await this.createChildProcess(); - const result = await worker.getNormalizedConfig({ - rstestPath, - configFilePath: this.configFilePath, - }); - worker.$close(); - return result; + try { + return await worker.getNormalizedConfig({ + rstestPath, + configFilePath: this.configFilePath, + }); + } finally { + worker.$close(); + } } public async listTests(include?: string[]) { diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts index 7d87eb5..6c81dc0 100644 --- a/packages/vscode/src/stacks/test/project.ts +++ b/packages/vscode/src/stacks/test/project.ts @@ -238,14 +238,14 @@ export class WorkspaceManager implements vscode.Disposable { ); } /** - * Retries projects whose config evaluation failed — dependencies may have + * Retries projects whose config dependency is missing — dependencies may have * been installed since. The project keeps its identity and the retry is * single-flight, so repeated dependency signals cannot overlap workers or * repeat an unchanged not-installed warning. */ public retryFailedProjects() { for (const project of this.projects.values()) { - if (!project.configLoadFailed) continue; + if (!project.hasConfigDependencyFailure) continue; void project.retryFailedConfig(); } } @@ -647,6 +647,10 @@ export class Project implements vscode.Disposable { return pending; } + get hasConfigDependencyFailure(): boolean { + return this.#configDependencyCause !== undefined; + } + /** Re-evaluates a failed config without replacing this project. */ public retryFailedConfig(): Promise | undefined { if (!this.configLoadFailed) return undefined; diff --git a/packages/vscode/tests/stacks/test/master.test.ts b/packages/vscode/tests/stacks/test/master.test.ts index 3c65594..6e79f24 100644 --- a/packages/vscode/tests/stacks/test/master.test.ts +++ b/packages/vscode/tests/stacks/test/master.test.ts @@ -545,3 +545,23 @@ describe('RstestApi worker spawn failures', () => { expect(crashes()).toEqual([]); }); }); + +it('closes the config worker when config evaluation rejects', async () => { + const api = createApi(); + const close = rs.fn(); + rs.spyOn(api, 'createChildProcess').mockResolvedValue({ + rstestPath: '/project/rstest', + worker: { + getNormalizedConfig: async () => { + throw new SyntaxError('Invalid config'); + }, + $close: close, + }, + } as never); + try { + await expect(api.getNormalizedConfig()).rejects.toThrow('Invalid config'); + expect(close).toHaveBeenCalledTimes(1); + } finally { + api.dispose(); + } +}); diff --git a/packages/vscode/tests/stacks/test/project.test.ts b/packages/vscode/tests/stacks/test/project.test.ts index d2d1453..ccc9d4a 100644 --- a/packages/vscode/tests/stacks/test/project.test.ts +++ b/packages/vscode/tests/stacks/test/project.test.ts @@ -329,6 +329,16 @@ describe('Project config/cwd/package-resolution decoupling', () => { 'Cannot load templates/app/rstest.config.ts: Unexpected token export', }); + const { WorkspaceManager } = + await import('../../../src/stacks/test/project'); + const callsBeforePoll = normalizedConfigCalls; + WorkspaceManager.prototype.retryFailedProjects.call({ + projects: new Map([['config', project]]), + } as never); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(normalizedConfigCalls).toBe(callsBeforePoll); + expect(loggedErrors).toHaveLength(1); + normalizedConfigFailure = undefined; normalizedConfigResult = { ok: true, From 81ec4ae4d43b528bf2717ce5cfd33a32b14c4565 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 13:33:00 +0800 Subject: [PATCH 13/44] fix(vscode): attribute bridge failures to the root config --- packages/vscode/src/stacks/lint/index.ts | 11 ++++++++--- packages/vscode/src/stacks/lint/resolution.ts | 9 +++++++++ packages/vscode/tests/lintDetection.test.ts | 15 ++++++++++++++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index 8d1e3d2..26158ad 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -10,7 +10,7 @@ import { formatNotInstalledLog } from '../../shared/notInstalled'; import { CoreResolver, type ResolvedCoreRuntime } from './CoreResolver'; import { Logger } from './logger'; import { Rslint } from './Rslint'; -import type { RslintMode } from './resolution'; +import { rootRstackConfigPath, type RslintMode } from './resolution'; import { registerRuleDocumentationProviders } from './ruleDocumentationProviders'; import { formatCoreSelectionFailure, RuntimeManager } from './RuntimeManager'; import { @@ -276,8 +276,13 @@ class RslintController implements StackController { }, bridgeConfigPath: installation.mode === 'bridged' - ? this.#snapshot?.forFolder(workspaceFolder)?.stacks.rslint - .rstackConfigFiles[0]?.fsPath + ? rootRstackConfigPath( + workspaceFolder.uri.fsPath, + this.#snapshot + ?.forFolder(workspaceFolder) + ?.stacks.rslint.rstackConfigFiles.map((uri) => uri.fsPath) ?? + [], + ) : undefined, onClosed: () => { if (this.#runtimes.get(resolved.key) === runtime) { diff --git a/packages/vscode/src/stacks/lint/resolution.ts b/packages/vscode/src/stacks/lint/resolution.ts index e5860a1..efb7de2 100644 --- a/packages/vscode/src/stacks/lint/resolution.ts +++ b/packages/vscode/src/stacks/lint/resolution.ts @@ -7,6 +7,15 @@ import { export type RslintMode = 'native' | 'bridged'; +export function rootRstackConfigPath( + folderRoot: string, + configPaths: readonly string[], +): string | undefined { + return configPaths.find( + (configPath) => path.dirname(configPath) === folderRoot, + ); +} + export interface RslintResolution { readonly mode: RslintMode; readonly coreDir: string; diff --git a/packages/vscode/tests/lintDetection.test.ts b/packages/vscode/tests/lintDetection.test.ts index 5abaf34..03353cc 100644 --- a/packages/vscode/tests/lintDetection.test.ts +++ b/packages/vscode/tests/lintDetection.test.ts @@ -1,7 +1,20 @@ +import path from 'node:path'; import { describe, expect, it } from '@rstest/core'; -import { decideRslintMode } from '../src/stacks/lint/resolution'; +import { + decideRslintMode, + rootRstackConfigPath, +} from '../src/stacks/lint/resolution'; describe('Rslint folder ownership', () => { + it('attributes bridge failures to the root config regardless of discovery order', () => { + const folder = path.resolve('/workspace'); + const root = path.join(folder, 'rstack.config.ts'); + const nested = path.join(folder, 'packages', 'app', 'rstack.config.ts'); + expect(rootRstackConfigPath(folder, [nested, root])).toBe(root); + expect(rootRstackConfigPath(folder, [root, nested])).toBe(root); + expect(rootRstackConfigPath(folder, [nested])).toBeUndefined(); + }); + it('gives native config presence precedence anywhere in the folder', () => { expect( decideRslintMode({ From 6c0a9f0697cc89fe48d25acfc8f70dcaac0c9d11 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 13:33:57 +0800 Subject: [PATCH 14/44] fix(vscode): retain config source-change retries --- packages/vscode/src/stacks/lint/Rslint.ts | 7 ++++++- .../vscode/tests/stacks/lint/start.test.ts | 18 +++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 142064d..8e100b1 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -707,7 +707,12 @@ export class Rslint implements Disposable { // The worker verdict already surfaced this rejection as a real config // error. Keep the live runtime for config edits without duplicate logs // or a generic startup failure replacing its precise status. - if (this.reportedConfigErrors === reportedBefore) throw error; + // Source-change races must still reach the existing startup retry. + if ( + isConfigSourceChangeDuringTransaction(error) || + this.reportedConfigErrors === reportedBefore + ) + throw error; } }); this.configReloadChain = refresh.catch(() => undefined); diff --git a/packages/vscode/tests/stacks/lint/start.test.ts b/packages/vscode/tests/stacks/lint/start.test.ts index 08a7e38..b50cfc8 100644 --- a/packages/vscode/tests/stacks/lint/start.test.ts +++ b/packages/vscode/tests/stacks/lint/start.test.ts @@ -2,7 +2,7 @@ import { expect, it, rs } from '@rstest/core'; import type { StackState } from '../../../src/types'; import type { RslintOptions } from '../../../src/stacks/lint/Rslint'; -let refreshOutcome: 'missing' | 'broken' | 'fixed' = 'missing'; +let refreshOutcome: 'missing' | 'broken' | 'fixed' | 'changed' = 'missing'; rs.mock('vscode', () => ({ RelativePattern: class {}, @@ -37,6 +37,13 @@ rs.mock('vscode-languageclient/node', () => ({ } async start() {} async sendRequest() { + if (refreshOutcome === 'changed') { + this.notification?.({ + failure: null, + error: 'config changed while loading', + }); + throw new Error('config changed while loading'); + } if (refreshOutcome !== 'missing') { this.notification?.({ failure: null, @@ -106,4 +113,13 @@ it('keeps an initialized runtime disabled when initial configRefresh rejects', a ).requestConfigRefresh('config-change'); expect(states.at(-1)?.kind).toBe('running'); expect(errors).toHaveLength(1); + + refreshOutcome = 'changed'; + await expect( + ( + runtime as unknown as { + requestConfigRefresh(reason: string): Promise; + } + ).requestConfigRefresh('initial'), + ).rejects.toThrow('config changed while loading'); }); From 12d17474b6b0f2fa8a183024441f02f80e6c8ae9 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 13:57:28 +0800 Subject: [PATCH 15/44] fix(vscode): retry missing Rstest core installations --- packages/vscode/AGENTS.md | 2 +- packages/vscode/e2e/rstest/runTest.ts | 44 ++++++++++++- .../dependency-recovery.test.ts | 65 +++++++++++++++++++ .../rstest/suite-dependency-recovery/index.ts | 3 + packages/vscode/src/stacks/test/index.ts | 1 + packages/vscode/src/stacks/test/project.ts | 11 ++-- packages/vscode/src/stacks/test/status.ts | 6 +- .../vscode/tests/stacks/test/project.test.ts | 23 +++++++ 8 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 packages/vscode/e2e/rstest/suite-dependency-recovery/dependency-recovery.test.ts create mode 100644 packages/vscode/e2e/rstest/suite-dependency-recovery/index.ts diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 17b8667..af2006e 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -9,7 +9,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - **Tracked upstream state.** `stacks/lint` is synced to web-infra-dev/rslint `packages/vscode-extension` at **39536fd6** (#1617 — per-document core resolution, `CoreResolver` + `RuntimeManager`, `corePath`, PnP removed) and **892482e0** (#1630 — `configPath` on `rslint/configRefresh`). Targeted later ports are **84f9c9b5** (#1967 — languageclient-owned live LSP tracing) and **b7176723** (#1951 — remove legacy JSON config watching); the Unicode BOM E2E comes from **5fc197a5** (#1560), with its native-config fixture shape from **b7176723**. `CoreResolver.ts` / `RuntimeManager.ts` / `WorkspaceDocumentRouter.ts` / `Rslint.ts` are the files to diff when syncing further; record the new commits here when you do. - **Ahead of upstream — offer these back when syncing** (bug fixes, not adaptations): (1) `RuntimeManager.reconcile` resolves the document's core **before** sweeping pending uses (`planDocumentCore`), so a reconcile landing on the key a pending start is already producing adopts that start instead of tearing it down mid-`initialize` — the teardown made vscode-languageclient force-notify ("couldn't create connection to server") whenever the register-time pass, a detection change and `didOpen` landed inside one worker startup window (`tests/stacks/lint/runtimeManager.test.ts`). (2) `Rslint.close()` gives a still-Starting language client a bounded chance to settle before tearing down its transport, so a legitimate mid-start close (document closed during start, core key changed) stops cleanly instead of triggering the same force-notified toasts. (3) The registry-harness E2E gives its never-settling startup operation 500ms to begin and accepts only the in-flight timeout message, so a stalled runner cannot satisfy the assertion through the already-expired path (`e2e/lint/suite/registry-harness.test.ts`). (4) `Project.retryFailedConfig()` keeps a failed Rstest project and retries its config evaluation in place with one single-flight promise, so repeated dependency-change passes neither overlap workers nor repeat an unchanged not-installed warning. -- **Targeted Rstest lifecycle port:** `RstestApi.getNormalizedConfig()` closes its worker in `finally`, including rejected config evaluation, matching web-infra-dev/rstest `packages/vscode/src/master.ts` at `d82db4fc31a61ee74b2a74917f14a458e1bca419`. This fixes a leak in our older copy; it is already fixed upstream. Dependency passes retry only projects with a classified missing-config-dependency verdict; real config errors retain config-edit/restart recovery. +- **Targeted Rstest lifecycle port:** `RstestApi.getNormalizedConfig()` closes its worker in `finally`, including rejected config evaluation, matching web-infra-dev/rstest `packages/vscode/src/master.ts` at `d82db4fc31a61ee74b2a74917f14a458e1bca419`. This fixes a leak in our older copy; it is already fixed upstream. Dependency passes retry only projects with a not-installed latch under their core or config-import source; real config errors retain config-edit/restart recovery. ## The nine adaptations diff --git a/packages/vscode/e2e/rstest/runTest.ts b/packages/vscode/e2e/rstest/runTest.ts index 9c19941..7ce4bb8 100644 --- a/packages/vscode/e2e/rstest/runTest.ts +++ b/packages/vscode/e2e/rstest/runTest.ts @@ -18,7 +18,13 @@ * the shell probe never runs. */ import { createHash } from 'node:crypto'; -import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { + cpSync, + existsSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { runTests } from '@vscode/test-electron'; @@ -80,7 +86,7 @@ async function main() { )}\n`, ); - await runTests({ + const launchOptions = { // Pinnable for CI; `stable` locally. `runTests` forwards the whole options // object to the downloader, so `version`/`timeout`/`vscodeExecutablePath` // all apply to it. A cached download under `.vscode-test/` is reused. @@ -114,7 +120,39 @@ async function main() { '--user-data-dir', scratchDir, ], - }); + }; + await runTests(launchOptions); + + // Reuse setupFixtures.mjs's exact published pin and generated lockfile, + // but start this isolated workspace with no inherited node_modules. + const recoveryRoot = mkdtempSync(path.join(tmpdir(), 'rst-recovery-')); + const recoveryWorkspace = path.join(recoveryRoot, 'workspace'); + try { + cpSync(path.join(fixturesRoot, 'workspace-1'), recoveryWorkspace, { + recursive: true, + filter: (source) => path.basename(source) !== 'node_modules', + }); + await runTests({ + ...launchOptions, + extensionTestsPath: path.resolve( + __dirname, + './suite-dependency-recovery/index', + ), + launchArgs: [ + recoveryWorkspace, + ...launchOptions.launchArgs.slice(1, -2), + '--user-data-dir', + path.join(recoveryRoot, 'profile'), + ], + }); + } finally { + rmSync(recoveryRoot, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 200, + }); + } } main().catch((error) => { diff --git a/packages/vscode/e2e/rstest/suite-dependency-recovery/dependency-recovery.test.ts b/packages/vscode/e2e/rstest/suite-dependency-recovery/dependency-recovery.test.ts new file mode 100644 index 0000000..52ab78d --- /dev/null +++ b/packages/vscode/e2e/rstest/suite-dependency-recovery/dependency-recovery.test.ts @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import { execFile as execFileCallback } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import vscode from 'vscode'; +import type { RstackExtensionExports } from '../../../src/types'; +import { getProjectItems, getRstestExports, waitFor } from '../suite/helpers'; + +const execFile = promisify(execFileCallback); + +suite('Rstest dependency polling recovery', function () { + this.timeout(180_000); + + test('recovers after pnpm install without a restart command', async () => { + const folder = vscode.workspace.workspaceFolders?.[0]; + assert.ok(folder); + const root = folder.uri.fsPath; + assert.equal(fs.existsSync(path.join(root, 'node_modules')), false); + const extension = + vscode.extensions.getExtension('rstack.rstack'); + assert.ok(extension); + const api = await extension.activate(); + api.setDependencyPollIntervalForTest(250); + const rstest = await getRstestExports(); + const hasNotInstalled = api.getStackExports('rstest') + ?.hasNotInstalledState as () => boolean; + await waitFor(() => assert.equal(hasNotInstalled(), true)); + + const lockfile = path.join(root, 'pnpm-lock.yaml'); + const contents = fs.readFileSync(lockfile); + const mtime = fs.statSync(lockfile).mtimeMs; + const polls = api.getDependencyPollCountForTest(); + await execFile( + 'pnpm', + ['install', '--frozen-lockfile', '--ignore-scripts'], + { + cwd: root, + timeout: 90_000, + // Windows needs a shell for pnpm.cmd; arguments are fixed safe tokens. + shell: process.platform === 'win32', + }, + ); + assert.deepEqual( + fs.readFileSync(lockfile), + contents, + 'lockfile contents changed', + ); + assert.equal( + fs.statSync(lockfile).mtimeMs, + mtime, + 'lockfile mtime changed', + ); + + await waitFor( + () => { + assert.equal(hasNotInstalled(), false); + const items = getProjectItems(rstest.testController); + assert.ok(items.some((item) => item.id.endsWith('/test/foo.test.ts'))); + assert.ok(api.getDependencyPollCountForTest() > polls); + }, + { timeoutMs: 90_000, pollMs: 100 }, + ); + }); +}); diff --git a/packages/vscode/e2e/rstest/suite-dependency-recovery/index.ts b/packages/vscode/e2e/rstest/suite-dependency-recovery/index.ts new file mode 100644 index 0000000..ab80101 --- /dev/null +++ b/packages/vscode/e2e/rstest/suite-dependency-recovery/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../../runSuite'; + +export const run = createRun(__dirname); diff --git a/packages/vscode/src/stacks/test/index.ts b/packages/vscode/src/stacks/test/index.ts index a2d6ce1..d20c7fa 100644 --- a/packages/vscode/src/stacks/test/index.ts +++ b/packages/vscode/src/stacks/test/index.ts @@ -82,6 +82,7 @@ class Rstest implements vscode.Disposable { */ buildExports(): Record { return { + hasNotInstalledState: () => status.hasNotInstalled(), testController: this.ctrl, runProfile: this.runProfile, startTestRun: this.startTestRun, diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts index 6c81dc0..3a8a3c2 100644 --- a/packages/vscode/src/stacks/test/project.ts +++ b/packages/vscode/src/stacks/test/project.ts @@ -238,14 +238,14 @@ export class WorkspaceManager implements vscode.Disposable { ); } /** - * Retries projects whose config dependency is missing — dependencies may have + * Retries projects whose core or config dependency is missing — dependencies may have * been installed since. The project keeps its identity and the retry is * single-flight, so repeated dependency signals cannot overlap workers or * repeat an unchanged not-installed warning. */ public retryFailedProjects() { for (const project of this.projects.values()) { - if (!project.hasConfigDependencyFailure) continue; + if (!project.hasNotInstalledDependencies) continue; void project.retryFailedConfig(); } } @@ -647,8 +647,11 @@ export class Project implements vscode.Disposable { return pending; } - get hasConfigDependencyFailure(): boolean { - return this.#configDependencyCause !== undefined; + get hasNotInstalledDependencies(): boolean { + return ( + status.hasNotInstalled(this.sourceUri.toString()) || + status.hasNotInstalled(this.configDependencyStatusSource) + ); } /** Re-evaluates a failed config without replacing this project. */ diff --git a/packages/vscode/src/stacks/test/status.ts b/packages/vscode/src/stacks/test/status.ts index 72e54d1..cdab5f5 100644 --- a/packages/vscode/src/stacks/test/status.ts +++ b/packages/vscode/src/stacks/test/status.ts @@ -70,8 +70,10 @@ class StatusHolder implements StatusReporter { this.#reporter = undefined; } - public hasNotInstalled(): boolean { - return this.#notInstalled.size > 0; + public hasNotInstalled(source?: string): boolean { + return source === undefined + ? this.#notInstalled.size > 0 + : this.#notInstalled.has(source); } get #latched(): boolean { diff --git a/packages/vscode/tests/stacks/test/project.test.ts b/packages/vscode/tests/stacks/test/project.test.ts index ccc9d4a..f350fe1 100644 --- a/packages/vscode/tests/stacks/test/project.test.ts +++ b/packages/vscode/tests/stacks/test/project.test.ts @@ -153,6 +153,27 @@ const createProject = async (source: any) => { }; describe('Project config/cwd/package-resolution decoupling', () => { + it('retries a core-missing project on a dependency pass', async () => { + const config = uri('/repo/pkg/rstest.config.ts'); + const { reporter } = createStatusRecorder(); + status.bind(reporter); + // RstestApi reports this source before rejecting with its reported marker. + status.notInstalled('@rstest/core is not installed', config.toString()); + normalizedConfigFailure = new ReportedRstestResolutionError(); + const { project } = await createProject({ sourceUri: config }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const { WorkspaceManager } = + await import('../../../src/stacks/test/project'); + WorkspaceManager.prototype.retryFailedProjects.call({ + projects: new Map([['config', project]]), + } as never); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(normalizedConfigCalls).toBe(2); + expect(loggedErrors).toEqual([]); + project.dispose(); + status.unbind(); + }); + it('keeps the upstream derivation for a native rstest config', async () => { const configFile = uri(path.join('/repo', 'pkg', 'rstest.config.ts')); @@ -332,12 +353,14 @@ describe('Project config/cwd/package-resolution decoupling', () => { const { WorkspaceManager } = await import('../../../src/stacks/test/project'); const callsBeforePoll = normalizedConfigCalls; + status.notInstalled('another project is missing core', 'other-project'); WorkspaceManager.prototype.retryFailedProjects.call({ projects: new Map([['config', project]]), } as never); await new Promise((resolve) => setTimeout(resolve, 0)); expect(normalizedConfigCalls).toBe(callsBeforePoll); expect(loggedErrors).toHaveLength(1); + status.forget('other-project'); normalizedConfigFailure = undefined; normalizedConfigResult = { From 9322471a24a61438c835b0429a4adb02b551b8bf Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 13:59:43 +0800 Subject: [PATCH 16/44] fix(vscode): defer startup config race failures --- packages/vscode/src/stacks/lint/Rslint.ts | 15 +---- .../lint/worker/configDependencyProtocol.ts | 12 ++++ .../vscode/src/stacks/lint/worker/index.ts | 5 ++ .../vscode/tests/stacks/lint/start.test.ts | 66 ++++++++++++++++--- .../vscode/tests/stacks/lint/worker.test.ts | 12 +++- 5 files changed, 87 insertions(+), 23 deletions(-) diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 8e100b1..d44bddc 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -40,8 +40,10 @@ import type { Logger } from './logger'; import type { RslintMode } from './resolution'; import { CONFIG_DEPENDENCY_STATUS_NOTIFICATION, + isConfigSourceChangeDuringTransaction, type ConfigDependencyStatusNotification, } from './worker/configDependencyProtocol'; +export { isConfigSourceChangeDuringTransaction } from './worker/configDependencyProtocol'; import { RslintVersionMismatchError, runningRslintStatus, @@ -133,19 +135,6 @@ export function configRefreshReasonForPath( : 'config-change'; } -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -export function isConfigSourceChangeDuringTransaction(error: unknown): boolean { - if (!isRecord(error)) return false; - return ( - error.code === 'CONFIG_CHANGED_DURING_LOAD' || - (typeof error.message === 'string' && - error.message.includes('config changed while')) - ); -} - export async function retryConfigRefreshOnSourceChange( initial: () => Promise, retry: () => Promise, diff --git a/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts b/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts index 5c12850..314a953 100644 --- a/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts +++ b/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts @@ -11,3 +11,15 @@ export interface ConfigDependencyStatusNotification { /** Present only when refresh rejected without a classified dependency cause. */ readonly error?: string; } + +/** Shared with the editor's startup retry; this module stays vscode-free. */ +export function isConfigSourceChangeDuringTransaction(error: unknown): boolean { + if (error === null || typeof error !== 'object' || Array.isArray(error)) + return false; + const value = error as Record; + return ( + value.code === 'CONFIG_CHANGED_DURING_LOAD' || + (typeof value.message === 'string' && + value.message.includes('config changed while')) + ); +} diff --git a/packages/vscode/src/stacks/lint/worker/index.ts b/packages/vscode/src/stacks/lint/worker/index.ts index aa3583e..56bf0fe 100644 --- a/packages/vscode/src/stacks/lint/worker/index.ts +++ b/packages/vscode/src/stacks/lint/worker/index.ts @@ -24,6 +24,7 @@ import { ActivationFingerprinter } from './fingerprint'; import { logger } from './logger'; import { CONFIG_DEPENDENCY_STATUS_NOTIFICATION, + isConfigSourceChangeDuringTransaction, type ConfigDependencyFailure, } from './configDependencyProtocol'; @@ -177,6 +178,10 @@ export function registerEditorProxy( ); return result; } catch (error) { + // The editor already retries this transaction race during startup. + // Leave its rejection untouched and send no premature failure (or + // success) verdict; the startup catch reports once if retries exhaust. + if (isConfigSourceChangeDuringTransaction(error)) throw error; const failure = options.takeConfigDependencyFailure() ?? null; await editorConnection.sendNotification( CONFIG_DEPENDENCY_STATUS_NOTIFICATION, diff --git a/packages/vscode/tests/stacks/lint/start.test.ts b/packages/vscode/tests/stacks/lint/start.test.ts index b50cfc8..9e8c37e 100644 --- a/packages/vscode/tests/stacks/lint/start.test.ts +++ b/packages/vscode/tests/stacks/lint/start.test.ts @@ -1,8 +1,10 @@ import { expect, it, rs } from '@rstest/core'; import type { StackState } from '../../../src/types'; import type { RslintOptions } from '../../../src/stacks/lint/Rslint'; +import { registerEditorProxy } from '../../../src/stacks/lint/worker/index'; -let refreshOutcome: 'missing' | 'broken' | 'fixed' | 'changed' = 'missing'; +let refreshOutcome: + 'missing' | 'broken' | 'fixed' | 'changed' | 'changed-once' = 'missing'; rs.mock('vscode', () => ({ RelativePattern: class {}, @@ -37,12 +39,34 @@ rs.mock('vscode-languageclient/node', () => ({ } async start() {} async sendRequest() { - if (refreshOutcome === 'changed') { - this.notification?.({ - failure: null, - error: 'config changed while loading', - }); - throw new Error('config changed while loading'); + if (refreshOutcome === 'changed' || refreshOutcome === 'changed-once') { + if (refreshOutcome === 'changed-once') refreshOutcome = 'fixed'; + let request!: (method: string, params: unknown) => Promise; + // Use the real worker proxy so the test observes its notification + // ordering, not a mock of the behavior being fixed. + registerEditorProxy( + { + onRequest: (handler: typeof request) => { + request = handler; + }, + onNotification() {}, + sendNotification: (_method: string, params: unknown) => + this.notification?.(params), + } as never, + { + sendRequest: async () => { + throw new Error('config changed while loading'); + }, + } as never, + { + protocolVersion: 2, + beginConfigRefresh() {}, + takeConfigDependencyFailure: () => undefined, + observeRefresh() {}, + requestStop() {}, + }, + ); + return request('rslint/configRefresh', { reason: 'initial' }); } if (refreshOutcome !== 'missing') { this.notification?.({ @@ -65,7 +89,7 @@ rs.mock('vscode-languageclient/node', () => ({ import { Rslint } from '../../../src/stacks/lint/Rslint'; -it('keeps an initialized runtime disabled when initial configRefresh rejects', async () => { +function createRuntime() { const states: StackState[] = []; const warnings: string[] = []; const errors: unknown[] = []; @@ -83,6 +107,13 @@ it('keeps an initialized runtime disabled when initial configRefresh rejects', a reportStatus: (state: StackState) => states.push(state), } as unknown as RslintOptions); + return { runtime, states, warnings, errors }; +} + +it('keeps an initialized runtime disabled when initial configRefresh rejects', async () => { + refreshOutcome = 'missing'; + const { runtime, states, warnings, errors } = createRuntime(); + await runtime.start(new AbortController().signal); expect(states.at(-1)?.kind).toBe('disabled'); expect(states.some((state) => state.kind === 'crashed')).toBe(false); @@ -123,3 +154,22 @@ it('keeps an initialized runtime disabled when initial configRefresh rejects', a ).requestConfigRefresh('initial'), ).rejects.toThrow('config changed while loading'); }); + +it('recovers a startup config source race without a crash or error log', async () => { + refreshOutcome = 'changed-once'; + const { runtime, states, errors } = createRuntime(); + await runtime.start(new AbortController().signal); + expect(states.some((state) => state.kind === 'crashed')).toBe(false); + expect(states.at(-1)?.kind).toBe('running'); + expect(errors).toEqual([]); +}); + +it('reports one startup crash when the config source retry is exhausted', async () => { + refreshOutcome = 'changed'; + const { runtime, states, errors } = createRuntime(); + await expect(runtime.start(new AbortController().signal)).rejects.toThrow( + 'config changed while loading', + ); + expect(states.filter((state) => state.kind === 'crashed')).toHaveLength(1); + expect(errors).toHaveLength(1); +}); diff --git a/packages/vscode/tests/stacks/lint/worker.test.ts b/packages/vscode/tests/stacks/lint/worker.test.ts index 350ea22..a48d6ac 100644 --- a/packages/vscode/tests/stacks/lint/worker.test.ts +++ b/packages/vscode/tests/stacks/lint/worker.test.ts @@ -40,12 +40,13 @@ function handle(message) { const hasParams = Object.prototype.hasOwnProperty.call(message, 'params'); if ( message.method === 'rslint/configRefresh' && - message.params?.reason === 'reject' + ['reject', 'changed'].includes(message.params?.reason) ) { send({ jsonrpc: '2.0', id: message.id, - error: { code: -32603, message: 'refresh rejected' }, + error: { code: -32603, message: message.params.reason === 'changed' + ? 'config changed while loading' : 'refresh rejected' }, }); return; } @@ -213,6 +214,13 @@ describe('lint worker config refresh', () => { { failure: null, error: 'refresh rejected' }, ]); + await expect( + editorConnection.sendRequest('rslint/configRefresh', { + reason: 'changed', + }), + ).rejects.toThrow('config changed while loading'); + expect(notifications).toHaveLength(2); + const shutdown = await editorConnection.sendRequest<{ readonly method: string; readonly hasParams: boolean; From 19a15d1d1d09b6371cf458d72d6e39454b547852 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 14:43:43 +0800 Subject: [PATCH 17/44] refactor(vscode): remove dependency poll test counter --- .../suite-dependency-recovery/dependency-recovery.test.ts | 5 ----- .../suite-dependency-recovery/dependency-recovery.test.ts | 2 -- packages/vscode/src/extension.ts | 3 --- packages/vscode/src/types.ts | 2 -- 4 files changed, 12 deletions(-) diff --git a/packages/vscode/e2e/lint/suite-dependency-recovery/dependency-recovery.test.ts b/packages/vscode/e2e/lint/suite-dependency-recovery/dependency-recovery.test.ts index ea0672a..cae63b5 100644 --- a/packages/vscode/e2e/lint/suite-dependency-recovery/dependency-recovery.test.ts +++ b/packages/vscode/e2e/lint/suite-dependency-recovery/dependency-recovery.test.ts @@ -61,7 +61,6 @@ suite('Rslint dependency polling recovery', function () { const lockfile = path.join(root, 'pnpm-lock.yaml'); const beforeContents = fs.readFileSync(lockfile); const beforeMtime = fs.statSync(lockfile).mtimeMs; - const pollCountBeforeInstall = api.getDependencyPollCountForTest(); await execFile( 'pnpm', @@ -88,10 +87,6 @@ suite('Rslint dependency polling recovery', function () { await waitForRslintDiagnostics(document, undefined, 90_000); await waitForFolderKind('running'); - assert.ok( - api.getDependencyPollCountForTest() > pollCountBeforeInstall, - 'the folder recovered without a dependency polling pass', - ); assert.strictEqual( lintExports().getNotInstalledWarnings().length, 1, diff --git a/packages/vscode/e2e/rstest/suite-dependency-recovery/dependency-recovery.test.ts b/packages/vscode/e2e/rstest/suite-dependency-recovery/dependency-recovery.test.ts index 52ab78d..d3e5704 100644 --- a/packages/vscode/e2e/rstest/suite-dependency-recovery/dependency-recovery.test.ts +++ b/packages/vscode/e2e/rstest/suite-dependency-recovery/dependency-recovery.test.ts @@ -30,7 +30,6 @@ suite('Rstest dependency polling recovery', function () { const lockfile = path.join(root, 'pnpm-lock.yaml'); const contents = fs.readFileSync(lockfile); const mtime = fs.statSync(lockfile).mtimeMs; - const polls = api.getDependencyPollCountForTest(); await execFile( 'pnpm', ['install', '--frozen-lockfile', '--ignore-scripts'], @@ -57,7 +56,6 @@ suite('Rstest dependency polling recovery', function () { assert.equal(hasNotInstalled(), false); const items = getProjectItems(rstest.testController); assert.ok(items.some((item) => item.id.endsWith('/test/foo.test.ts'))); - assert.ok(api.getDependencyPollCountForTest() > polls); }, { timeoutMs: 90_000, pollMs: 100 }, ); diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 792a959..b52bd51 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -63,7 +63,6 @@ class ExtensionShell { #dependencyPollIntervalMs = DEFAULT_DEPENDENCY_POLL_INTERVAL_MS; #dependencyPollTimer: ReturnType | undefined; #dependencyPollInFlight = false; - #dependencyPollCount = 0; #disposed = false; constructor(private readonly context: vscode.ExtensionContext) { @@ -264,7 +263,6 @@ class ExtensionShell { } try { await this.#detection.refreshForDependencyChange(); - this.#dependencyPollCount++; } catch (error) { if (!this.#disposed) { this.#channels.shell.error( @@ -545,7 +543,6 @@ class ExtensionShell { } this.syncDependencyPoll(); }, - getDependencyPollCountForTest: () => this.#dependencyPollCount, }; } diff --git a/packages/vscode/src/types.ts b/packages/vscode/src/types.ts index d33f28d..e91cf6c 100644 --- a/packages/vscode/src/types.ts +++ b/packages/vscode/src/types.ts @@ -177,8 +177,6 @@ export interface RstackExtensionExports { whenStackActive(stack: StackId): Promise>; /** E2E only: shorten the shell's dependency-recovery polling interval. */ setDependencyPollIntervalForTest(intervalMs: number): void; - /** E2E only: completed dependency-recovery detection passes. */ - getDependencyPollCountForTest(): number; } export type StackControllerFactory = () => StackController; From cf41f4110a4c4128bbc8cd4418b7ce36936beaa7 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 14:44:22 +0800 Subject: [PATCH 18/44] refactor(vscode): inline lint startup reporting guards --- packages/vscode/src/stacks/lint/Rslint.ts | 14 +++----------- packages/vscode/src/stacks/lint/status.ts | 12 ------------ packages/vscode/tests/stacks/lint/status.test.ts | 10 ---------- 3 files changed, 3 insertions(+), 33 deletions(-) diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index d44bddc..226b406 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -47,7 +47,6 @@ export { isConfigSourceChangeDuringTransaction } from './worker/configDependency import { RslintVersionMismatchError, runningRslintStatus, - shouldReportRslintStartFailure, statusForRslintStartFailure, } from './status'; import { @@ -430,12 +429,7 @@ export class Rslint implements Disposable { } private reportStartFailure(error: unknown): void { - if ( - !shouldReportRslintStartFailure( - this.isPlannedStartAbort(error), - this.hasConfigDependencyFailure(), - ) - ) { + if (this.isPlannedStartAbort(error) || this.hasConfigDependencyFailure()) { return; } this.report(statusForRslintStartFailure(error)); @@ -598,10 +592,8 @@ export class Rslint implements Disposable { // A close or supersede during start is a planned abort, not a failure; // logging it as an error made every teardown race look like a crash. if ( - shouldReportRslintStartFailure( - this.isPlannedStartAbort(error), - this.hasConfigDependencyFailure(), - ) + !this.isPlannedStartAbort(error) && + !this.hasConfigDependencyFailure() ) { this.logger.error('Failed to start Rslint language client', error); } diff --git a/packages/vscode/src/stacks/lint/status.ts b/packages/vscode/src/stacks/lint/status.ts index c99f6a7..6cb2271 100644 --- a/packages/vscode/src/stacks/lint/status.ts +++ b/packages/vscode/src/stacks/lint/status.ts @@ -40,18 +40,6 @@ export const statusForRslintStartFailure = (error: unknown): StackState => { }; }; -/** - * A rejected start is already represented by the worker's config-dependency - * notification when that verdict arrived first. In that case the catch must - * preserve `disabled` and its one-line warning instead of replacing it with a - * `crashed` state and a stack trace. Planned aborts are silent for the same - * reason they were before config-dependency reporting existed. - */ -export const shouldReportRslintStartFailure = ( - plannedAbort: boolean, - hasConfigDependencyFailure: boolean, -): boolean => !plannedAbort && !hasConfigDependencyFailure; - /** * Names the Rslint core a runtime's failure came from. With several runtimes * in one folder, "the language server stopped" alone does not say which core diff --git a/packages/vscode/tests/stacks/lint/status.test.ts b/packages/vscode/tests/stacks/lint/status.test.ts index a29f615..bd83f39 100644 --- a/packages/vscode/tests/stacks/lint/status.test.ts +++ b/packages/vscode/tests/stacks/lint/status.test.ts @@ -8,7 +8,6 @@ import { missingPackageOf, RslintVersionMismatchError, runningRslintStatus, - shouldReportRslintStartFailure, statusForRslintStartFailure, } from '../../../src/stacks/lint/status'; @@ -91,15 +90,6 @@ describe('Rslint status classification', () => { detail: 'Node 22.17 is below the floor', }); }); - - it('preserves a classified config dependency when initial refresh rejects', () => { - // The worker notification is delivered before the rejected - // rslint/configRefresh response. Both the start catch's logger and its - // outer status catch use this verdict, so neither may replace `disabled`. - expect(shouldReportRslintStartFailure(false, true)).toBe(false); - expect(shouldReportRslintStartFailure(true, false)).toBe(false); - expect(shouldReportRslintStartFailure(false, false)).toBe(true); - }); }); describe('hasNotInstalledRslintState', () => { From da1cdd8b73dc1bd364f1ae2a6e6cf9bbeb4185db Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 14:45:27 +0800 Subject: [PATCH 19/44] refactor(vscode): collapse fmt message presentation --- packages/vscode/src/stacks/fmt/index.ts | 2 +- .../vscode/src/stacks/fmt/sessionError.ts | 59 ++++++------------- .../tests/stacks/fmt/sessionError.test.ts | 30 ++++++---- 3 files changed, 38 insertions(+), 53 deletions(-) diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index 5c72e6a..6b553b3 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import vscode from 'vscode'; +import type { ShowMessageParams } from 'vscode-languageclient'; import { CloseAction, ErrorAction, @@ -46,7 +47,6 @@ import { pickBinEntry } from './binEntry'; import { finishSuccessfulFormatting, handleFmtShowMessage, - type ShowMessageParams, } from './sessionError'; import { foldFolderStatus, diff --git a/packages/vscode/src/stacks/fmt/sessionError.ts b/packages/vscode/src/stacks/fmt/sessionError.ts index 54f2869..9c38fc3 100644 --- a/packages/vscode/src/stacks/fmt/sessionError.ts +++ b/packages/vscode/src/stacks/fmt/sessionError.ts @@ -1,5 +1,8 @@ import path from 'node:path'; -import type { MessageType as LspMessageType } from 'vscode-languageclient/node'; +import type { + MessageType as LspMessageType, + ShowMessageParams, +} from 'vscode-languageclient'; import { classifyMissingDependencyMessage } from '../../shared/missingDependency'; import type { ConfigDependencyEpisode } from '../../shared/notInstalled'; @@ -14,23 +17,18 @@ const MessageType = { Info: 3 as LspMessageType, }; -export interface FmtConfigDependencyFailure { +interface FmtConfigDependencyFailure { readonly configPath: string; readonly cause: string; } -export interface ShowMessageParams { - readonly type: LspMessageType; - readonly message: string; -} - -export interface ShowMessagePresenter { +interface ShowMessagePresenter { showErrorMessage(message: string): void; showWarningMessage(message: string): void; showInformationMessage(message: string): void; } -export interface FmtShowMessageHandler extends ShowMessagePresenter { +interface FmtShowMessageHandler extends ShowMessagePresenter { onConfigDependency(failure: FmtConfigDependencyFailure): void; } @@ -58,37 +56,6 @@ export function classifyFmtSessionError( }; } -export const showMessagePresentation = ( - type: LspMessageType, -): 'error' | 'warning' | 'information' => { - switch (type) { - case MessageType.Error: - return 'error'; - case MessageType.Warning: - return 'warning'; - default: - return 'information'; - } -}; - -/** Reproduces vscode-languageclient's default show-message UI routing. */ -export const presentShowMessage = ( - message: ShowMessageParams, - presenter: ShowMessagePresenter, -): void => { - switch (showMessagePresentation(message.type)) { - case 'error': - presenter.showErrorMessage(message.message); - break; - case 'warning': - presenter.showWarningMessage(message.message); - break; - case 'information': - presenter.showInformationMessage(message.message); - break; - } -}; - /** Filters the one stack-owned state transition and passes every other server UI request through. */ export const handleFmtShowMessage = ( message: ShowMessageParams, @@ -104,7 +71,17 @@ export const handleFmtShowMessage = ( handler.onConfigDependency(failure); return; } - presentShowMessage(message, handler); + switch (message.type) { + case MessageType.Error: + handler.showErrorMessage(message.message); + break; + case MessageType.Warning: + handler.showWarningMessage(message.message); + break; + default: + handler.showInformationMessage(message.message); + break; + } }; /** diff --git a/packages/vscode/tests/stacks/fmt/sessionError.test.ts b/packages/vscode/tests/stacks/fmt/sessionError.test.ts index d701fe6..9585eb9 100644 --- a/packages/vscode/tests/stacks/fmt/sessionError.test.ts +++ b/packages/vscode/tests/stacks/fmt/sessionError.test.ts @@ -6,7 +6,6 @@ import { finishSuccessfulFormatting, FMT_SESSION_ERROR_PREFIX, handleFmtShowMessage, - showMessagePresentation, } from '../../../src/stacks/fmt/sessionError'; rs.mock('vscode', () => ({ @@ -73,17 +72,26 @@ describe('classifyFmtSessionError', () => { }); }); -describe('showMessagePresentation', () => { - it('matches vscode-languageclient default show-message routing', () => { - expect(showMessagePresentation(1)).toBe('error'); - expect(showMessagePresentation(2)).toBe('warning'); - expect(showMessagePresentation(3)).toBe('information'); - expect(showMessagePresentation(4)).toBe('information'); - expect(showMessagePresentation(5)).toBe('information'); - }); -}); - describe('handleFmtShowMessage', () => { + it('routes other message types to information without changing state', () => { + for (const type of [4, 5] as const) { + const information = rs.fn(); + const unexpected = rs.fn(); + handleFmtShowMessage( + { type, message: 'message' }, + '/project', + undefined, + { + showInformationMessage: information, + showErrorMessage: unexpected, + showWarningMessage: unexpected, + onConfigDependency: unexpected, + }, + ); + expect(information).toHaveBeenCalledExactlyOnceWith('message'); + expect(unexpected).not.toHaveBeenCalled(); + } + }); it('re-presents non-classified Error, Warning and Info messages without state changes', () => { const shown: string[] = []; let stateChanges = 0; From fcd7061aeaabce858e61c62cc540d7797c7874ce Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 14:46:13 +0800 Subject: [PATCH 20/44] refactor(vscode): inline raw not-installed folds --- packages/vscode/src/stacks/fmt/index.ts | 5 ++--- packages/vscode/src/stacks/fmt/status.ts | 5 ----- packages/vscode/src/stacks/lint/index.ts | 8 +++----- packages/vscode/src/stacks/lint/status.ts | 5 ----- .../vscode/tests/stacks/fmt/status.test.ts | 12 ------------ .../vscode/tests/stacks/lint/status.test.ts | 18 ------------------ 6 files changed, 5 insertions(+), 48 deletions(-) diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index 6b553b3..0b39028 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -50,7 +50,6 @@ import { } from './sessionError'; import { foldFolderStatus, - hasNotInstalledFmtState, type FmtFolderStatus, type FmtRuntimeState, isFailedFmtState, @@ -829,8 +828,8 @@ class FmtController implements StackController { } hasNotInstalledState(): boolean { - return hasNotInstalledFmtState( - [...this.#runtimes.values()].map((runtime) => runtime.state), + return [...this.#runtimes.values()].some( + (runtime) => runtime.state === 'disabled', ); } diff --git a/packages/vscode/src/stacks/fmt/status.ts b/packages/vscode/src/stacks/fmt/status.ts index 36a91a8..09109a4 100644 --- a/packages/vscode/src/stacks/fmt/status.ts +++ b/packages/vscode/src/stacks/fmt/status.ts @@ -71,11 +71,6 @@ const STATE_RANK: Readonly> = { export const isFailedFmtState = (state: FmtRuntimeState): boolean => state === 'disabled' || state === 'version-mismatch' || state === 'crashed'; -/** The raw not-installed predicate used by the shell's conditional poll. */ -export const hasNotInstalledFmtState = ( - states: Iterable, -): boolean => [...states].some((state) => state === 'disabled'); - /** * Folds every folder runtime's state into the one report the shell shows for * the fmt stack. The worst folder wins, and with multiple folders the detail diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index 26158ad..5c2b51a 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -17,7 +17,6 @@ import { aggregateFolderStates, attributeToCore, foldRslintFolderState, - hasNotInstalledRslintState, statusForRslintStartFailure, missingPackageOf, } from './status'; @@ -414,10 +413,9 @@ class RslintController implements StackController { hasNotInstalledState(): boolean { return [...this.#folderStates.values()].some((states) => - hasNotInstalledRslintState([ - ...states.runtimes.values(), - ...states.failures.values(), - ]), + [...states.runtimes.values(), ...states.failures.values()].some( + (state) => state.kind === 'disabled', + ), ); } diff --git a/packages/vscode/src/stacks/lint/status.ts b/packages/vscode/src/stacks/lint/status.ts index 6cb2271..cb11602 100644 --- a/packages/vscode/src/stacks/lint/status.ts +++ b/packages/vscode/src/stacks/lint/status.ts @@ -139,11 +139,6 @@ export interface RslintFolderStatus { readonly state: StackState; } -/** The raw not-installed predicate used by the shell's conditional poll. */ -export const hasNotInstalledRslintState = ( - states: Iterable, -): boolean => [...states].some((state) => state.kind === 'disabled'); - /** * Folds every workspace folder's state into the one state the status bar shows * for the Rslint stack. The worst state wins, and the detail names the folders diff --git a/packages/vscode/tests/stacks/fmt/status.test.ts b/packages/vscode/tests/stacks/fmt/status.test.ts index c15d526..f47bd92 100644 --- a/packages/vscode/tests/stacks/fmt/status.test.ts +++ b/packages/vscode/tests/stacks/fmt/status.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from '@rstest/core'; import { foldFolderStatus, - hasNotInstalledFmtState, type FmtFolderStatus, type FmtRuntimeState, isFailedFmtState, @@ -154,14 +153,3 @@ describe('isFailedFmtState', () => { } }); }); - -describe('hasNotInstalledFmtState', () => { - it('reads raw folder states even when a crash would outrank disabled', () => { - expect(hasNotInstalledFmtState(['running', 'crashed', 'disabled'])).toBe( - true, - ); - expect( - hasNotInstalledFmtState(['running', 'crashed', 'version-mismatch']), - ).toBe(false); - }); -}); diff --git a/packages/vscode/tests/stacks/lint/status.test.ts b/packages/vscode/tests/stacks/lint/status.test.ts index bd83f39..6c54021 100644 --- a/packages/vscode/tests/stacks/lint/status.test.ts +++ b/packages/vscode/tests/stacks/lint/status.test.ts @@ -4,7 +4,6 @@ import { aggregateFolderStates, attributeToCore, foldRslintFolderState, - hasNotInstalledRslintState, missingPackageOf, RslintVersionMismatchError, runningRslintStatus, @@ -92,23 +91,6 @@ describe('Rslint status classification', () => { }); }); -describe('hasNotInstalledRslintState', () => { - it('reads the raw runtime and resolution states rather than the aggregate', () => { - expect( - hasNotInstalledRslintState([ - { kind: 'crashed', detail: 'worker stopped' }, - { kind: 'disabled', reason: 'dependencies missing' }, - ]), - ).toBe(true); - expect( - hasNotInstalledRslintState([ - { kind: 'running' }, - { kind: 'version-mismatch', detail: 'core too old' }, - ]), - ).toBe(false); - }); -}); - describe('attributeToCore', () => { const core = '/w/packages/a/node_modules/@rslint/core'; From 0c194d48fdd6c3a9ba9736dda554c1c5cca5bbed Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 14:48:00 +0800 Subject: [PATCH 21/44] refactor(vscode): share config dependency failure type --- packages/vscode/src/shared/notInstalled.ts | 5 +++++ packages/vscode/src/stacks/fmt/sessionError.ts | 14 ++++++-------- .../stacks/lint/worker/ConfigTransactionAdapter.ts | 2 +- .../stacks/lint/worker/configDependencyProtocol.ts | 7 ++----- packages/vscode/src/stacks/lint/worker/index.ts | 2 +- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/vscode/src/shared/notInstalled.ts b/packages/vscode/src/shared/notInstalled.ts index 32e391b..e84d689 100644 --- a/packages/vscode/src/shared/notInstalled.ts +++ b/packages/vscode/src/shared/notInstalled.ts @@ -50,6 +50,11 @@ export const formatConfigDependencyMissingLog = ( ): string => `Cannot load ${configPath}: ${cause}. Install the project dependencies to enable ${STACK_LABELS[stack]} for this config.`; +export interface ConfigDependencyFailure { + readonly configPath: string; + readonly cause: string; +} + export interface ConfigDependencyEpisodeReport { readonly reason: string; readonly warning: string | undefined; diff --git a/packages/vscode/src/stacks/fmt/sessionError.ts b/packages/vscode/src/stacks/fmt/sessionError.ts index 9c38fc3..cdcb50f 100644 --- a/packages/vscode/src/stacks/fmt/sessionError.ts +++ b/packages/vscode/src/stacks/fmt/sessionError.ts @@ -4,7 +4,10 @@ import type { ShowMessageParams, } from 'vscode-languageclient'; import { classifyMissingDependencyMessage } from '../../shared/missingDependency'; -import type { ConfigDependencyEpisode } from '../../shared/notInstalled'; +import type { + ConfigDependencyEpisode, + ConfigDependencyFailure, +} from '../../shared/notInstalled'; export const FMT_SESSION_ERROR_PREFIX = 'rs fmt cannot format this workspace: '; @@ -17,11 +20,6 @@ const MessageType = { Info: 3 as LspMessageType, }; -interface FmtConfigDependencyFailure { - readonly configPath: string; - readonly cause: string; -} - interface ShowMessagePresenter { showErrorMessage(message: string): void; showWarningMessage(message: string): void; @@ -29,14 +27,14 @@ interface ShowMessagePresenter { } interface FmtShowMessageHandler extends ShowMessagePresenter { - onConfigDependency(failure: FmtConfigDependencyFailure): void; + onConfigDependency(failure: ConfigDependencyFailure): void; } export function classifyFmtSessionError( message: ShowMessageParams, workspaceRoot: string, configPath: string, -): FmtConfigDependencyFailure | undefined { +): ConfigDependencyFailure | undefined { if ( message.type !== MessageType.Error || !message.message.startsWith(FMT_SESSION_ERROR_PREFIX) diff --git a/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts b/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts index b020387..2e4c772 100644 --- a/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts +++ b/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts @@ -9,7 +9,7 @@ import type { LoadConfigsResponse, } from '@rslint/core/config-loader'; import { classifyMissingDependencyMessage } from '../../../shared/missingDependency'; -import type { ConfigDependencyFailure } from './configDependencyProtocol'; +import type { ConfigDependencyFailure } from '../../../shared/notInstalled'; interface ConfigDependencyObserver { resolveFrom(candidate: ConfigModuleCandidate): string; diff --git a/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts b/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts index 314a953..0489eee 100644 --- a/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts +++ b/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts @@ -1,11 +1,8 @@ +import type { ConfigDependencyFailure } from '../../../shared/notInstalled'; + export const CONFIG_DEPENDENCY_STATUS_NOTIFICATION = 'rstack/rslintConfigDependency'; -export interface ConfigDependencyFailure { - readonly configPath: string; - readonly cause: string; -} - export interface ConfigDependencyStatusNotification { readonly failure: ConfigDependencyFailure | null; /** Present only when refresh rejected without a classified dependency cause. */ diff --git a/packages/vscode/src/stacks/lint/worker/index.ts b/packages/vscode/src/stacks/lint/worker/index.ts index 56bf0fe..bdd8863 100644 --- a/packages/vscode/src/stacks/lint/worker/index.ts +++ b/packages/vscode/src/stacks/lint/worker/index.ts @@ -25,8 +25,8 @@ import { logger } from './logger'; import { CONFIG_DEPENDENCY_STATUS_NOTIFICATION, isConfigSourceChangeDuringTransaction, - type ConfigDependencyFailure, } from './configDependencyProtocol'; +import type { ConfigDependencyFailure } from '../../../shared/notInstalled'; export { CONFIG_DEPENDENCY_STATUS_NOTIFICATION, From fdecb46c285493a9a1de0fb2b4b63d0c2126ed29 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 14:48:59 +0800 Subject: [PATCH 22/44] refactor(vscode): discriminate lint config refresh verdicts --- packages/vscode/src/stacks/lint/Rslint.ts | 10 ++++---- .../lint/worker/configDependencyProtocol.ts | 9 +++---- .../vscode/src/stacks/lint/worker/index.ts | 25 +++++++++---------- .../vscode/tests/stacks/lint/start.test.ts | 10 +++++--- .../vscode/tests/stacks/lint/worker.test.ts | 8 +++--- 5 files changed, 32 insertions(+), 30 deletions(-) diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 226b406..7845221 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -372,24 +372,24 @@ export class Rslint implements Disposable { private handleConfigDependencyStatus( notification: ConfigDependencyStatusNotification, ): void { - if (notification.error !== undefined) { + if (notification.kind === 'error') { this.configRefreshFailed = true; this.reportedConfigErrors++; - this.report({ kind: 'crashed', detail: notification.error }); + this.report({ kind: 'crashed', detail: notification.message }); this.configDependencyEpisode.clear(); this.logger.error( - `Failed to refresh config discovery: ${notification.error}`, + `Failed to refresh config discovery: ${notification.message}`, ); return; } const wasFailed = this.configRefreshFailed; this.configRefreshFailed = false; - const failure = notification.failure; - if (failure === null) { + if (notification.kind === 'ok') { const wasMissing = this.configDependencyEpisode.clear(); if ((wasMissing || wasFailed) && this.isRunning()) this.reportRunning(); return; } + const failure = notification.failure; const displayPath = this.displayConfigPath(failure.configPath); const report = this.configDependencyEpisode.observe( 'rslint', diff --git a/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts b/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts index 0489eee..e4296bd 100644 --- a/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts +++ b/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts @@ -3,11 +3,10 @@ import type { ConfigDependencyFailure } from '../../../shared/notInstalled'; export const CONFIG_DEPENDENCY_STATUS_NOTIFICATION = 'rstack/rslintConfigDependency'; -export interface ConfigDependencyStatusNotification { - readonly failure: ConfigDependencyFailure | null; - /** Present only when refresh rejected without a classified dependency cause. */ - readonly error?: string; -} +export type ConfigDependencyStatusNotification = + | { readonly kind: 'ok' } + | { readonly kind: 'missing'; readonly failure: ConfigDependencyFailure } + | { readonly kind: 'error'; readonly message: string }; /** Shared with the editor's startup retry; this module stays vscode-free. */ export function isConfigSourceChangeDuringTransaction(error: unknown): boolean { diff --git a/packages/vscode/src/stacks/lint/worker/index.ts b/packages/vscode/src/stacks/lint/worker/index.ts index bdd8863..6cb0b04 100644 --- a/packages/vscode/src/stacks/lint/worker/index.ts +++ b/packages/vscode/src/stacks/lint/worker/index.ts @@ -172,9 +172,10 @@ export function registerEditorProxy( ), token, ); + const failure = options.takeConfigDependencyFailure(); await editorConnection.sendNotification( CONFIG_DEPENDENCY_STATUS_NOTIFICATION, - { failure: options.takeConfigDependencyFailure() ?? null }, + failure ? { kind: 'missing', failure } : { kind: 'ok' }, ); return result; } catch (error) { @@ -182,20 +183,18 @@ export function registerEditorProxy( // Leave its rejection untouched and send no premature failure (or // success) verdict; the startup catch reports once if retries exhaust. if (isConfigSourceChangeDuringTransaction(error)) throw error; - const failure = options.takeConfigDependencyFailure() ?? null; + const failure = options.takeConfigDependencyFailure(); await editorConnection.sendNotification( CONFIG_DEPENDENCY_STATUS_NOTIFICATION, - { - failure, - ...(failure === null - ? { - error: (error instanceof Error - ? error.message - : String(error) - ).split('\n', 1)[0], - } - : {}), - }, + failure + ? { kind: 'missing', failure } + : { + kind: 'error', + message: (error instanceof Error + ? error.message + : String(error) + ).split('\n', 1)[0], + }, ); throw error; } diff --git a/packages/vscode/tests/stacks/lint/start.test.ts b/packages/vscode/tests/stacks/lint/start.test.ts index 9e8c37e..5ef9ae0 100644 --- a/packages/vscode/tests/stacks/lint/start.test.ts +++ b/packages/vscode/tests/stacks/lint/start.test.ts @@ -69,14 +69,16 @@ rs.mock('vscode-languageclient/node', () => ({ return request('rslint/configRefresh', { reason: 'initial' }); } if (refreshOutcome !== 'missing') { - this.notification?.({ - failure: null, - ...(refreshOutcome === 'broken' ? { error: 'Invalid config' } : {}), - }); + this.notification?.( + refreshOutcome === 'broken' + ? { kind: 'error', message: 'Invalid config' } + : { kind: 'ok' }, + ); if (refreshOutcome === 'broken') throw new Error('Invalid config'); return; } this.notification?.({ + kind: 'missing', failure: { configPath: '/project/rslint.config.mjs', cause: "Cannot find package 'missing'", diff --git a/packages/vscode/tests/stacks/lint/worker.test.ts b/packages/vscode/tests/stacks/lint/worker.test.ts index a48d6ac..f466968 100644 --- a/packages/vscode/tests/stacks/lint/worker.test.ts +++ b/packages/vscode/tests/stacks/lint/worker.test.ts @@ -200,7 +200,9 @@ describe('lint worker config refresh', () => { }, }); expect(observedReasons).toEqual(['config-change']); - expect(notifications).toEqual([{ failure: notificationFailure }]); + expect(notifications).toEqual([ + { kind: 'missing', failure: notificationFailure }, + ]); activeFailure = undefined; await expect( @@ -210,8 +212,8 @@ describe('lint worker config refresh', () => { ).rejects.toThrow('refresh rejected'); expect(observedReasons).toEqual(['config-change', 'reject']); expect(notifications).toEqual([ - { failure: notificationFailure }, - { failure: null, error: 'refresh rejected' }, + { kind: 'missing', failure: notificationFailure }, + { kind: 'error', message: 'refresh rejected' }, ]); await expect( From 002e2a936141543318461342fc8beef729073fff Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 14:50:17 +0800 Subject: [PATCH 23/44] refactor(vscode): share not-installed warning episodes --- packages/vscode/src/shared/notInstalled.ts | 32 +++++++++++++++---- packages/vscode/src/stacks/fmt/index.ts | 21 ++++++------ .../vscode/src/stacks/fmt/sessionError.ts | 4 +-- packages/vscode/src/stacks/lint/Rslint.ts | 4 +-- packages/vscode/src/stacks/test/master.ts | 24 ++++++-------- packages/vscode/src/stacks/test/project.ts | 24 ++++++-------- .../vscode/tests/shared/notInstalled.test.ts | 24 +++++++++++--- .../tests/stacks/fmt/sessionError.test.ts | 4 +-- 8 files changed, 82 insertions(+), 55 deletions(-) diff --git a/packages/vscode/src/shared/notInstalled.ts b/packages/vscode/src/shared/notInstalled.ts index e84d689..7370be0 100644 --- a/packages/vscode/src/shared/notInstalled.ts +++ b/packages/vscode/src/shared/notInstalled.ts @@ -55,17 +55,17 @@ export interface ConfigDependencyFailure { readonly cause: string; } -export interface ConfigDependencyEpisodeReport { +interface NotInstalledEpisodeReport { readonly reason: string; readonly warning: string | undefined; } /** - * Deduplicates one config-dependency warning until a successful load ends the - * episode. Lint and fmt receive their failures over different protocols, but + * Deduplicates one not-installed warning until a successful load ends the + * episode. Stacks receive their failures over different protocols, but * the latch semantics and the user-facing words are the same. */ -export class ConfigDependencyEpisode { +export class NotInstalledEpisode { #fingerprint: string | undefined; get active(): boolean { @@ -76,8 +76,8 @@ export class ConfigDependencyEpisode { stack: StackId, configPath: string, cause: string, - ): ConfigDependencyEpisodeReport { - const fingerprint = `${configPath}\0${cause}`; + ): NotInstalledEpisodeReport { + const fingerprint = `config\0${configPath}\0${cause}`; const warning = fingerprint === this.#fingerprint ? undefined @@ -89,6 +89,26 @@ export class ConfigDependencyEpisode { }; } + observePackage( + packageName: string, + folderName: string, + searchedFrom: string, + consequence?: string, + ): string | undefined { + const fingerprint = `package\0${packageName}\0${searchedFrom}`; + const warning = + fingerprint === this.#fingerprint + ? undefined + : formatNotInstalledLog( + packageName, + folderName, + searchedFrom, + consequence, + ); + this.#fingerprint = fingerprint; + return warning; + } + clear(): boolean { const wasActive = this.active; this.#fingerprint = undefined; diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index 0b39028..a088841 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -14,8 +14,7 @@ import { import { RSTACK_CONFIG_GLOB } from '../../detection'; import { getConfiguredNodeExecutable } from '../../shared/nodeExecutableSetting'; import { - ConfigDependencyEpisode, - formatNotInstalledLog, + NotInstalledEpisode, formatNotInstalledStatus, } from '../../shared/notInstalled'; import { @@ -162,8 +161,8 @@ class FmtFolderRuntime { #defaultErrorHandler: ErrorHandler | undefined; #stateWatcher: vscode.Disposable | undefined; #configPath: string | undefined; - #missingPackage: string | undefined; - readonly #configDependencyEpisode = new ConfigDependencyEpisode(); + readonly #packageEpisode = new NotInstalledEpisode(); + readonly #configDependencyEpisode = new NotInstalledEpisode(); readonly #configDependencyWarnings: string[] = []; #suppressedShowMessages = 0; #closing = false; @@ -356,15 +355,15 @@ class FmtFolderRuntime { // The shell polls while this state remains disabled. The trailing restart // hint stays as the explicit fallback if recovery is delayed. this.setState('disabled', formatNotInstalledStatus('fmt', 'rstack')); - if (this.#missingPackage !== 'rstack') { - context.output.warn( - formatNotInstalledLog('rstack', this.folder.name, folderRoot), - ); - } - this.#missingPackage = 'rstack'; + const warning = this.#packageEpisode.observePackage( + 'rstack', + this.folder.name, + folderRoot, + ); + if (warning !== undefined) context.output.warn(warning); return; } - this.#missingPackage = undefined; + this.#packageEpisode.clear(); // One read for the version and the bin entry; `readPackageJson` re-reads // from disk by design, so a reinstall is picked up on the next start. diff --git a/packages/vscode/src/stacks/fmt/sessionError.ts b/packages/vscode/src/stacks/fmt/sessionError.ts index cdcb50f..35ab468 100644 --- a/packages/vscode/src/stacks/fmt/sessionError.ts +++ b/packages/vscode/src/stacks/fmt/sessionError.ts @@ -5,7 +5,7 @@ import type { } from 'vscode-languageclient'; import { classifyMissingDependencyMessage } from '../../shared/missingDependency'; import type { - ConfigDependencyEpisode, + NotInstalledEpisode, ConfigDependencyFailure, } from '../../shared/notInstalled'; @@ -88,7 +88,7 @@ export const handleFmtShowMessage = ( * of a new notification cannot prove recovery on a repeated request. */ export const finishSuccessfulFormatting = ( - episode: ConfigDependencyEpisode, + episode: NotInstalledEpisode, suppressedBeforeRequest: number, suppressedAfterRequest: number, editCount: number, diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 7845221..28635c1 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -26,7 +26,7 @@ import { type ServerOptions, State, } from 'vscode-languageclient/node'; -import { ConfigDependencyEpisode } from '../../shared/notInstalled'; +import { NotInstalledEpisode } from '../../shared/notInstalled'; import { configuredNodeBelowFloor, NodePreflightError, @@ -324,7 +324,7 @@ export class Rslint implements Disposable { private stateWatcher: Disposable | undefined; private lifecycleEpoch = 0; private advisory: string | undefined; - private readonly configDependencyEpisode = new ConfigDependencyEpisode(); + private readonly configDependencyEpisode = new NotInstalledEpisode(); private configRefreshFailed = false; private reportedConfigErrors = 0; private startPromise: Promise | undefined; diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index b873069..7f5f8b0 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -19,7 +19,7 @@ import { } from '../../shared/nodeExecutableSetting'; import { CONFIG_SECTION, getConfigValue } from './config'; import { - formatNotInstalledLog, + NotInstalledEpisode, formatNotInstalledStatus, } from '../../shared/notInstalled'; import { @@ -133,7 +133,7 @@ export class RstestApi { // `createChildProcess`. private disposed = false; private lastResolvedRstestPath?: string; - private reportedCoreMissingFrom?: string; + private readonly coreMissingEpisode = new NotInstalledEpisode(); constructor( private workspace: vscode.WorkspaceFolder, @@ -337,17 +337,13 @@ export class RstestApi { // out plus one warn line — the normal state of a repository whose // dependencies are not installed yet, never a notification. private reportCoreNotInstalled(searchedFrom: string): void { - if (this.reportedCoreMissingFrom !== searchedFrom) { - logger.warn( - formatNotInstalledLog( - '@rstest/core', - this.workspace.name, - searchedFrom, - CORE_NOT_INSTALLED_CONSEQUENCE, - ), - ); - } - this.reportedCoreMissingFrom = searchedFrom; + const warning = this.coreMissingEpisode.observePackage( + '@rstest/core', + this.workspace.name, + searchedFrom, + CORE_NOT_INSTALLED_CONSEQUENCE, + ); + if (warning !== undefined) logger.warn(warning); status.notInstalled(CORE_NOT_INSTALLED_STATUS, this.statusSource); } @@ -401,7 +397,7 @@ export class RstestApi { if (!nodeExport) return ''; } - this.reportedCoreMissingFrom = undefined; + this.coreMissingEpisode.clear(); const coreVersion = readPackageVersion(corePackageJsonPath); diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts index 3a8a3c2..b204916 100644 --- a/packages/vscode/src/stacks/test/project.ts +++ b/packages/vscode/src/stacks/test/project.ts @@ -7,7 +7,7 @@ import { RSTACK_CONFIG_NAMES } from '../../detection'; import { resolveRstackShim } from './bridge'; import { watchConfigValue } from './config'; import { - formatConfigDependencyMissingLog, + NotInstalledEpisode, formatConfigDependencyMissingStatus, } from '../../shared/notInstalled'; import { @@ -563,7 +563,7 @@ export class Project implements vscode.Disposable { readonly isBridge: boolean; #watch?: vscode.Disposable; #configLoad: Promise | undefined; - #configDependencyCause: string | undefined; + readonly #configDependencyEpisode = new NotInstalledEpisode(); constructor( private workspaceFolder: vscode.WorkspaceFolder, source: ProjectSource, @@ -603,7 +603,7 @@ export class Project implements vscode.Disposable { return; } this.configLoadFailed = false; - this.#configDependencyCause = undefined; + this.#configDependencyEpisode.clear(); status.forget(this.configDependencyStatusSource); this.root = vscode.Uri.file(result.root); this.include = result.include; @@ -615,7 +615,7 @@ export class Project implements vscode.Disposable { .catch((error) => { if (this.cancellationSource.token.isCancellationRequested) return; this.configLoadFailed = true; - this.#configDependencyCause = undefined; + this.#configDependencyEpisode.clear(); if (!(error instanceof ReportedRstestResolutionError)) { const cause = error instanceof Error @@ -677,16 +677,12 @@ export class Project implements vscode.Disposable { // Latched under this project's key, which `dispose` forgets. private reportMissingDependency(cause: string): void { this.configLoadFailed = true; - if (cause !== this.#configDependencyCause) { - logger.warn( - formatConfigDependencyMissingLog( - 'rstest', - this.sourceUri.fsPath, - cause, - ), - ); - } - this.#configDependencyCause = cause; + const report = this.#configDependencyEpisode.observe( + 'rstest', + this.sourceUri.fsPath, + cause, + ); + if (report.warning !== undefined) logger.warn(report.warning); status.notInstalled( formatConfigDependencyMissingStatus( 'rstest', diff --git a/packages/vscode/tests/shared/notInstalled.test.ts b/packages/vscode/tests/shared/notInstalled.test.ts index 2758800..7e756bc 100644 --- a/packages/vscode/tests/shared/notInstalled.test.ts +++ b/packages/vscode/tests/shared/notInstalled.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from '@rstest/core'; import { - ConfigDependencyEpisode, + NotInstalledEpisode, formatConfigDependencyMissingLog, formatConfigDependencyMissingStatus, formatNotInstalledLog, @@ -53,9 +53,9 @@ describe('not-installed wording', () => { }); }); -describe('ConfigDependencyEpisode', () => { +describe('NotInstalledEpisode', () => { it('warns once until success clears the episode', () => { - const episode = new ConfigDependencyEpisode(); + const episode = new NotInstalledEpisode(); const first = episode.observe( 'fmt', 'rstack.config.ts', @@ -84,11 +84,27 @@ describe('ConfigDependencyEpisode', () => { }); it('starts a new warning when the missing dependency changes', () => { - const episode = new ConfigDependencyEpisode(); + const episode = new NotInstalledEpisode(); episode.observe('rslint', 'rslint.config.ts', "Cannot find package 'a'"); expect( episode.observe('rslint', 'rslint.config.ts', "Cannot find package 'b'") .warning, ).toContain("Cannot find package 'b'"); }); + + it('deduplicates package warnings by package and search directory until cleared', () => { + const episode = new NotInstalledEpisode(); + expect(episode.observePackage('rstack', 'app', '/app')).toBe( + formatNotInstalledLog('rstack', 'app', '/app'), + ); + expect(episode.observePackage('rstack', 'app', '/app')).toBeUndefined(); + expect(episode.observePackage('rstack', 'app', '/other')).toBeDefined(); + expect( + episode.observePackage('@rstest/core', 'app', '/other'), + ).toBeDefined(); + episode.clear(); + expect( + episode.observePackage('@rstest/core', 'app', '/other'), + ).toBeDefined(); + }); }); diff --git a/packages/vscode/tests/stacks/fmt/sessionError.test.ts b/packages/vscode/tests/stacks/fmt/sessionError.test.ts index 9585eb9..844073c 100644 --- a/packages/vscode/tests/stacks/fmt/sessionError.test.ts +++ b/packages/vscode/tests/stacks/fmt/sessionError.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, rs } from '@rstest/core'; import vscode from 'vscode'; -import { ConfigDependencyEpisode } from '../../../src/shared/notInstalled'; +import { NotInstalledEpisode } from '../../../src/shared/notInstalled'; import { classifyFmtSessionError, finishSuccessfulFormatting, @@ -142,7 +142,7 @@ describe('handleFmtShowMessage', () => { describe('finishSuccessfulFormatting', () => { it('clears the warning latch only after a request without a config failure', () => { - const episode = new ConfigDependencyEpisode(); + const episode = new NotInstalledEpisode(); episode.observe('fmt', 'rstack.config.ts', "Cannot find package 'missing'"); expect(finishSuccessfulFormatting(episode, 0, 1, 0)).toBe(false); From 4a36aade27c8dbb74f57400bad8c6bb8ae59ed9e Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 14:51:33 +0800 Subject: [PATCH 24/44] refactor(vscode): reset config refresh errors per request --- packages/vscode/src/stacks/lint/Rslint.ts | 18 +++++++++++++----- .../vscode/tests/stacks/lint/start.test.ts | 12 +++++++++++- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 28635c1..819c75b 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -326,7 +326,6 @@ export class Rslint implements Disposable { private advisory: string | undefined; private readonly configDependencyEpisode = new NotInstalledEpisode(); private configRefreshFailed = false; - private reportedConfigErrors = 0; private startPromise: Promise | undefined; private startOperation: Promise | undefined; private clientStartPromise: Promise | undefined; @@ -374,7 +373,6 @@ export class Rslint implements Disposable { ): void { if (notification.kind === 'error') { this.configRefreshFailed = true; - this.reportedConfigErrors++; this.report({ kind: 'crashed', detail: notification.message }); this.configDependencyEpisode.clear(); this.logger.error( @@ -681,9 +679,17 @@ export class Rslint implements Disposable { if (!client) return; const refresh = this.configReloadChain.then(async () => { if (!this.isLifecycleCurrent(epoch, client)) return; - const reportedBefore = this.reportedConfigErrors; + const wasFailed = this.configRefreshFailed; + this.configRefreshFailed = false; try { await client.sendRequest('rslint/configRefresh', { reason }); + if ( + wasFailed && + !this.configRefreshFailed && + !this.hasConfigDependencyFailure() && + this.isRunning() + ) + this.reportRunning(); } catch (error) { // The worker verdict already surfaced this rejection as a real config // error. Keep the live runtime for config edits without duplicate logs @@ -691,9 +697,11 @@ export class Rslint implements Disposable { // Source-change races must still reach the existing startup retry. if ( isConfigSourceChangeDuringTransaction(error) || - this.reportedConfigErrors === reportedBefore - ) + !this.configRefreshFailed + ) { + this.configRefreshFailed = wasFailed; throw error; + } } }); this.configReloadChain = refresh.catch(() => undefined); diff --git a/packages/vscode/tests/stacks/lint/start.test.ts b/packages/vscode/tests/stacks/lint/start.test.ts index 5ef9ae0..a3cc4b8 100644 --- a/packages/vscode/tests/stacks/lint/start.test.ts +++ b/packages/vscode/tests/stacks/lint/start.test.ts @@ -137,6 +137,16 @@ it('keeps an initialized runtime disabled when initial configRefresh rejects', a ['Failed to refresh config discovery: Invalid config'], ]); + await ( + runtime as unknown as { + requestConfigRefresh(reason: string): Promise; + } + ).requestConfigRefresh('config-change'); + expect(errors).toEqual([ + ['Failed to refresh config discovery: Invalid config'], + ['Failed to refresh config discovery: Invalid config'], + ]); + refreshOutcome = 'fixed'; // Config-file events use this same refresh path after dependency polling stops. await ( @@ -145,7 +155,7 @@ it('keeps an initialized runtime disabled when initial configRefresh rejects', a } ).requestConfigRefresh('config-change'); expect(states.at(-1)?.kind).toBe('running'); - expect(errors).toHaveLength(1); + expect(errors).toHaveLength(2); refreshOutcome = 'changed'; await expect( From 45eb6385294e805a102821dcfc80b15f34dc14c5 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 14:51:51 +0800 Subject: [PATCH 25/44] refactor(vscode): set fmt config after construction --- packages/vscode/src/stacks/fmt/index.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index a088841..814cf12 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -184,10 +184,7 @@ class FmtFolderRuntime { * stack's one report (`foldFolderStatus`). */ private readonly onDidChangeStatus: () => void, - configPath: string | undefined, - ) { - this.#configPath = configPath; - } + ) {} get state(): FmtRuntimeState { return this.#state; @@ -787,12 +784,10 @@ class FmtController implements StackController { // The callback re-reads `#snapshot`, so a server that finishes starting // after a detection change reports from the freshest snapshot — and the // closure captures nothing beyond `this`. - const runtime = new FmtFolderRuntime( - folder, - context, - () => this.reportStatus(), - configPath, + const runtime = new FmtFolderRuntime(folder, context, () => + this.reportStatus(), ); + runtime.setConfigPath(configPath); this.#runtimes.set(folderPath, runtime); void runtime.start(this.#retiring.get(folderPath)); } From f107e847a617aacea1edbde1e4025aabd08fc98e Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 14:53:09 +0800 Subject: [PATCH 26/44] refactor(vscode): carry root bridge config in detection --- packages/vscode/src/detection.ts | 2 +- packages/vscode/src/stacks/fmt/index.ts | 4 +- packages/vscode/src/stacks/lint/index.ts | 10 +-- packages/vscode/src/stacks/lint/resolution.ts | 9 --- packages/vscode/src/types.ts | 1 + packages/vscode/tests/lintDetection.test.ts | 61 ++++++++++++++++--- 6 files changed, 57 insertions(+), 30 deletions(-) diff --git a/packages/vscode/src/detection.ts b/packages/vscode/src/detection.ts index 897625f..486f982 100644 --- a/packages/vscode/src/detection.ts +++ b/packages/vscode/src/detection.ts @@ -213,7 +213,7 @@ export const detectFolder = async ( }, }; - return { folder, stacks }; + return { folder, rootRstackConfigPath, stacks }; }; const signatureOf = (snapshot: DetectionSnapshot): string => diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index 814cf12..ed2484b 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -744,9 +744,7 @@ class FmtController implements StackController { const detected = new Map( snapshot.foldersFor('fmt').map((entry) => { const folderPath = entry.folder.uri.fsPath; - const configPath = entry.stacks.fmt.rstackConfigFiles.find( - (uri) => path.dirname(uri.fsPath) === folderPath, - )?.fsPath; + const configPath = entry.rootRstackConfigPath; return [folderPath, { folder: entry.folder, configPath }] as const; }), ); diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index 5c2b51a..b4b42cf 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -10,7 +10,7 @@ import { formatNotInstalledLog } from '../../shared/notInstalled'; import { CoreResolver, type ResolvedCoreRuntime } from './CoreResolver'; import { Logger } from './logger'; import { Rslint } from './Rslint'; -import { rootRstackConfigPath, type RslintMode } from './resolution'; +import type { RslintMode } from './resolution'; import { registerRuleDocumentationProviders } from './ruleDocumentationProviders'; import { formatCoreSelectionFailure, RuntimeManager } from './RuntimeManager'; import { @@ -275,13 +275,7 @@ class RslintController implements StackController { }, bridgeConfigPath: installation.mode === 'bridged' - ? rootRstackConfigPath( - workspaceFolder.uri.fsPath, - this.#snapshot - ?.forFolder(workspaceFolder) - ?.stacks.rslint.rstackConfigFiles.map((uri) => uri.fsPath) ?? - [], - ) + ? this.#snapshot?.forFolder(workspaceFolder)?.rootRstackConfigPath : undefined, onClosed: () => { if (this.#runtimes.get(resolved.key) === runtime) { diff --git a/packages/vscode/src/stacks/lint/resolution.ts b/packages/vscode/src/stacks/lint/resolution.ts index efb7de2..e5860a1 100644 --- a/packages/vscode/src/stacks/lint/resolution.ts +++ b/packages/vscode/src/stacks/lint/resolution.ts @@ -7,15 +7,6 @@ import { export type RslintMode = 'native' | 'bridged'; -export function rootRstackConfigPath( - folderRoot: string, - configPaths: readonly string[], -): string | undefined { - return configPaths.find( - (configPath) => path.dirname(configPath) === folderRoot, - ); -} - export interface RslintResolution { readonly mode: RslintMode; readonly coreDir: string; diff --git a/packages/vscode/src/types.ts b/packages/vscode/src/types.ts index e91cf6c..c7087fc 100644 --- a/packages/vscode/src/types.ts +++ b/packages/vscode/src/types.ts @@ -89,6 +89,7 @@ export interface StackDetection { export interface FolderDetection { readonly folder: vscode.WorkspaceFolder; + readonly rootRstackConfigPath?: string; readonly stacks: Readonly>; } diff --git a/packages/vscode/tests/lintDetection.test.ts b/packages/vscode/tests/lintDetection.test.ts index 03353cc..f29e8d3 100644 --- a/packages/vscode/tests/lintDetection.test.ts +++ b/packages/vscode/tests/lintDetection.test.ts @@ -1,18 +1,61 @@ import path from 'node:path'; -import { describe, expect, it } from '@rstest/core'; -import { - decideRslintMode, - rootRstackConfigPath, -} from '../src/stacks/lint/resolution'; +import { describe, expect, it, rs } from '@rstest/core'; +import vscode from 'vscode'; +import { decideRslintMode } from '../src/stacks/lint/resolution'; +import { detectFolder, RSTACK_CONFIG_GLOB } from '../src/detection'; + +let configPaths: string[] = []; +rs.mock('vscode', () => { + const file = (fsPath: string) => ({ fsPath, toString: () => fsPath }); + const api = { + Uri: { + file, + joinPath: (uri: { fsPath: string }, ...parts: string[]) => + file(path.join(uri.fsPath, ...parts)), + }, + RelativePattern: class { + constructor( + readonly folder: unknown, + readonly pattern: string, + ) {} + }, + workspace: { + getConfiguration: () => ({ get: () => undefined }), + findFiles: async ({ pattern }: { pattern: string }) => + pattern === RSTACK_CONFIG_GLOB ? configPaths.map(file) : [], + fs: { + stat: async () => { + throw new Error('not found'); + }, + }, + }, + }; + return { ...api, default: api }; +}); describe('Rslint folder ownership', () => { - it('attributes bridge failures to the root config regardless of discovery order', () => { + it('attributes bridge failures to the root config regardless of discovery order', async () => { const folder = path.resolve('/workspace'); const root = path.join(folder, 'rstack.config.ts'); const nested = path.join(folder, 'packages', 'app', 'rstack.config.ts'); - expect(rootRstackConfigPath(folder, [nested, root])).toBe(root); - expect(rootRstackConfigPath(folder, [root, nested])).toBe(root); - expect(rootRstackConfigPath(folder, [nested])).toBeUndefined(); + const workspaceFolder = { + uri: vscode.Uri.file(folder), + name: 'workspace', + index: 0, + }; + for (const ordered of [ + [nested, root], + [root, nested], + ]) { + configPaths = ordered; + const snapshot = await detectFolder(workspaceFolder); + expect(snapshot.rootRstackConfigPath).toBe(root); + expect(snapshot.stacks.rslint.mode).toBe('bridged'); + } + configPaths = [nested]; + expect( + (await detectFolder(workspaceFolder)).rootRstackConfigPath, + ).toBeUndefined(); }); it('gives native config presence precedence anywhere in the folder', () => { From 97e048ec1b675f08e21c2e1aa58ddef370d3bad7 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 14:53:56 +0800 Subject: [PATCH 27/44] refactor(vscode): import lint protocol directly --- .../e2e/lint/suite-jsconfig/config-transaction.test.ts | 2 +- packages/vscode/src/stacks/lint/Rslint.ts | 1 - packages/vscode/src/stacks/lint/worker/index.ts | 5 ----- packages/vscode/tests/stacks/lint/worker.test.ts | 4 ++-- 4 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts b/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts index 1b50b25..1847c9c 100644 --- a/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts +++ b/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts @@ -24,10 +24,10 @@ import { CONFIG_REFRESH_WATCH_GLOB, configRefreshReasonForPath, createLanguageClientOptions, - isConfigSourceChangeDuringTransaction, recoverConfigDiscoveryOnServerState, retryConfigRefreshOnSourceChange, } from '../../../src/stacks/lint/Rslint'; +import { isConfigSourceChangeDuringTransaction } from '../../../src/stacks/lint/worker/configDependencyProtocol'; import { LspConfigTransactionAdapter } from '../../../src/stacks/lint/worker/ConfigTransactionAdapter'; import { State } from 'vscode-languageclient/node'; import { diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 819c75b..cddca62 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -43,7 +43,6 @@ import { isConfigSourceChangeDuringTransaction, type ConfigDependencyStatusNotification, } from './worker/configDependencyProtocol'; -export { isConfigSourceChangeDuringTransaction } from './worker/configDependencyProtocol'; import { RslintVersionMismatchError, runningRslintStatus, diff --git a/packages/vscode/src/stacks/lint/worker/index.ts b/packages/vscode/src/stacks/lint/worker/index.ts index 6cb0b04..1e8369d 100644 --- a/packages/vscode/src/stacks/lint/worker/index.ts +++ b/packages/vscode/src/stacks/lint/worker/index.ts @@ -28,11 +28,6 @@ import { } from './configDependencyProtocol'; import type { ConfigDependencyFailure } from '../../../shared/notInstalled'; -export { - CONFIG_DEPENDENCY_STATUS_NOTIFICATION, - type ConfigDependencyStatusNotification, -} from './configDependencyProtocol'; - const GRACEFUL_EXIT_TIMEOUT_MS = 500; const FORCED_EXIT_TIMEOUT_MS = 1_500; diff --git a/packages/vscode/tests/stacks/lint/worker.test.ts b/packages/vscode/tests/stacks/lint/worker.test.ts index f466968..9f0f20a 100644 --- a/packages/vscode/tests/stacks/lint/worker.test.ts +++ b/packages/vscode/tests/stacks/lint/worker.test.ts @@ -19,8 +19,8 @@ import { LspConfigTransactionAdapter } from '../../../src/stacks/lint/worker/Con import { CONFIG_DEPENDENCY_STATUS_NOTIFICATION, type ConfigDependencyStatusNotification, - registerEditorProxy, -} from '../../../src/stacks/lint/worker/index'; +} from '../../../src/stacks/lint/worker/configDependencyProtocol'; +import { registerEditorProxy } from '../../../src/stacks/lint/worker/index'; const fakeGoSource = String.raw` let buffer = Buffer.alloc(0); From d7f8df187789a2c10808fc1e03dd7bd044febd8e Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 14:54:28 +0800 Subject: [PATCH 28/44] refactor(vscode): require config dependency observer --- .../suite-jsconfig/config-transaction.test.ts | 24 +++++++++++++++++++ .../lint/worker/ConfigTransactionAdapter.ts | 7 +++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts b/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts index 1847c9c..68d46e3 100644 --- a/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts +++ b/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts @@ -282,6 +282,10 @@ suite('LSP config discovery transactions', () => { pool, () => 'fingerprint-1', CONFIG_DISCOVERY_PROTOCOL_VERSION, + { + resolveFrom: (candidate) => candidate.configDirectory, + report: () => assert.fail('unexpected missing dependency'), + }, ); const loaded = await adapter.loadConfigs(loadRequest()); @@ -327,6 +331,10 @@ suite('LSP config discovery transactions', () => { pool, () => 'fingerprint-1', CONFIG_DISCOVERY_PROTOCOL_VERSION, + { + resolveFrom: (candidate) => candidate.configDirectory, + report: () => assert.fail('unexpected missing dependency'), + }, ); await adapter.loadConfigs(loadRequest('tx-abort')); @@ -389,6 +397,10 @@ suite('LSP config discovery transactions', () => { pool, () => 'fingerprint-degraded', CONFIG_DISCOVERY_PROTOCOL_VERSION, + { + resolveFrom: (candidate) => candidate.configDirectory, + report: () => assert.fail('unexpected missing dependency'), + }, ); await adapter.loadConfigs(loadRequest('tx-degraded')); @@ -424,6 +436,10 @@ suite('LSP config discovery transactions', () => { pool, () => 'fingerprint-before-prepare', CONFIG_DISCOVERY_PROTOCOL_VERSION, + { + resolveFrom: (candidate) => candidate.configDirectory, + report: () => assert.fail('unexpected missing dependency'), + }, ); await adapter.loadConfigs(loadRequest('tx-prepare-race')); @@ -448,6 +464,10 @@ suite('LSP config discovery transactions', () => { pool, () => 'fingerprint-1', CONFIG_DISCOVERY_PROTOCOL_VERSION, + { + resolveFrom: (candidate) => candidate.configDirectory, + report: () => assert.fail('unexpected missing dependency'), + }, ); await adapter.loadConfigs(loadRequest('tx-response-lost')); @@ -485,6 +505,10 @@ suite('LSP config discovery transactions', () => { new TestPluginPool(), () => 'fingerprint-1', CONFIG_DISCOVERY_PROTOCOL_VERSION, + { + resolveFrom: (candidate) => candidate.configDirectory, + report: () => assert.fail('unexpected missing dependency'), + }, ); await assert.rejects(adapter.loadConfigs(loadRequest()), /load failed/); diff --git a/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts b/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts index 2e4c772..b7e0fba 100644 --- a/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts +++ b/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts @@ -96,7 +96,7 @@ export class LspConfigTransactionAdapter { private readonly pluginLintPool: PluginLintPoolAdapter, private readonly fingerprint: (plan: ConfigModuleActivationPlan) => string, private readonly protocolVersion: number, - private readonly configDependencyObserver?: ConfigDependencyObserver, + private readonly configDependencyObserver: ConfigDependencyObserver, ) {} async loadConfigs( @@ -130,12 +130,11 @@ export class LspConfigTransactionAdapter { } const cause = classifyMissingDependencyMessage( result.error.message, - this.configDependencyObserver?.resolveFrom(candidate) ?? - candidate.configDirectory, + this.configDependencyObserver.resolveFrom(candidate), ); if (cause === undefined) return result; classified = true; - this.configDependencyObserver?.report({ + this.configDependencyObserver.report({ configPath: candidate.configPath, cause, }); From 412e1678b94a63adeed0b12f84127c818b9a90a5 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 14:55:00 +0800 Subject: [PATCH 29/44] refactor(vscode): make status report hook parameterless --- packages/vscode/src/statusBar.ts | 7 ++----- packages/vscode/tests/statusBar.test.ts | 14 +++----------- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/packages/vscode/src/statusBar.ts b/packages/vscode/src/statusBar.ts index d53e129..c6ee72d 100644 --- a/packages/vscode/src/statusBar.ts +++ b/packages/vscode/src/statusBar.ts @@ -276,13 +276,10 @@ export class StatusBar implements vscode.Disposable { this.#item.show(); } - reporterFor( - stack: StackId, - onReport?: (state: StackState) => void, - ): StatusReporter { + reporterFor(stack: StackId, onReport?: () => void): StatusReporter { const report = (state: StackState): void => { this.setState(stack, state); - onReport?.(state); + onReport?.(); }; return { stack, diff --git a/packages/vscode/tests/statusBar.test.ts b/packages/vscode/tests/statusBar.test.ts index 0e3d51f..d1aab9d 100644 --- a/packages/vscode/tests/statusBar.test.ts +++ b/packages/vscode/tests/statusBar.test.ts @@ -347,10 +347,8 @@ describe('StatusBar item', () => { describe('StatusBar reporter', () => { it('runs the report hook for direct and convenience reports', () => { const { bar } = build(); - const reports: string[] = []; - const reporter = bar.reporterFor('fmt', (state) => - reports.push(state.kind), - ); + const reports = rs.fn(); + const reporter = bar.reporterFor('fmt', reports); reporter.report({ kind: 'disabled', reason: 'missing' }); reporter.starting(); @@ -358,12 +356,6 @@ describe('StatusBar reporter', () => { reporter.crashed('stopped'); reporter.versionMismatch('old version'); - expect(reports).toEqual([ - 'disabled', - 'starting', - 'running', - 'crashed', - 'version-mismatch', - ]); + expect(reports).toHaveBeenCalledTimes(5); }); }); From 385651eba5c2f64893e03bc5d5af0be4fe6b1d73 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 14:55:33 +0800 Subject: [PATCH 30/44] refactor(vscode): consume config dependency verdict on read --- packages/vscode/src/stacks/lint/worker/index.ts | 9 ++++----- packages/vscode/tests/stacks/lint/start.test.ts | 1 - packages/vscode/tests/stacks/lint/worker.test.ts | 14 +++++++++++--- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/vscode/src/stacks/lint/worker/index.ts b/packages/vscode/src/stacks/lint/worker/index.ts index 1e8369d..fd526fa 100644 --- a/packages/vscode/src/stacks/lint/worker/index.ts +++ b/packages/vscode/src/stacks/lint/worker/index.ts @@ -141,7 +141,6 @@ function forwardRequest( interface EditorProxyOptions { readonly protocolVersion: number; readonly configPath?: string; - beginConfigRefresh(): void; takeConfigDependencyFailure(): ConfigDependencyFailure | undefined; observeRefresh(reason: unknown): void; requestStop(request: StopRequest): void; @@ -155,7 +154,6 @@ export function registerEditorProxy( editorConnection.onRequest(async (method, params, token) => { if (method === 'rslint/configRefresh') { const refresh = params as ConfigRefreshParams; - options.beginConfigRefresh(); options.observeRefresh(refresh?.reason); try { const result = await goConnection.sendRequest( @@ -174,11 +172,11 @@ export function registerEditorProxy( ); return result; } catch (error) { + const failure = options.takeConfigDependencyFailure(); // The editor already retries this transaction race during startup. // Leave its rejection untouched and send no premature failure (or // success) verdict; the startup catch reports once if retries exhaust. if (isConfigSourceChangeDuringTransaction(error)) throw error; - const failure = options.takeConfigDependencyFailure(); await editorConnection.sendNotification( CONFIG_DEPENDENCY_STATUS_NOTIFICATION, failure @@ -257,10 +255,11 @@ export async function runLintWorker( registerEditorProxy(editorConnection, goConnection, { protocolVersion: installation.protocolVersion, configPath: options.configPath, - beginConfigRefresh: () => { + takeConfigDependencyFailure: () => { + const failure = configDependencyFailure; configDependencyFailure = undefined; + return failure; }, - takeConfigDependencyFailure: () => configDependencyFailure, observeRefresh: (reason) => fingerprinter.observeRefresh(reason), requestStop, }); diff --git a/packages/vscode/tests/stacks/lint/start.test.ts b/packages/vscode/tests/stacks/lint/start.test.ts index a3cc4b8..4113a9e 100644 --- a/packages/vscode/tests/stacks/lint/start.test.ts +++ b/packages/vscode/tests/stacks/lint/start.test.ts @@ -60,7 +60,6 @@ rs.mock('vscode-languageclient/node', () => ({ } as never, { protocolVersion: 2, - beginConfigRefresh() {}, takeConfigDependencyFailure: () => undefined, observeRefresh() {}, requestStop() {}, diff --git a/packages/vscode/tests/stacks/lint/worker.test.ts b/packages/vscode/tests/stacks/lint/worker.test.ts index 9f0f20a..8dcbd37 100644 --- a/packages/vscode/tests/stacks/lint/worker.test.ts +++ b/packages/vscode/tests/stacks/lint/worker.test.ts @@ -170,8 +170,11 @@ describe('lint worker config refresh', () => { registerEditorProxy(workerConnection, goConnection, { protocolVersion: 2, configPath, - beginConfigRefresh: () => undefined, - takeConfigDependencyFailure: () => activeFailure, + takeConfigDependencyFailure: () => { + const failure = activeFailure; + activeFailure = undefined; + return failure; + }, observeRefresh: (reason) => observedReasons.push(reason), requestStop: () => undefined, }); @@ -204,7 +207,6 @@ describe('lint worker config refresh', () => { { kind: 'missing', failure: notificationFailure }, ]); - activeFailure = undefined; await expect( editorConnection.sendRequest('rslint/configRefresh', { reason: 'reject', @@ -216,6 +218,7 @@ describe('lint worker config refresh', () => { { kind: 'error', message: 'refresh rejected' }, ]); + activeFailure = notificationFailure; await expect( editorConnection.sendRequest('rslint/configRefresh', { reason: 'changed', @@ -223,6 +226,11 @@ describe('lint worker config refresh', () => { ).rejects.toThrow('config changed while loading'); expect(notifications).toHaveLength(2); + await editorConnection.sendRequest('rslint/configRefresh', { + reason: 'initial', + }); + expect(notifications.at(-1)).toEqual({ kind: 'ok' }); + const shutdown = await editorConnection.sendRequest<{ readonly method: string; readonly hasParams: boolean; From 64b2e408d02b0315a738fe2d3c6bafeae93c9354 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 14:57:07 +0800 Subject: [PATCH 31/44] refactor(vscode): record E2E warnings at output channels --- .../dependency-recovery.test.ts | 5 ++-- .../missing-config-dependency.test.ts | 5 ++-- packages/vscode/e2e/run.mjs | 2 +- .../fmt-missing-config-dependency.test.ts | 6 ++--- packages/vscode/src/channels.ts | 17 ++++++++++++++ packages/vscode/src/extension.ts | 1 + packages/vscode/src/stacks/fmt/index.ts | 23 ++++--------------- packages/vscode/src/stacks/lint/Rslint.ts | 7 ------ packages/vscode/src/stacks/lint/index.ts | 10 -------- packages/vscode/src/types.ts | 2 ++ 10 files changed, 31 insertions(+), 47 deletions(-) diff --git a/packages/vscode/e2e/lint/suite-dependency-recovery/dependency-recovery.test.ts b/packages/vscode/e2e/lint/suite-dependency-recovery/dependency-recovery.test.ts index cae63b5..d410c0b 100644 --- a/packages/vscode/e2e/lint/suite-dependency-recovery/dependency-recovery.test.ts +++ b/packages/vscode/e2e/lint/suite-dependency-recovery/dependency-recovery.test.ts @@ -12,7 +12,6 @@ const execFile = promisify(execFileCallback); function lintExports(): { getFolderStates(): ReadonlyMap; - getNotInstalledWarnings(): readonly string[]; } { const exports = extensionExports().getStackExports('rslint'); assert.ok(exports, 'lint stack exports are unavailable'); @@ -54,7 +53,7 @@ suite('Rslint dependency polling recovery', function () { ); await vscode.window.showTextDocument(document); await waitForFolderKind('disabled'); - const warnings = lintExports().getNotInstalledWarnings(); + const warnings = api.getRecordedWarnings('rslint'); assert.strictEqual(warnings.length, 1); assert.match(warnings[0], /@rslint\/core is not installed/); @@ -88,7 +87,7 @@ suite('Rslint dependency polling recovery', function () { await waitForRslintDiagnostics(document, undefined, 90_000); await waitForFolderKind('running'); assert.strictEqual( - lintExports().getNotInstalledWarnings().length, + api.getRecordedWarnings('rslint').length, 1, 'poll retries must not repeat the unresolved episode warning', ); diff --git a/packages/vscode/e2e/lint/suite-missing-config-dependency/missing-config-dependency.test.ts b/packages/vscode/e2e/lint/suite-missing-config-dependency/missing-config-dependency.test.ts index 1840d31..810cd19 100644 --- a/packages/vscode/e2e/lint/suite-missing-config-dependency/missing-config-dependency.test.ts +++ b/packages/vscode/e2e/lint/suite-missing-config-dependency/missing-config-dependency.test.ts @@ -12,7 +12,6 @@ import { extensionExports } from '../utils/extension'; function lintExports(): { getFolderStates(): ReadonlyMap; getRuntimeStates(): ReadonlyMap; - getConfigDependencyWarnings(): readonly string[]; } { const exports = extensionExports().getStackExports('rslint'); assert.ok(exports, 'lint stack exports are unavailable'); @@ -63,7 +62,7 @@ suite('Rslint missing config dependency', function () { assert.ok(runtimeStates.every((state) => state.kind === 'disabled')); assert.deepStrictEqual(getRslintDiagnostics(document), []); - const warnings = lintExports().getConfigDependencyWarnings(); + const warnings = extensionExports().getRecordedWarnings('rslint'); assert.strictEqual(warnings.length, 1); assert.match(warnings[0], /missing-rslint-config-dependency/); assert.ok(!warnings[0].includes('\n'), 'warning must remain one line'); @@ -82,7 +81,7 @@ suite('Rslint missing config dependency', function () { ); await waitForRuntimeKind('disabled'); assert.strictEqual( - lintExports().getConfigDependencyWarnings().length, + extensionExports().getRecordedWarnings('rslint').length, warnings.length + 1, 'the new missing-dependency episode must add exactly one warning', ); diff --git a/packages/vscode/e2e/run.mjs b/packages/vscode/e2e/run.mjs index ed20e57..4b69b6f 100644 --- a/packages/vscode/e2e/run.mjs +++ b/packages/vscode/e2e/run.mjs @@ -63,7 +63,7 @@ const run = (command, args, opts = {}) => { const result = spawnSync(command, args, { cwd: packageRoot, stdio: 'inherit', - env: process.env, + env: { ...process.env, RSTACK_E2E_RECORD_WARNINGS: '1' }, // With `shell: true` Node concatenates command and args UNESCAPED, so a // path containing spaces (the checkout, `process.execPath`) would fall // apart into several arguments — callers opt in only where the command diff --git a/packages/vscode/e2e/suite-fmt-missing-config-dependency/fmt-missing-config-dependency.test.ts b/packages/vscode/e2e/suite-fmt-missing-config-dependency/fmt-missing-config-dependency.test.ts index 73d1737..3187633 100644 --- a/packages/vscode/e2e/suite-fmt-missing-config-dependency/fmt-missing-config-dependency.test.ts +++ b/packages/vscode/e2e/suite-fmt-missing-config-dependency/fmt-missing-config-dependency.test.ts @@ -13,8 +13,6 @@ suite('fmt missing config dependency', () => { const folderStates = exports.folderStates as () => Record; const suppressedConfigDependencyMessages = exports.suppressedConfigDependencyMessages as () => number; - const configDependencyWarnings = - exports.configDependencyWarnings as () => readonly string[]; const folder = vscode.workspace.workspaceFolders?.[0]; assert.ok(folder, 'fmt fixture workspace is unavailable'); const observedStates: string[] = []; @@ -55,7 +53,7 @@ suite('fmt missing config dependency', () => { // outside it: a transient crash must not disappear behind later recovery. assert.ok(!observedStates.includes('crashed'), observedStates.join(' -> ')); assert.equal(suppressedConfigDependencyMessages(), 1); - const warnings = configDependencyWarnings(); + const warnings = api.getRecordedWarnings('fmt'); assert.equal(warnings.length, 1); assert.match(warnings[0], /missing-fmt-config-dependency/); assert.ok(!warnings[0].includes('\n'), 'warning must remain one line'); @@ -68,6 +66,6 @@ suite('fmt missing config dependency', () => { { tabSize: 2, insertSpaces: true }, ); assert.equal(sampleState(), 'disabled'); - assert.equal(configDependencyWarnings().length, 1); + assert.equal(api.getRecordedWarnings('fmt').length, 1); }); }); diff --git a/packages/vscode/src/channels.ts b/packages/vscode/src/channels.ts index 852b4ab..944296d 100644 --- a/packages/vscode/src/channels.ts +++ b/packages/vscode/src/channels.ts @@ -19,6 +19,7 @@ export class Channels implements vscode.Disposable { readonly shell: vscode.LogOutputChannel; readonly #stacks: Record; + readonly #recordedWarnings = new Map(); constructor() { this.shell = vscode.window.createOutputChannel(CHANNEL_NAMES.shell, { @@ -30,6 +31,22 @@ export class Channels implements vscode.Disposable { vscode.window.createOutputChannel(CHANNEL_NAMES[stack], { log: true }), ]), ) as Record; + if (process.env.RSTACK_E2E_RECORD_WARNINGS === '1') { + for (const stack of STACK_IDS) { + const channel = this.#stacks[stack]; + const warnings: string[] = []; + this.#recordedWarnings.set(stack, warnings); + const warn = channel.warn.bind(channel); + channel.warn = (message, ...args) => { + warnings.push(message); + warn(message, ...args); + }; + } + } + } + + getRecordedWarnings(stack: StackId): readonly string[] { + return [...(this.#recordedWarnings.get(stack) ?? [])]; } forStack(stack: StackId): vscode.LogOutputChannel { diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index b52bd51..eb6e863 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -520,6 +520,7 @@ class ExtensionShell { buildExports(): RstackExtensionExports { return { + getRecordedWarnings: (stack) => this.#channels.getRecordedWarnings(stack), getStackExports: (stack) => this.#stackExports.get(stack), whenStackActive: (stack) => { const current = this.#stackExports.get(stack); diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index ed2484b..a3e61c0 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -163,8 +163,7 @@ class FmtFolderRuntime { #configPath: string | undefined; readonly #packageEpisode = new NotInstalledEpisode(); readonly #configDependencyEpisode = new NotInstalledEpisode(); - readonly #configDependencyWarnings: string[] = []; - #suppressedShowMessages = 0; + suppressedShowMessages = 0; #closing = false; #disposed = false; /** True only across `startImpl`'s `client.start()` await — the window `interruptInFlightStart` exists for. */ @@ -208,14 +207,6 @@ class FmtFolderRuntime { this.#configPath = configPath; } - get configDependencyWarnings(): readonly string[] { - return this.#configDependencyWarnings; - } - - get suppressedShowMessages(): number { - return this.#suppressedShowMessages; - } - private setState(state: FmtRuntimeState, detail = ''): void { this.#state = state; this.#detail = detail; @@ -237,9 +228,8 @@ class FmtFolderRuntime { ); if (report.warning !== undefined) { this.context.output.warn(report.warning); - this.#configDependencyWarnings.push(report.warning); } - this.#suppressedShowMessages++; + this.suppressedShowMessages++; this.setState('disabled', report.reason); }, showErrorMessage: (text) => { @@ -556,13 +546,13 @@ class FmtFolderRuntime { token, next, ) => { - const suppressedBeforeRequest = this.#suppressedShowMessages; + const suppressedBeforeRequest = this.suppressedShowMessages; const edits = await next(document, options, token); if ( finishSuccessfulFormatting( this.#configDependencyEpisode, suppressedBeforeRequest, - this.#suppressedShowMessages, + this.suppressedShowMessages, edits?.length ?? 0, ) && this.#state === 'disabled' @@ -720,11 +710,6 @@ class FmtController implements StackController { (count, runtime) => count + runtime.suppressedShowMessages, 0, ), - /** E2E only: one-line warnings emitted for classified config failures. */ - configDependencyWarnings: (): readonly string[] => - [...this.#runtimes.values()].flatMap( - (runtime) => runtime.configDependencyWarnings, - ), }); } diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index cddca62..1b573da 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -314,7 +314,6 @@ export class Rslint implements Disposable { private readonly lspOutputChannel: OutputChannel; private readonly outputChannel: OutputChannel; private readonly onClosed: (() => void) | undefined; - private readonly configDependencyWarnings: string[] = []; private readonly configWatchers: FileSystemWatcher[] = []; private configReloadTimer: ReturnType | undefined; private configReloadChain: Promise = Promise.resolve(); @@ -396,7 +395,6 @@ export class Rslint implements Disposable { if (report.warning !== undefined) { const warning = report.warning; this.logger.warn(warning); - this.configDependencyWarnings.push(warning); } this.report({ kind: 'disabled', @@ -716,11 +714,6 @@ export class Rslint implements Disposable { return this.requestConfigRefresh('dependency-change'); } - /** E2E-only observation surfaced through the controller's activation exports. */ - public getConfigDependencyWarnings(): readonly string[] { - return this.configDependencyWarnings; - } - private isLifecycleCurrent(epoch: number, client: LanguageClient): boolean { return ( epoch === this.lifecycleEpoch && client === this.client && !this.closing diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index b4b42cf..4ed54f1 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -68,8 +68,6 @@ class RslintController implements StackController { #snapshot: DetectionSnapshot | undefined; readonly #subscriptions: vscode.Disposable[] = []; readonly #folderStates = new Map(); - /** E2E-only record of the controller-level not-installed warning episodes. */ - readonly #notInstalledWarnings: string[] = []; // Mirror of the live runtimes, kept here (not on the router) so answering // "does this document's server advertise hover?" needs no new surface on the // upstream-copied WorkspaceDocumentRouter. Reachability is still gated by @@ -158,13 +156,6 @@ class RslintController implements StackController { ...states.runtimes, ]), ), - getConfigDependencyWarnings: (): readonly string[] => - [...this.#runtimes.values()].flatMap((runtime) => - runtime.getConfigDependencyWarnings(), - ), - getNotInstalledWarnings: (): readonly string[] => [ - ...this.#notInstalledWarnings, - ], }; } @@ -213,7 +204,6 @@ class RslintController implements StackController { `${document.uri} ${keeping ? `keeps ${keeping}` : 'will not lint'} until it is installed`, ); logger.warn(warning); - this.#notInstalledWarnings.push(warning); } } else { logger.error( diff --git a/packages/vscode/src/types.ts b/packages/vscode/src/types.ts index c7087fc..a7b3c4b 100644 --- a/packages/vscode/src/types.ts +++ b/packages/vscode/src/types.ts @@ -169,6 +169,8 @@ export interface StackController { * tests; not a stable API for other extensions. */ export interface RstackExtensionExports { + /** E2E only: warnings captured when RSTACK_E2E_RECORD_WARNINGS is enabled. */ + getRecordedWarnings(stack: StackId): readonly string[]; /** Live exports the stack published at registration; undefined when inactive. */ getStackExports(stack: StackId): Record | undefined; /** From 710a530b6ad245b93f558e1f9e03064a4937c5a4 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 14:57:40 +0800 Subject: [PATCH 32/44] refactor(vscode): name formatting episode mutation explicitly --- packages/vscode/src/stacks/fmt/index.ts | 4 ++-- packages/vscode/src/stacks/fmt/sessionError.ts | 2 +- packages/vscode/tests/stacks/fmt/sessionError.test.ts | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index a3e61c0..c91aca3 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -44,7 +44,7 @@ import type { import { LanguageServerProcessOwner } from '../lint/LanguageServerProcessOwner'; import { pickBinEntry } from './binEntry'; import { - finishSuccessfulFormatting, + clearEpisodeAfterSuccessfulFormatting, handleFmtShowMessage, } from './sessionError'; import { @@ -549,7 +549,7 @@ class FmtFolderRuntime { const suppressedBeforeRequest = this.suppressedShowMessages; const edits = await next(document, options, token); if ( - finishSuccessfulFormatting( + clearEpisodeAfterSuccessfulFormatting( this.#configDependencyEpisode, suppressedBeforeRequest, this.suppressedShowMessages, diff --git a/packages/vscode/src/stacks/fmt/sessionError.ts b/packages/vscode/src/stacks/fmt/sessionError.ts index 35ab468..c5cba45 100644 --- a/packages/vscode/src/stacks/fmt/sessionError.ts +++ b/packages/vscode/src/stacks/fmt/sessionError.ts @@ -87,7 +87,7 @@ export const handleFmtShowMessage = ( * server also returns them on failure and deduplicates showMessage, so absence * of a new notification cannot prove recovery on a repeated request. */ -export const finishSuccessfulFormatting = ( +export const clearEpisodeAfterSuccessfulFormatting = ( episode: NotInstalledEpisode, suppressedBeforeRequest: number, suppressedAfterRequest: number, diff --git a/packages/vscode/tests/stacks/fmt/sessionError.test.ts b/packages/vscode/tests/stacks/fmt/sessionError.test.ts index 844073c..8530567 100644 --- a/packages/vscode/tests/stacks/fmt/sessionError.test.ts +++ b/packages/vscode/tests/stacks/fmt/sessionError.test.ts @@ -3,7 +3,7 @@ import vscode from 'vscode'; import { NotInstalledEpisode } from '../../../src/shared/notInstalled'; import { classifyFmtSessionError, - finishSuccessfulFormatting, + clearEpisodeAfterSuccessfulFormatting, FMT_SESSION_ERROR_PREFIX, handleFmtShowMessage, } from '../../../src/stacks/fmt/sessionError'; @@ -140,17 +140,17 @@ describe('handleFmtShowMessage', () => { }); }); -describe('finishSuccessfulFormatting', () => { +describe('clearEpisodeAfterSuccessfulFormatting', () => { it('clears the warning latch only after a request without a config failure', () => { const episode = new NotInstalledEpisode(); episode.observe('fmt', 'rstack.config.ts', "Cannot find package 'missing'"); - expect(finishSuccessfulFormatting(episode, 0, 1, 0)).toBe(false); + expect(clearEpisodeAfterSuccessfulFormatting(episode, 0, 1, 0)).toBe(false); expect(episode.active).toBe(true); - expect(finishSuccessfulFormatting(episode, 1, 1, 0)).toBe(false); + expect(clearEpisodeAfterSuccessfulFormatting(episode, 1, 1, 0)).toBe(false); expect(episode.active).toBe(true); - expect(finishSuccessfulFormatting(episode, 1, 1, 1)).toBe(true); + expect(clearEpisodeAfterSuccessfulFormatting(episode, 1, 1, 1)).toBe(true); expect(episode.active).toBe(false); expect( episode.observe( From e960657c8b4df2d4b72093dda3de63d9cdb6a7f7 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 15:15:29 +0800 Subject: [PATCH 33/44] fix(vscode): pick the root rstack config by loader precedence --- packages/vscode/src/detection.ts | 10 +++++----- packages/vscode/tests/lintDetection.test.ts | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/vscode/src/detection.ts b/packages/vscode/src/detection.ts index 486f982..d0ffd90 100644 --- a/packages/vscode/src/detection.ts +++ b/packages/vscode/src/detection.ts @@ -182,11 +182,11 @@ export const detectFolder = async ( ), ] as const); - const rootRstackConfigPath = rstackConfigFiles.find((uri) => - RSTACK_CONFIG_NAMES.some( - (name) => - vscode.Uri.joinPath(folder.uri, name).toString() === uri.toString(), - ), + // Match the shim's loader precedence, not findFiles discovery order. + const rootRstackConfigPath = RSTACK_CONFIG_NAMES.map((name) => + vscode.Uri.joinPath(folder.uri, name), + ).find((candidate) => + rstackConfigFiles.some((uri) => uri.toString() === candidate.toString()), )?.fsPath; const rslintMode = decideRslintMode({ nativeConfigPaths: rslintConfigFiles.map((uri) => uri.fsPath), diff --git a/packages/vscode/tests/lintDetection.test.ts b/packages/vscode/tests/lintDetection.test.ts index f29e8d3..d0da7f3 100644 --- a/packages/vscode/tests/lintDetection.test.ts +++ b/packages/vscode/tests/lintDetection.test.ts @@ -34,6 +34,26 @@ rs.mock('vscode', () => { }); describe('Rslint folder ownership', () => { + it('selects the root config by loader precedence rather than discovery order', async () => { + const folder = path.resolve('/workspace'); + const ts = path.join(folder, 'rstack.config.ts'); + const js = path.join(folder, 'rstack.config.js'); + const workspaceFolder = { + uri: vscode.Uri.file(folder), + name: 'workspace', + index: 0, + }; + for (const ordered of [ + [js, ts], + [ts, js], + ]) { + configPaths = ordered; + expect((await detectFolder(workspaceFolder)).rootRstackConfigPath).toBe( + ts, + ); + } + }); + it('attributes bridge failures to the root config regardless of discovery order', async () => { const folder = path.resolve('/workspace'); const root = path.join(folder, 'rstack.config.ts'); From 8f255d618caa299a2255a65eff3078a51ab2d220 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 15:26:01 +0800 Subject: [PATCH 34/44] fix(vscode): retry a core lost after config load --- packages/vscode/src/stacks/test/project.ts | 5 +- .../vscode/tests/stacks/test/project.test.ts | 58 ++++++++++++++++++- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts index b204916..6713c5c 100644 --- a/packages/vscode/src/stacks/test/project.ts +++ b/packages/vscode/src/stacks/test/project.ts @@ -654,9 +654,10 @@ export class Project implements vscode.Disposable { ); } - /** Re-evaluates a failed config without replacing this project. */ + /** Re-evaluates a failed config or a core lost after loading, in place. */ public retryFailedConfig(): Promise | undefined { - if (!this.configLoadFailed) return undefined; + if (!this.configLoadFailed && !this.hasNotInstalledDependencies) + return undefined; return this.loadConfig(); } diff --git a/packages/vscode/tests/stacks/test/project.test.ts b/packages/vscode/tests/stacks/test/project.test.ts index f350fe1..5c14af5 100644 --- a/packages/vscode/tests/stacks/test/project.test.ts +++ b/packages/vscode/tests/stacks/test/project.test.ts @@ -74,7 +74,10 @@ rs.mock('vscode', () => { }), }, CancellationTokenSource: class { - token = { isCancellationRequested: false }; + token = { + isCancellationRequested: false, + onCancellationRequested: () => ({ dispose: () => {} }), + }; cancel() { this.token.isCancellationRequested = true; } @@ -153,6 +156,59 @@ const createProject = async (source: any) => { }; describe('Project config/cwd/package-resolution decoupling', () => { + it('re-resolves a core lost after successful config loading on a dependency pass', async () => { + const config = uri('/repo/pkg/rstest.config.ts'); + const { reporter, reported } = createStatusRecorder(); + status.bind(reporter); + const loaded: NormalizedConfigResult = { + ok: true, + root: '/repo/pkg', + include: ['**/*.test.ts'], + exclude: [], + childProjects: [], + }; + normalizedConfigResult = loaded; + const { project } = await createProject({ sourceUri: config }); + try { + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(project.configLoadFailed).toBe(false); + + // listTests -> createChildProcess -> resolveRstestPath reports this core + // source, without going through Project.loadConfig's failure handler. + project.api.listTests = rs.fn(async () => { + status.notInstalled('@rstest/core is not installed', config.toString()); + throw new ReportedRstestResolutionError(); + }); + await expect(project.api.listTests()).rejects.toThrow( + 'Failed to resolve rstest path', + ); + expect(project.configLoadFailed).toBe(false); + expect(project.hasNotInstalledDependencies).toBe(true); + + // A new config request resolves the core before its worker RPC. Model + // successful resolution's versionOk, which clears the core-source latch. + const reResolve = rs + .spyOn(project.api, 'getNormalizedConfig') + .mockImplementation(async () => { + status.versionOk(config.toString()); + return loaded; + }); + const { WorkspaceManager } = + await import('../../../src/stacks/test/project'); + WorkspaceManager.prototype.retryFailedProjects.call({ + projects: new Map([['config', project]]), + } as never); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(reResolve).toHaveBeenCalledTimes(1); + expect(project.hasNotInstalledDependencies).toBe(false); + expect(reported.at(-1)).toEqual({ kind: 'running', detail: undefined }); + expect(loggedErrors).toEqual([]); + } finally { + project.dispose(); + status.unbind(); + } + }); + it('retries a core-missing project on a dependency pass', async () => { const config = uri('/repo/pkg/rstest.config.ts'); const { reporter } = createStatusRecorder(); From fc5f80fe2947eb5a462231031c2d00a193866775 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 15:36:17 +0800 Subject: [PATCH 35/44] fix(vscode): refresh bridge attribution on detection --- packages/vscode/src/stacks/lint/Rslint.ts | 7 +- packages/vscode/src/stacks/lint/index.ts | 5 + .../vscode/tests/stacks/lint/start.test.ts | 111 ++++++++++++++++-- 3 files changed, 112 insertions(+), 11 deletions(-) diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 1b573da..84a40ba 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -309,7 +309,7 @@ export class Rslint implements Disposable { public readonly workspaceFolder: WorkspaceFolder; private readonly router: WorkspaceDocumentRouter; private readonly reportStatus: RslintStatusSink; - private readonly bridgeConfigPath: string | undefined; + private bridgeConfigPath: string | undefined; private readonly installation: CoreInstallation; private readonly lspOutputChannel: OutputChannel; private readonly outputChannel: OutputChannel; @@ -343,6 +343,11 @@ export class Rslint implements Disposable { this.onClosed = options.onClosed; } + public setBridgeConfigPath(configPath: string | undefined): void { + if (this.installation.mode === 'bridged') + this.bridgeConfigPath = configPath; + } + private report(state: StackState): void { this.reportStatus(state); } diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index 4ed54f1..2871c5b 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -94,6 +94,11 @@ class RslintController implements StackController { this.#subscriptions.push( context.onDidChangeDetection((snapshot) => { this.#snapshot = snapshot; + for (const runtime of this.#runtimes.values()) { + runtime.setBridgeConfigPath( + snapshot.forFolder(runtime.workspaceFolder)?.rootRstackConfigPath, + ); + } this.pruneDepartedFolders(); // A detection pass fires on config topology and lockfile changes — // exactly the moments a document's core may have appeared, moved or diff --git a/packages/vscode/tests/stacks/lint/start.test.ts b/packages/vscode/tests/stacks/lint/start.test.ts index 4113a9e..64ad989 100644 --- a/packages/vscode/tests/stacks/lint/start.test.ts +++ b/packages/vscode/tests/stacks/lint/start.test.ts @@ -1,21 +1,54 @@ import { expect, it, rs } from '@rstest/core'; -import type { StackState } from '../../../src/types'; +import type { + DetectionSnapshot, + StackContext, + StackState, +} from '../../../src/types'; import type { RslintOptions } from '../../../src/stacks/lint/Rslint'; +import type { ResolvedCoreRuntime } from '../../../src/stacks/lint/CoreResolver'; import { registerEditorProxy } from '../../../src/stacks/lint/worker/index'; let refreshOutcome: 'missing' | 'broken' | 'fixed' | 'changed' | 'changed-once' = 'missing'; -rs.mock('vscode', () => ({ - RelativePattern: class {}, - workspace: { - createFileSystemWatcher: () => ({ - onDidCreate() {}, - onDidChange() {}, - onDidDelete() {}, - }), +rs.mock('vscode', () => { + const api = { + RelativePattern: class {}, + workspace: { + textDocuments: [], + onDidChangeWorkspaceFolders: () => ({ dispose() {} }), + onDidOpenTextDocument: () => ({ dispose() {} }), + onDidCloseTextDocument: () => ({ dispose() {} }), + createFileSystemWatcher: () => ({ + onDidCreate() {}, + onDidChange() {}, + onDidDelete() {}, + }), + }, + env: {}, + }; + return { ...api, default: api }; +}); +let runtimeFactory: (resolved: ResolvedCoreRuntime) => Rslint; +rs.mock('../../../src/stacks/lint/RuntimeManager', () => ({ + RuntimeManager: class { + constructor( + _router: unknown, + _resolver: unknown, + create: typeof runtimeFactory, + ) { + runtimeFactory = create; + } + initialize() {} + clearResolutionCache() {} + async reconcileOpenDocuments() {} }, - env: {}, +})); +rs.mock('../../../src/stacks/lint/CoreResolver', () => ({ + CoreResolver: class {}, +})); +rs.mock('../../../src/stacks/lint/ruleDocumentationProviders', () => ({ + registerRuleDocumentationProviders: () => [], })); rs.mock('../../../src/shared/nodeExecutableSetting', () => ({ getConfiguredNodeExecutable: () => undefined, @@ -89,6 +122,64 @@ rs.mock('vscode-languageclient/node', () => ({ })); import { Rslint } from '../../../src/stacks/lint/Rslint'; +import { createRslintController } from '../../../src/stacks/lint'; + +it('updates a surviving bridge runtime attribution before the next config failure', async () => { + const folder = { + name: 'project', + uri: { fsPath: '/project', toString: () => 'file:///project' }, + }; + const snapshot = (configPath: string): DetectionSnapshot => { + const entry = { + folder, + rootRstackConfigPath: configPath, + stacks: { rslint: { mode: 'bridged' } }, + }; + return { + forFolder: () => entry, + foldersFor: () => [entry], + } as unknown as DetectionSnapshot; + }; + let onDetection!: (snapshot: DetectionSnapshot) => void; + const warnings: string[] = []; + const states: StackState[] = []; + const controller = createRslintController(); + await controller.register({ + detection: snapshot('/project/rstack.config.js'), + onDidChangeDetection: (listener: typeof onDetection) => { + onDetection = listener; + return { dispose() {} }; + }, + output: { warn: (message: string) => warnings.push(message) }, + status: { report: (state: StackState) => states.push(state) }, + } as unknown as StackContext); + const shimPath = '/project/node_modules/rstack/dist/rslintConfig.js'; + const runtime = runtimeFactory({ + key: 'bridge', + workspaceFolder: folder, + installation: { + mode: 'bridged', + packageDirectory: '/project/core', + shimPath, + }, + } as unknown as ResolvedCoreRuntime); + + onDetection(snapshot('/project/rstack.config.ts')); + // Deliver the next worker verdict to the same runtime, not a replacement. + ( + runtime as unknown as { handleConfigDependencyStatus(value: unknown): void } + ).handleConfigDependencyStatus({ + kind: 'missing', + failure: { configPath: shimPath, cause: "Cannot find package 'missing'" }, + }); + expect(states.at(-1)).toMatchObject({ + kind: 'disabled', + reason: expect.stringContaining('rstack.config.ts'), + }); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('Cannot load rstack.config.ts:'); + expect(warnings[0]).not.toContain('rstack.config.js'); +}); function createRuntime() { const states: StackState[] = []; From 6593c86550dc64cb8557a3c2d514f39cf38a8a8a Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 15:48:18 +0800 Subject: [PATCH 36/44] fix(vscode): prefer real config errors over missing dependencies --- .../suite-jsconfig/config-transaction.test.ts | 6 ++ .../lint/worker/ConfigTransactionAdapter.ts | 29 +++--- .../vscode/src/stacks/lint/worker/index.ts | 38 +++++-- .../vscode/tests/stacks/lint/start.test.ts | 1 + .../vscode/tests/stacks/lint/worker.test.ts | 99 +++++++++++++++++++ 5 files changed, 152 insertions(+), 21 deletions(-) diff --git a/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts b/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts index 68d46e3..a17e9dd 100644 --- a/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts +++ b/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts @@ -285,6 +285,7 @@ suite('LSP config discovery transactions', () => { { resolveFrom: (candidate) => candidate.configDirectory, report: () => assert.fail('unexpected missing dependency'), + reportError: () => assert.fail('unexpected config error'), }, ); @@ -334,6 +335,7 @@ suite('LSP config discovery transactions', () => { { resolveFrom: (candidate) => candidate.configDirectory, report: () => assert.fail('unexpected missing dependency'), + reportError: () => assert.fail('unexpected config error'), }, ); @@ -400,6 +402,7 @@ suite('LSP config discovery transactions', () => { { resolveFrom: (candidate) => candidate.configDirectory, report: () => assert.fail('unexpected missing dependency'), + reportError: () => assert.fail('unexpected config error'), }, ); @@ -439,6 +442,7 @@ suite('LSP config discovery transactions', () => { { resolveFrom: (candidate) => candidate.configDirectory, report: () => assert.fail('unexpected missing dependency'), + reportError: () => assert.fail('unexpected config error'), }, ); @@ -467,6 +471,7 @@ suite('LSP config discovery transactions', () => { { resolveFrom: (candidate) => candidate.configDirectory, report: () => assert.fail('unexpected missing dependency'), + reportError: () => assert.fail('unexpected config error'), }, ); @@ -508,6 +513,7 @@ suite('LSP config discovery transactions', () => { { resolveFrom: (candidate) => candidate.configDirectory, report: () => assert.fail('unexpected missing dependency'), + reportError: () => assert.fail('unexpected config error'), }, ); diff --git a/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts b/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts index b7e0fba..1879f24 100644 --- a/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts +++ b/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts @@ -14,6 +14,7 @@ import type { ConfigDependencyFailure } from '../../../shared/notInstalled'; interface ConfigDependencyObserver { resolveFrom(candidate: ConfigModuleCandidate): string; report(failure: ConfigDependencyFailure): void; + reportError(message: string): void; } interface ConfigActivationWireResponse { @@ -119,20 +120,26 @@ export class LspConfigTransactionAdapter { return { ...response, results: response.results.map((result, index) => { - if (classified || result.status !== 'failed') return result; + if (result.status !== 'failed') return result; const candidate = request.candidates[index]; - if ( - candidate === undefined || - (result.error.code !== 'ERR_MODULE_NOT_FOUND' && - result.error.code !== 'MODULE_NOT_FOUND') - ) { + const cause = + candidate !== undefined && + (result.error.code === 'ERR_MODULE_NOT_FOUND' || + result.error.code === 'MODULE_NOT_FOUND') + ? classifyMissingDependencyMessage( + result.error.message, + this.configDependencyObserver.resolveFrom(candidate), + ) + : undefined; + // Scan every failure: a later real error must not be hidden by the + // first missing dependency, even though only that result is rewritten. + if (cause === undefined || candidate === undefined) { + this.configDependencyObserver.reportError( + result.error.message.split('\n', 1)[0], + ); return result; } - const cause = classifyMissingDependencyMessage( - result.error.message, - this.configDependencyObserver.resolveFrom(candidate), - ); - if (cause === undefined) return result; + if (classified) return result; classified = true; this.configDependencyObserver.report({ configPath: candidate.configPath, diff --git a/packages/vscode/src/stacks/lint/worker/index.ts b/packages/vscode/src/stacks/lint/worker/index.ts index fd526fa..e35ad93 100644 --- a/packages/vscode/src/stacks/lint/worker/index.ts +++ b/packages/vscode/src/stacks/lint/worker/index.ts @@ -142,6 +142,7 @@ interface EditorProxyOptions { readonly protocolVersion: number; readonly configPath?: string; takeConfigDependencyFailure(): ConfigDependencyFailure | undefined; + takeConfigError(): string | undefined; observeRefresh(reason: unknown): void; requestStop(request: StopRequest): void; } @@ -166,28 +167,36 @@ export function registerEditorProxy( token, ); const failure = options.takeConfigDependencyFailure(); + const configError = options.takeConfigError(); await editorConnection.sendNotification( CONFIG_DEPENDENCY_STATUS_NOTIFICATION, - failure ? { kind: 'missing', failure } : { kind: 'ok' }, + configError !== undefined + ? { kind: 'error', message: configError } + : failure + ? { kind: 'missing', failure } + : { kind: 'ok' }, ); return result; } catch (error) { const failure = options.takeConfigDependencyFailure(); + const configError = options.takeConfigError(); // The editor already retries this transaction race during startup. // Leave its rejection untouched and send no premature failure (or // success) verdict; the startup catch reports once if retries exhaust. if (isConfigSourceChangeDuringTransaction(error)) throw error; await editorConnection.sendNotification( CONFIG_DEPENDENCY_STATUS_NOTIFICATION, - failure - ? { kind: 'missing', failure } - : { - kind: 'error', - message: (error instanceof Error - ? error.message - : String(error) - ).split('\n', 1)[0], - }, + configError !== undefined + ? { kind: 'error', message: configError } + : failure + ? { kind: 'missing', failure } + : { + kind: 'error', + message: (error instanceof Error + ? error.message + : String(error) + ).split('\n', 1)[0], + }, ); throw error; } @@ -227,6 +236,7 @@ export async function runLintWorker( installation.createPluginLintHost, ); let configDependencyFailure: ConfigDependencyFailure | undefined; + let configError: string | undefined; const adapter = new LspConfigTransactionAdapter( installation.createConfigModuleHost(), pluginLintPool, @@ -240,6 +250,9 @@ export async function runLintWorker( report: (failure) => { configDependencyFailure ??= failure; }, + reportError: (message) => { + configError ??= message; + }, }, ); @@ -260,6 +273,11 @@ export async function runLintWorker( configDependencyFailure = undefined; return failure; }, + takeConfigError: () => { + const message = configError; + configError = undefined; + return message; + }, observeRefresh: (reason) => fingerprinter.observeRefresh(reason), requestStop, }); diff --git a/packages/vscode/tests/stacks/lint/start.test.ts b/packages/vscode/tests/stacks/lint/start.test.ts index 64ad989..a3f953e 100644 --- a/packages/vscode/tests/stacks/lint/start.test.ts +++ b/packages/vscode/tests/stacks/lint/start.test.ts @@ -94,6 +94,7 @@ rs.mock('vscode-languageclient/node', () => ({ { protocolVersion: 2, takeConfigDependencyFailure: () => undefined, + takeConfigError: () => undefined, observeRefresh() {}, requestStop() {}, }, diff --git a/packages/vscode/tests/stacks/lint/worker.test.ts b/packages/vscode/tests/stacks/lint/worker.test.ts index 8dcbd37..e720c3d 100644 --- a/packages/vscode/tests/stacks/lint/worker.test.ts +++ b/packages/vscode/tests/stacks/lint/worker.test.ts @@ -175,6 +175,7 @@ describe('lint worker config refresh', () => { activeFailure = undefined; return failure; }, + takeConfigError: () => undefined, observeRefresh: (reason) => observedReasons.push(reason), requestStop: () => undefined, }); @@ -255,6 +256,99 @@ describe('lint worker config refresh', () => { }); describe('lint worker config dependency classification', () => { + it('prefers a real candidate error over a missing dependency in the same refresh', async () => { + let missing: { configPath: string; cause: string } | undefined; + let configError: string | undefined; + const observer = { + resolveFrom: () => '/project', + report: (failure: NonNullable) => { + missing = failure; + }, + reportError: (message: string) => { + configError ??= message; + }, + }; + const adapter = new LspConfigTransactionAdapter( + { + loadConfigs: async () => ({ + transactionId: 'mixed', + results: [ + { + id: 'missing', + status: 'failed' as const, + error: { + code: 'ERR_MODULE_NOT_FOUND', + message: "Cannot find package 'absent'", + }, + }, + { + id: 'broken', + status: 'failed' as const, + error: { + code: 'SyntaxError', + message: 'SyntaxError: Unexpected token\n at config.ts:1', + }, + }, + ], + }), + activateConfigs: async () => { + throw new Error('unused'); + }, + deleteSession: () => true, + }, + { + prepare: async () => true, + commit: async () => true, + abort: async () => {}, + }, + () => 'fingerprint', + 3, + observer, + ); + const notifications: unknown[] = []; + let refresh!: (method: string, params: unknown) => Promise; + const options = { + protocolVersion: 3, + takeConfigDependencyFailure: () => missing, + takeConfigError: () => configError, + observeRefresh() {}, + requestStop() {}, + }; + registerEditorProxy( + { + onRequest: (handler: typeof refresh) => { + refresh = handler; + }, + onNotification() {}, + sendNotification: async (_method: string, value: unknown) => { + notifications.push(value); + }, + } as never, + { + sendRequest: async () => { + await adapter.loadConfigs({ + protocolVersion: 3, + transactionId: 'mixed', + loadMode: 'fresh', + candidates: ['missing', 'broken'].map((id) => ({ + id, + configPath: `/project/${id}.config.ts`, + configDirectory: '/project', + })), + }); + throw new Error('config refresh failed'); + }, + } as never, + options, + ); + await expect( + refresh('rslint/configRefresh', { reason: 'initial' }), + ).rejects.toThrow('config refresh failed'); + expect(notifications).toEqual([ + { kind: 'error', message: 'SyntaxError: Unexpected token' }, + ]); + }); + it('reports and truncates only the first classified failed candidate', async () => { const firstMessage = "Cannot find module 'first-missing'\nRequire stack:\n- /project/first.config.cjs"; @@ -295,6 +389,9 @@ describe('lint worker config dependency classification', () => { { resolveFrom: (candidate) => candidate.configDirectory, report: (failure) => failures.push(failure), + reportError: () => { + throw new Error('unexpected config error'); + }, }, ); const request: LoadConfigsRequest = { @@ -373,6 +470,8 @@ describe('lint worker config dependency classification', () => { { resolveFrom: () => '/project', report: (failure) => failures.push(failure), + reportError: (message) => + expect(message).toBe("Cannot find package './relative.js'"), }, ); From e364fd133ab2a74690e8a2cdaab8d3627c4afdd3 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 17:49:48 +0800 Subject: [PATCH 37/44] feat(vscode): poll every minute until every stack is running --- .../0005-not-installed-recovery-by-polling.md | 6 +- packages/vscode/AGENTS.md | 4 +- packages/vscode/src/extension.ts | 6 +- packages/vscode/src/stacks/fmt/index.ts | 17 ++++-- packages/vscode/src/stacks/lint/Rslint.ts | 14 +++-- packages/vscode/src/stacks/lint/index.ts | 27 ++++++--- packages/vscode/src/stacks/test/index.ts | 4 +- packages/vscode/src/stacks/test/master.ts | 10 +++- packages/vscode/src/stacks/test/project.ts | 31 ++++++---- packages/vscode/src/stacks/test/status.ts | 15 +++-- packages/vscode/src/types.ts | 4 +- packages/vscode/tests/extension.test.ts | 56 +++++++++++++------ .../vscode/tests/stacks/lint/start.test.ts | 16 +----- .../vscode/tests/stacks/test/master.test.ts | 31 +++++++++- .../vscode/tests/stacks/test/project.test.ts | 28 +++++++--- .../vscode/tests/stacks/test/status.test.ts | 19 +++++-- 16 files changed, 195 insertions(+), 93 deletions(-) diff --git a/docs/adr/0005-not-installed-recovery-by-polling.md b/docs/adr/0005-not-installed-recovery-by-polling.md index 2850fd8..d167b4d 100644 --- a/docs/adr/0005-not-installed-recovery-by-polling.md +++ b/docs/adr/0005-not-installed-recovery-by-polling.md @@ -18,9 +18,9 @@ The result is not explained by pnpm symlinks: the hoisted core was an ordinary d The watcher's original rationale was also factually wrong. At Microsoft VS Code commit [`008427a`](https://github.com/microsoft/vscode/commit/008427a901bf4aa79b47f175ccc8da1731750f78), the default `files.watcherExclude` contains only `.git/objects`, `.git/subtree-cache`, and `.hg/store`, each at the root and one directory below; it does not exclude `node_modules` ([`files.contribution.ts:294-310`](https://github.com/microsoft/vscode/blob/008427a901bf4aa79b47f175ccc8da1731750f78/src/vs/workbench/contrib/files/browser/files.contribution.ts#L294-L310)). The failure is the absent pnpm per-file event observed above, not a VS Code default exclude. -**Decision.** The extension shell owns one recursive 10-second timer. It exists only while any live controller's raw folder/project state says dependencies are not installed, enters the shell's existing serialized queue, and forces the same detection notification as a lockfile event even when the detection signature is unchanged. The three stacks reuse their existing dependency-change paths: lint reconciles open documents and refreshes config dependencies, fmt restarts failed folder runtimes in place, and Rstest re-resolves shims and retries failed config evaluation. The timer stops as soon as no not-installed state remains. Lockfile watchers stay as the lower-latency path. +**Decision.** The extension shell owns one recursive 60-second timer. It exists while any live controller's raw folder/project/runtime state is disabled, crashed or version-mismatched, enters the shell's existing serialized queue, and forces the same detection notification as a lockfile event even when the detection signature is unchanged. The three stacks reuse their existing dependency-change paths: lint reconciles open documents and refreshes failed configs, fmt restarts failed folder runtimes in place, and Rstest re-resolves shims and retries failed config evaluation. The timer stops when no failed state remains (running, starting or idle). Lockfile watchers stay as the lower-latency path. -The aggregate status is deliberately not the predicate: a crash or version mismatch can outrank an unrelated missing folder. Polling continues while any raw state is not installed and stops when none is, including when a retry replaces not-installed with a real config error surfaced in status and Output for the user to fix. Warnings are deduplicated per unresolved episode so a persistent missing install does not add a line every ten seconds. The restart hint remains in the status as an explicit fallback. +The aggregate status is deliberately not the predicate: every owned raw failure needs recovery. A retry landing mid-install can read half-written `node_modules` and fail with a syntax error instead of a missing dependency. Continuing every minute through that real error makes the transient harmless without a provisional-error heuristic. Real errors still replace not-installed in status and Output; persistent error messages and not-installed warnings are deduplicated so retries do not log every minute. The restart hint remains in the status as an explicit fallback. fmt has one tool-forced limitation. Restarting `rs fmt --lsp` re-runs package resolution, but the server loads project config lazily on the next formatting request. A poll can therefore move the folder to `running` before config loading has been proved; the next format either succeeds or reports the same config failure and returns the folder to `disabled`, which restarts polling. @@ -35,5 +35,5 @@ fmt has one tool-forced limitation. Restarting `rs fmt --lsp` re-runs package re - Healthy workspaces incur no polling work. An unresolved workspace retries at most once per timer interval, through the existing serialized shell queue. - Recovery no longer depends on installer-specific file events; lockfile watchers remain the faster path when they do fire. -- A new, real config error replaces not-installed and stops its poll. Fixing that error still uses config events or the explicit restart command. +- A real config error replaces not-installed without stopping recovery. Config events and the explicit restart command remain available alongside the minute poll. - fmt cannot prove config recovery at initialize time. Only a later format producing edits ends its warning episode; empty edits are ambiguous because the server uses them for both no-op formatting and failures whose showMessage may already have been sent. diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index af2006e..4e826e8 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -9,7 +9,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - **Tracked upstream state.** `stacks/lint` is synced to web-infra-dev/rslint `packages/vscode-extension` at **39536fd6** (#1617 — per-document core resolution, `CoreResolver` + `RuntimeManager`, `corePath`, PnP removed) and **892482e0** (#1630 — `configPath` on `rslint/configRefresh`). Targeted later ports are **84f9c9b5** (#1967 — languageclient-owned live LSP tracing) and **b7176723** (#1951 — remove legacy JSON config watching); the Unicode BOM E2E comes from **5fc197a5** (#1560), with its native-config fixture shape from **b7176723**. `CoreResolver.ts` / `RuntimeManager.ts` / `WorkspaceDocumentRouter.ts` / `Rslint.ts` are the files to diff when syncing further; record the new commits here when you do. - **Ahead of upstream — offer these back when syncing** (bug fixes, not adaptations): (1) `RuntimeManager.reconcile` resolves the document's core **before** sweeping pending uses (`planDocumentCore`), so a reconcile landing on the key a pending start is already producing adopts that start instead of tearing it down mid-`initialize` — the teardown made vscode-languageclient force-notify ("couldn't create connection to server") whenever the register-time pass, a detection change and `didOpen` landed inside one worker startup window (`tests/stacks/lint/runtimeManager.test.ts`). (2) `Rslint.close()` gives a still-Starting language client a bounded chance to settle before tearing down its transport, so a legitimate mid-start close (document closed during start, core key changed) stops cleanly instead of triggering the same force-notified toasts. (3) The registry-harness E2E gives its never-settling startup operation 500ms to begin and accepts only the in-flight timeout message, so a stalled runner cannot satisfy the assertion through the already-expired path (`e2e/lint/suite/registry-harness.test.ts`). (4) `Project.retryFailedConfig()` keeps a failed Rstest project and retries its config evaluation in place with one single-flight promise, so repeated dependency-change passes neither overlap workers nor repeat an unchanged not-installed warning. -- **Targeted Rstest lifecycle port:** `RstestApi.getNormalizedConfig()` closes its worker in `finally`, including rejected config evaluation, matching web-infra-dev/rstest `packages/vscode/src/master.ts` at `d82db4fc31a61ee74b2a74917f14a458e1bca419`. This fixes a leak in our older copy; it is already fixed upstream. Dependency passes retry only projects with a not-installed latch under their core or config-import source; real config errors retain config-edit/restart recovery. +- **Targeted Rstest lifecycle port:** `RstestApi.getNormalizedConfig()` closes its worker in `finally`, including rejected config evaluation, matching web-infra-dev/rstest `packages/vscode/src/master.ts` at `d82db4fc31a61ee74b2a74917f14a458e1bca419`. This fixes a leak in our older copy; it is already fixed upstream. Dependency passes retry failed projects, including real config errors, while preserving single-flight loading and worker cleanup. ## The nine adaptations @@ -27,7 +27,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - **Pre-1.0.0 the extension breaks freely.** No compatibility is owed with earlier unpublished states of this extension — settings, command ids and behavior may change without deprecation paths, and dead compat code for them is removed, not kept. No settings migration exists either — not for earlier states of this extension, and not for the two retired standalone extensions (removed in #15; users re-enter their settings under `rstack.*`). Testing and fixtures track only the latest published releases, pinned exactly and bumped by Renovate; a green E2E run speaks only for those releases. `SUPPORT_MATRIX` floors are the minimum versions the extension accepts: each entry is the lowest release evidence shows works with the current code, and its comment records that evidence. Move a floor only when a change makes older releases stop working, never because a devDependency or fixture moved. Raising a floor needs no transition story; the status names the required version. - **The three tools are treated uniformly by default.** Detection, dependency-change retry, restart semantics, version gating and status reporting follow one shared pattern across the lint/test/fmt stacks; a stack diverges only when its tool forces it, and the divergence is recorded here as a gotcha. When adding behavior to one stack, first ask whether it belongs to all three. This is about behavior, not code — the upstream copies still must not be deduplicated. -- **Not installed is a state, not an error — uniformly.** A folder or project whose dependencies are not installed (no `rstack`, no `@rstest/core`, no `@rslint/core`, a config importing a package that is not there) is the normal state of a fresh clone and of scaffolded templates beside their generator (`create-rstack`'s `template-*`, which declare their own dependencies and are never installed). Every stack reports it the same way: a `disabled` status whose reason keeps the restart command as an explicit fallback, one `warn` line per unresolved episode in the output channel without a stack trace, never a `crashed` status and never a notification. The shell owns one 10-second recursive poll while any controller's raw folder/project state is not installed; it enters the existing serialized queue, forces the same detection notification as a lockfile event, and stops when no such state remains (ADR 0005). The words come from one place, `shared/notInstalled.ts` (the `formatVersionMismatch` precedent) — each stack keeps its own status machinery, none its own wording; the restart hint is derived from `stackCommandTitle`, which `tests/extension.test.ts` checks against the manifest. Rstest classifies config-import failures in its worker (`missingDependencyCauseOf`: Node's `code`, a bare package specifier, and for a subpath a walk-up proving the package really is absent) because IPC drops the `code`; Rslint makes the same code-gated decision where its worker still has structured loader results and sends a dedicated verdict to the editor; fmt intercepts only the exact `rs fmt cannot format this workspace:` Error notification and applies the shared message classifier. A typo'd relative import or a missing subpath of an installed package stays a real error in all three. +- **Not installed is a state, not an error — uniformly.** A folder or project whose dependencies are not installed (no `rstack`, no `@rstest/core`, no `@rslint/core`, a config importing a package that is not there) is the normal state of a fresh clone and of scaffolded templates beside their generator (`create-rstack`'s `template-*`, which declare their own dependencies and are never installed). Every stack reports it the same way: a `disabled` status whose reason keeps the restart command as an explicit fallback, one `warn` line per unresolved episode in the output channel without a stack trace, never a `crashed` status and never a notification. The shell owns one 60-second recursive poll while any controller's raw folder/project/runtime state is disabled, crashed or version-mismatched; it enters the existing serialized queue, forces the same detection notification as a lockfile event, and stops when no failed state remains (ADR 0005). A mid-install retry can read half-written `node_modules` and produce a real syntax error; continuing through failed states makes that transient harmless without a provisional-error heuristic. Real errors remain visible in status and Output, deduplicated by message rather than logged every minute. The words come from one place, `shared/notInstalled.ts` (the `formatVersionMismatch` precedent) — each stack keeps its own status machinery, none its own wording; the restart hint is derived from `stackCommandTitle`, which `tests/extension.test.ts` checks against the manifest. Rstest classifies config-import failures in its worker (`missingDependencyCauseOf`: Node's `code`, a bare package specifier, and for a subpath a walk-up proving the package really is absent) because IPC drops the `code`; Rslint makes the same code-gated decision where its worker still has structured loader results and sends a dedicated verdict to the editor; fmt intercepts only the exact `rs fmt cannot format this workspace:` Error notification and applies the shared message classifier. A typo'd relative import or a missing subpath of an installed package stays a real error in all three. - One stack failing to register or crashing must never take another stack (or the shell) down. - The shell always activates; per-folder config detection decides which stacks start, and re-runs on config/lockfile changes without a window reload. The per-stack enable settings are coarse kill switches only. - Reconciles and restarts share one serialized queue (`enqueue`); a reconcile leaves a live stack alone, so the restart path — the commands, and the full pass any relevant settings change triggers — is the only thing that rebuilds one. Do not add a second queue. diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index eb6e863..3cbcae9 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -28,7 +28,7 @@ const STACK_FACTORIES: Readonly> = { /** Stacks that run project-loading children on the shared User Node runtime. */ const USER_NODE_STACKS: readonly StackId[] = ['rslint', 'rstest', 'fmt']; -const DEFAULT_DEPENDENCY_POLL_INTERVAL_MS = 10_000; +const DEFAULT_DEPENDENCY_POLL_INTERVAL_MS = 60_000; const errorMessage = (error: unknown): string => error instanceof Error ? (error.stack ?? error.message) : String(error); @@ -229,14 +229,14 @@ class ExtensionShell { return ( !this.#disposed && [...this.#controllers.values()].some((controller) => - controller.hasNotInstalledState(), + controller.hasFailedState(), ) ); } /** * Starts one recursive timer only while a live controller owns a - * not-installed state. The timer enters the same shell queue as every + * failed state. The timer enters the same shell queue as every * reconcile/restart, then sends the same forced detection event as a * lockfile change; each stack therefore reuses its existing retry path. */ diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index c91aca3..329cf65 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -163,6 +163,7 @@ class FmtFolderRuntime { #configPath: string | undefined; readonly #packageEpisode = new NotInstalledEpisode(); readonly #configDependencyEpisode = new NotInstalledEpisode(); + #startError: string | undefined; suppressedShowMessages = 0; #closing = false; #disposed = false; @@ -451,9 +452,17 @@ class FmtFolderRuntime { error instanceof Error ? error.message : String(error) }`, ); - context.output.error('Failed to start the rs fmt language server', error); + const message = error instanceof Error ? error.message : String(error); + if (this.#startError !== message) { + this.#startError = message; + context.output.error( + 'Failed to start the rs fmt language server', + error, + ); + } return; } + this.#startError = undefined; context.output.info(`rs fmt language server started for ${folderRoot}`); } @@ -804,9 +813,9 @@ class FmtController implements StackController { ); } - hasNotInstalledState(): boolean { - return [...this.#runtimes.values()].some( - (runtime) => runtime.state === 'disabled', + hasFailedState(): boolean { + return [...this.#runtimes.values()].some((runtime) => + isFailedFmtState(runtime.state), ); } diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 84a40ba..0fdf1df 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -324,6 +324,7 @@ export class Rslint implements Disposable { private advisory: string | undefined; private readonly configDependencyEpisode = new NotInstalledEpisode(); private configRefreshFailed = false; + private configError: string | undefined; private startPromise: Promise | undefined; private startOperation: Promise | undefined; private clientStartPromise: Promise | undefined; @@ -378,11 +379,15 @@ export class Rslint implements Disposable { this.configRefreshFailed = true; this.report({ kind: 'crashed', detail: notification.message }); this.configDependencyEpisode.clear(); - this.logger.error( - `Failed to refresh config discovery: ${notification.message}`, - ); + if (this.configError !== notification.message) { + this.configError = notification.message; + this.logger.error( + `Failed to refresh config discovery: ${notification.message}`, + ); + } return; } + this.configError = undefined; const wasFailed = this.configRefreshFailed; this.configRefreshFailed = false; if (notification.kind === 'ok') { @@ -715,7 +720,8 @@ export class Rslint implements Disposable { } public retryConfigDependency(): Promise | undefined { - if (!this.hasConfigDependencyFailure()) return undefined; + if (!this.hasConfigDependencyFailure() && !this.configRefreshFailed) + return undefined; return this.requestConfigRefresh('dependency-change'); } diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index 2871c5b..cc8be15 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -193,10 +193,13 @@ class RslintController implements StackController { // lints, so its consequence says what it keeps, not "will not". const missing = missingPackageOf(error); const status = statusForRslintStartFailure(error); + const attributed = resolved + ? attributeToCore(status, resolved.installation.packageDirectory) + : status; + const previous = this.#folderStates + .get(folderKeyOf(workspaceFolder)) + ?.failures.get(document.uri.toString()); if (missing !== undefined) { - const previous = this.#folderStates - .get(folderKeyOf(workspaceFolder)) - ?.failures.get(document.uri.toString()); if ( previous?.kind !== 'disabled' || previous.reason !== @@ -210,7 +213,12 @@ class RslintController implements StackController { ); logger.warn(warning); } - } else { + } else if ( + previous?.kind !== attributed.kind || + !('detail' in previous) || + !('detail' in attributed) || + previous.detail !== attributed.detail + ) { logger.error( formatCoreSelectionFailure(document.uri.toString(), keeping), error, @@ -224,9 +232,7 @@ class RslintController implements StackController { folderKeyOf(workspaceFolder), 'failures', document.uri.toString(), - resolved - ? attributeToCore(status, resolved.installation.packageDirectory) - : status, + attributed, ); }, onDocumentSettled: (document) => { @@ -400,10 +406,13 @@ class RslintController implements StackController { ); } - hasNotInstalledState(): boolean { + hasFailedState(): boolean { return [...this.#folderStates.values()].some((states) => [...states.runtimes.values(), ...states.failures.values()].some( - (state) => state.kind === 'disabled', + (state) => + state.kind === 'disabled' || + state.kind === 'crashed' || + state.kind === 'version-mismatch', ), ); } diff --git a/packages/vscode/src/stacks/test/index.ts b/packages/vscode/src/stacks/test/index.ts index d20c7fa..99a4a7f 100644 --- a/packages/vscode/src/stacks/test/index.ts +++ b/packages/vscode/src/stacks/test/index.ts @@ -556,8 +556,8 @@ class RstestController implements StackController { return this.#rstest.buildExports(); } - hasNotInstalledState(): boolean { - return status.hasNotInstalled(); + hasFailedState(): boolean { + return status.hasFailed(); } dispose(): void { diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index 7f5f8b0..bc2388a 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -134,6 +134,7 @@ export class RstestApi { private disposed = false; private lastResolvedRstestPath?: string; private readonly coreMissingEpisode = new NotInstalledEpisode(); + private lastUnsupportedCoreMessage?: string; constructor( private workspace: vscode.WorkspaceFolder, @@ -421,10 +422,13 @@ export class RstestApi { this.statusSource, ) ) { - logger.error( - `Unsupported @rstest/core version ${coreVersion ?? 'unknown'} resolved from ${this.cwd}`, - ); + const message = `Unsupported @rstest/core version ${coreVersion ?? 'unknown'} resolved from ${this.cwd}`; + if (message !== this.lastUnsupportedCoreMessage) { + logger.error(message); + this.lastUnsupportedCoreMessage = message; + } } else { + this.lastUnsupportedCoreMessage = undefined; status.versionOk(this.statusSource); } } diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts index 6713c5c..43da6ba 100644 --- a/packages/vscode/src/stacks/test/project.ts +++ b/packages/vscode/src/stacks/test/project.ts @@ -238,14 +238,14 @@ export class WorkspaceManager implements vscode.Disposable { ); } /** - * Retries projects whose core or config dependency is missing — dependencies may have - * been installed since. The project keeps its identity and the retry is - * single-flight, so repeated dependency signals cannot overlap workers or - * repeat an unchanged not-installed warning. + * Retries failed projects after a dependency change. Installs and upgrades + * can recover missing packages, version mismatches, worker failures and real + * config errors alike. The project keeps its identity and the retry is + * single-flight. */ public retryFailedProjects() { for (const project of this.projects.values()) { - if (!project.hasNotInstalledDependencies) continue; + if (!project.hasFailedState) continue; void project.retryFailedConfig(); } } @@ -564,6 +564,7 @@ export class Project implements vscode.Disposable { #watch?: vscode.Disposable; #configLoad: Promise | undefined; readonly #configDependencyEpisode = new NotInstalledEpisode(); + readonly #reportedConfigErrors = new Set(); constructor( private workspaceFolder: vscode.WorkspaceFolder, source: ProjectSource, @@ -604,6 +605,7 @@ export class Project implements vscode.Disposable { } this.configLoadFailed = false; this.#configDependencyEpisode.clear(); + this.#reportedConfigErrors.clear(); status.forget(this.configDependencyStatusSource); this.root = vscode.Uri.file(result.root); this.include = result.include; @@ -631,7 +633,14 @@ export class Project implements vscode.Disposable { ); } status.installed(this.configDependencyStatusSource); - logUnlessReported('Failed to initialize project config', error); + const errorKey = + error instanceof Error + ? `${error.name}:${error.message}` + : String(error); + if (!this.#reportedConfigErrors.has(errorKey)) { + this.#reportedConfigErrors.add(errorKey); + logUnlessReported('Failed to initialize project config', error); + } // Let the manager settle its tree even when a config fails to load. this.onConfigResolved?.(); }); @@ -647,17 +656,17 @@ export class Project implements vscode.Disposable { return pending; } - get hasNotInstalledDependencies(): boolean { + get hasFailedState(): boolean { return ( - status.hasNotInstalled(this.sourceUri.toString()) || - status.hasNotInstalled(this.configDependencyStatusSource) + this.configLoadFailed || + status.hasFailed(this.sourceUri.toString()) || + status.hasFailed(this.configDependencyStatusSource) ); } /** Re-evaluates a failed config or a core lost after loading, in place. */ public retryFailedConfig(): Promise | undefined { - if (!this.configLoadFailed && !this.hasNotInstalledDependencies) - return undefined; + if (!this.hasFailedState) return undefined; return this.loadConfig(); } diff --git a/packages/vscode/src/stacks/test/status.ts b/packages/vscode/src/stacks/test/status.ts index cdab5f5..5d54db5 100644 --- a/packages/vscode/src/stacks/test/status.ts +++ b/packages/vscode/src/stacks/test/status.ts @@ -70,10 +70,17 @@ class StatusHolder implements StatusReporter { this.#reporter = undefined; } - public hasNotInstalled(source?: string): boolean { - return source === undefined - ? this.#notInstalled.size > 0 - : this.#notInstalled.has(source); + public hasFailed(source?: string): boolean { + if (source === undefined) return this.#latched; + return ( + this.#crashes.has(source) || + this.#mismatches.has(source) || + this.#notInstalled.has(source) + ); + } + + public hasNotInstalled(): boolean { + return this.#notInstalled.size > 0; } get #latched(): boolean { diff --git a/packages/vscode/src/types.ts b/packages/vscode/src/types.ts index a7b3c4b..be4cce2 100644 --- a/packages/vscode/src/types.ts +++ b/packages/vscode/src/types.ts @@ -158,8 +158,8 @@ export interface StackController { */ readonly restartOnSettings?: readonly string[]; register(context: StackContext): Promise | void>; - /** True while at least one owned folder/project needs dependencies installed. */ - hasNotInstalledState(): boolean; + /** True while an owned folder/project is disabled, crashed or version-mismatched. */ + hasFailedState(): boolean; /** Teardown may be asynchronous (stopping a language server, workers). */ dispose(): void | Promise; } diff --git a/packages/vscode/tests/extension.test.ts b/packages/vscode/tests/extension.test.ts index 7ccfbed..2886c16 100644 --- a/packages/vscode/tests/extension.test.ts +++ b/packages/vscode/tests/extension.test.ts @@ -21,7 +21,7 @@ interface FakeController { register(context: { status: StatusReporter; }): Promise>; - hasNotInstalledState(): boolean; + hasFailedState(): boolean; dispose(): Promise; } @@ -76,8 +76,8 @@ const harness = rs.hoisted(() => { settings: new Map(), /** How often `runRestart` reset the host-scoped User Node memo. */ nodeResets: 0, - /** Stacks whose raw controller state currently says not installed. */ - notInstalled: new Set(), + /** Stacks with a raw failed state, independent of the aggregate report. */ + failed: new Set(), /** Shell-wrapped reporters handed to the fake controllers. */ reporters: new Map(), /** Every configuration listener the shell installed. */ @@ -109,7 +109,7 @@ const harness = rs.hoisted(() => { } return { stack }; }, - hasNotInstalledState: () => state.notInstalled.has(stack), + hasFailedState: () => state.failed.has(stack), dispose: async () => { state.events.push(`dispose:${stack}`); state.reporters.delete(stack); @@ -484,21 +484,41 @@ describe('dependency recovery polling', () => { await deactivate(); }); - it('polls through the forced detection path only while not installed', async () => { - const exports = await activate(context); - exports.setDependencyPollIntervalForTest(5); - harness.notInstalled.add('rslint'); - harness.reporters.get('rslint')?.report({ kind: 'disabled' }); + it('uses a one-minute default interval', async () => { + const timer = rs.spyOn(globalThis, 'setTimeout'); + try { + await activate(context); + harness.failed.add('rslint'); + harness.reporters.get('rslint')?.report({ kind: 'disabled' }); + expect(timer.mock.calls.some(([, delay]) => delay === 60_000)).toBe(true); + expect(harness.dependencyRefreshes).toBe(0); + } finally { + timer.mockRestore(); + } + }); - await waitFor(() => harness.dependencyRefreshes > 0); - expect(harness.refreshes).toBe(0); + it.each(['disabled', 'crashed', 'version-mismatch'] as const)( + 'polls through the forced detection path while %s', + async (kind) => { + const exports = await activate(context); + exports.setDependencyPollIntervalForTest(5); + harness.failed.add('rslint'); + harness.reporters.get('rslint')?.report({ kind, detail: 'retry needed' }); - harness.notInstalled.delete('rslint'); - harness.reporters.get('rslint')?.running(); - const completed = harness.dependencyRefreshes; - await new Promise((resolve) => setTimeout(resolve, 25)); - expect(harness.dependencyRefreshes).toBe(completed); - }); + await waitFor(() => harness.dependencyRefreshes > 0); + expect(harness.refreshes).toBe(0); + + const beforeRealError = harness.dependencyRefreshes; + harness.reporters.get('rslint')?.crashed('half-written package'); + await waitFor(() => harness.dependencyRefreshes > beforeRealError); + + harness.failed.delete('rslint'); + harness.reporters.get('rslint')?.running(); + const completed = harness.dependencyRefreshes; + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(harness.dependencyRefreshes).toBe(completed); + }, + ); it('queues a poll tick behind an in-flight reconcile', async () => { const exports = await activate(context); @@ -510,7 +530,7 @@ describe('dependency recovery polling', () => { for (const emit of harness.detectionListeners) emit(); await waitFor(() => harness.registering.has('fmt')); - harness.notInstalled.add('rslint'); + harness.failed.add('rslint'); harness.reporters.get('rslint')?.report({ kind: 'disabled' }); await new Promise((resolve) => setTimeout(resolve, 25)); expect(harness.dependencyRefreshes).toBe(0); diff --git a/packages/vscode/tests/stacks/lint/start.test.ts b/packages/vscode/tests/stacks/lint/start.test.ts index a3f953e..ce4ab25 100644 --- a/packages/vscode/tests/stacks/lint/start.test.ts +++ b/packages/vscode/tests/stacks/lint/start.test.ts @@ -228,25 +228,15 @@ it('keeps an initialized runtime disabled when initial configRefresh rejects', a ['Failed to refresh config discovery: Invalid config'], ]); - await ( - runtime as unknown as { - requestConfigRefresh(reason: string): Promise; - } - ).requestConfigRefresh('config-change'); + await runtime.retryConfigDependency(); expect(errors).toEqual([ ['Failed to refresh config discovery: Invalid config'], - ['Failed to refresh config discovery: Invalid config'], ]); refreshOutcome = 'fixed'; - // Config-file events use this same refresh path after dependency polling stops. - await ( - runtime as unknown as { - requestConfigRefresh(reason: string): Promise; - } - ).requestConfigRefresh('config-change'); + await runtime.retryConfigDependency(); expect(states.at(-1)?.kind).toBe('running'); - expect(errors).toHaveLength(2); + expect(errors).toHaveLength(1); refreshOutcome = 'changed'; await expect( diff --git a/packages/vscode/tests/stacks/test/master.test.ts b/packages/vscode/tests/stacks/test/master.test.ts index 6e79f24..6ed797f 100644 --- a/packages/vscode/tests/stacks/test/master.test.ts +++ b/packages/vscode/tests/stacks/test/master.test.ts @@ -150,7 +150,7 @@ const createApi = (cwd = noCoreDir, rstestResolutionDir = cwd) => { ); }; -const writeCoreInstall = (root: string) => { +const writeCoreInstall = (root: string, version = '0.11.8') => { const packageDir = path.join(root, 'node_modules', '@rstest', 'core'); const entry = path.join(packageDir, 'index.js'); const bin = path.join(packageDir, 'bin', 'rstest.js'); @@ -159,7 +159,7 @@ const writeCoreInstall = (root: string) => { path.join(packageDir, 'package.json'), JSON.stringify({ name: '@rstest/core', - version: '0.11.8', + version, main: 'index.js', bin: { rstest: 'bin/rstest.js' }, }), @@ -189,6 +189,7 @@ describe('RstestApi package-resolution anchor', () => { storeEntry = path.join(cwd, 'node_modules', '.pnpm', 'rstack@0.6.1'); rstackDir = path.join(storeEntry, 'node_modules', 'rstack'); fs.mkdirSync(rstackDir, { recursive: true }); + loggedErrors.length = 0; }); afterEach(() => { @@ -228,6 +229,32 @@ describe('RstestApi package-resolution anchor', () => { bin: configured.bin, }); }); + + it('deduplicates each unsupported-version message until a supported version resolves', () => { + writeCoreInstall(cwd, '0.5.0'); + const api = createApi(cwd); + + resolveRstestPaths(api); + resolveRstestPaths(api); + expect(loggedErrors).toEqual([ + `Unsupported @rstest/core version 0.5.0 resolved from ${cwd}`, + ]); + + writeCoreInstall(cwd, '0.4.0'); + resolveRstestPaths(api); + expect(loggedErrors.at(-1)).toBe( + `Unsupported @rstest/core version 0.4.0 resolved from ${cwd}`, + ); + + writeCoreInstall(cwd); + resolveRstestPaths(api); + writeCoreInstall(cwd, '0.4.0'); + resolveRstestPaths(api); + expect(loggedErrors).toHaveLength(3); + + resolveRstestPaths(createApi(cwd)); + expect(loggedErrors).toHaveLength(4); + }); }); describe('RstestApi with a missing @rstest/core', () => { diff --git a/packages/vscode/tests/stacks/test/project.test.ts b/packages/vscode/tests/stacks/test/project.test.ts index 5c14af5..8d770f3 100644 --- a/packages/vscode/tests/stacks/test/project.test.ts +++ b/packages/vscode/tests/stacks/test/project.test.ts @@ -183,7 +183,7 @@ describe('Project config/cwd/package-resolution decoupling', () => { 'Failed to resolve rstest path', ); expect(project.configLoadFailed).toBe(false); - expect(project.hasNotInstalledDependencies).toBe(true); + expect(project.hasFailedState).toBe(true); // A new config request resolves the core before its worker RPC. Model // successful resolution's versionOk, which clears the core-source latch. @@ -200,7 +200,7 @@ describe('Project config/cwd/package-resolution decoupling', () => { } as never); await new Promise((resolve) => setTimeout(resolve, 0)); expect(reResolve).toHaveBeenCalledTimes(1); - expect(project.hasNotInstalledDependencies).toBe(false); + expect(project.hasFailedState).toBe(false); expect(reported.at(-1)).toEqual({ kind: 'running', detail: undefined }); expect(loggedErrors).toEqual([]); } finally { @@ -379,7 +379,7 @@ describe('Project config/cwd/package-resolution decoupling', () => { status.unbind(); }); - it('replaces not installed with a logged config error when a retry rejects', async () => { + it('keeps retrying a real config error on dependency passes and deduplicates it', async () => { const config = uri('/repo/templates/app/rstest.config.ts'); normalizedConfigResult = { ok: false, @@ -390,13 +390,13 @@ describe('Project config/cwd/package-resolution decoupling', () => { const { project } = await createProject({ sourceUri: config }); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(status.hasNotInstalled()).toBe(true); + expect(status.hasFailed()).toBe(true); normalizedConfigResult = undefined; normalizedConfigFailure = new SyntaxError('Unexpected token export'); await project.retryFailedConfig(); expect(project.configLoadFailed).toBe(true); - expect(status.hasNotInstalled()).toBe(false); + expect(status.hasFailed()).toBe(true); expect(loggedWarnings).toHaveLength(1); expect(loggedErrors).toHaveLength(1); expect(loggedErrors[0]).toContain('Failed to initialize project config'); @@ -408,15 +408,19 @@ describe('Project config/cwd/package-resolution decoupling', () => { const { WorkspaceManager } = await import('../../../src/stacks/test/project'); - const callsBeforePoll = normalizedConfigCalls; - status.notInstalled('another project is missing core', 'other-project'); WorkspaceManager.prototype.retryFailedProjects.call({ projects: new Map([['config', project]]), } as never); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(normalizedConfigCalls).toBe(callsBeforePoll); + expect(normalizedConfigCalls).toBe(3); expect(loggedErrors).toHaveLength(1); - status.forget('other-project'); + + normalizedConfigFailure = new SyntaxError('Unexpected token import'); + WorkspaceManager.prototype.retryFailedProjects.call({ + projects: new Map([['config', project]]), + } as never); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(loggedErrors).toHaveLength(2); normalizedConfigFailure = undefined; normalizedConfigResult = { @@ -429,6 +433,12 @@ describe('Project config/cwd/package-resolution decoupling', () => { await project.retryFailedConfig(); expect(reported.at(-1)).toEqual({ kind: 'running', detail: undefined }); + normalizedConfigResult = undefined; + normalizedConfigFailure = new SyntaxError('Unexpected token export'); + status.crashed('retry this recovered project', config.toString()); + await project.retryFailedConfig(); + expect(loggedErrors).toHaveLength(3); + project.dispose(); status.unbind(); }); diff --git a/packages/vscode/tests/stacks/test/status.test.ts b/packages/vscode/tests/stacks/test/status.test.ts index 275dc19..7f8b0a4 100644 --- a/packages/vscode/tests/stacks/test/status.test.ts +++ b/packages/vscode/tests/stacks/test/status.test.ts @@ -155,13 +155,24 @@ describe('StatusHolder failure latches', () => { expect(calls).toEqual(['report:disabled']); }); - it('exposes a missing install even when a crash outranks it', () => { + it('exposes every raw failure state', () => { bindRecorder(); status.notInstalled('core missing', '/a'); - status.crashed('worker stopped', '/b'); - expect(status.hasNotInstalled()).toBe(true); + expect(status.hasFailed()).toBe(true); + expect(status.hasFailed('/a')).toBe(true); status.installed('/a'); - expect(status.hasNotInstalled()).toBe(false); + expect(status.hasFailed()).toBe(false); + + status.versionMismatch('core too old', '/a'); + expect(status.hasFailed()).toBe(true); + status.versionOk('/a'); + expect(status.hasFailed()).toBe(false); + + status.crashed('worker stopped', '/b'); + expect(status.hasFailed()).toBe(true); + expect(status.hasFailed('/b')).toBe(true); + status.workerSpawned('/b'); + expect(status.hasFailed()).toBe(false); }); it('ranks a missing install below a mismatch and a crash', () => { From 5cc33d1b2246556a232a57542204538b99ad7759 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 18:11:52 +0800 Subject: [PATCH 38/44] fix(vscode): replace stopped runtimes on dependency polls --- packages/vscode/AGENTS.md | 2 +- .../suite-jsconfig/runtime-manager.test.ts | 4 ++ packages/vscode/src/stacks/lint/Rslint.ts | 4 ++ .../vscode/src/stacks/lint/RuntimeManager.ts | 20 +++++-- .../tests/stacks/lint/runtimeManager.test.ts | 50 ++++++++++++++++++ .../vscode/tests/stacks/test/project.test.ts | 52 +++++++++++++++++++ 6 files changed, 128 insertions(+), 4 deletions(-) diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 4e826e8..490e5d0 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -7,7 +7,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - `stacks/lint` and `stacks/test` are deliberate near-verbatim copies of the upstream extensions, kept close to upstream so changes can be synced by diffing. Do NOT deduplicate or refactor across the two stacks — the duplication is the point; consolidation is a later, explicit phase. - The copies diverge from upstream in exactly nine ways (the "adaptations" below). When syncing upstream, preserve them. A tenth divergence is either a bug or must be added to this list. - **Tracked upstream state.** `stacks/lint` is synced to web-infra-dev/rslint `packages/vscode-extension` at **39536fd6** (#1617 — per-document core resolution, `CoreResolver` + `RuntimeManager`, `corePath`, PnP removed) and **892482e0** (#1630 — `configPath` on `rslint/configRefresh`). Targeted later ports are **84f9c9b5** (#1967 — languageclient-owned live LSP tracing) and **b7176723** (#1951 — remove legacy JSON config watching); the Unicode BOM E2E comes from **5fc197a5** (#1560), with its native-config fixture shape from **b7176723**. `CoreResolver.ts` / `RuntimeManager.ts` / `WorkspaceDocumentRouter.ts` / `Rslint.ts` are the files to diff when syncing further; record the new commits here when you do. -- **Ahead of upstream — offer these back when syncing** (bug fixes, not adaptations): (1) `RuntimeManager.reconcile` resolves the document's core **before** sweeping pending uses (`planDocumentCore`), so a reconcile landing on the key a pending start is already producing adopts that start instead of tearing it down mid-`initialize` — the teardown made vscode-languageclient force-notify ("couldn't create connection to server") whenever the register-time pass, a detection change and `didOpen` landed inside one worker startup window (`tests/stacks/lint/runtimeManager.test.ts`). (2) `Rslint.close()` gives a still-Starting language client a bounded chance to settle before tearing down its transport, so a legitimate mid-start close (document closed during start, core key changed) stops cleanly instead of triggering the same force-notified toasts. (3) The registry-harness E2E gives its never-settling startup operation 500ms to begin and accepts only the in-flight timeout message, so a stalled runner cannot satisfy the assertion through the already-expired path (`e2e/lint/suite/registry-harness.test.ts`). (4) `Project.retryFailedConfig()` keeps a failed Rstest project and retries its config evaluation in place with one single-flight promise, so repeated dependency-change passes neither overlap workers nor repeat an unchanged not-installed warning. +- **Ahead of upstream — offer these back when syncing** (bug fixes, not adaptations): (1) `RuntimeManager.reconcile` resolves the document's core **before** sweeping pending uses (`planDocumentCore`), so a reconcile landing on the key a pending start is already producing adopts that start instead of tearing it down mid-`initialize` — the teardown made vscode-languageclient force-notify ("couldn't create connection to server") whenever the register-time pass, a detection change and `didOpen` landed inside one worker startup window (`tests/stacks/lint/runtimeManager.test.ts`). (2) `Rslint.close()` gives a still-Starting language client a bounded chance to settle before tearing down its transport, so a legitimate mid-start close (document closed during start, core key changed) stops cleanly instead of triggering the same force-notified toasts. (3) The registry-harness E2E gives its never-settling startup operation 500ms to begin and accepts only the in-flight timeout message, so a stalled runner cannot satisfy the assertion through the already-expired path (`e2e/lint/suite/registry-harness.test.ts`). (4) `Project.retryFailedConfig()` keeps a failed Rstest project and retries its config evaluation in place with one single-flight promise, so repeated dependency-change passes neither overlap workers nor repeat an unchanged not-installed warning. (5) `RuntimeManager` retires a stopped client even when its resolved key is unchanged. The existing closing barrier and pending-use adoption share one replacement across documents; running and starting clients remain untouched (`tests/stacks/lint/runtimeManager.test.ts`). - **Targeted Rstest lifecycle port:** `RstestApi.getNormalizedConfig()` closes its worker in `finally`, including rejected config evaluation, matching web-infra-dev/rstest `packages/vscode/src/master.ts` at `d82db4fc31a61ee74b2a74917f14a458e1bca419`. This fixes a leak in our older copy; it is already fixed upstream. Dependency passes retry failed projects, including real config errors, while preserving single-flight loading and worker cleanup. diff --git a/packages/vscode/e2e/lint/suite-jsconfig/runtime-manager.test.ts b/packages/vscode/e2e/lint/suite-jsconfig/runtime-manager.test.ts index ca071fa..a72b34c 100644 --- a/packages/vscode/e2e/lint/suite-jsconfig/runtime-manager.test.ts +++ b/packages/vscode/e2e/lint/suite-jsconfig/runtime-manager.test.ts @@ -78,6 +78,10 @@ class FakeRuntime implements ManagedRslintRuntime { startCalls = 0; closeCalls = 0; + isStopped(): boolean { + return this.closeCalls > 0; + } + constructor( readonly rootKey: string, readonly workspaceFolder: WorkspaceFolder, diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 0fdf1df..26a1cc3 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -850,6 +850,10 @@ export class Rslint implements Disposable { return this.client?.state === State.Running; } + public isStopped(): boolean { + return this.client?.state === State.Stopped; + } + public serverAdvertisesHover(): boolean { return Boolean(this.client?.initializeResult?.capabilities.hoverProvider); } diff --git a/packages/vscode/src/stacks/lint/RuntimeManager.ts b/packages/vscode/src/stacks/lint/RuntimeManager.ts index 5b94fd8..58aa441 100644 --- a/packages/vscode/src/stacks/lint/RuntimeManager.ts +++ b/packages/vscode/src/stacks/lint/RuntimeManager.ts @@ -16,8 +16,8 @@ // - The extra hooks (`onDocumentFailure` / `onDocumentSettled` / // `onRuntimeClosed`) exist only so the controller can keep its per-folder // status fold in step; they carry no lifecycle decisions. -// - One ahead-of-upstream fix: `reconcile` resolves before sweeping pending -// uses (`planDocumentCore`) — see AGENTS.md ("Ahead of upstream"). +// - Ahead-of-upstream fixes: resolve before sweeping pending uses, and retire +// stopped same-key clients on reconcile — see AGENTS.md ("Ahead of upstream"). import { workspace, type TextDocument, type WorkspaceFolder } from 'vscode'; import type { @@ -34,6 +34,8 @@ import { export interface ManagedRslintRuntime extends DocumentRoutingRuntime { start(signal: AbortSignal): Promise; close(): Promise; + /** A stopped client is unusable; a pending/automatic start is not. */ + isStopped(): boolean; } export type ManagedRslintRuntimeFactory = ( @@ -266,7 +268,11 @@ export class RuntimeManager { return; } const { workspaceFolder, resolved } = plan; - if (existing?.resolved.key === resolved.key) { + if ( + existing?.resolved.key === resolved.key && + !existing.closePromise && + !existing.runtime.isStopped() + ) { this.options.onDocumentSettled?.(document); return; } @@ -274,6 +280,14 @@ export class RuntimeManager { let replacement: RuntimeEntry | undefined; let switched = false; try { + const entry = this.entries.get(resolved.key); + if (entry?.active && entry.runtime.isStopped()) { + // Retire the dead same-key owner before activating its replacement. + // closeRuntime removes it immediately and installs the shared closing + // barrier; concurrent documents then acquire/adopt one pending start. + await this.closeRuntime(entry); + if (!this.isCurrentDocument(document, epoch)) return; + } replacement = this.acquireRuntime(resolved, key); await replacement.startPromise; if (!this.isCurrentDocument(document, epoch)) { diff --git a/packages/vscode/tests/stacks/lint/runtimeManager.test.ts b/packages/vscode/tests/stacks/lint/runtimeManager.test.ts index 233b80c..a7f5ddc 100644 --- a/packages/vscode/tests/stacks/lint/runtimeManager.test.ts +++ b/packages/vscode/tests/stacks/lint/runtimeManager.test.ts @@ -65,6 +65,7 @@ interface FakeRuntime { releaseStart(): void; closes: number; aborted: boolean; + stopped: boolean; } function fakeRuntime(): FakeRuntime { @@ -76,9 +77,11 @@ function fakeRuntime(): FakeRuntime { releaseStart, closes: 0, aborted: false, + stopped: false, runtime: { rootKey: 'core-key', workspaceFolder: folder, + isStopped: () => fake.stopped, sendDocumentOpen: async () => undefined, sendDocumentClose: async () => undefined, clearDocumentDiagnostics: () => undefined, @@ -173,6 +176,53 @@ async function settleWithStartsReleased( } describe('RuntimeManager reconcile-during-start', () => { + it.each([false, true])( + 'replaces a same-key runtime only when stopped=%s', + async (stopped) => { + const harness = createHarness(); + const document = documentOf('/project/src/index.ts'); + await settleWithStartsReleased( + harness, + harness.manager.reconcile(document), + ); + harness.runtimes[0].stopped = stopped; + + await settleWithStartsReleased( + harness, + harness.manager.reconcile(document), + ); + expect(harness.runtimes).toHaveLength(stopped ? 2 : 1); + expect(harness.runtimes[0].closes).toBe(stopped ? 1 : 0); + expect(harness.failures).toEqual([]); + await harness.manager.close(); + }, + ); + + it('shares one replacement across stopped-runtime users and adopts its pending start', async () => { + const harness = createHarness(); + const a = documentOf('/project/src/a.ts'); + const b = documentOf('/project/src/b.ts'); + await settleWithStartsReleased( + harness, + Promise.all([harness.manager.reconcile(a), harness.manager.reconcile(b)]), + ); + harness.runtimes[0].stopped = true; + const first = harness.manager.reconcile(a); + const second = harness.manager.reconcile(b); + await rs.waitUntil(() => harness.runtimes.length === 2, WAIT); + const successor = harness.manager.reconcile(a); + await settleWithStartsReleased( + harness, + Promise.all([first, second, successor]), + ); + expect(harness.runtimes).toHaveLength(2); + expect(harness.runtimes[0].closes).toBe(1); + expect(harness.runtimes[1].closes).toBe(0); + expect(harness.runtimes[1].aborted).toBe(false); + expect(harness.failures).toEqual([]); + await harness.manager.close(); + }); + it('keeps the pending runtime when a second reconcile resolves to the same key', async () => { const harness = createHarness(); const document = documentOf('/project/src/index.ts'); diff --git a/packages/vscode/tests/stacks/test/project.test.ts b/packages/vscode/tests/stacks/test/project.test.ts index 8d770f3..4053cce 100644 --- a/packages/vscode/tests/stacks/test/project.test.ts +++ b/packages/vscode/tests/stacks/test/project.test.ts @@ -209,6 +209,58 @@ describe('Project config/cwd/package-resolution decoupling', () => { } }); + it('retries a worker crash after successful config loading only until it recovers', async () => { + const config = uri('/repo/pkg/rstest.config.ts'); + const { reporter, reported } = createStatusRecorder(); + status.bind(reporter); + const loaded: NormalizedConfigResult = { + ok: true, + root: '/repo/pkg', + include: ['**/*.test.ts'], + exclude: [], + childProjects: [], + }; + normalizedConfigResult = loaded; + const { project } = await createProject({ sourceUri: config }); + try { + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(project.configLoadFailed).toBe(false); + + // createChildProcess reports unexpected worker errors and exits against + // the project source, independently of the successful config load. + status.crashed('worker process exited unexpectedly', config.toString()); + expect(project.configLoadFailed).toBe(false); + expect(project.hasFailedState).toBe(true); + + // A config retry creates a fresh worker. Model its spawn notification, + // which retires the crash recorded for this project source. + const retry = rs + .spyOn(project.api, 'getNormalizedConfig') + .mockImplementation(async () => { + status.workerSpawned(config.toString()); + return loaded; + }); + const { WorkspaceManager } = + await import('../../../src/stacks/test/project'); + const manager = { + projects: new Map([['config', project]]), + } as never; + + WorkspaceManager.prototype.retryFailedProjects.call(manager); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(retry).toHaveBeenCalledTimes(1); + expect(project.hasFailedState).toBe(false); + expect(reported.at(-1)).toEqual({ kind: 'running', detail: undefined }); + + WorkspaceManager.prototype.retryFailedProjects.call(manager); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(retry).toHaveBeenCalledTimes(1); + } finally { + project.dispose(); + status.unbind(); + } + }); + it('retries a core-missing project on a dependency pass', async () => { const config = uri('/repo/pkg/rstest.config.ts'); const { reporter } = createStatusRecorder(); From 0d4dfba136b333b9529f65356037661098e28a0c Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 18:34:27 +0800 Subject: [PATCH 39/44] fix(vscode): keep failed fmt sessions polling, dedupe Rstest resolve toasts --- .../0005-not-installed-recovery-by-polling.md | 2 +- packages/vscode/AGENTS.md | 2 +- packages/vscode/src/stacks/fmt/index.ts | 42 ++-- .../vscode/src/stacks/fmt/sessionError.ts | 22 +- packages/vscode/src/stacks/test/master.ts | 12 +- .../vscode/tests/stacks/fmt/runtime.test.ts | 189 ++++++++++++++++++ .../tests/stacks/fmt/sessionError.test.ts | 5 + .../vscode/tests/stacks/test/master.test.ts | 27 +++ 8 files changed, 279 insertions(+), 22 deletions(-) create mode 100644 packages/vscode/tests/stacks/fmt/runtime.test.ts diff --git a/docs/adr/0005-not-installed-recovery-by-polling.md b/docs/adr/0005-not-installed-recovery-by-polling.md index d167b4d..3df2c70 100644 --- a/docs/adr/0005-not-installed-recovery-by-polling.md +++ b/docs/adr/0005-not-installed-recovery-by-polling.md @@ -22,7 +22,7 @@ The watcher's original rationale was also factually wrong. At Microsoft VS Code The aggregate status is deliberately not the predicate: every owned raw failure needs recovery. A retry landing mid-install can read half-written `node_modules` and fail with a syntax error instead of a missing dependency. Continuing every minute through that real error makes the transient harmless without a provisional-error heuristic. Real errors still replace not-installed in status and Output; persistent error messages and not-installed warnings are deduplicated so retries do not log every minute. The restart hint remains in the status as an explicit fallback. -fmt has one tool-forced limitation. Restarting `rs fmt --lsp` re-runs package resolution, but the server loads project config lazily on the next formatting request. A poll can therefore move the folder to `running` before config loading has been proved; the next format either succeeds or reports the same config failure and returns the folder to `disabled`, which restarts polling. +fmt has one tool-forced limitation. Restarting `rs fmt --lsp` re-runs package resolution, but the server loads project config lazily on the next formatting request. After a missing dependency, a poll can therefore move the folder to `running` before config loading has been proved; the next format either succeeds or reports the same config failure and returns the folder to `disabled`, which restarts polling. A known real config error instead remains `crashed` across restarts until a format produces edits. ## Considered options diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 490e5d0..d10362f 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -41,7 +41,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten ## Gotchas — decisions that look wrong but aren't -- fmt's `handleShowMessage` suppresses only the classified config-dependency Error. Non-classified `window/showMessage` notifications are re-presented exactly as vscode-languageclient's default handler would (Error/Warning/Info toast). This passes through the server's protocol UI request unchanged; "stacks own no UI chrome" constrains UI the stack originates, not protocol UI it relays. `stacks/fmt/sessionError.ts`, like `binEntry.ts` and `status.ts`, stays pure and vscode-free for unit testing; it mirrors LSP MessageType constants because the languageclient runtime import loads `vscode`. Warning episodes end on nonempty formatting edits, not merely a response with no new notification: the server returns empty edits on failure and deduplicates showMessage. +- fmt's `handleShowMessage` suppresses only the classified config-dependency Error. Non-classified `window/showMessage` notifications are re-presented exactly as vscode-languageclient's default handler would (Error/Warning/Info toast). An exact-prefix `rs fmt cannot format this workspace:` Error that is not a missing dependency also reports `crashed` and one Output error line per distinct message; the known config error survives server restarts so polling continues until a format produces edits. This passes through the server's protocol UI request unchanged; "stacks own no UI chrome" constrains UI the stack originates, not protocol UI it relays. `stacks/fmt/sessionError.ts`, like `binEntry.ts` and `status.ts`, stays pure and vscode-free for unit testing; it mirrors LSP MessageType constants because the languageclient runtime import loads `vscode`. Warning and error episodes end on nonempty formatting edits, not merely a response with no new notification: the server returns empty edits on failure and deduplicates showMessage. - The lint × `rstack.config.*` bridge stays thin on purpose: only a root Rstack config can claim a bridged folder, any native config anywhere in the folder wins ownership, and the worker evaluates rstack's published shim from the folder root. Never generate a shim, load the Rstack config in the extension host, or interpret `define.lint()` ourselves. - **Yarn Plug'n'Play is unsupported by decision, extension-wide.** Every stack resolves through physical `node_modules` (`shared/packageResolve.ts`, `resolution.ts`'s rstack → `@rslint/core` chain, the fmt bin probe, the rstest package lookup) and the lint worker's own `createRequire` from the core directory does too. Lint once carried a `.pnp.cjs` branch for the find-`@rslint/core` hop only; nothing after that hop (config evaluation, plugin resolution, the other stacks) had PnP hooks, so it never produced a working folder, and upstream removed its own PnP path in the same refactor that introduced `corePath`. Real support would be a PnP editor-SDK-shaped project across all three stacks, not a resolver branch — do not reintroduce one. - **A Lint runtime lives as long as a document needs it, and a folder with none is `running: idle`.** Since the #1617 sync, `RuntimeManager` refcounts each runtime by open document: the first document to resolve a core starts one, the last to release it closes it, so a detected folder with nothing open holds zero workers and zero Go processes. That folder still reports `running` — with the detail `idle` — because it is live and will start a runtime on the next `didOpen`; do **not** add a `StackState` kind for it (the shell's status bar and `when` clauses read the kinds, and idle is not a kind of health). A folder's state is the **worst of** its runtimes plus any document whose core resolution currently fails (last-good: that document keeps the runtime it already had), so one failing core is never masked by a healthy sibling — the same invariant fmt pins across folders, applied inside one and across them alike (lint's rank table matches fmt's: `disabled` there means "a package is not installed" — no `rstack`, or no `@rslint/core` — not the kill switch). Dependency retries come only through the shell's detection pass: lockfile events are the low-latency path and ADR 0005's conditional poll covers unchanged lockfiles. The former lint-owned `node_modules/@rslint/core/package.json` watcher was removed because pnpm produced no event in either isolated or hoisted layout. Failures report through the status only: upstream's `window.showWarningMessage` is dropped, since stacks own no UI chrome. Consequently `whenStackActive('rslint')` means "the controller registered its folders", not "a server is up" — E2E suites open a document and await diagnostics. diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index 329cf65..d81d6fa 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -164,6 +164,8 @@ class FmtFolderRuntime { readonly #packageEpisode = new NotInstalledEpisode(); readonly #configDependencyEpisode = new NotInstalledEpisode(); #startError: string | undefined; + #sessionError: string | undefined; + #sessionErrorCount = 0; suppressedShowMessages = 0; #closing = false; #disposed = false; @@ -222,6 +224,7 @@ class FmtFolderRuntime { private handleShowMessage(message: ShowMessageParams): void { handleFmtShowMessage(message, this.folderPath, this.#configPath, { onConfigDependency: (failure) => { + this.#sessionError = undefined; const report = this.#configDependencyEpisode.observe( 'fmt', failure.configPath, @@ -233,6 +236,13 @@ class FmtFolderRuntime { this.suppressedShowMessages++; this.setState('disabled', report.reason); }, + onConfigError: (message) => { + this.#sessionErrorCount++; + this.#configDependencyEpisode.clear(); + if (this.#sessionError !== message) this.context.output.error(message); + this.#sessionError = message; + this.setState('crashed', message); + }, showErrorMessage: (text) => { void vscode.window.showErrorMessage(text); }, @@ -413,12 +423,12 @@ class FmtFolderRuntime { // owner's call; either way this folder is currently not formatting. this.setState('crashed', 'the rs fmt language server stopped'); } else if (event.newState === State.Running) { - // The one writer for `running`. It fires on the first start - // (synchronously, before `client.start()` resolves) and again when - // vscode-languageclient's error handler restarts a crashed server — - // the way back out of `crashed`, the same transition the lint stack's - // state watcher makes. - this.setState('running'); + // Initialize does not load config. Keep a known real config error + // polling across restarts until formatting actually produces edits. + this.setState( + this.#sessionError === undefined ? 'running' : 'crashed', + this.#sessionError, + ); } }); @@ -555,18 +565,26 @@ class FmtFolderRuntime { token, next, ) => { - const suppressedBeforeRequest = this.suppressedShowMessages; + const failuresBeforeRequest = + this.suppressedShowMessages + this.#sessionErrorCount; const edits = await next(document, options, token); + const hadConfigDependency = this.#configDependencyEpisode.active; if ( clearEpisodeAfterSuccessfulFormatting( this.#configDependencyEpisode, - suppressedBeforeRequest, - this.suppressedShowMessages, + failuresBeforeRequest, + this.suppressedShowMessages + this.#sessionErrorCount, edits?.length ?? 0, - ) && - this.#state === 'disabled' + ) ) { - this.setState('running'); + const hadSessionError = this.#sessionError !== undefined; + this.#sessionError = undefined; + if ( + (hadConfigDependency && this.#state === 'disabled') || + (hadSessionError && this.#state === 'crashed') + ) { + this.setState('running'); + } } return edits; }, diff --git a/packages/vscode/src/stacks/fmt/sessionError.ts b/packages/vscode/src/stacks/fmt/sessionError.ts index c5cba45..e4edffb 100644 --- a/packages/vscode/src/stacks/fmt/sessionError.ts +++ b/packages/vscode/src/stacks/fmt/sessionError.ts @@ -28,6 +28,7 @@ interface ShowMessagePresenter { interface FmtShowMessageHandler extends ShowMessagePresenter { onConfigDependency(failure: ConfigDependencyFailure): void; + onConfigError(message: string): void; } export function classifyFmtSessionError( @@ -54,7 +55,7 @@ export function classifyFmtSessionError( }; } -/** Filters the one stack-owned state transition and passes every other server UI request through. */ +/** Reports config failures as state, suppressing only missing-dependency UI requests. */ export const handleFmtShowMessage = ( message: ShowMessageParams, workspaceRoot: string, @@ -71,6 +72,13 @@ export const handleFmtShowMessage = ( } switch (message.type) { case MessageType.Error: + if (message.message.startsWith(FMT_SESSION_ERROR_PREFIX)) { + handler.onConfigError( + message.message + .slice(FMT_SESSION_ERROR_PREFIX.length) + .split('\n', 1)[0], + ); + } handler.showErrorMessage(message.message); break; case MessageType.Warning: @@ -89,10 +97,12 @@ export const handleFmtShowMessage = ( */ export const clearEpisodeAfterSuccessfulFormatting = ( episode: NotInstalledEpisode, - suppressedBeforeRequest: number, - suppressedAfterRequest: number, + failuresBeforeRequest: number, + failuresAfterRequest: number, editCount: number, -): boolean => - editCount > 0 && - suppressedBeforeRequest === suppressedAfterRequest && +): boolean => { + if (editCount <= 0 || failuresBeforeRequest !== failuresAfterRequest) + return false; episode.clear(); + return true; +}; diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index bc2388a..8f5f867 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -135,6 +135,7 @@ export class RstestApi { private lastResolvedRstestPath?: string; private readonly coreMissingEpisode = new NotInstalledEpisode(); private lastUnsupportedCoreMessage?: string; + private lastResolutionErrorMessage?: string; constructor( private workspace: vscode.WorkspaceFolder, @@ -348,6 +349,12 @@ export class RstestApi { status.notInstalled(CORE_NOT_INSTALLED_STATUS, this.statusSource); } + private reportResolutionError(message: string): void { + if (message === this.lastResolutionErrorMessage) return; + vscode.window.showErrorMessage(message); + this.lastResolutionErrorMessage = message; + } + // Returns '' when resolution failed. Every such branch has already reported // itself — silently for a missing core, with a notification otherwise — so // callers must fail quietly rather than report again. @@ -369,7 +376,7 @@ export class RstestApi { paths: [this.cwd], }); } catch (e) { - vscode.window.showErrorMessage( + this.reportResolutionError( 'Failed to resolve @rstest/core/package.json. Please upgrade @rstest/core to the latest version.', ); logger.error('Failed to resolve @rstest/core/package.json', e); @@ -434,9 +441,10 @@ export class RstestApi { } this.lastResolvedRstestPath = nodeExport; + this.lastResolutionErrorMessage = undefined; return nodeExport; } catch (e) { - vscode.window.showErrorMessage(toErrorMessage(e)); + this.reportResolutionError(toErrorMessage(e)); throw e; } } diff --git a/packages/vscode/tests/stacks/fmt/runtime.test.ts b/packages/vscode/tests/stacks/fmt/runtime.test.ts new file mode 100644 index 0000000..def15d8 --- /dev/null +++ b/packages/vscode/tests/stacks/fmt/runtime.test.ts @@ -0,0 +1,189 @@ +import { afterEach, beforeEach, expect, it, rs } from '@rstest/core'; +import type { + LanguageClientOptions, + ShowMessageParams, +} from 'vscode-languageclient'; +import type { StackContext, StackState } from '../../../src/types'; +import { FMT_SESSION_ERROR_PREFIX } from '../../../src/stacks/fmt/sessionError'; + +const clients: Array<{ + notify(message: ShowMessageParams): void; + options: LanguageClientOptions; +}> = []; +const toasts: string[] = []; +rs.mock('vscode', () => ({ + default: { + RelativePattern: class {}, + env: {}, + window: { + showErrorMessage: (message: string) => toasts.push(message), + showWarningMessage() {}, + showInformationMessage() {}, + }, + workspace: { + createFileSystemWatcher: () => ({ + onDidCreate: () => ({ dispose() {} }), + onDidChange: () => ({ dispose() {} }), + onDidDelete: () => ({ dispose() {} }), + dispose() {}, + }), + }, + }, +})); +rs.mock('../../../src/detection', () => ({ + RSTACK_CONFIG_GLOB: '**/rstack.config.*', +})); +rs.mock('../../../src/shared/nodeExecutableSetting', () => ({ + getConfiguredNodeExecutable: () => undefined, +})); +rs.mock('../../../src/shared/nodeResolution', () => ({ + resolveUserNodeOnce: async () => ({ executable: 'node' }), +})); +rs.mock('../../../src/shared/packageResolve', () => ({ + findPackageJsonUncached: () => '/project/node_modules/rstack/package.json', + readPackageJson: () => ({ version: '0.7.2', bin: 'bin/rs.js' }), +})); +rs.mock('../../../src/stacks/lint/LanguageServerProcessOwner', () => ({ + LanguageServerProcessOwner: class { + beginClose() {} + async close() {} + }, +})); +rs.mock('vscode-languageclient/node', () => ({ + State: { Running: 2, Stopped: 1 }, + ShowMessageNotification: { type: 'window/showMessage' }, + LanguageClient: class { + state = 2; + notify!: (message: ShowMessageParams) => void; + change!: (event: { newState: number }) => void; + constructor( + _id: string, + _name: string, + _server: unknown, + readonly options: LanguageClientOptions, + ) { + clients.push(this); + } + onNotification(_method: unknown, callback: typeof this.notify) { + this.notify = callback; + } + onDidChangeState(callback: typeof this.change) { + this.change = callback; + return { dispose() {} }; + } + createDefaultErrorHandler() { + return {}; + } + async start() { + this.change({ newState: 2 }); + } + async dispose() {} + async stop() {} + }, +})); + +import { createFmtController } from '../../../src/stacks/fmt'; + +let controller: ReturnType; +let states: StackState[]; +let errors: string[]; +let warnings: string[]; +let redetect: () => void; +beforeEach(async () => { + clients.length = 0; + toasts.length = 0; + states = []; + errors = []; + warnings = []; + const detection = { + foldersFor: () => [ + { + folder: { name: 'project', uri: { fsPath: '/project' } }, + rootRstackConfigPath: '/project/rstack.config.ts', + }, + ], + }; + controller = createFmtController(); + await controller.register({ + detection, + onDidChangeDetection: (listener: (snapshot: typeof detection) => void) => { + redetect = () => listener(detection); + return { dispose() {} }; + }, + status: { report: (state: StackState) => states.push(state) }, + output: { + info() {}, + debug() {}, + warn: (message: string) => warnings.push(message), + error: (message: string) => errors.push(message), + }, + } as unknown as StackContext); + await rs.waitUntil(() => states.at(-1)?.kind === 'running'); +}); +afterEach(async () => { + await controller.dispose(); +}); + +async function format(editCount: number, duringRequest?: () => void) { + const provide = + clients.at(-1)!.options.middleware!.provideDocumentFormattingEdits!; + return provide({} as never, {} as never, {} as never, async () => { + duringRequest?.(); + return Array.from({ length: editCount }, () => ({}) as never); + }); +} + +it('reports real session errors, deduplicates logs across restarts, and preserves protocol toasts', async () => { + const message = { + type: 1 as const, + message: `${FMT_SESSION_ERROR_PREFIX}SyntaxError: Unexpected token\nstack trace`, + }; + clients[0].notify(message); + expect(states.at(-1)).toMatchObject({ + kind: 'crashed', + detail: 'SyntaxError: Unexpected token', + }); + expect(controller.hasFailedState()).toBe(true); + expect(errors).toEqual(['SyntaxError: Unexpected token']); + expect(toasts).toEqual([message.message]); + clients[0].notify(message); + expect(errors).toHaveLength(1); + expect(toasts).toHaveLength(2); + + redetect(); + await rs.waitUntil(() => clients.length === 2); + expect(states.at(-1)?.kind).toBe('crashed'); + clients[1].notify(message); + expect(errors).toHaveLength(1); + await format(0); + expect(controller.hasFailedState()).toBe(true); + await format(1, () => clients[1].notify(message)); + expect(controller.hasFailedState()).toBe(true); + expect(errors).toHaveLength(1); + await format(1); + expect(states.at(-1)?.kind).toBe('running'); + expect(controller.hasFailedState()).toBe(false); + clients[1].notify(message); + expect(errors).toHaveLength(2); + clients[1].notify({ + type: 1, + message: `${FMT_SESSION_ERROR_PREFIX}Error: Different failure`, + }); + expect(errors.at(-1)).toBe('Error: Different failure'); + expect(errors).toHaveLength(3); +}); + +it('keeps classified missing dependencies disabled with one warning and no toast', async () => { + const message = { + type: 1 as const, + message: `${FMT_SESSION_ERROR_PREFIX}Error: Cannot find package 'missing'`, + }; + clients[0].notify(message); + clients[0].notify(message); + expect(states.at(-1)?.kind).toBe('disabled'); + expect(warnings).toHaveLength(1); + expect(errors).toEqual([]); + expect(toasts).toEqual([]); + await format(1); + expect(states.at(-1)?.kind).toBe('running'); +}); diff --git a/packages/vscode/tests/stacks/fmt/sessionError.test.ts b/packages/vscode/tests/stacks/fmt/sessionError.test.ts index 8530567..d44fee8 100644 --- a/packages/vscode/tests/stacks/fmt/sessionError.test.ts +++ b/packages/vscode/tests/stacks/fmt/sessionError.test.ts @@ -86,6 +86,7 @@ describe('handleFmtShowMessage', () => { showErrorMessage: unexpected, showWarningMessage: unexpected, onConfigDependency: unexpected, + onConfigError: unexpected, }, ); expect(information).toHaveBeenCalledExactlyOnceWith('message'); @@ -96,6 +97,9 @@ describe('handleFmtShowMessage', () => { const shown: string[] = []; let stateChanges = 0; const handler = { + onConfigError: () => { + stateChanges += 1; + }, onConfigDependency: () => { stateChanges += 1; }, @@ -113,6 +117,7 @@ describe('handleFmtShowMessage', () => { handleFmtShowMessage(message, '/project', '/project/rstack.config.ts', { ...vscode.window, onConfigDependency: handler.onConfigDependency, + onConfigError: handler.onConfigError, }); handleFmtShowMessage( message, diff --git a/packages/vscode/tests/stacks/test/master.test.ts b/packages/vscode/tests/stacks/test/master.test.ts index 6ed797f..6b6fb31 100644 --- a/packages/vscode/tests/stacks/test/master.test.ts +++ b/packages/vscode/tests/stacks/test/master.test.ts @@ -365,6 +365,33 @@ describe('RstestApi with an unresolvable rstestPackagePath', () => { expect(shownMessages[0]).toContain(configured); }); + it('deduplicates a resolution error until resolution succeeds', () => { + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'rstest-vscode-')), + ); + const installed = writeCoreInstall(root); + const api = createApi(root); + const resolve = () => (api as any).resolveRstestPath() as string; + + try { + expect(resolve).toThrow(); + expect(resolve).toThrow(); + expect(shownMessages).toHaveLength(1); + + settings.rstestPackagePath = path.join( + installed.packageDir, + 'package.json', + ); + expect(resolve()).toBe(installed.entry); + + settings.rstestPackagePath = configured; + expect(resolve).toThrow(); + expect(shownMessages).toHaveLength(2); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it('should notify for a terminal run', () => { createApi().runInTerminal({}); expect(shownMessages).toHaveLength(1); From cda9a062762f78e25fda28b84051d5faf31e1f20 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 7 Sep 2026 18:49:04 +0800 Subject: [PATCH 40/44] fix(vscode): poll raw Rstest failures, unblock lint reconcile from hung refreshes --- packages/vscode/src/stacks/lint/Rslint.ts | 13 +- packages/vscode/src/stacks/lint/index.ts | 36 +++-- packages/vscode/src/stacks/test/index.ts | 11 +- .../vscode/tests/stacks/lint/start.test.ts | 78 ++++++++++- .../tests/stacks/test/controller.test.ts | 123 ++++++++++++++++++ 5 files changed, 236 insertions(+), 25 deletions(-) create mode 100644 packages/vscode/tests/stacks/test/controller.test.ts diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 26a1cc3..a09977e 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -323,6 +323,7 @@ export class Rslint implements Disposable { private lifecycleEpoch = 0; private advisory: string | undefined; private readonly configDependencyEpisode = new NotInstalledEpisode(); + private configDependencyRetryPending = false; private configRefreshFailed = false; private configError: string | undefined; private startPromise: Promise | undefined; @@ -720,9 +721,17 @@ export class Rslint implements Disposable { } public retryConfigDependency(): Promise | undefined { - if (!this.hasConfigDependencyFailure() && !this.configRefreshFailed) + if ( + this.configDependencyRetryPending || + (!this.hasConfigDependencyFailure() && !this.configRefreshFailed) + ) return undefined; - return this.requestConfigRefresh('dependency-change'); + // Polls must not queue more requests behind a user config that never + // settles. The first caller already observes this retry's outcome. + this.configDependencyRetryPending = true; + return this.requestConfigRefresh('dependency-change').finally(() => { + this.configDependencyRetryPending = false; + }); } private isLifecycleCurrent(epoch: number, client: LanguageClient): boolean { diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index cc8be15..257e871 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -103,7 +103,7 @@ class RslintController implements StackController { // A detection pass fires on config topology and lockfile changes — // exactly the moments a document's core may have appeared, moved or // changed ownership. This replaces the coordinator's `retryFailedRoots`. - this.retryConfigDependenciesThenReconcile(); + this.retryConfigDependenciesAndReconcile(); }), vscode.workspace.onDidChangeWorkspaceFolders(() => { this.pruneDepartedFolders(); @@ -335,27 +335,21 @@ class RslintController implements StackController { }); } - private retryConfigDependenciesThenReconcile(): void { - const retries = [...this.#runtimes.values()].flatMap((runtime) => { + private retryConfigDependenciesAndReconcile(): void { + for (const runtime of this.#runtimes.values()) { const retry = runtime.retryConfigDependency(); - return retry ? [{ runtime, retry }] : []; - }); - void Promise.allSettled(retries.map(({ retry }) => retry)).then( - (results) => { - results.forEach((result, index) => { - if ( - result.status === 'rejected' && - !retries[index]?.runtime.hasConfigDependencyFailure() - ) { - this.#logger?.error( - 'Failed to retry Rslint config dependency discovery', - result.reason, - ); - } - }); - this.reconcileOpenDocuments('detection change'); - }, - ); + void retry?.catch((error: unknown) => { + if (!runtime.hasConfigDependencyFailure()) { + this.#logger?.error( + 'Failed to retry Rslint config dependency discovery', + error, + ); + } + }); + } + // A user config can hang indefinitely. Other documents must still + // re-resolve their cores on this pass; refreshes run independently. + this.reconcileOpenDocuments('detection change'); } private setState( diff --git a/packages/vscode/src/stacks/test/index.ts b/packages/vscode/src/stacks/test/index.ts index 99a4a7f..10f36ef 100644 --- a/packages/vscode/src/stacks/test/index.ts +++ b/packages/vscode/src/stacks/test/index.ts @@ -71,6 +71,15 @@ class Rstest implements vscode.Disposable { return this.ctrl; } + hasFailedState(): boolean { + for (const workspace of this.workspaces.values()) { + for (const project of workspace.projects.values()) { + if (project.hasFailedState) return true; + } + } + return false; + } + /** * What upstream's `activate()` effectively exported (the `Rstest` instance): * the E2E suites (`e2e/rstest/`) consume `testController`, `runProfile` @@ -557,7 +566,7 @@ class RstestController implements StackController { } hasFailedState(): boolean { - return status.hasFailed(); + return status.hasFailed() || (this.#rstest?.hasFailedState() ?? false); } dispose(): void { diff --git a/packages/vscode/tests/stacks/lint/start.test.ts b/packages/vscode/tests/stacks/lint/start.test.ts index ce4ab25..8d76a3d 100644 --- a/packages/vscode/tests/stacks/lint/start.test.ts +++ b/packages/vscode/tests/stacks/lint/start.test.ts @@ -10,6 +10,9 @@ import { registerEditorProxy } from '../../../src/stacks/lint/worker/index'; let refreshOutcome: 'missing' | 'broken' | 'fixed' | 'changed' | 'changed-once' = 'missing'; +let pendingRefresh: Promise | undefined; +let refreshCalls = 0; +let reconciles = 0; rs.mock('vscode', () => { const api = { @@ -41,7 +44,9 @@ rs.mock('../../../src/stacks/lint/RuntimeManager', () => ({ } initialize() {} clearResolutionCache() {} - async reconcileOpenDocuments() {} + async reconcileOpenDocuments() { + reconciles++; + } }, })); rs.mock('../../../src/stacks/lint/CoreResolver', () => ({ @@ -72,6 +77,8 @@ rs.mock('vscode-languageclient/node', () => ({ } async start() {} async sendRequest() { + refreshCalls++; + if (pendingRefresh) return pendingRefresh; if (refreshOutcome === 'changed' || refreshOutcome === 'changed-once') { if (refreshOutcome === 'changed-once') refreshOutcome = 'fixed'; let request!: (method: string, params: unknown) => Promise; @@ -125,6 +132,50 @@ rs.mock('vscode-languageclient/node', () => ({ import { Rslint } from '../../../src/stacks/lint/Rslint'; import { createRslintController } from '../../../src/stacks/lint'; +it('reconciles documents on every detection pass even while a config refresh is hung', async () => { + const folder = { + name: 'project', + uri: { fsPath: '/project', toString: () => 'file:///project' }, + }; + const entry = { folder, stacks: { rslint: { mode: 'native' } } }; + const snapshot = { + forFolder: () => entry, + foldersFor: () => [entry], + } as unknown as DetectionSnapshot; + let onDetection!: (snapshot: DetectionSnapshot) => void; + const controller = createRslintController(); + await controller.register({ + detection: snapshot, + onDidChangeDetection: (listener: typeof onDetection) => { + onDetection = listener; + return { dispose() {} }; + }, + output: { debug() {}, info() {}, warn() {}, error() {} }, + status: { report() {} }, + } as unknown as StackContext); + const runtime = runtimeFactory({ + key: 'core', + workspaceFolder: folder, + installation: { mode: 'native', packageDirectory: '/project/core' }, + } as unknown as ResolvedCoreRuntime); + const hung = Promise.withResolvers(); + const retry = rs + .spyOn(runtime, 'retryConfigDependency') + .mockReturnValue(hung.promise); + const before = reconciles; + try { + onDetection(snapshot); + await Promise.resolve(); + expect(reconciles).toBe(before + 1); + onDetection(snapshot); + await Promise.resolve(); + expect(reconciles).toBe(before + 2); + } finally { + hung.resolve(); + retry.mockRestore(); + } +}); + it('updates a surviving bridge runtime attribution before the next config failure', async () => { const folder = { name: 'project', @@ -203,6 +254,31 @@ function createRuntime() { return { runtime, states, warnings, errors }; } +it('keeps dependency retries single-flight until a hung refresh settles', async () => { + refreshOutcome = 'missing'; + const { runtime, states } = createRuntime(); + await runtime.start(new AbortController().signal); + const gate = Promise.withResolvers(); + pendingRefresh = gate.promise; + const before = refreshCalls; + const first = runtime.retryConfigDependency(); + try { + await rs.waitUntil(() => refreshCalls === before + 1); + for (let tick = 0; tick < 5; tick++) { + expect(runtime.retryConfigDependency()).toBeUndefined(); + } + expect(refreshCalls).toBe(before + 1); + } finally { + pendingRefresh = undefined; + refreshOutcome = 'fixed'; + gate.resolve(); + await first; + } + await runtime.retryConfigDependency(); + expect(refreshCalls).toBe(before + 2); + expect(states.at(-1)?.kind).toBe('running'); +}); + it('keeps an initialized runtime disabled when initial configRefresh rejects', async () => { refreshOutcome = 'missing'; const { runtime, states, warnings, errors } = createRuntime(); diff --git a/packages/vscode/tests/stacks/test/controller.test.ts b/packages/vscode/tests/stacks/test/controller.test.ts new file mode 100644 index 0000000..8680a0e --- /dev/null +++ b/packages/vscode/tests/stacks/test/controller.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, rs } from '@rstest/core'; +import { status } from '../../../src/stacks/test/status'; + +const projects = new Map(); + +rs.mock('../../../src/stacks/test/project', () => ({ + Project: class {}, + WorkspaceManager: class { + projects = projects; + activeProjects = new Map(); + constructor() {} + refresh() {} + retryFailedProjects() {} + setRstackConfigFiles() {} + dispose() {} + }, +})); + +rs.mock('../../../src/stacks/test/diagnostics', () => ({ + RstestDiagnostics: class { + dispose() {} + }, +})); +rs.mock('../../../src/stacks/test/master', () => ({ + runningWorkers: new Set(), + warmWorkerNodePreflight: () => {}, +})); +rs.mock('../../../src/stacks/test/terminal', () => ({ + disposeTerminal: () => {}, +})); +rs.mock('../../../src/stacks/test/testRunReporter', () => ({ + RstestFileCoverage: class {}, +})); +rs.mock('../../../src/stacks/test/testTree', () => ({ + gatherTestItems: () => [], + ProjectFolder: class {}, + TestCase: class {}, + TestFile: class {}, + TestFolder: class {}, + testData: new WeakMap(), +})); + +const testController = { + items: { replace: () => {} }, + createRunProfile: () => ({ dispose: () => {} }), + dispose: () => {}, +}; + +rs.mock('vscode', () => { + const vscode = { + tests: { createTestController: () => testController }, + commands: { + registerCommand: () => ({ dispose: () => {} }), + executeCommand: () => Promise.resolve(), + }, + window: {}, + env: { clipboard: { writeText: () => Promise.resolve() } }, + TestRunProfileKind: { Run: 1, Debug: 2, Coverage: 3 }, + }; + return { ...vscode, default: vscode }; +}); + +const folder = { + uri: { + scheme: 'file', + fsPath: '/repo', + toString: () => 'file:///repo', + }, + name: 'repo', + index: 0, +} as any; + +const context = { + detection: { + foldersFor: () => [{ folder }], + forFolder: () => ({ stacks: { rstest: { rstackConfigFiles: [] } } }), + }, + output: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }, + status: { + stack: 'rstest', + report: () => {}, + starting: () => {}, + running: () => {}, + crashed: () => {}, + versionMismatch: () => {}, + }, + onDidChangeDetection: () => ({ dispose: () => {} }), +} as any; + +describe('RstestController failed state', () => { + beforeEach(() => { + projects.clear(); + }); + + it('folds raw project failures without losing singleton status crashes', async () => { + const { createRstestController } = + await import('../../../src/stacks/test/index'); + const controller = createRstestController(); + await controller.register(context); + + const project = { hasFailedState: false }; + projects.set('file:///repo/rstest.config.ts', project); + expect(status.hasFailed()).toBe(false); + expect(controller.hasFailedState()).toBe(false); + + project.hasFailedState = true; + expect(status.hasFailed()).toBe(false); + expect(controller.hasFailedState()).toBe(true); + + project.hasFailedState = false; + expect(controller.hasFailedState()).toBe(false); + + status.crashed('worker stopped', 'singleton'); + expect(controller.hasFailedState()).toBe(true); + + controller.dispose(); + }); +}); From 653184fa92c2ee0168e204330300b5e7353b5e94 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 8 Sep 2026 15:18:31 +0800 Subject: [PATCH 41/44] refactor(vscode): ablate unneeded abstractions and mirror tests --- packages/vscode/AGENTS.md | 2 +- packages/vscode/src/extension.ts | 9 +- packages/vscode/src/shared/messageLatch.ts | 14 ++ packages/vscode/src/shared/notInstalled.ts | 13 +- packages/vscode/src/stacks/fmt/index.ts | 100 ++++++----- .../vscode/src/stacks/fmt/sessionError.ts | 108 ----------- packages/vscode/src/stacks/lint/Rslint.ts | 41 ++--- packages/vscode/src/stacks/lint/index.ts | 41 ++--- packages/vscode/src/stacks/test/master.ts | 15 +- packages/vscode/src/stacks/test/project.ts | 7 +- packages/vscode/tests/detection.test.ts | 9 - packages/vscode/tests/extension.test.ts | 15 +- .../vscode/tests/shared/notInstalled.test.ts | 57 ------ .../vscode/tests/stacks/fmt/runtime.test.ts | 7 +- .../tests/stacks/fmt/sessionError.test.ts | 168 ------------------ .../tests/stacks/test/controller.test.ts | 123 ------------- .../vscode/tests/stacks/test/project.test.ts | 21 --- .../vscode/tests/stacks/test/status.test.ts | 20 --- packages/vscode/tests/statusBar.test.ts | 16 -- 19 files changed, 125 insertions(+), 661 deletions(-) create mode 100644 packages/vscode/src/shared/messageLatch.ts delete mode 100644 packages/vscode/src/stacks/fmt/sessionError.ts delete mode 100644 packages/vscode/tests/stacks/fmt/sessionError.test.ts delete mode 100644 packages/vscode/tests/stacks/test/controller.test.ts diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index d10362f..a42f958 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -41,7 +41,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten ## Gotchas — decisions that look wrong but aren't -- fmt's `handleShowMessage` suppresses only the classified config-dependency Error. Non-classified `window/showMessage` notifications are re-presented exactly as vscode-languageclient's default handler would (Error/Warning/Info toast). An exact-prefix `rs fmt cannot format this workspace:` Error that is not a missing dependency also reports `crashed` and one Output error line per distinct message; the known config error survives server restarts so polling continues until a format produces edits. This passes through the server's protocol UI request unchanged; "stacks own no UI chrome" constrains UI the stack originates, not protocol UI it relays. `stacks/fmt/sessionError.ts`, like `binEntry.ts` and `status.ts`, stays pure and vscode-free for unit testing; it mirrors LSP MessageType constants because the languageclient runtime import loads `vscode`. Warning and error episodes end on nonempty formatting edits, not merely a response with no new notification: the server returns empty edits on failure and deduplicates showMessage. +- fmt's `handleShowMessage` suppresses only the classified config-dependency Error. Non-classified `window/showMessage` notifications are re-presented exactly as vscode-languageclient's default handler would (Error/Warning/Info toast). An exact-prefix `rs fmt cannot format this workspace:` Error that is not a missing dependency also reports `crashed` and one Output error line per distinct message; the known config error survives server restarts so polling continues until a format produces edits. This passes through the server's protocol UI request unchanged; "stacks own no UI chrome" constrains UI the stack originates, not protocol UI it relays. Warning and error episodes end on nonempty formatting edits, not merely a response with no new notification: the server returns empty edits on failure and deduplicates showMessage. - The lint × `rstack.config.*` bridge stays thin on purpose: only a root Rstack config can claim a bridged folder, any native config anywhere in the folder wins ownership, and the worker evaluates rstack's published shim from the folder root. Never generate a shim, load the Rstack config in the extension host, or interpret `define.lint()` ourselves. - **Yarn Plug'n'Play is unsupported by decision, extension-wide.** Every stack resolves through physical `node_modules` (`shared/packageResolve.ts`, `resolution.ts`'s rstack → `@rslint/core` chain, the fmt bin probe, the rstest package lookup) and the lint worker's own `createRequire` from the core directory does too. Lint once carried a `.pnp.cjs` branch for the find-`@rslint/core` hop only; nothing after that hop (config evaluation, plugin resolution, the other stacks) had PnP hooks, so it never produced a working folder, and upstream removed its own PnP path in the same refactor that introduced `corePath`. Real support would be a PnP editor-SDK-shaped project across all three stacks, not a resolver branch — do not reintroduce one. - **A Lint runtime lives as long as a document needs it, and a folder with none is `running: idle`.** Since the #1617 sync, `RuntimeManager` refcounts each runtime by open document: the first document to resolve a core starts one, the last to release it closes it, so a detected folder with nothing open holds zero workers and zero Go processes. That folder still reports `running` — with the detail `idle` — because it is live and will start a runtime on the next `didOpen`; do **not** add a `StackState` kind for it (the shell's status bar and `when` clauses read the kinds, and idle is not a kind of health). A folder's state is the **worst of** its runtimes plus any document whose core resolution currently fails (last-good: that document keeps the runtime it already had), so one failing core is never masked by a healthy sibling — the same invariant fmt pins across folders, applied inside one and across them alike (lint's rank table matches fmt's: `disabled` there means "a package is not installed" — no `rstack`, or no `@rslint/core` — not the kill switch). Dependency retries come only through the shell's detection pass: lockfile events are the low-latency path and ADR 0005's conditional poll covers unchanged lockfiles. The former lint-owned `node_modules/@rslint/core/package.json` watcher was removed because pnpm produced no event in either isolated or hoisted layout. Failures report through the status only: upstream's `window.showWarningMessage` is dropped, since stacks own no UI chrome. Consequently `whenStackActive('rslint')` means "the controller registered its folders", not "a server is up" — E2E suites open a document and await diagnostics. diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 3cbcae9..a355904 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -10,7 +10,6 @@ import { type StackControllerFactory, type StackId, type StackState, - type StatusReporter, STACK_IDS, STACK_LABELS, stackCommand, @@ -277,10 +276,6 @@ class ExtensionShell { }, this.#dependencyPollIntervalMs); } - private stackStatusReporter(stack: StackId): StatusReporter { - return this.#statusBar.reporterFor(stack, () => this.syncDependencyPoll()); - } - /** * `rstack.restart` (every stack) and `rstack..restart` (one) — a full * reset, not a "retry whatever looks broken". @@ -450,7 +445,9 @@ class ExtensionShell { stack, extensionContext: this.context, output: this.#channels.forStack(stack), - status: this.stackStatusReporter(stack), + status: this.#statusBar.reporterFor(stack, () => + this.syncDependencyPoll(), + ), detection: snapshot, onDidChangeDetection: this.#detectionEmitter.event, }); diff --git a/packages/vscode/src/shared/messageLatch.ts b/packages/vscode/src/shared/messageLatch.ts new file mode 100644 index 0000000..a372fa8 --- /dev/null +++ b/packages/vscode/src/shared/messageLatch.ts @@ -0,0 +1,14 @@ +/** Suppresses consecutive identical messages until the owning operation recovers. */ +export class MessageLatch { + #message: string | undefined; + + changed(message: string): boolean { + if (this.#message === message) return false; + this.#message = message; + return true; + } + + clear(): void { + this.#message = undefined; + } +} diff --git a/packages/vscode/src/shared/notInstalled.ts b/packages/vscode/src/shared/notInstalled.ts index 7370be0..14027c6 100644 --- a/packages/vscode/src/shared/notInstalled.ts +++ b/packages/vscode/src/shared/notInstalled.ts @@ -13,7 +13,7 @@ import { * `formatVersionMismatch` — each keeps its own status machinery, but what * the user reads is one sentence, not three near-copies. * - * A shell-owned poll now covers installs that change no lockfile. The trailing + * A shell-owned poll covers installs that change no lockfile. The trailing * restart hint remains the explicit fallback when recovery is delayed or the * project stays broken for another reason (ADR 0005). */ @@ -55,11 +55,6 @@ export interface ConfigDependencyFailure { readonly cause: string; } -interface NotInstalledEpisodeReport { - readonly reason: string; - readonly warning: string | undefined; -} - /** * Deduplicates one not-installed warning until a successful load ends the * episode. Stacks receive their failures over different protocols, but @@ -72,11 +67,7 @@ export class NotInstalledEpisode { return this.#fingerprint !== undefined; } - observe( - stack: StackId, - configPath: string, - cause: string, - ): NotInstalledEpisodeReport { + observe(stack: StackId, configPath: string, cause: string) { const fingerprint = `config\0${configPath}\0${cause}`; const warning = fingerprint === this.#fingerprint diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index d81d6fa..64b1faa 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -5,6 +5,7 @@ import { CloseAction, ErrorAction, LanguageClient, + MessageType, ShowMessageNotification, State, type ErrorHandler, @@ -12,6 +13,8 @@ import { type ServerOptions, } from 'vscode-languageclient/node'; import { RSTACK_CONFIG_GLOB } from '../../detection'; +import { MessageLatch } from '../../shared/messageLatch'; +import { classifyMissingDependencyMessage } from '../../shared/missingDependency'; import { getConfiguredNodeExecutable } from '../../shared/nodeExecutableSetting'; import { NotInstalledEpisode, @@ -43,10 +46,6 @@ import type { // no lint behaviour is shared, and the file has no lint imports. import { LanguageServerProcessOwner } from '../lint/LanguageServerProcessOwner'; import { pickBinEntry } from './binEntry'; -import { - clearEpisodeAfterSuccessfulFormatting, - handleFmtShowMessage, -} from './sessionError'; import { foldFolderStatus, type FmtFolderStatus, @@ -54,6 +53,8 @@ import { isFailedFmtState, } from './status'; +const FMT_SESSION_ERROR_PREFIX = 'rs fmt cannot format this workspace: '; + // prettier 3.9.6 getSupportInfo() vscodeLanguageIds snapshot (rs fmt's pinned // prettier). Revisit when the pinned prettier changes. const LANGUAGE_IDS = [ @@ -163,7 +164,7 @@ class FmtFolderRuntime { #configPath: string | undefined; readonly #packageEpisode = new NotInstalledEpisode(); readonly #configDependencyEpisode = new NotInstalledEpisode(); - #startError: string | undefined; + readonly #startError = new MessageLatch(); #sessionError: string | undefined; #sessionErrorCount = 0; suppressedShowMessages = 0; @@ -222,37 +223,49 @@ class FmtFolderRuntime { } private handleShowMessage(message: ShowMessageParams): void { - handleFmtShowMessage(message, this.folderPath, this.#configPath, { - onConfigDependency: (failure) => { - this.#sessionError = undefined; - const report = this.#configDependencyEpisode.observe( - 'fmt', - failure.configPath, - failure.cause, - ); - if (report.warning !== undefined) { - this.context.output.warn(report.warning); + switch (message.type) { + case MessageType.Error: + if (message.message.startsWith(FMT_SESSION_ERROR_PREFIX)) { + const firstLine = message.message + .slice(FMT_SESSION_ERROR_PREFIX.length) + .split('\n', 1)[0]; + const cause = + this.#configPath === undefined + ? undefined + : classifyMissingDependencyMessage( + firstLine.replace(/^Error(?: \[[A-Z_]+\])?: /, ''), + this.folderPath, + ); + if (cause !== undefined && this.#configPath !== undefined) { + this.#sessionError = undefined; + const relative = path.relative(this.folderPath, this.#configPath); + const report = this.#configDependencyEpisode.observe( + 'fmt', + relative.length > 0 ? relative : path.basename(this.#configPath), + cause, + ); + if (report.warning !== undefined) + this.context.output.warn(report.warning); + this.suppressedShowMessages++; + this.setState('disabled', report.reason); + return; + } + this.#sessionErrorCount++; + this.#configDependencyEpisode.clear(); + if (this.#sessionError !== firstLine) + this.context.output.error(firstLine); + this.#sessionError = firstLine; + this.setState('crashed', firstLine); } - this.suppressedShowMessages++; - this.setState('disabled', report.reason); - }, - onConfigError: (message) => { - this.#sessionErrorCount++; - this.#configDependencyEpisode.clear(); - if (this.#sessionError !== message) this.context.output.error(message); - this.#sessionError = message; - this.setState('crashed', message); - }, - showErrorMessage: (text) => { - void vscode.window.showErrorMessage(text); - }, - showWarningMessage: (text) => { - void vscode.window.showWarningMessage(text); - }, - showInformationMessage: (text) => { - void vscode.window.showInformationMessage(text); - }, - }); + void vscode.window.showErrorMessage(message.message); + break; + case MessageType.Warning: + void vscode.window.showWarningMessage(message.message); + break; + default: + void vscode.window.showInformationMessage(message.message); + break; + } } /** @@ -463,8 +476,7 @@ class FmtFolderRuntime { }`, ); const message = error instanceof Error ? error.message : String(error); - if (this.#startError !== message) { - this.#startError = message; + if (this.#startError.changed(message)) { context.output.error( 'Failed to start the rs fmt language server', error, @@ -472,7 +484,7 @@ class FmtFolderRuntime { } return; } - this.#startError = undefined; + this.#startError.clear(); context.output.info(`rs fmt language server started for ${folderRoot}`); } @@ -569,14 +581,14 @@ class FmtFolderRuntime { this.suppressedShowMessages + this.#sessionErrorCount; const edits = await next(document, options, token); const hadConfigDependency = this.#configDependencyEpisode.active; + // Empty edits also signal failure; notifications during this request + // must not be cleared by its edits. if ( - clearEpisodeAfterSuccessfulFormatting( - this.#configDependencyEpisode, - failuresBeforeRequest, - this.suppressedShowMessages + this.#sessionErrorCount, - edits?.length ?? 0, - ) + (edits?.length ?? 0) > 0 && + failuresBeforeRequest === + this.suppressedShowMessages + this.#sessionErrorCount ) { + this.#configDependencyEpisode.clear(); const hadSessionError = this.#sessionError !== undefined; this.#sessionError = undefined; if ( diff --git a/packages/vscode/src/stacks/fmt/sessionError.ts b/packages/vscode/src/stacks/fmt/sessionError.ts deleted file mode 100644 index e4edffb..0000000 --- a/packages/vscode/src/stacks/fmt/sessionError.ts +++ /dev/null @@ -1,108 +0,0 @@ -import path from 'node:path'; -import type { - MessageType as LspMessageType, - ShowMessageParams, -} from 'vscode-languageclient'; -import { classifyMissingDependencyMessage } from '../../shared/missingDependency'; -import type { - NotInstalledEpisode, - ConfigDependencyFailure, -} from '../../shared/notInstalled'; - -export const FMT_SESSION_ERROR_PREFIX = 'rs fmt cannot format this workspace: '; - -// Importing the runtime value from vscode-languageclient also evaluates its -// `vscode` dependency, which would make this otherwise pure module unusable in -// Node unit tests. These are the LSP MessageType values it re-exports. -const MessageType = { - Error: 1 as LspMessageType, - Warning: 2 as LspMessageType, - Info: 3 as LspMessageType, -}; - -interface ShowMessagePresenter { - showErrorMessage(message: string): void; - showWarningMessage(message: string): void; - showInformationMessage(message: string): void; -} - -interface FmtShowMessageHandler extends ShowMessagePresenter { - onConfigDependency(failure: ConfigDependencyFailure): void; - onConfigError(message: string): void; -} - -export function classifyFmtSessionError( - message: ShowMessageParams, - workspaceRoot: string, - configPath: string, -): ConfigDependencyFailure | undefined { - if ( - message.type !== MessageType.Error || - !message.message.startsWith(FMT_SESSION_ERROR_PREFIX) - ) { - return undefined; - } - const cause = message.message - .slice(FMT_SESSION_ERROR_PREFIX.length) - .replace(/^Error(?: \[[A-Z_]+\])?: /, ''); - const firstLine = cause.split('\n', 1)[0]; - const classified = classifyMissingDependencyMessage(firstLine, workspaceRoot); - if (classified === undefined) return undefined; - const relative = path.relative(workspaceRoot, configPath); - return { - configPath: relative.length > 0 ? relative : path.basename(configPath), - cause: classified, - }; -} - -/** Reports config failures as state, suppressing only missing-dependency UI requests. */ -export const handleFmtShowMessage = ( - message: ShowMessageParams, - workspaceRoot: string, - configPath: string | undefined, - handler: FmtShowMessageHandler, -): void => { - const failure = - configPath === undefined - ? undefined - : classifyFmtSessionError(message, workspaceRoot, configPath); - if (failure !== undefined) { - handler.onConfigDependency(failure); - return; - } - switch (message.type) { - case MessageType.Error: - if (message.message.startsWith(FMT_SESSION_ERROR_PREFIX)) { - handler.onConfigError( - message.message - .slice(FMT_SESSION_ERROR_PREFIX.length) - .split('\n', 1)[0], - ); - } - handler.showErrorMessage(message.message); - break; - case MessageType.Warning: - handler.showWarningMessage(message.message); - break; - default: - handler.showInformationMessage(message.message); - break; - } -}; - -/** - * Nonempty edits prove formatting succeeded. Empty edits are ambiguous: the - * server also returns them on failure and deduplicates showMessage, so absence - * of a new notification cannot prove recovery on a repeated request. - */ -export const clearEpisodeAfterSuccessfulFormatting = ( - episode: NotInstalledEpisode, - failuresBeforeRequest: number, - failuresAfterRequest: number, - editCount: number, -): boolean => { - if (editCount <= 0 || failuresBeforeRequest !== failuresAfterRequest) - return false; - episode.clear(); - return true; -}; diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index a09977e..0623dd0 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -26,6 +26,7 @@ import { type ServerOptions, State, } from 'vscode-languageclient/node'; +import { MessageLatch } from '../../shared/messageLatch'; import { NotInstalledEpisode } from '../../shared/notInstalled'; import { configuredNodeBelowFloor, @@ -297,8 +298,6 @@ export interface RslintOptions { readonly router: WorkspaceDocumentRouter; readonly logger: Logger; readonly reportStatus: RslintStatusSink; - /** Root Rstack config represented by the worker's physical bridge shim. */ - readonly bridgeConfigPath?: string; readonly onClosed?: () => void; } @@ -325,7 +324,7 @@ export class Rslint implements Disposable { private readonly configDependencyEpisode = new NotInstalledEpisode(); private configDependencyRetryPending = false; private configRefreshFailed = false; - private configError: string | undefined; + private readonly configError = new MessageLatch(); private startPromise: Promise | undefined; private startOperation: Promise | undefined; private clientStartPromise: Promise | undefined; @@ -337,7 +336,6 @@ export class Rslint implements Disposable { this.workspaceFolder = options.workspaceFolder; this.router = options.router; this.reportStatus = options.reportStatus; - this.bridgeConfigPath = options.bridgeConfigPath; this.installation = options.installation; this.logger = options.logger; this.lspOutputChannel = options.lspOutputChannel; @@ -359,20 +357,6 @@ export class Rslint implements Disposable { this.report(runningRslintStatus(this.advisory)); } - private displayConfigPath(configPath: string): string { - const physicalPath = - configPath === this.installation.shimPath && this.bridgeConfigPath - ? this.bridgeConfigPath - : configPath; - const relative = path.relative( - this.workspaceFolder.uri.fsPath, - physicalPath, - ); - return relative.length > 0 && !relative.startsWith('..') - ? relative - : path.basename(physicalPath); - } - private handleConfigDependencyStatus( notification: ConfigDependencyStatusNotification, ): void { @@ -380,15 +364,14 @@ export class Rslint implements Disposable { this.configRefreshFailed = true; this.report({ kind: 'crashed', detail: notification.message }); this.configDependencyEpisode.clear(); - if (this.configError !== notification.message) { - this.configError = notification.message; + if (this.configError.changed(notification.message)) { this.logger.error( `Failed to refresh config discovery: ${notification.message}`, ); } return; } - this.configError = undefined; + this.configError.clear(); const wasFailed = this.configRefreshFailed; this.configRefreshFailed = false; if (notification.kind === 'ok') { @@ -397,15 +380,25 @@ export class Rslint implements Disposable { return; } const failure = notification.failure; - const displayPath = this.displayConfigPath(failure.configPath); + const physicalPath = + failure.configPath === this.installation.shimPath && this.bridgeConfigPath + ? this.bridgeConfigPath + : failure.configPath; + const relative = path.relative( + this.workspaceFolder.uri.fsPath, + physicalPath, + ); + const displayPath = + relative.length > 0 && !relative.startsWith('..') + ? relative + : path.basename(physicalPath); const report = this.configDependencyEpisode.observe( 'rslint', displayPath, failure.cause, ); if (report.warning !== undefined) { - const warning = report.warning; - this.logger.warn(warning); + this.logger.warn(report.warning); } this.report({ kind: 'disabled', diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index 257e871..5f84f42 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -100,10 +100,19 @@ class RslintController implements StackController { ); } this.pruneDepartedFolders(); - // A detection pass fires on config topology and lockfile changes — - // exactly the moments a document's core may have appeared, moved or - // changed ownership. This replaces the coordinator's `retryFailedRoots`. - this.retryConfigDependenciesAndReconcile(); + for (const runtime of this.#runtimes.values()) { + void runtime.retryConfigDependency()?.catch((error: unknown) => { + if (!runtime.hasConfigDependencyFailure()) { + this.#logger?.error( + 'Failed to retry Rslint config dependency discovery', + error, + ); + } + }); + } + // A user config can hang indefinitely. Other documents must still + // re-resolve their cores on this pass; refreshes run independently. + this.reconcileOpenDocuments('detection change'); }), vscode.workspace.onDidChangeWorkspaceFolders(() => { this.pruneDepartedFolders(); @@ -274,16 +283,15 @@ class RslintController implements StackController { attributeToCore(state, installation.packageDirectory), ); }, - bridgeConfigPath: - installation.mode === 'bridged' - ? this.#snapshot?.forFolder(workspaceFolder)?.rootRstackConfigPath - : undefined, onClosed: () => { if (this.#runtimes.get(resolved.key) === runtime) { this.#runtimes.delete(resolved.key); } }, }); + runtime.setBridgeConfigPath( + this.#snapshot?.forFolder(workspaceFolder)?.rootRstackConfigPath, + ); this.#runtimes.set(resolved.key, runtime); return runtime; } @@ -335,23 +343,6 @@ class RslintController implements StackController { }); } - private retryConfigDependenciesAndReconcile(): void { - for (const runtime of this.#runtimes.values()) { - const retry = runtime.retryConfigDependency(); - void retry?.catch((error: unknown) => { - if (!runtime.hasConfigDependencyFailure()) { - this.#logger?.error( - 'Failed to retry Rslint config dependency discovery', - error, - ); - } - }); - } - // A user config can hang indefinitely. Other documents must still - // re-resolve their cores on this pass; refreshes run independently. - this.reconcileOpenDocuments('detection change'); - } - private setState( folderKey: string, bucket: keyof FolderStates, diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index 8f5f867..4f7b4c3 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -18,6 +18,7 @@ import { getConfiguredNodeExecutable, } from '../../shared/nodeExecutableSetting'; import { CONFIG_SECTION, getConfigValue } from './config'; +import { MessageLatch } from '../../shared/messageLatch'; import { NotInstalledEpisode, formatNotInstalledStatus, @@ -134,8 +135,8 @@ export class RstestApi { private disposed = false; private lastResolvedRstestPath?: string; private readonly coreMissingEpisode = new NotInstalledEpisode(); - private lastUnsupportedCoreMessage?: string; - private lastResolutionErrorMessage?: string; + private readonly unsupportedCoreMessage = new MessageLatch(); + private readonly resolutionErrorMessage = new MessageLatch(); constructor( private workspace: vscode.WorkspaceFolder, @@ -350,9 +351,8 @@ export class RstestApi { } private reportResolutionError(message: string): void { - if (message === this.lastResolutionErrorMessage) return; + if (!this.resolutionErrorMessage.changed(message)) return; vscode.window.showErrorMessage(message); - this.lastResolutionErrorMessage = message; } // Returns '' when resolution failed. Every such branch has already reported @@ -430,18 +430,17 @@ export class RstestApi { ) ) { const message = `Unsupported @rstest/core version ${coreVersion ?? 'unknown'} resolved from ${this.cwd}`; - if (message !== this.lastUnsupportedCoreMessage) { + if (this.unsupportedCoreMessage.changed(message)) { logger.error(message); - this.lastUnsupportedCoreMessage = message; } } else { - this.lastUnsupportedCoreMessage = undefined; + this.unsupportedCoreMessage.clear(); status.versionOk(this.statusSource); } } this.lastResolvedRstestPath = nodeExport; - this.lastResolutionErrorMessage = undefined; + this.resolutionErrorMessage.clear(); return nodeExport; } catch (e) { this.reportResolutionError(toErrorMessage(e)); diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts index 43da6ba..c500e85 100644 --- a/packages/vscode/src/stacks/test/project.ts +++ b/packages/vscode/src/stacks/test/project.ts @@ -245,7 +245,6 @@ export class WorkspaceManager implements vscode.Disposable { */ public retryFailedProjects() { for (const project of this.projects.values()) { - if (!project.hasFailedState) continue; void project.retryFailedConfig(); } } @@ -623,10 +622,8 @@ export class Project implements vscode.Disposable { error instanceof Error ? error.message.split('\n', 1)[0] : String(error); - // Replace the previous not-installed verdict with the real config - // error before clearing that latch. Crash outranks disabled, so the - // synchronous transition never paints a healthy intermediate state; - // clearing the raw latch stops dependency polling as intended. + // Crash outranks disabled: replacing the missing-dependency verdict + // must not paint a healthy intermediate state. status.crashed( `Cannot load ${relativeTo(this.workspaceFolder, this.sourceUri)}: ${cause}`, this.configDependencyStatusSource, diff --git a/packages/vscode/tests/detection.test.ts b/packages/vscode/tests/detection.test.ts index c7e852b..27558be 100644 --- a/packages/vscode/tests/detection.test.ts +++ b/packages/vscode/tests/detection.test.ts @@ -224,15 +224,6 @@ describe('DetectionService — notification rules', () => { service.dispose(); }); - it('force-notifies an unchanged signature for dependency recovery', async () => { - const service = new DetectionService(fakeOutput()); - const seen = listen(service); - await service.initialize(); - await service.refreshForDependencyChange(); - expect(seen).toHaveLength(1); - service.dispose(); - }); - it('does not notify after disposal', async () => { const service = new DetectionService(fakeOutput()); const seen = listen(service); diff --git a/packages/vscode/tests/extension.test.ts b/packages/vscode/tests/extension.test.ts index 2886c16..e5f56be 100644 --- a/packages/vscode/tests/extension.test.ts +++ b/packages/vscode/tests/extension.test.ts @@ -484,20 +484,7 @@ describe('dependency recovery polling', () => { await deactivate(); }); - it('uses a one-minute default interval', async () => { - const timer = rs.spyOn(globalThis, 'setTimeout'); - try { - await activate(context); - harness.failed.add('rslint'); - harness.reporters.get('rslint')?.report({ kind: 'disabled' }); - expect(timer.mock.calls.some(([, delay]) => delay === 60_000)).toBe(true); - expect(harness.dependencyRefreshes).toBe(0); - } finally { - timer.mockRestore(); - } - }); - - it.each(['disabled', 'crashed', 'version-mismatch'] as const)( + it.each(['crashed', 'version-mismatch'] as const)( 'polls through the forced detection path while %s', async (kind) => { const exports = await activate(context); diff --git a/packages/vscode/tests/shared/notInstalled.test.ts b/packages/vscode/tests/shared/notInstalled.test.ts index 7e756bc..0c81480 100644 --- a/packages/vscode/tests/shared/notInstalled.test.ts +++ b/packages/vscode/tests/shared/notInstalled.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from '@rstest/core'; import { - NotInstalledEpisode, formatConfigDependencyMissingLog, formatConfigDependencyMissingStatus, formatNotInstalledLog, @@ -52,59 +51,3 @@ describe('not-installed wording', () => { ); }); }); - -describe('NotInstalledEpisode', () => { - it('warns once until success clears the episode', () => { - const episode = new NotInstalledEpisode(); - const first = episode.observe( - 'fmt', - 'rstack.config.ts', - "Cannot find package 'missing'", - ); - expect(first.warning).toContain("Cannot find package 'missing'"); - expect(episode.active).toBe(true); - - expect( - episode.observe( - 'fmt', - 'rstack.config.ts', - "Cannot find package 'missing'", - ).warning, - ).toBe(undefined); - - expect(episode.clear()).toBe(true); - expect(episode.active).toBe(false); - expect( - episode.observe( - 'fmt', - 'rstack.config.ts', - "Cannot find package 'missing'", - ).warning, - ).toContain("Cannot find package 'missing'"); - }); - - it('starts a new warning when the missing dependency changes', () => { - const episode = new NotInstalledEpisode(); - episode.observe('rslint', 'rslint.config.ts', "Cannot find package 'a'"); - expect( - episode.observe('rslint', 'rslint.config.ts', "Cannot find package 'b'") - .warning, - ).toContain("Cannot find package 'b'"); - }); - - it('deduplicates package warnings by package and search directory until cleared', () => { - const episode = new NotInstalledEpisode(); - expect(episode.observePackage('rstack', 'app', '/app')).toBe( - formatNotInstalledLog('rstack', 'app', '/app'), - ); - expect(episode.observePackage('rstack', 'app', '/app')).toBeUndefined(); - expect(episode.observePackage('rstack', 'app', '/other')).toBeDefined(); - expect( - episode.observePackage('@rstest/core', 'app', '/other'), - ).toBeDefined(); - episode.clear(); - expect( - episode.observePackage('@rstest/core', 'app', '/other'), - ).toBeDefined(); - }); -}); diff --git a/packages/vscode/tests/stacks/fmt/runtime.test.ts b/packages/vscode/tests/stacks/fmt/runtime.test.ts index def15d8..682db04 100644 --- a/packages/vscode/tests/stacks/fmt/runtime.test.ts +++ b/packages/vscode/tests/stacks/fmt/runtime.test.ts @@ -4,7 +4,8 @@ import type { ShowMessageParams, } from 'vscode-languageclient'; import type { StackContext, StackState } from '../../../src/types'; -import { FMT_SESSION_ERROR_PREFIX } from '../../../src/stacks/fmt/sessionError'; + +const FMT_SESSION_ERROR_PREFIX = 'rs fmt cannot format this workspace: '; const clients: Array<{ notify(message: ShowMessageParams): void; @@ -50,6 +51,7 @@ rs.mock('../../../src/stacks/lint/LanguageServerProcessOwner', () => ({ }, })); rs.mock('vscode-languageclient/node', () => ({ + MessageType: { Error: 1, Warning: 2, Info: 3 }, State: { Running: 2, Stopped: 1 }, ShowMessageNotification: { type: 'window/showMessage' }, LanguageClient: class { @@ -186,4 +188,7 @@ it('keeps classified missing dependencies disabled with one warning and no toast expect(toasts).toEqual([]); await format(1); expect(states.at(-1)?.kind).toBe('running'); + clients[0].notify(message); + expect(states.at(-1)?.kind).toBe('disabled'); + expect(warnings).toHaveLength(2); }); diff --git a/packages/vscode/tests/stacks/fmt/sessionError.test.ts b/packages/vscode/tests/stacks/fmt/sessionError.test.ts deleted file mode 100644 index d44fee8..0000000 --- a/packages/vscode/tests/stacks/fmt/sessionError.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { describe, expect, it, rs } from '@rstest/core'; -import vscode from 'vscode'; -import { NotInstalledEpisode } from '../../../src/shared/notInstalled'; -import { - classifyFmtSessionError, - clearEpisodeAfterSuccessfulFormatting, - FMT_SESSION_ERROR_PREFIX, - handleFmtShowMessage, -} from '../../../src/stacks/fmt/sessionError'; - -rs.mock('vscode', () => ({ - default: { - window: { - showErrorMessage: rs.fn(), - showWarningMessage: rs.fn(), - showInformationMessage: rs.fn(), - }, - }, -})); - -describe('classifyFmtSessionError', () => { - const root = '/project'; - const configPath = '/project/rstack.config.ts'; - - it('classifies the first line of the rs fmt config-loading error', () => { - for (const errorPrefix of ['Error: ', 'Error [ERR_MODULE_NOT_FOUND]: ']) { - expect( - classifyFmtSessionError( - { - type: 1, - message: `${FMT_SESSION_ERROR_PREFIX}${errorPrefix}Cannot find package 'missing' imported from /project/rstack.config.ts\nmore detail`, - }, - root, - configPath, - ), - ).toEqual({ - configPath: 'rstack.config.ts', - cause: - "Cannot find package 'missing' imported from /project/rstack.config.ts", - }); - } - }); - - it('leaves unrelated messages and loader failures to the default UI', () => { - expect( - classifyFmtSessionError( - { type: 2, message: 'warning' }, - root, - configPath, - ), - ).toBe(undefined); - expect( - classifyFmtSessionError( - { - type: 1, - message: `${FMT_SESSION_ERROR_PREFIX}SyntaxError: Unexpected token`, - }, - root, - configPath, - ), - ).toBe(undefined); - expect( - classifyFmtSessionError( - { - type: 1, - message: "Cannot find package 'missing'", - }, - root, - configPath, - ), - ).toBe(undefined); - }); -}); - -describe('handleFmtShowMessage', () => { - it('routes other message types to information without changing state', () => { - for (const type of [4, 5] as const) { - const information = rs.fn(); - const unexpected = rs.fn(); - handleFmtShowMessage( - { type, message: 'message' }, - '/project', - undefined, - { - showInformationMessage: information, - showErrorMessage: unexpected, - showWarningMessage: unexpected, - onConfigDependency: unexpected, - onConfigError: unexpected, - }, - ); - expect(information).toHaveBeenCalledExactlyOnceWith('message'); - expect(unexpected).not.toHaveBeenCalled(); - } - }); - it('re-presents non-classified Error, Warning and Info messages without state changes', () => { - const shown: string[] = []; - let stateChanges = 0; - const handler = { - onConfigError: () => { - stateChanges += 1; - }, - onConfigDependency: () => { - stateChanges += 1; - }, - showErrorMessage: (message: string) => shown.push(`error:${message}`), - showWarningMessage: (message: string) => shown.push(`warning:${message}`), - showInformationMessage: (message: string) => - shown.push(`information:${message}`), - }; - - for (const message of [ - { type: 1 as const, message: 'bad config syntax' }, - { type: 2 as const, message: 'deprecated option' }, - { type: 3 as const, message: 'formatter ready' }, - ]) { - handleFmtShowMessage(message, '/project', '/project/rstack.config.ts', { - ...vscode.window, - onConfigDependency: handler.onConfigDependency, - onConfigError: handler.onConfigError, - }); - handleFmtShowMessage( - message, - '/project', - '/project/rstack.config.ts', - handler, - ); - } - - expect(shown).toEqual([ - 'error:bad config syntax', - 'warning:deprecated option', - 'information:formatter ready', - ]); - expect(stateChanges).toBe(0); - expect(vscode.window.showErrorMessage).toHaveBeenCalledExactlyOnceWith( - 'bad config syntax', - ); - expect(vscode.window.showWarningMessage).toHaveBeenCalledExactlyOnceWith( - 'deprecated option', - ); - expect( - vscode.window.showInformationMessage, - ).toHaveBeenCalledExactlyOnceWith('formatter ready'); - }); -}); - -describe('clearEpisodeAfterSuccessfulFormatting', () => { - it('clears the warning latch only after a request without a config failure', () => { - const episode = new NotInstalledEpisode(); - episode.observe('fmt', 'rstack.config.ts', "Cannot find package 'missing'"); - - expect(clearEpisodeAfterSuccessfulFormatting(episode, 0, 1, 0)).toBe(false); - expect(episode.active).toBe(true); - expect(clearEpisodeAfterSuccessfulFormatting(episode, 1, 1, 0)).toBe(false); - expect(episode.active).toBe(true); - - expect(clearEpisodeAfterSuccessfulFormatting(episode, 1, 1, 1)).toBe(true); - expect(episode.active).toBe(false); - expect( - episode.observe( - 'fmt', - 'rstack.config.ts', - "Cannot find package 'missing'", - ).warning, - ).toContain("Cannot find package 'missing'"); - }); -}); diff --git a/packages/vscode/tests/stacks/test/controller.test.ts b/packages/vscode/tests/stacks/test/controller.test.ts deleted file mode 100644 index 8680a0e..0000000 --- a/packages/vscode/tests/stacks/test/controller.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { beforeEach, describe, expect, it, rs } from '@rstest/core'; -import { status } from '../../../src/stacks/test/status'; - -const projects = new Map(); - -rs.mock('../../../src/stacks/test/project', () => ({ - Project: class {}, - WorkspaceManager: class { - projects = projects; - activeProjects = new Map(); - constructor() {} - refresh() {} - retryFailedProjects() {} - setRstackConfigFiles() {} - dispose() {} - }, -})); - -rs.mock('../../../src/stacks/test/diagnostics', () => ({ - RstestDiagnostics: class { - dispose() {} - }, -})); -rs.mock('../../../src/stacks/test/master', () => ({ - runningWorkers: new Set(), - warmWorkerNodePreflight: () => {}, -})); -rs.mock('../../../src/stacks/test/terminal', () => ({ - disposeTerminal: () => {}, -})); -rs.mock('../../../src/stacks/test/testRunReporter', () => ({ - RstestFileCoverage: class {}, -})); -rs.mock('../../../src/stacks/test/testTree', () => ({ - gatherTestItems: () => [], - ProjectFolder: class {}, - TestCase: class {}, - TestFile: class {}, - TestFolder: class {}, - testData: new WeakMap(), -})); - -const testController = { - items: { replace: () => {} }, - createRunProfile: () => ({ dispose: () => {} }), - dispose: () => {}, -}; - -rs.mock('vscode', () => { - const vscode = { - tests: { createTestController: () => testController }, - commands: { - registerCommand: () => ({ dispose: () => {} }), - executeCommand: () => Promise.resolve(), - }, - window: {}, - env: { clipboard: { writeText: () => Promise.resolve() } }, - TestRunProfileKind: { Run: 1, Debug: 2, Coverage: 3 }, - }; - return { ...vscode, default: vscode }; -}); - -const folder = { - uri: { - scheme: 'file', - fsPath: '/repo', - toString: () => 'file:///repo', - }, - name: 'repo', - index: 0, -} as any; - -const context = { - detection: { - foldersFor: () => [{ folder }], - forFolder: () => ({ stacks: { rstest: { rstackConfigFiles: [] } } }), - }, - output: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - }, - status: { - stack: 'rstest', - report: () => {}, - starting: () => {}, - running: () => {}, - crashed: () => {}, - versionMismatch: () => {}, - }, - onDidChangeDetection: () => ({ dispose: () => {} }), -} as any; - -describe('RstestController failed state', () => { - beforeEach(() => { - projects.clear(); - }); - - it('folds raw project failures without losing singleton status crashes', async () => { - const { createRstestController } = - await import('../../../src/stacks/test/index'); - const controller = createRstestController(); - await controller.register(context); - - const project = { hasFailedState: false }; - projects.set('file:///repo/rstest.config.ts', project); - expect(status.hasFailed()).toBe(false); - expect(controller.hasFailedState()).toBe(false); - - project.hasFailedState = true; - expect(status.hasFailed()).toBe(false); - expect(controller.hasFailedState()).toBe(true); - - project.hasFailedState = false; - expect(controller.hasFailedState()).toBe(false); - - status.crashed('worker stopped', 'singleton'); - expect(controller.hasFailedState()).toBe(true); - - controller.dispose(); - }); -}); diff --git a/packages/vscode/tests/stacks/test/project.test.ts b/packages/vscode/tests/stacks/test/project.test.ts index 4053cce..d3c4e8a 100644 --- a/packages/vscode/tests/stacks/test/project.test.ts +++ b/packages/vscode/tests/stacks/test/project.test.ts @@ -261,27 +261,6 @@ describe('Project config/cwd/package-resolution decoupling', () => { } }); - it('retries a core-missing project on a dependency pass', async () => { - const config = uri('/repo/pkg/rstest.config.ts'); - const { reporter } = createStatusRecorder(); - status.bind(reporter); - // RstestApi reports this source before rejecting with its reported marker. - status.notInstalled('@rstest/core is not installed', config.toString()); - normalizedConfigFailure = new ReportedRstestResolutionError(); - const { project } = await createProject({ sourceUri: config }); - await new Promise((resolve) => setTimeout(resolve, 0)); - const { WorkspaceManager } = - await import('../../../src/stacks/test/project'); - WorkspaceManager.prototype.retryFailedProjects.call({ - projects: new Map([['config', project]]), - } as never); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(normalizedConfigCalls).toBe(2); - expect(loggedErrors).toEqual([]); - project.dispose(); - status.unbind(); - }); - it('keeps the upstream derivation for a native rstest config', async () => { const configFile = uri(path.join('/repo', 'pkg', 'rstest.config.ts')); diff --git a/packages/vscode/tests/stacks/test/status.test.ts b/packages/vscode/tests/stacks/test/status.test.ts index 7f8b0a4..85aa55f 100644 --- a/packages/vscode/tests/stacks/test/status.test.ts +++ b/packages/vscode/tests/stacks/test/status.test.ts @@ -155,26 +155,6 @@ describe('StatusHolder failure latches', () => { expect(calls).toEqual(['report:disabled']); }); - it('exposes every raw failure state', () => { - bindRecorder(); - status.notInstalled('core missing', '/a'); - expect(status.hasFailed()).toBe(true); - expect(status.hasFailed('/a')).toBe(true); - status.installed('/a'); - expect(status.hasFailed()).toBe(false); - - status.versionMismatch('core too old', '/a'); - expect(status.hasFailed()).toBe(true); - status.versionOk('/a'); - expect(status.hasFailed()).toBe(false); - - status.crashed('worker stopped', '/b'); - expect(status.hasFailed()).toBe(true); - expect(status.hasFailed('/b')).toBe(true); - status.workerSpawned('/b'); - expect(status.hasFailed()).toBe(false); - }); - it('ranks a missing install below a mismatch and a crash', () => { const calls = bindRecorder(); status.notInstalled('core missing', '/a'); diff --git a/packages/vscode/tests/statusBar.test.ts b/packages/vscode/tests/statusBar.test.ts index d1aab9d..3b5d518 100644 --- a/packages/vscode/tests/statusBar.test.ts +++ b/packages/vscode/tests/statusBar.test.ts @@ -343,19 +343,3 @@ describe('StatusBar item', () => { expect(itemOf().backgroundColor).toBeUndefined(); }); }); - -describe('StatusBar reporter', () => { - it('runs the report hook for direct and convenience reports', () => { - const { bar } = build(); - const reports = rs.fn(); - const reporter = bar.reporterFor('fmt', reports); - - reporter.report({ kind: 'disabled', reason: 'missing' }); - reporter.starting(); - reporter.running(); - reporter.crashed('stopped'); - reporter.versionMismatch('old version'); - - expect(reports).toHaveBeenCalledTimes(5); - }); -}); From 972f8de4218b799e6213e824e56fb501611279ec Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 8 Sep 2026 15:35:17 +0800 Subject: [PATCH 42/44] fix(vscode): report raw Rstest failures to the shell, rebuild watchers after recovery --- packages/vscode/src/stacks/test/project.ts | 19 +- .../vscode/tests/stacks/test/project.test.ts | 174 +++++++++++++++++- 2 files changed, 182 insertions(+), 11 deletions(-) diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts index c500e85..8072d89 100644 --- a/packages/vscode/src/stacks/test/project.ts +++ b/packages/vscode/src/stacks/test/project.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; import type { TestInfo } from '@rstest/core'; import picomatch from 'picomatch'; import { glob } from 'tinyglobby'; @@ -606,6 +607,16 @@ export class Project implements vscode.Disposable { this.#configDependencyEpisode.clear(); this.#reportedConfigErrors.clear(); status.forget(this.configDependencyStatusSource); + if ( + this.root.fsPath !== result.root || + !isDeepStrictEqual(this.include, result.include) || + !isDeepStrictEqual(this.exclude, result.exclude) + ) { + // The watcher captures the root and matchers. Cancel its pending + // collection before discovering files with the recovered config. + this.#watch?.dispose(); + this.#watch = undefined; + } this.root = vscode.Uri.file(result.root); this.include = result.include; this.exclude = result.exclude; @@ -617,7 +628,13 @@ export class Project implements vscode.Disposable { if (this.cancellationSource.token.isCancellationRequested) return; this.configLoadFailed = true; this.#configDependencyEpisode.clear(); - if (!(error instanceof ReportedRstestResolutionError)) { + // A reported setup error can have only a toast/log, not a status. + // Publish that raw failure so the shell schedules recovery, without + // replacing an already-reported missing-core or version verdict. + if ( + !(error instanceof ReportedRstestResolutionError) || + !status.hasFailed(this.sourceUri.toString()) + ) { const cause = error instanceof Error ? error.message.split('\n', 1)[0] diff --git a/packages/vscode/tests/stacks/test/project.test.ts b/packages/vscode/tests/stacks/test/project.test.ts index d3c4e8a..fa02416 100644 --- a/packages/vscode/tests/stacks/test/project.test.ts +++ b/packages/vscode/tests/stacks/test/project.test.ts @@ -21,6 +21,15 @@ const apiCalls: { let normalizedConfigFailure: unknown; let normalizedConfigResult: NormalizedConfigResult | undefined; let normalizedConfigCalls = 0; +let pendingConfig: Promise | undefined; +let runtimeCollection = false; +let listedFiles: string[] = []; +const renderedFiles = new Set(); +const fileWatchers: { + root: string; + active: boolean; + create?: (uri: any) => void; +}[] = []; rs.mock('../../../src/stacks/test/master', () => { class RstestApi { @@ -37,6 +46,7 @@ rs.mock('../../../src/stacks/test/master', () => { // otherwise start watchers this test has no filesystem for. getNormalizedConfig() { normalizedConfigCalls += 1; + if (pendingConfig) return pendingConfig; if (normalizedConfigFailure) { return Promise.reject(normalizedConfigFailure); } @@ -45,6 +55,12 @@ rs.mock('../../../src/stacks/test/master', () => { } return new Promise(() => {}); } + async listTests(include?: string[]) { + return (include ?? listedFiles).map((testPath) => ({ + testPath, + tests: [], + })); + } dispose() {} } return { RstestApi, runningWorkers: new Set() }; @@ -66,6 +82,7 @@ const channel = { rs.mock('vscode', () => { const vscode = { Uri: { + parse: (value: string) => uri(value.slice('file://'.length)), file: (fsPath: string) => ({ scheme: 'file', fsPath, @@ -74,12 +91,17 @@ rs.mock('vscode', () => { }), }, CancellationTokenSource: class { + listeners: (() => void)[] = []; token = { isCancellationRequested: false, - onCancellationRequested: () => ({ dispose: () => {} }), + onCancellationRequested: (listener: () => void) => { + this.listeners.push(listener); + return { dispose() {} }; + }, }; cancel() { this.token.isCancellationRequested = true; + this.listeners.forEach((listener) => listener()); } dispose() {} }, @@ -94,14 +116,31 @@ rs.mock('vscode', () => { }, workspace: { fs: {}, - getConfiguration: () => ({ get: () => undefined }), - onDidChangeConfiguration: () => ({ dispose: () => {} }), - createFileSystemWatcher: () => ({ - onDidCreate: () => ({ dispose: () => {} }), - onDidChange: () => ({ dispose: () => {} }), - onDidDelete: () => ({ dispose: () => {} }), - dispose: () => {}, + getConfiguration: () => ({ + get: (key: string) => + runtimeCollection && key === 'testCaseCollectMethod' + ? 'runtime' + : undefined, }), + onDidChangeConfiguration: () => ({ dispose: () => {} }), + createFileSystemWatcher: (pattern: { base: { fsPath: string } }) => { + const watcher: (typeof fileWatchers)[number] = { + root: pattern.base.fsPath, + active: true, + }; + fileWatchers.push(watcher); + return { + onDidCreate: (listener: (uri: any) => void) => { + watcher.create = listener; + return { dispose() {} }; + }, + onDidChange: () => ({ dispose() {} }), + onDidDelete: () => ({ dispose() {} }), + dispose: () => { + watcher.active = false; + }, + }; + }, }, }; return { ...vscode, default: vscode }; @@ -130,8 +169,12 @@ const controller = { } as any; const collection = { - replace: () => {}, - add: () => {}, + replace: () => { + renderedFiles.clear(); + }, + add: (item: { id: string }) => { + renderedFiles.add(item.id); + }, forEach: () => {}, } as any; @@ -139,6 +182,11 @@ beforeEach(() => { normalizedConfigFailure = undefined; normalizedConfigResult = undefined; normalizedConfigCalls = 0; + pendingConfig = undefined; + runtimeCollection = false; + listedFiles = []; + renderedFiles.clear(); + fileWatchers.length = 0; loggedErrors.length = 0; loggedWarnings.length = 0; logger.bind(channel as never); @@ -156,6 +204,112 @@ const createProject = async (source: any) => { }; describe('Project config/cwd/package-resolution decoupling', () => { + it('reports a late resolution failure to the shell and clears it after recovery', async () => { + const gate = Promise.withResolvers(); + pendingConfig = gate.promise; + const { reporter, reported } = createStatusRecorder(); + status.bind(reporter); + const { project } = await createProject({ + sourceUri: uri('/repo/rstest.config.ts'), + }); + try { + expect(reported).toEqual([]); + gate.reject(new ReportedRstestResolutionError()); + await rs.waitUntil(() => project.configLoadFailed); + expect(reported.at(-1)).toEqual({ + kind: 'crashed', + detail: 'Cannot load rstest.config.ts: Failed to resolve rstest path', + }); + expect(loggedErrors).toEqual([]); + pendingConfig = undefined; + normalizedConfigResult = { + ok: true, + root: '/repo', + include: [], + exclude: [], + childProjects: [], + }; + await project.retryFailedConfig(); + expect(reported.at(-1)?.kind).toBe('running'); + expect(project.hasFailedState).toBe(false); + } finally { + project.dispose(); + status.unbind(); + } + }); + + it('replaces stale test files and watches the recovered root and globs only when changed', async () => { + const oldRoot = path.join('/repo', 'old'); + const newRoot = path.join('/repo', 'new'); + const oldFile = path.join(oldRoot, 'old.test.ts'); + const currentFile = path.join(newRoot, 'current.spec.ts'); + const addedFile = path.join(newRoot, 'added.spec.ts'); + runtimeCollection = true; + listedFiles = [oldFile]; + normalizedConfigResult = { + ok: true, + root: oldRoot, + include: ['**/*.test.ts'], + exclude: [], + childProjects: [], + }; + const { reporter } = createStatusRecorder(); + status.bind(reporter); + const { project } = await createProject({ + sourceUri: uri('/repo/rstest.config.ts'), + }); + const files = () => [...renderedFiles].sort(); + const createFile = (file: string) => { + for (const watcher of fileWatchers) { + if (watcher.active && file.startsWith(`${watcher.root}${path.sep}`)) + watcher.create?.(uri(file)); + } + }; + try { + await rs.waitUntil(() => files().includes(uri(oldFile).toString())); + normalizedConfigFailure = new SyntaxError('half-written dependency'); + status.crashed('worker stopped', project.sourceUri.toString()); + await project.retryFailedConfig(); + normalizedConfigFailure = undefined; + listedFiles = [currentFile]; + normalizedConfigResult = { + ok: true, + root: newRoot, + include: ['**/*.spec.ts'], + exclude: ['**/ignored.spec.ts'], + childProjects: [], + }; + await project.retryFailedConfig(); + await rs.waitUntil(() => files().includes(uri(currentFile).toString())); + expect(files()).toEqual([uri(currentFile).toString()]); + createFile(path.join(oldRoot, 'stale.test.ts')); + createFile(path.join(newRoot, 'ignored.spec.ts')); + createFile(path.join(newRoot, 'wrong.test.ts')); + createFile(addedFile); + await rs.waitUntil(() => files().includes(uri(addedFile).toString())); + expect(files()).toEqual([ + uri(addedFile).toString(), + uri(currentFile).toString(), + ]); + + // Unchanged normalization must preserve the collected items rather than + // re-listing and removing the file delivered through the watcher. + normalizedConfigResult = { + ...normalizedConfigResult, + include: [...normalizedConfigResult.include], + exclude: [...normalizedConfigResult.exclude], + }; + await project.retryFailedConfig(); + expect(files()).toEqual([ + uri(addedFile).toString(), + uri(currentFile).toString(), + ]); + } finally { + project.dispose(); + status.unbind(); + } + }); + it('re-resolves a core lost after successful config loading on a dependency pass', async () => { const config = uri('/repo/pkg/rstest.config.ts'); const { reporter, reported } = createStatusRecorder(); From 74d697b04a03b20044fe6a5684f0ed98fb66e58d Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 8 Sep 2026 15:50:23 +0800 Subject: [PATCH 43/44] refactor(vscode): simplify not-installed recovery paths --- packages/vscode/src/extension.ts | 17 ++-- packages/vscode/src/shared/displayPath.ts | 9 ++ packages/vscode/src/shared/messageLatch.ts | 4 + .../vscode/src/shared/missingDependency.ts | 10 +- packages/vscode/src/shared/notInstalled.ts | 36 ++------ packages/vscode/src/stacks/fmt/index.ts | 91 +++++++++---------- packages/vscode/src/stacks/fmt/status.ts | 4 +- packages/vscode/src/stacks/lint/Rslint.ts | 25 ++--- packages/vscode/src/stacks/lint/index.ts | 44 ++++----- .../lint/worker/ConfigTransactionAdapter.ts | 11 ++- .../lint/worker/configDependencyProtocol.ts | 11 +-- .../vscode/src/stacks/lint/worker/core.ts | 2 +- .../vscode/src/stacks/lint/worker/index.ts | 46 ++++------ packages/vscode/src/stacks/test/master.ts | 21 +++-- packages/vscode/src/stacks/test/project.ts | 11 +-- packages/vscode/src/types.ts | 6 ++ .../vscode/tests/stacks/lint/start.test.ts | 3 +- .../vscode/tests/stacks/lint/worker.test.ts | 15 ++- 18 files changed, 168 insertions(+), 198 deletions(-) create mode 100644 packages/vscode/src/shared/displayPath.ts diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index a355904..eac8eb7 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -225,12 +225,11 @@ class ExtensionShell { } private get dependencyPollNeeded(): boolean { - return ( - !this.#disposed && - [...this.#controllers.values()].some((controller) => - controller.hasFailedState(), - ) - ); + if (this.#disposed) return false; + for (const controller of this.#controllers.values()) { + if (controller.hasFailedState()) return true; + } + return false; } /** @@ -240,6 +239,7 @@ class ExtensionShell { * lockfile change; each stack therefore reuses its existing retry path. */ private syncDependencyPoll(): void { + if (this.#dependencyPollInFlight) return; if (!this.dependencyPollNeeded) { if (this.#dependencyPollTimer !== undefined) { clearTimeout(this.#dependencyPollTimer); @@ -247,10 +247,7 @@ class ExtensionShell { } return; } - if ( - this.#dependencyPollTimer !== undefined || - this.#dependencyPollInFlight - ) { + if (this.#dependencyPollTimer !== undefined) { return; } this.#dependencyPollTimer = setTimeout(() => { diff --git a/packages/vscode/src/shared/displayPath.ts b/packages/vscode/src/shared/displayPath.ts new file mode 100644 index 0000000..1c75c34 --- /dev/null +++ b/packages/vscode/src/shared/displayPath.ts @@ -0,0 +1,9 @@ +import path from 'node:path'; + +/** A folder-relative status label, without exposing paths outside the folder. */ +export const displayPath = (folderPath: string, filePath: string): string => { + const relative = path.relative(folderPath, filePath); + return relative.length > 0 && !relative.startsWith('..') + ? relative + : path.basename(filePath); +}; diff --git a/packages/vscode/src/shared/messageLatch.ts b/packages/vscode/src/shared/messageLatch.ts index a372fa8..228a4df 100644 --- a/packages/vscode/src/shared/messageLatch.ts +++ b/packages/vscode/src/shared/messageLatch.ts @@ -2,6 +2,10 @@ export class MessageLatch { #message: string | undefined; + get current(): string | undefined { + return this.#message; + } + changed(message: string): boolean { if (this.#message === message) return false; this.#message = message; diff --git a/packages/vscode/src/shared/missingDependency.ts b/packages/vscode/src/shared/missingDependency.ts index 43c202a..675d7e0 100644 --- a/packages/vscode/src/shared/missingDependency.ts +++ b/packages/vscode/src/shared/missingDependency.ts @@ -1,6 +1,12 @@ import path from 'node:path'; import { findPackageJsonUncached } from './packageResolve'; +export function isMissingDependencyCode( + code: unknown, +): code is 'ERR_MODULE_NOT_FOUND' | 'MODULE_NOT_FOUND' { + return code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND'; +} + /** * The classifier behind the "config imports a package that is not installed" * verdict of the uniform not-installed policy (AGENTS.md). Nothing in it is @@ -60,8 +66,6 @@ export function missingDependencyCauseOf( ): string | undefined { if (!(error instanceof Error)) return undefined; const { code } = error as NodeJS.ErrnoException; - if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'MODULE_NOT_FOUND') { - return undefined; - } + if (!isMissingDependencyCode(code)) return undefined; return classifyMissingDependencyMessage(error.message, resolveFrom); } diff --git a/packages/vscode/src/shared/notInstalled.ts b/packages/vscode/src/shared/notInstalled.ts index 14027c6..7ebd976 100644 --- a/packages/vscode/src/shared/notInstalled.ts +++ b/packages/vscode/src/shared/notInstalled.ts @@ -1,3 +1,4 @@ +import { MessageLatch } from './messageLatch'; import { COMMAND_CATEGORY, STACK_LABELS, @@ -61,48 +62,25 @@ export interface ConfigDependencyFailure { * the latch semantics and the user-facing words are the same. */ export class NotInstalledEpisode { - #fingerprint: string | undefined; + readonly #message = new MessageLatch(); get active(): boolean { - return this.#fingerprint !== undefined; + return this.#message.current !== undefined; } observe(stack: StackId, configPath: string, cause: string) { - const fingerprint = `config\0${configPath}\0${cause}`; - const warning = - fingerprint === this.#fingerprint - ? undefined - : formatConfigDependencyMissingLog(stack, configPath, cause); - this.#fingerprint = fingerprint; + const warning = this.#message.changed(`${configPath}\0${cause}`) + ? formatConfigDependencyMissingLog(stack, configPath, cause) + : undefined; return { reason: formatConfigDependencyMissingStatus(stack, configPath), warning, }; } - observePackage( - packageName: string, - folderName: string, - searchedFrom: string, - consequence?: string, - ): string | undefined { - const fingerprint = `package\0${packageName}\0${searchedFrom}`; - const warning = - fingerprint === this.#fingerprint - ? undefined - : formatNotInstalledLog( - packageName, - folderName, - searchedFrom, - consequence, - ); - this.#fingerprint = fingerprint; - return warning; - } - clear(): boolean { const wasActive = this.active; - this.#fingerprint = undefined; + this.#message.clear(); return wasActive; } } diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index 64b1faa..20c7089 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -14,10 +14,12 @@ import { } from 'vscode-languageclient/node'; import { RSTACK_CONFIG_GLOB } from '../../detection'; import { MessageLatch } from '../../shared/messageLatch'; +import { displayPath } from '../../shared/displayPath'; import { classifyMissingDependencyMessage } from '../../shared/missingDependency'; import { getConfiguredNodeExecutable } from '../../shared/nodeExecutableSetting'; import { NotInstalledEpisode, + formatNotInstalledLog, formatNotInstalledStatus, } from '../../shared/notInstalled'; import { @@ -162,11 +164,11 @@ class FmtFolderRuntime { #defaultErrorHandler: ErrorHandler | undefined; #stateWatcher: vscode.Disposable | undefined; #configPath: string | undefined; - readonly #packageEpisode = new NotInstalledEpisode(); + readonly #packageEpisode = new MessageLatch(); readonly #configDependencyEpisode = new NotInstalledEpisode(); readonly #startError = new MessageLatch(); - #sessionError: string | undefined; - #sessionErrorCount = 0; + readonly #sessionError = new MessageLatch(); + #failureSeq = 0; suppressedShowMessages = 0; #closing = false; #disposed = false; @@ -224,24 +226,26 @@ class FmtFolderRuntime { private handleShowMessage(message: ShowMessageParams): void { switch (message.type) { - case MessageType.Error: - if (message.message.startsWith(FMT_SESSION_ERROR_PREFIX)) { - const firstLine = message.message - .slice(FMT_SESSION_ERROR_PREFIX.length) - .split('\n', 1)[0]; - const cause = - this.#configPath === undefined - ? undefined - : classifyMissingDependencyMessage( - firstLine.replace(/^Error(?: \[[A-Z_]+\])?: /, ''), - this.folderPath, - ); - if (cause !== undefined && this.#configPath !== undefined) { - this.#sessionError = undefined; - const relative = path.relative(this.folderPath, this.#configPath); + case MessageType.Error: { + if (!message.message.startsWith(FMT_SESSION_ERROR_PREFIX)) { + void vscode.window.showErrorMessage(message.message); + break; + } + const firstLine = message.message + .slice(FMT_SESSION_ERROR_PREFIX.length) + .split('\n', 1)[0]; + const configPath = this.#configPath; + this.#failureSeq++; + if (configPath !== undefined) { + const cause = classifyMissingDependencyMessage( + firstLine.replace(/^Error(?: \[[A-Z_]+\])?: /, ''), + this.folderPath, + ); + if (cause !== undefined) { + this.#sessionError.clear(); const report = this.#configDependencyEpisode.observe( 'fmt', - relative.length > 0 ? relative : path.basename(this.#configPath), + displayPath(this.folderPath, configPath), cause, ); if (report.warning !== undefined) @@ -250,15 +254,14 @@ class FmtFolderRuntime { this.setState('disabled', report.reason); return; } - this.#sessionErrorCount++; - this.#configDependencyEpisode.clear(); - if (this.#sessionError !== firstLine) - this.context.output.error(firstLine); - this.#sessionError = firstLine; - this.setState('crashed', firstLine); } + this.#configDependencyEpisode.clear(); + if (this.#sessionError.changed(firstLine)) + this.context.output.error(firstLine); + this.setState('crashed', firstLine); void vscode.window.showErrorMessage(message.message); break; + } case MessageType.Warning: void vscode.window.showWarningMessage(message.message); break; @@ -366,12 +369,11 @@ class FmtFolderRuntime { // The shell polls while this state remains disabled. The trailing restart // hint stays as the explicit fallback if recovery is delayed. this.setState('disabled', formatNotInstalledStatus('fmt', 'rstack')); - const warning = this.#packageEpisode.observePackage( - 'rstack', - this.folder.name, - folderRoot, - ); - if (warning !== undefined) context.output.warn(warning); + if (this.#packageEpisode.changed(folderRoot)) { + context.output.warn( + formatNotInstalledLog('rstack', this.folder.name, folderRoot), + ); + } return; } this.#packageEpisode.clear(); @@ -439,8 +441,8 @@ class FmtFolderRuntime { // Initialize does not load config. Keep a known real config error // polling across restarts until formatting actually produces edits. this.setState( - this.#sessionError === undefined ? 'running' : 'crashed', - this.#sessionError, + this.#sessionError.current === undefined ? 'running' : 'crashed', + this.#sessionError.current, ); } }); @@ -469,13 +471,11 @@ class FmtFolderRuntime { if (interrupted || this.#disposed) { return; } + const message = error instanceof Error ? error.message : String(error); this.setState( 'crashed', - `the rs fmt language server failed to start: ${ - error instanceof Error ? error.message : String(error) - }`, + `the rs fmt language server failed to start: ${message}`, ); - const message = error instanceof Error ? error.message : String(error); if (this.#startError.changed(message)) { context.output.error( 'Failed to start the rs fmt language server', @@ -577,20 +577,18 @@ class FmtFolderRuntime { token, next, ) => { - const failuresBeforeRequest = - this.suppressedShowMessages + this.#sessionErrorCount; + const failuresBeforeRequest = this.#failureSeq; const edits = await next(document, options, token); const hadConfigDependency = this.#configDependencyEpisode.active; // Empty edits also signal failure; notifications during this request // must not be cleared by its edits. if ( (edits?.length ?? 0) > 0 && - failuresBeforeRequest === - this.suppressedShowMessages + this.#sessionErrorCount + failuresBeforeRequest === this.#failureSeq ) { this.#configDependencyEpisode.clear(); - const hadSessionError = this.#sessionError !== undefined; - this.#sessionError = undefined; + const hadSessionError = this.#sessionError.current !== undefined; + this.#sessionError.clear(); if ( (hadConfigDependency && this.#state === 'disabled') || (hadSessionError && this.#state === 'crashed') @@ -844,9 +842,10 @@ class FmtController implements StackController { } hasFailedState(): boolean { - return [...this.#runtimes.values()].some((runtime) => - isFailedFmtState(runtime.state), - ); + for (const runtime of this.#runtimes.values()) { + if (isFailedFmtState(runtime.state)) return true; + } + return false; } /** diff --git a/packages/vscode/src/stacks/fmt/status.ts b/packages/vscode/src/stacks/fmt/status.ts index 09109a4..3d54d0c 100644 --- a/packages/vscode/src/stacks/fmt/status.ts +++ b/packages/vscode/src/stacks/fmt/status.ts @@ -1,4 +1,4 @@ -import type { StackState } from '../../types'; +import { isFailedStackState, type StackState } from '../../types'; /** * A folder runtime's lifecycle state, as the E2E exports report it. @@ -69,7 +69,7 @@ const STATE_RANK: Readonly> = { * retried by the next pass. */ export const isFailedFmtState = (state: FmtRuntimeState): boolean => - state === 'disabled' || state === 'version-mismatch' || state === 'crashed'; + isFailedStackState(state); /** * Folds every folder runtime's state into the one report the shell shows for diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index 0623dd0..a924fd9 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -27,6 +27,7 @@ import { State, } from 'vscode-languageclient/node'; import { MessageLatch } from '../../shared/messageLatch'; +import { displayPath } from '../../shared/displayPath'; import { NotInstalledEpisode } from '../../shared/notInstalled'; import { configuredNodeBelowFloor, @@ -353,7 +354,7 @@ export class Rslint implements Disposable { } private reportRunning(): void { - if (this.configRefreshFailed) return; + if (this.configRefreshFailed || this.hasConfigDependencyFailure()) return; this.report(runningRslintStatus(this.advisory)); } @@ -384,17 +385,9 @@ export class Rslint implements Disposable { failure.configPath === this.installation.shimPath && this.bridgeConfigPath ? this.bridgeConfigPath : failure.configPath; - const relative = path.relative( - this.workspaceFolder.uri.fsPath, - physicalPath, - ); - const displayPath = - relative.length > 0 && !relative.startsWith('..') - ? relative - : path.basename(physicalPath); const report = this.configDependencyEpisode.observe( 'rslint', - displayPath, + displayPath(this.workspaceFolder.uri.fsPath, physicalPath), failure.cause, ); if (report.warning !== undefined) { @@ -516,7 +509,7 @@ export class Rslint implements Disposable { detail: 'the Rslint language server stopped', }); } else if (event.newState === State.Running) { - if (!this.hasConfigDependencyFailure()) this.reportRunning(); + this.reportRunning(); } }); @@ -576,7 +569,7 @@ export class Rslint implements Disposable { ); } this.logger.info('Rslint language client started successfully'); - if (!this.hasConfigDependencyFailure()) this.reportRunning(); + this.reportRunning(); } catch (error: unknown) { // Keep the initialized runtime available for configRefresh retries. // Rethrowing this classified rejection would make RuntimeManager close @@ -684,13 +677,7 @@ export class Rslint implements Disposable { this.configRefreshFailed = false; try { await client.sendRequest('rslint/configRefresh', { reason }); - if ( - wasFailed && - !this.configRefreshFailed && - !this.hasConfigDependencyFailure() && - this.isRunning() - ) - this.reportRunning(); + if (wasFailed && this.isRunning()) this.reportRunning(); } catch (error) { // The worker verdict already surfaced this rejection as a real config // error. Keep the live runtime for config edits without duplicate logs diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index 5f84f42..4639551 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -1,4 +1,5 @@ import vscode from 'vscode'; +import { isFailedStackState } from '../../types'; import type { DetectionSnapshot, StackContext, @@ -94,13 +95,11 @@ class RslintController implements StackController { this.#subscriptions.push( context.onDidChangeDetection((snapshot) => { this.#snapshot = snapshot; + this.pruneDepartedFolders(); for (const runtime of this.#runtimes.values()) { runtime.setBridgeConfigPath( snapshot.forFolder(runtime.workspaceFolder)?.rootRstackConfigPath, ); - } - this.pruneDepartedFolders(); - for (const runtime of this.#runtimes.values()) { void runtime.retryConfigDependency()?.catch((error: unknown) => { if (!runtime.hasConfigDependencyFailure()) { this.#logger?.error( @@ -208,12 +207,8 @@ class RslintController implements StackController { const previous = this.#folderStates .get(folderKeyOf(workspaceFolder)) ?.failures.get(document.uri.toString()); - if (missing !== undefined) { - if ( - previous?.kind !== 'disabled' || - previous.reason !== - (status.kind === 'disabled' ? status.reason : undefined) - ) { + if (JSON.stringify(previous) !== JSON.stringify(attributed)) { + if (missing !== undefined) { const warning = formatNotInstalledLog( missing, workspaceFolder.name, @@ -221,17 +216,12 @@ class RslintController implements StackController { `${document.uri} ${keeping ? `keeps ${keeping}` : 'will not lint'} until it is installed`, ); logger.warn(warning); + } else { + logger.error( + formatCoreSelectionFailure(document.uri.toString(), keeping), + error, + ); } - } else if ( - previous?.kind !== attributed.kind || - !('detail' in previous) || - !('detail' in attributed) || - previous.detail !== attributed.detail - ) { - logger.error( - formatCoreSelectionFailure(document.uri.toString(), keeping), - error, - ); } // Last-good semantics: the document keeps whatever runtime it had. // The failure is still the folder's worst news, so it is folded in @@ -392,14 +382,14 @@ class RslintController implements StackController { } hasFailedState(): boolean { - return [...this.#folderStates.values()].some((states) => - [...states.runtimes.values(), ...states.failures.values()].some( - (state) => - state.kind === 'disabled' || - state.kind === 'crashed' || - state.kind === 'version-mismatch', - ), - ); + for (const states of this.#folderStates.values()) { + for (const bucket of [states.runtimes, states.failures]) { + for (const state of bucket.values()) { + if (isFailedStackState(state.kind)) return true; + } + } + } + return false; } private async closeRuntimeManager(): Promise { diff --git a/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts b/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts index 1879f24..4576c31 100644 --- a/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts +++ b/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts @@ -8,7 +8,10 @@ import type { LoadConfigsRequest, LoadConfigsResponse, } from '@rslint/core/config-loader'; -import { classifyMissingDependencyMessage } from '../../../shared/missingDependency'; +import { + classifyMissingDependencyMessage, + isMissingDependencyCode, +} from '../../../shared/missingDependency'; import type { ConfigDependencyFailure } from '../../../shared/notInstalled'; interface ConfigDependencyObserver { @@ -116,6 +119,9 @@ export class LspConfigTransactionAdapter { ); this.assertActive(); throwIfAborted(signal); + if (!response.results.some((result) => result.status === 'failed')) { + return response; + } let classified = false; return { ...response, @@ -124,8 +130,7 @@ export class LspConfigTransactionAdapter { const candidate = request.candidates[index]; const cause = candidate !== undefined && - (result.error.code === 'ERR_MODULE_NOT_FOUND' || - result.error.code === 'MODULE_NOT_FOUND') + isMissingDependencyCode(result.error.code) ? classifyMissingDependencyMessage( result.error.message, this.configDependencyObserver.resolveFrom(candidate), diff --git a/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts b/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts index e4296bd..4d9dc49 100644 --- a/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts +++ b/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts @@ -1,4 +1,5 @@ import type { ConfigDependencyFailure } from '../../../shared/notInstalled'; +import { isRecord } from './core'; export const CONFIG_DEPENDENCY_STATUS_NOTIFICATION = 'rstack/rslintConfigDependency'; @@ -10,12 +11,10 @@ export type ConfigDependencyStatusNotification = /** Shared with the editor's startup retry; this module stays vscode-free. */ export function isConfigSourceChangeDuringTransaction(error: unknown): boolean { - if (error === null || typeof error !== 'object' || Array.isArray(error)) - return false; - const value = error as Record; + if (!isRecord(error)) return false; return ( - value.code === 'CONFIG_CHANGED_DURING_LOAD' || - (typeof value.message === 'string' && - value.message.includes('config changed while')) + error.code === 'CONFIG_CHANGED_DURING_LOAD' || + (typeof error.message === 'string' && + error.message.includes('config changed while')) ); } diff --git a/packages/vscode/src/stacks/lint/worker/core.ts b/packages/vscode/src/stacks/lint/worker/core.ts index 82e1e96..80c69b1 100644 --- a/packages/vscode/src/stacks/lint/worker/core.ts +++ b/packages/vscode/src/stacks/lint/worker/core.ts @@ -37,7 +37,7 @@ export interface CoreInstallation { createPluginLintHost: typeof createPluginLintHost; } -function isRecord(value: unknown): value is Record { +export function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } diff --git a/packages/vscode/src/stacks/lint/worker/index.ts b/packages/vscode/src/stacks/lint/worker/index.ts index e35ad93..772fd22 100644 --- a/packages/vscode/src/stacks/lint/worker/index.ts +++ b/packages/vscode/src/stacks/lint/worker/index.ts @@ -25,6 +25,7 @@ import { logger } from './logger'; import { CONFIG_DEPENDENCY_STATUS_NOTIFICATION, isConfigSourceChangeDuringTransaction, + type ConfigDependencyStatusNotification, } from './configDependencyProtocol'; import type { ConfigDependencyFailure } from '../../../shared/notInstalled'; @@ -141,8 +142,7 @@ function forwardRequest( interface EditorProxyOptions { readonly protocolVersion: number; readonly configPath?: string; - takeConfigDependencyFailure(): ConfigDependencyFailure | undefined; - takeConfigError(): string | undefined; + takeConfigStatus(): ConfigDependencyStatusNotification; observeRefresh(reason: unknown): void; requestStop(request: StopRequest): void; } @@ -166,37 +166,28 @@ export function registerEditorProxy( ), token, ); - const failure = options.takeConfigDependencyFailure(); - const configError = options.takeConfigError(); await editorConnection.sendNotification( CONFIG_DEPENDENCY_STATUS_NOTIFICATION, - configError !== undefined - ? { kind: 'error', message: configError } - : failure - ? { kind: 'missing', failure } - : { kind: 'ok' }, + options.takeConfigStatus(), ); return result; } catch (error) { - const failure = options.takeConfigDependencyFailure(); - const configError = options.takeConfigError(); + const status = options.takeConfigStatus(); // The editor already retries this transaction race during startup. // Leave its rejection untouched and send no premature failure (or // success) verdict; the startup catch reports once if retries exhaust. if (isConfigSourceChangeDuringTransaction(error)) throw error; await editorConnection.sendNotification( CONFIG_DEPENDENCY_STATUS_NOTIFICATION, - configError !== undefined - ? { kind: 'error', message: configError } - : failure - ? { kind: 'missing', failure } - : { - kind: 'error', - message: (error instanceof Error - ? error.message - : String(error) - ).split('\n', 1)[0], - }, + status.kind === 'ok' + ? { + kind: 'error', + message: (error instanceof Error + ? error.message + : String(error) + ).split('\n', 1)[0], + } + : status, ); throw error; } @@ -268,15 +259,14 @@ export async function runLintWorker( registerEditorProxy(editorConnection, goConnection, { protocolVersion: installation.protocolVersion, configPath: options.configPath, - takeConfigDependencyFailure: () => { + takeConfigStatus: () => { const failure = configDependencyFailure; - configDependencyFailure = undefined; - return failure; - }, - takeConfigError: () => { const message = configError; + configDependencyFailure = undefined; configError = undefined; - return message; + if (message !== undefined) return { kind: 'error', message }; + if (failure !== undefined) return { kind: 'missing', failure }; + return { kind: 'ok' }; }, observeRefresh: (reason) => fingerprinter.observeRefresh(reason), requestStop, diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index 4f7b4c3..4be0302 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -20,7 +20,7 @@ import { import { CONFIG_SECTION, getConfigValue } from './config'; import { MessageLatch } from '../../shared/messageLatch'; import { - NotInstalledEpisode, + formatNotInstalledLog, formatNotInstalledStatus, } from '../../shared/notInstalled'; import { @@ -134,7 +134,7 @@ export class RstestApi { // `createChildProcess`. private disposed = false; private lastResolvedRstestPath?: string; - private readonly coreMissingEpisode = new NotInstalledEpisode(); + private readonly coreMissingEpisode = new MessageLatch(); private readonly unsupportedCoreMessage = new MessageLatch(); private readonly resolutionErrorMessage = new MessageLatch(); @@ -340,13 +340,16 @@ export class RstestApi { // out plus one warn line — the normal state of a repository whose // dependencies are not installed yet, never a notification. private reportCoreNotInstalled(searchedFrom: string): void { - const warning = this.coreMissingEpisode.observePackage( - '@rstest/core', - this.workspace.name, - searchedFrom, - CORE_NOT_INSTALLED_CONSEQUENCE, - ); - if (warning !== undefined) logger.warn(warning); + if (this.coreMissingEpisode.changed(searchedFrom)) { + logger.warn( + formatNotInstalledLog( + '@rstest/core', + this.workspace.name, + searchedFrom, + CORE_NOT_INSTALLED_CONSEQUENCE, + ), + ); + } status.notInstalled(CORE_NOT_INSTALLED_STATUS, this.statusSource); } diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts index 8072d89..10e044a 100644 --- a/packages/vscode/src/stacks/test/project.ts +++ b/packages/vscode/src/stacks/test/project.ts @@ -657,16 +657,11 @@ export class Project implements vscode.Disposable { } // Let the manager settle its tree even when a config fails to load. this.onConfigResolved?.(); + }) + .finally(() => { + if (this.#configLoad === pending) this.#configLoad = undefined; }); this.#configLoad = pending; - void pending.then( - () => { - if (this.#configLoad === pending) this.#configLoad = undefined; - }, - () => { - if (this.#configLoad === pending) this.#configLoad = undefined; - }, - ); return pending; } diff --git a/packages/vscode/src/types.ts b/packages/vscode/src/types.ts index be4cce2..baacaa3 100644 --- a/packages/vscode/src/types.ts +++ b/packages/vscode/src/types.ts @@ -56,6 +56,12 @@ export type StackState = | { readonly kind: 'crashed'; readonly detail: string } | { readonly kind: 'version-mismatch'; readonly detail: string }; +/** Raw runtime failures that need dependency recovery, not shell gate states. */ +export const isFailedStackState = ( + kind: StackState['kind'] | 'stopped', +): boolean => + kind === 'disabled' || kind === 'crashed' || kind === 'version-mismatch'; + /** * The seam every stack reports through instead of owning a status bar item * (the status-aggregation adaptation). The shell aggregates all three diff --git a/packages/vscode/tests/stacks/lint/start.test.ts b/packages/vscode/tests/stacks/lint/start.test.ts index 8d76a3d..de9c19e 100644 --- a/packages/vscode/tests/stacks/lint/start.test.ts +++ b/packages/vscode/tests/stacks/lint/start.test.ts @@ -100,8 +100,7 @@ rs.mock('vscode-languageclient/node', () => ({ } as never, { protocolVersion: 2, - takeConfigDependencyFailure: () => undefined, - takeConfigError: () => undefined, + takeConfigStatus: () => ({ kind: 'ok' }), observeRefresh() {}, requestStop() {}, }, diff --git a/packages/vscode/tests/stacks/lint/worker.test.ts b/packages/vscode/tests/stacks/lint/worker.test.ts index e720c3d..228ab00 100644 --- a/packages/vscode/tests/stacks/lint/worker.test.ts +++ b/packages/vscode/tests/stacks/lint/worker.test.ts @@ -170,12 +170,13 @@ describe('lint worker config refresh', () => { registerEditorProxy(workerConnection, goConnection, { protocolVersion: 2, configPath, - takeConfigDependencyFailure: () => { + takeConfigStatus: () => { const failure = activeFailure; activeFailure = undefined; - return failure; + if (failure !== undefined) + return { kind: 'missing' as const, failure }; + return { kind: 'ok' as const }; }, - takeConfigError: () => undefined, observeRefresh: (reason) => observedReasons.push(reason), requestStop: () => undefined, }); @@ -309,8 +310,12 @@ describe('lint worker config dependency classification', () => { let refresh!: (method: string, params: unknown) => Promise; const options = { protocolVersion: 3, - takeConfigDependencyFailure: () => missing, - takeConfigError: () => configError, + takeConfigStatus: () => + configError !== undefined + ? { kind: 'error' as const, message: configError } + : missing !== undefined + ? { kind: 'missing' as const, failure: missing } + : { kind: 'ok' as const }, observeRefresh() {}, requestStop() {}, }; From 27ec8f5d85024c58b1e6d7d7de0aa38597150b34 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 8 Sep 2026 15:55:05 +0800 Subject: [PATCH 44/44] fix(vscode): recollect Rstest files after unchanged-config recovery, latch resolution logs --- packages/vscode/src/stacks/test/master.ts | 16 +++++--- packages/vscode/src/stacks/test/project.ts | 5 +++ .../vscode/tests/stacks/test/master.test.ts | 37 +++++++++++++++++ .../vscode/tests/stacks/test/project.test.ts | 41 ++++++++++++++++++- 4 files changed, 92 insertions(+), 7 deletions(-) diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index 4be0302..00a98af 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -353,9 +353,10 @@ export class RstestApi { status.notInstalled(CORE_NOT_INSTALLED_STATUS, this.statusSource); } - private reportResolutionError(message: string): void { - if (!this.resolutionErrorMessage.changed(message)) return; + private reportResolutionError(message: string): boolean { + if (!this.resolutionErrorMessage.changed(message)) return false; vscode.window.showErrorMessage(message); + return true; } // Returns '' when resolution failed. Every such branch has already reported @@ -379,10 +380,13 @@ export class RstestApi { paths: [this.cwd], }); } catch (e) { - this.reportResolutionError( - 'Failed to resolve @rstest/core/package.json. Please upgrade @rstest/core to the latest version.', - ); - logger.error('Failed to resolve @rstest/core/package.json', e); + if ( + this.reportResolutionError( + 'Failed to resolve @rstest/core/package.json. Please upgrade @rstest/core to the latest version.', + ) + ) { + logger.error('Failed to resolve @rstest/core/package.json', e); + } return ''; } } else { diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts index 10e044a..bebb5bc 100644 --- a/packages/vscode/src/stacks/test/project.ts +++ b/packages/vscode/src/stacks/test/project.ts @@ -562,6 +562,7 @@ export class Project implements vscode.Disposable { readonly rstestResolutionDir: string; readonly isBridge: boolean; #watch?: vscode.Disposable; + #collectionFailed = false; #configLoad: Promise | undefined; readonly #configDependencyEpisode = new NotInstalledEpisode(); readonly #reportedConfigErrors = new Set(); @@ -608,6 +609,7 @@ export class Project implements vscode.Disposable { this.#reportedConfigErrors.clear(); status.forget(this.configDependencyStatusSource); if ( + this.#collectionFailed || this.root.fsPath !== result.root || !isDeepStrictEqual(this.include, result.include) || !isDeepStrictEqual(this.exclude, result.exclude) @@ -820,6 +822,7 @@ export class Project implements vscode.Disposable { if (token.isCancellationRequested) return; + this.#collectionFailed = false; const visited = new Set(); for (const { uri, tests } of files) { this.updateOrCreateFile(uri, tests); @@ -856,6 +859,7 @@ export class Project implements vscode.Disposable { }) .catch((error) => { if (!token.isCancellationRequested) { + this.#collectionFailed = true; logUnlessReported( 'Failed to update runtime test list', error, @@ -892,6 +896,7 @@ export class Project implements vscode.Disposable { }); } catch (error) { if (!token.isCancellationRequested) { + this.#collectionFailed = true; logUnlessReported('Failed to collect test files', error); } } finally { diff --git a/packages/vscode/tests/stacks/test/master.test.ts b/packages/vscode/tests/stacks/test/master.test.ts index 6b6fb31..802e0e4 100644 --- a/packages/vscode/tests/stacks/test/master.test.ts +++ b/packages/vscode/tests/stacks/test/master.test.ts @@ -6,6 +6,7 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, rs } from '@rstest/core'; import { logger } from '../../../src/stacks/test/logger'; import { RstestApi } from '../../../src/stacks/test/master'; +import { nodeRequire } from '../../../src/stacks/test/nodeRequire'; import { type NodeProbe, configuredNodeBelowFloor, @@ -392,6 +393,42 @@ describe('RstestApi with an unresolvable rstestPackagePath', () => { } }); + it('deduplicates package metadata errors and toasts together until recovery', () => { + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'rstest-log-')), + ); + const installed = writeCoreInstall(root); + const metadata = path.join(installed.packageDir, 'package.json'); + settings.rstestPackagePath = metadata; + loggedErrors.length = 0; + const original = nodeRequire.resolve; + let broken = true; + const spy = rs + .spyOn(nodeRequire, 'resolve') + .mockImplementation((specifier, options) => { + if (broken && specifier === metadata) + throw new Error('incomplete package metadata'); + return original(specifier, options); + }); + const api = createApi(root); + const resolve = () => (api as any).resolveRstestPath() as string; + try { + expect(resolve()).toBe(''); + expect(resolve()).toBe(''); + expect(shownMessages).toHaveLength(1); + expect(loggedErrors).toHaveLength(1); + broken = false; + expect(resolve()).toBe(installed.entry); + broken = true; + expect(resolve()).toBe(''); + expect(shownMessages).toHaveLength(2); + expect(loggedErrors).toHaveLength(2); + } finally { + spy.mockRestore(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it('should notify for a terminal run', () => { createApi().runInTerminal({}); expect(shownMessages).toHaveLength(1); diff --git a/packages/vscode/tests/stacks/test/project.test.ts b/packages/vscode/tests/stacks/test/project.test.ts index fa02416..a03c40e 100644 --- a/packages/vscode/tests/stacks/test/project.test.ts +++ b/packages/vscode/tests/stacks/test/project.test.ts @@ -23,6 +23,7 @@ let normalizedConfigResult: NormalizedConfigResult | undefined; let normalizedConfigCalls = 0; let pendingConfig: Promise | undefined; let runtimeCollection = false; +let collectionFailure: unknown; let listedFiles: string[] = []; const renderedFiles = new Set(); const fileWatchers: { @@ -37,7 +38,7 @@ rs.mock('../../../src/stacks/test/master', () => { _workspace: unknown, cwd: string, configFilePath: string, - _project: unknown, + private project: { sourceUri: { toString(): string } }, rstestResolutionDir: string, ) { apiCalls.push({ cwd, configFilePath, rstestResolutionDir }); @@ -56,6 +57,13 @@ rs.mock('../../../src/stacks/test/master', () => { return new Promise(() => {}); } async listTests(include?: string[]) { + if (collectionFailure) { + status.notInstalled( + 'core disappeared', + this.project.sourceUri.toString(), + ); + throw collectionFailure; + } return (include ?? listedFiles).map((testPath) => ({ testPath, tests: [], @@ -184,6 +192,7 @@ beforeEach(() => { normalizedConfigCalls = 0; pendingConfig = undefined; runtimeCollection = false; + collectionFailure = undefined; listedFiles = []; renderedFiles.clear(); fileWatchers.length = 0; @@ -204,6 +213,36 @@ const createProject = async (source: any) => { }; describe('Project config/cwd/package-resolution decoupling', () => { + it('collects files missed during an outage after unchanged-config recovery', async () => { + runtimeCollection = true; + collectionFailure = new ReportedRstestResolutionError('core disappeared'); + normalizedConfigResult = { + ok: true, + root: '/repo', + include: ['**/*.test.ts'], + exclude: [], + childProjects: [], + }; + const { reporter } = createStatusRecorder(); + status.bind(reporter); + const { project } = await createProject({ + sourceUri: uri('/repo/rstest.config.ts'), + }); + try { + await rs.waitUntil(() => status.hasFailed(project.sourceUri.toString())); + expect(renderedFiles.size).toBe(0); + collectionFailure = undefined; + listedFiles = ['/repo/outage.test.ts']; + await project.retryFailedConfig(); + await rs.waitUntil(() => + renderedFiles.has(uri(listedFiles[0]!).toString()), + ); + } finally { + project.dispose(); + status.unbind(); + } + }); + it('reports a late resolution failure to the shell and clears it after recovery', async () => { const gate = Promise.withResolvers(); pendingConfig = gate.promise;