From c3285c00e8d3ccf7aa3598a049ca95d17d5891a0 Mon Sep 17 00:00:00 2001 From: e-filchenko-bosh Date: Thu, 10 Sep 2026 12:23:06 +0300 Subject: [PATCH] Add Language Services Supervisor --- .vscode-test.mjs | 9 +- src/aspectValidation.ts | 42 ++- src/extension.ts | 130 +++---- src/languageClient.ts | 88 +++-- src/languageServer.ts | 79 ++++- src/languageServicesSupervisor.ts | 368 ++++++++++++++++++++ src/test/aspectValidationController.test.ts | 74 ++++ src/test/languageClient.test.ts | 53 +++ src/test/languageServer.test.ts | 124 +++++++ src/test/languageServicesSupervisor.test.ts | 296 ++++++++++++++++ src/test/validationTestHarness.ts | 14 +- 11 files changed, 1145 insertions(+), 132 deletions(-) create mode 100644 src/languageServicesSupervisor.ts create mode 100644 src/test/languageClient.test.ts create mode 100644 src/test/languageServer.test.ts create mode 100644 src/test/languageServicesSupervisor.test.ts diff --git a/.vscode-test.mjs b/.vscode-test.mjs index d320b89..6ac11c0 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -1,9 +1,14 @@ import { defineConfig } from '@vscode/test-cli'; +const runId = process.env.VSCODE_TEST_RUN_ID ?? 'default'; + export default defineConfig({ files: 'out/test/**/*.test.js', + useInstallation: process.env.VSCODE_EXECUTABLE_PATH + ? { fromPath: process.env.VSCODE_EXECUTABLE_PATH } + : undefined, launchArgs: [ - '--user-data-dir=/tmp/extension-vscode-test-user-data', - '--extensions-dir=/tmp/extension-vscode-test-extensions' + `--user-data-dir=/tmp/extension-vscode-test-user-data-${runId}`, + `--extensions-dir=/tmp/extension-vscode-test-extensions-${runId}` ], }); diff --git a/src/aspectValidation.ts b/src/aspectValidation.ts index 377c354..ef47a29 100644 --- a/src/aspectValidation.ts +++ b/src/aspectValidation.ts @@ -12,7 +12,7 @@ */ import * as vscode from 'vscode'; -import type { ExtensionLogger } from './outputChannel'; +import type {ExtensionLogger} from './outputChannel'; export const VALIDATE_DOCUMENT_REQUEST = 'turtle/aspectValidation/validateDocument'; export const VALIDATE_DOCUMENT_COMMAND = 'semantic-models.validateDocumentNow'; @@ -26,13 +26,18 @@ export interface TurtleDiagnostic { } export interface DiagnosticReport { - diagnostics: Array + diagnostics: Array; } export interface RequestClient { sendRequest(method: string, params?: unknown): Thenable; } +type RequestClientBinding = { + client: RequestClient; + generation: number; +}; + export interface ValidationWindow { showInformationMessage(message: string): Thenable; showWarningMessage(message: string): Thenable; @@ -51,15 +56,19 @@ export interface ValidationWorkspace { export type ValidationOutputChannel = ExtensionLogger; export class AspectValidationController { + private binding: RequestClientBinding; + constructor( - private client: RequestClient, + client: RequestClient, private readonly window: ValidationWindow, private readonly workspace: ValidationWorkspace, private readonly outputChannel: ValidationOutputChannel, - ) {} + ) { + this.binding = {client, generation: 0}; + } - setClient(client: RequestClient): void { - this.client = client; + setClient(client: RequestClient, generation = this.binding.generation + 1): void { + this.binding = {client, generation}; } register(context: vscode.ExtensionContext): void { @@ -68,6 +77,9 @@ export class AspectValidationController { const editor = vscode.window.activeTextEditor; await this.validateDocument(editor?.document, 'manual'); }), + this.workspace.onDidSaveTextDocument(document => { + void this.validateDocument(document, 'save'); + }), ); } @@ -82,27 +94,35 @@ export class AspectValidationController { return undefined; } + const binding = this.binding; const request = () => - this.client.sendRequest(VALIDATE_DOCUMENT_REQUEST, { + binding.client.sendRequest(VALIDATE_DOCUMENT_REQUEST, { uri: document.uri.toString(), reason: trigger, }); - return this.runValidation(`document:${document.uri.toString()}`, 'Aspect model validation', trigger, request); + return this.runValidation('Aspect model validation', trigger, binding, request); } private async runValidation( - key: string, title: string, trigger: AspectValidationTrigger, + binding: RequestClientBinding, request: () => Thenable, ): Promise { try { const result = await this.runWithProgress(title, trigger, request); - + if (this.binding !== binding) { + this.outputChannel.info(`[validation] Ignoring stale result from generation ${binding.generation}.`); + return undefined; + } await this.showSummary(result, trigger); return result; } catch (error) { + if (this.binding !== binding) { + this.outputChannel.info(`[validation] Ignoring stale failure from generation ${binding.generation}.`); + return undefined; + } await this.handleFailure(error, trigger); return undefined; } @@ -164,7 +184,7 @@ export class AspectValidationController { if (violationCount === 0) { return 'Aspect validation completed without issues.'; } - return result.diagnostics.map(x => x.message).join(", "); + return result.diagnostics.map(x => x.message).join(', '); } private toFailureMessage(error: unknown): string { diff --git a/src/extension.ts b/src/extension.ts index 34f28fc..df8ccd5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -12,13 +12,14 @@ */ import * as vscode from 'vscode'; -import { AspectValidationController, RequestClient } from './aspectValidation'; -import { TurtleLanguageServer } from './languageServer'; -import { SammCliDownloader } from './sammCliDownloader'; -import { TurtleExtensionSettings } from './settings'; -import { TurtleLanguageClient } from './languageClient'; -import { GitHubRepositoryValidator } from './githubRepositoryValidator'; -import type { ExtensionLogger } from './outputChannel'; +import {AspectValidationController, RequestClient} from './aspectValidation'; +import {TurtleLanguageServer} from './languageServer'; +import {SammCliDownloader} from './sammCliDownloader'; +import {TurtleExtensionSettings} from './settings'; +import {TurtleLanguageClient} from './languageClient'; +import {GitHubRepositoryValidator} from './githubRepositoryValidator'; +import type {ExtensionLogger} from './outputChannel'; +import {LanguageServicesSupervisor, LanguageServicesMode, TerminalAction} from './languageServicesSupervisor'; const SELECT_EXECUTABLE_COMMAND = 'semantic-models.selectSammCliExecutable'; const SELECT_EXECUTABLE_TITLE = 'Select SAMM CLI Executable'; @@ -26,28 +27,27 @@ const RESTART_LANGUAGE_SERVICES_COMMAND = 'semantic-models.restartLanguageServic const GITHUB_REPOSITORY_VALIDATION_DEBOUNCE_MS = 2000; let settings: TurtleExtensionSettings; -let languageServer: TurtleLanguageServer | undefined; -let languageClient: TurtleLanguageClient; let aspectValidationController: AspectValidationController; let sammCliDownloader: SammCliDownloader; let gitHubRepositoryValidator: GitHubRepositoryValidator; let outputChannel: ExtensionLogger; +let logOutputChannel: vscode.LogOutputChannel; let context: vscode.ExtensionContext; -let restartChain: Promise = Promise.resolve(); +let languageServicesSupervisor: LanguageServicesSupervisor; let githubRepositoryValidationTimeout: ReturnType | undefined; export async function activate(ctx: vscode.ExtensionContext): Promise { context = ctx; - const logOutputChannel = vscode.window.createOutputChannel('RDF/Turtle and SAMM Aspect Models Language Server', { log: true }); + logOutputChannel = vscode.window.createOutputChannel('RDF/Turtle and SAMM Aspect Models Language Server', {log: true}); context.subscriptions.push(logOutputChannel); outputChannel = logOutputChannel; settings = new TurtleExtensionSettings(); sammCliDownloader = new SammCliDownloader(context, settings, outputChannel); gitHubRepositoryValidator = new GitHubRepositoryValidator(outputChannel); - languageClient = new TurtleLanguageClient(outputChannel, settings.getSammCliLspServerPort(), settings.getLanguageClientTraceLevel()); aspectValidationController = new AspectValidationController(createUnavailableClient(), vscode.window, vscode.workspace, outputChannel); aspectValidationController.register(context); + languageServicesSupervisor = createLanguageServicesSupervisor(); context.subscriptions.push( vscode.languages.setLanguageConfiguration('turtle', { @@ -56,11 +56,11 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { beforeText: /\.\s*$/, action: { indentAction: vscode.IndentAction.Outdent, - appendText: "\n" + appendText: '\n', }, }, ], - }) + }), ); context.subscriptions.push( @@ -68,16 +68,16 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { await selectSammCliExecutable(); }), vscode.commands.registerCommand(RESTART_LANGUAGE_SERVICES_COMMAND, async () => { - await queueLanguageServicesRestart('Manual restart command'); + await languageServicesSupervisor.restart('Manual restart command'); }), vscode.workspace.onDidChangeConfiguration((e: vscode.ConfigurationChangeEvent) => { if (e.affectsConfiguration('semantic-models.languageServerSettings')) { - void queueLanguageServicesRestart('Configuration change detected'); + void languageServicesSupervisor.restart('Configuration change detected'); } if (e.affectsConfiguration('semantic-models.modelResolution')) { scheduleGithubRepositoryValidation(); } - }) + }), ); void validateConfiguredGithubRepositories(); @@ -93,7 +93,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { return; } - void queueLanguageServicesRestart('extension activation'); + void languageServicesSupervisor.start('extension activation'); if (settings.sammCliAutoUpdateIsEnabled() && settings.isEmbeddedLanguageServerStartEnabled()) { sammCliDownloader.checkForSammCliUpdates().catch(error => { @@ -119,60 +119,46 @@ function scheduleGithubRepositoryValidation(): void { }, GITHUB_REPOSITORY_VALIDATION_DEBOUNCE_MS); } -function queueLanguageServicesRestart(reason: string): Promise { - restartChain = restartChain - .then(() => restartLanguageServices(reason)) - .catch(async error => { - const selection = await vscode.window.showErrorMessage(`Failed to start required language services: ${error instanceof Error ? error.message : String(error)}`, 'Configure SAMM CLI Executable', 'Check Extension Settings'); - if (selection === 'Configure SAMM CLI Executable') { - void selectSammCliExecutable(); - } else if (selection === 'Check Extension Settings') { - void vscode.commands.executeCommand('workbench.action.openSettings', 'semantic-models.languageServerSettings'); - } - }); - - return restartChain; -} - -async function startLanguageServer(): Promise { - const executablePath = settings.getSammCliPath(); - languageServer = new TurtleLanguageServer(context, outputChannel, executablePath, settings.getSammCliLspServerPort()); - await languageServer.start(); +function createLanguageServicesSupervisor(): LanguageServicesSupervisor { + return new LanguageServicesSupervisor({ + configuration: () => ({ + mode: settings.isEmbeddedLanguageServerStartEnabled() ? 'embedded' : 'external', + port: settings.getSammCliLspServerPort(), + }), + createServer: configuration => new TurtleLanguageServer(context, outputChannel, settings.getSammCliPath(), configuration.port), + createClient: configuration => new TurtleLanguageClient(outputChannel, configuration.port, settings.getLanguageClientTraceLevel()), + setRequestClient: (client, generation) => aspectValidationController.setClient(client, generation), + unavailableClient: createUnavailableClient, + logger: outputChannel, + notifyTerminal: notifyTerminalRecoveryFailure, + showOutput: () => logOutputChannel.show(true), + showSettings: () => { + void vscode.commands.executeCommand('workbench.action.openSettings', 'semantic-models.languageServerSettings'); + }, + }); } -async function stopLanguageServer(): Promise { - if (!languageServer) { - return; +async function notifyTerminalRecoveryFailure(mode: LanguageServicesMode): Promise { + const guidance = + mode === 'external' + ? 'Start or check the separately managed language server, then retry.' + : 'Check the configured SAMM CLI executable and extension settings.'; + const selection = await vscode.window.showErrorMessage( + `The Turtle language server could not be recovered after four attempts. ${guidance}`, + 'Restart Now', + 'Show Language Server Output', + 'Check Extension Settings', + ); + if (selection === 'Restart Now') { + return 'restart'; } - - await languageServer.stop(); - languageServer = undefined; -} - -async function restartLanguageServices(reason: string): Promise { - outputChannel.info(`Restarting language services (${reason}).`); - - aspectValidationController.setClient(createUnavailableClient()); - await languageClient.disconnect(); - await stopLanguageServer(); - - try { - if (!settings.isEmbeddedLanguageServerStartEnabled()) { - outputChannel.info('SAMM CLI LSP activation is disabled. Assuming an external server is already running.'); - } else { - await startLanguageServer(); - } - } catch (error) { - await stopLanguageServer().catch(() => undefined); - aspectValidationController.setClient(createUnavailableClient()); - - throw error; + if (selection === 'Show Language Server Output') { + return 'output'; } - - const nextClient = new TurtleLanguageClient(outputChannel, settings.getSammCliLspServerPort(), settings.getLanguageClientTraceLevel()); - await nextClient.connect(); - languageClient = nextClient; - aspectValidationController.setClient(nextClient); + if (selection === 'Check Extension Settings') { + return 'settings'; + } + return undefined; } type SammCliQuickPickItem = vscode.QuickPickItem & { @@ -251,11 +237,11 @@ async function selectSammCliExecutable(): Promise { } vscode.window.showInformationMessage(restartReason); - await queueLanguageServicesRestart(restartReason); + await languageServicesSupervisor.restart(restartReason); } async function promptForDownloadType(): Promise<'native' | 'jar' | undefined> { - const items: Array = [ + const items: Array = [ { label: '$(file-binary) Native Executable', detail: 'Platform-specific binary. No Java required.', @@ -302,7 +288,5 @@ export async function deactivate(): Promise { clearTimeout(githubRepositoryValidationTimeout); githubRepositoryValidationTimeout = undefined; } - await restartChain; - await languageClient.disconnect(); - await stopLanguageServer(); + await languageServicesSupervisor?.dispose(); } diff --git a/src/languageClient.ts b/src/languageClient.ts index 615e10f..10464a7 100644 --- a/src/languageClient.ts +++ b/src/languageClient.ts @@ -11,52 +11,85 @@ * SPDX-License-Identifier: MPL-2.0 */ -import { Trace } from 'vscode-jsonrpc'; +import {Trace} from 'vscode-jsonrpc'; import * as net from 'node:net'; import * as vscode from 'vscode'; -import { LanguageClient, LanguageClientOptions, State, StreamInfo } from 'vscode-languageclient/node'; -import type { RequestClient } from './aspectValidation'; -import type { ExtensionLogger } from './outputChannel'; +import {CloseAction, ErrorAction, ErrorHandler, LanguageClient, LanguageClientOptions, State, StreamInfo} from 'vscode-languageclient/node'; +import type {RequestClient} from './aspectValidation'; +import type {ExtensionLogger} from './outputChannel'; +import type {DisposableLike} from './languageServicesSupervisor'; const CLIENT_START_TIMEOUT_MS = 60_000; +export function createDoNotRestartErrorHandler(onUnexpectedClose: () => void): ErrorHandler { + let closeReported = false; + return { + error: () => ({action: ErrorAction.Continue}), + closed: () => { + if (!closeReported) { + closeReported = true; + onUnexpectedClose(); + } + return {action: CloseAction.DoNotRestart}; + }, + }; +} + export class TurtleLanguageClient implements RequestClient { private client: LanguageClient; + private readonly closeListeners = new Set<() => void>(); + private disconnecting = false; + private closeReported = false; constructor( private outputChannel: ExtensionLogger, private readonly serverPort: number, - private readonly traceLevel: 'off' | 'messages' | 'verbose' = 'off' + private readonly traceLevel: 'off' | 'messages' | 'verbose' = 'off', ) { this.client = this.initLanguageClient(this.serverPort); } + onUnexpectedClose(listener: () => void): DisposableLike { + this.closeListeners.add(listener); + return {dispose: () => this.closeListeners.delete(listener)}; + } + private toTrace(level: 'off' | 'messages' | 'verbose'): Trace { switch (level) { - case 'messages': return Trace.Messages; - case 'verbose': return Trace.Verbose; - default: return Trace.Off; + case 'messages': + return Trace.Messages; + case 'verbose': + return Trace.Verbose; + default: + return Trace.Off; } } private initLanguageClient(serverPort: number): LanguageClient { - const serverOptions = async (): Promise => new Promise((resolve, reject) => { - const socket = net.connect({ host: '127.0.0.1', port: serverPort }, () => { - resolve({ reader: socket, writer: socket }); + const serverOptions = async (): Promise => + new Promise((resolve, reject) => { + const socket = net.connect({host: '127.0.0.1', port: serverPort}, () => { + resolve({reader: socket, writer: socket}); + }); + + socket.once('error', error => { + socket.destroy(); + reject(error); + }); }); - socket.once('error', error => { - socket.destroy(); - reject(error); - }); - }); - const clientOptions: LanguageClientOptions = { documentSelector: ['turtle'], synchronize: { fileEvents: vscode.workspace.createFileSystemWatcher('**/*.ttl'), configurationSection: 'semantic-models.modelResolution', }, + errorHandler: createDoNotRestartErrorHandler(() => { + if (!this.disconnecting && !this.closeReported) { + this.closeReported = true; + this.closeListeners.forEach(listener => listener()); + } + }), }; const client = new LanguageClient('RDF/Turtle and SAMM Aspect Models Language Client', serverOptions, clientOptions); @@ -65,19 +98,23 @@ export class TurtleLanguageClient implements RequestClient { } async connect(): Promise { + this.disconnecting = false; + this.closeReported = false; let timeoutHandle: ReturnType | undefined; // Hold a reference so we can suppress an unhandled rejection if the // race is won by the timeout and start() rejects later. const startPromise = this.client.start(); try { const timeout = new Promise((_, reject) => { - timeoutHandle = setTimeout(() => reject(new Error('Timed out while starting the language client.')), CLIENT_START_TIMEOUT_MS); + timeoutHandle = setTimeout( + () => reject(new Error('Timed out while starting the language client.')), + CLIENT_START_TIMEOUT_MS, + ); }); await Promise.race([startPromise, timeout]); this.outputChannel.info('Language client started.'); - } - catch (error) { + } catch (error) { // Prevent an unhandled-rejection warning if start() rejects after // the timeout already won the race. startPromise.catch(() => undefined); @@ -85,8 +122,7 @@ export class TurtleLanguageClient implements RequestClient { const message = error instanceof Error ? error.message : 'An unknown error occurred while starting the language client.'; this.outputChannel.error(`Failed to start language client: ${message}`); throw error; - } - finally { + } finally { if (timeoutHandle) { clearTimeout(timeoutHandle); } @@ -94,11 +130,16 @@ export class TurtleLanguageClient implements RequestClient { } async disconnect(): Promise { + this.disconnecting = true; if (this.client.state === State.Stopped) { return; } - await this.client.stop(); + await this.client.stop().catch(error => { + if (this.client.state !== State.Stopped) { + throw error; + } + }); } sendRequest(method: string, params?: unknown): Promise { @@ -108,5 +149,4 @@ export class TurtleLanguageClient implements RequestClient { return this.client.sendRequest(method, params) as Promise; } - } diff --git a/src/languageServer.ts b/src/languageServer.ts index 1ec604e..77df503 100644 --- a/src/languageServer.ts +++ b/src/languageServer.ts @@ -12,33 +12,46 @@ */ import * as vscode from 'vscode'; -import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process'; +import {ChildProcessWithoutNullStreams, spawn} from 'node:child_process'; import * as net from 'node:net'; -import type { ExtensionLogger } from './outputChannel'; -import { JAVA_OPTIONS } from './constants'; +import type {ExtensionLogger} from './outputChannel'; +import {JAVA_OPTIONS} from './constants'; +import type {DisposableLike, ServerExitEvent} from './languageServicesSupervisor'; const SERVER_READY_TIMEOUT_MS = 60_000; const SERVER_READY_RETRY_DELAY_MS = 250; export class TurtleLanguageServer { private serverProcess: ChildProcessWithoutNullStreams | undefined; + private readonly exitListeners = new Set<(event: ServerExitEvent) => void>(); + private readonly expectedProcesses = new WeakSet(); + private readonly reportedProcesses = new WeakSet(); constructor( private readonly context: vscode.ExtensionContext, private readonly outputChannel: ExtensionLogger, private readonly sammCliExecutablePath: string, - private readonly serverPort: number - ) { } + private readonly serverPort: number, + ) {} - async start(): Promise { + get pid(): number | undefined { + return this.serverProcess?.pid; + } + + onExit(listener: (event: ServerExitEvent) => void): DisposableLike { + this.exitListeners.add(listener); + return {dispose: () => this.exitListeners.delete(listener)}; + } + async start(): Promise { const [executable, args] = this.sammCliExecutablePath.endsWith('.jar') ? ['java', [...JAVA_OPTIONS, '-jar', this.sammCliExecutablePath, 'lsp', '--port', String(this.serverPort)]] : [this.sammCliExecutablePath, ['lsp', '--port', String(this.serverPort)]]; - this.outputChannel.info(`Starting language server: ${executable} ${args.join(' ')}`); - this.serverProcess = this.spawnProcess(executable, args); + this.outputChannel.info( + `[language-server] event=spawned mode=${this.sammCliExecutablePath.endsWith('.jar') ? 'jar' : 'native'} port=${this.serverPort} pid=${String(this.serverProcess.pid)}`, + ); try { await this.waitForServerPort(this.serverPort, this.serverProcess); @@ -51,9 +64,13 @@ export class TurtleLanguageServer { async stop(): Promise { const process = this.serverProcess; + if (!process) { + return; + } + this.expectedProcesses.add(process); this.serverProcess = undefined; - if (!process) { + if (process.exitCode !== null || process.signalCode !== null) { return; } @@ -82,13 +99,7 @@ export class TurtleLanguageServer { } private spawnProcess(executable: string, args: string[]): ChildProcessWithoutNullStreams { - const spawnOptions = { - cwd: this.context.extensionPath, - env: process.env, - stdio: 'pipe' as const, - }; - - const child = spawn(executable, args, spawnOptions) as ChildProcessWithoutNullStreams; + const child = this.createChildProcess(executable, args); child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); @@ -103,15 +114,30 @@ export class TurtleLanguageServer { child.once('error', error => { this.outputChannel.error(`Server process error: ${String(error instanceof Error ? error.message : error)}`); + this.reportExit(child, null, null); }); + child.once('exit', (code, signal) => this.reportExit(child, code, signal)); return child; } - private async waitForServerPort(port: number, process: ChildProcessWithoutNullStreams): Promise { + protected createChildProcess(executable: string, args: string[]): ChildProcessWithoutNullStreams { + const spawnOptions = { + cwd: this.context.extensionPath, + env: process.env, + stdio: 'pipe' as const, + }; + + return spawn(executable, args, spawnOptions) as ChildProcessWithoutNullStreams; + } + + protected async waitForServerPort(port: number, process: ChildProcessWithoutNullStreams): Promise { const deadline = Date.now() + SERVER_READY_TIMEOUT_MS; while (Date.now() < deadline) { + if (this.serverProcess !== process || process.exitCode !== null || process.signalCode !== null) { + throw new Error('The Turtle language server exited before it became ready.'); + } if (await this.isServerListening(port, process)) { return; } @@ -142,7 +168,7 @@ export class TurtleLanguageServer { process.once('exit', exitListener); - const socket = net.connect({ host: '127.0.0.1', port }, () => { + const socket = net.connect({host: '127.0.0.1', port}, () => { finish(() => { socket.end(); resolve(true); @@ -161,4 +187,21 @@ export class TurtleLanguageServer { private delay(milliseconds: number): Promise { return new Promise(resolve => setTimeout(resolve, milliseconds)); } + + private reportExit(process: ChildProcessWithoutNullStreams, code: number | null, signal: NodeJS.Signals | null): void { + if (this.reportedProcesses.has(process)) { + return; + } + this.reportedProcesses.add(process); + if (this.serverProcess === process) { + this.serverProcess = undefined; + } + const event: ServerExitEvent = Object.freeze({ + pid: process.pid, + code, + signal, + expected: this.expectedProcesses.has(process), + }); + this.exitListeners.forEach(listener => listener(event)); + } } diff --git a/src/languageServicesSupervisor.ts b/src/languageServicesSupervisor.ts new file mode 100644 index 0000000..cb64fce --- /dev/null +++ b/src/languageServicesSupervisor.ts @@ -0,0 +1,368 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +import type {RequestClient} from './aspectValidation'; +import type {ExtensionLogger} from './outputChannel'; + +const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000] as const; +const STABLE_WINDOW_MS = 180_000; + +export type LanguageServicesMode = 'embedded' | 'external'; + +export interface DisposableLike { + dispose(): void; +} + +export interface ServerExitEvent { + pid?: number; + code: number | null; + signal: NodeJS.Signals | null; + expected: boolean; +} + +export interface ManagedLanguageServer { + readonly pid?: number; + start(): Promise; + stop(): Promise; + onExit(listener: (event: ServerExitEvent) => void): DisposableLike; +} + +export interface ManagedLanguageClient extends RequestClient { + connect(): Promise; + disconnect(): Promise; + onUnexpectedClose(listener: () => void): DisposableLike; +} + +export interface LanguageServicesConfiguration { + mode: LanguageServicesMode; + port: number; +} + +export interface SupervisorClock { + now(): number; + setTimeout(callback: () => void, delayMs: number): DisposableLike; +} + +export type TerminalAction = 'restart' | 'output' | 'settings' | undefined; + +export interface LanguageServicesSupervisorOptions { + configuration(): LanguageServicesConfiguration; + createServer(configuration: LanguageServicesConfiguration): ManagedLanguageServer; + createClient(configuration: LanguageServicesConfiguration): ManagedLanguageClient; + setRequestClient(client: RequestClient, generation: number): void; + unavailableClient(): RequestClient; + notifyTerminal(mode: LanguageServicesMode): Promise; + showOutput(): void; + showSettings(): void; + logger: ExtensionLogger; + clock?: SupervisorClock; +} + +type Candidate = { + generation: number; + mode: LanguageServicesMode; + server?: ManagedLanguageServer; + client?: ManagedLanguageClient; + subscriptions: DisposableLike[]; +}; + +const systemClock: SupervisorClock = { + now: () => Date.now(), + setTimeout: (callback, delayMs) => { + const handle = setTimeout(callback, delayMs); + return {dispose: () => clearTimeout(handle)}; + }, +}; + +export class LanguageServicesSupervisor { + private readonly clock: SupervisorClock; + private chain: Promise = Promise.resolve(); + private generation = 0; + private automaticAttempt = 0; + private current: Candidate | undefined; + private retryTimer: DisposableLike | undefined; + private stableTimer: DisposableLike | undefined; + private disposed = false; + private terminalNotified = false; + + constructor(private readonly options: LanguageServicesSupervisorOptions) { + this.clock = options.clock ?? systemClock; + } + + start(reason: string): Promise { + return this.restart(reason); + } + + restart(reason: string): Promise { + if (this.disposed) { + return Promise.resolve(); + } + + this.cancelTimers(); + this.automaticAttempt = 0; + this.terminalNotified = false; + const generation = ++this.generation; + this.publishUnavailable(generation); + this.log('explicit-restart', {reason: this.reasonCategory(reason), generation}); + return this.enqueue(async () => { + await this.cleanupCurrent('explicit-restart'); + await this.startCandidate(generation, 'explicit'); + }); + } + + async dispose(): Promise { + if (this.disposed) { + return this.chain; + } + this.disposed = true; + this.cancelTimers(); + const generation = ++this.generation; + this.publishUnavailable(generation); + await this.enqueue(() => this.cleanupCurrent('dispose')); + } + + private enqueue(operation: () => Promise): Promise { + this.chain = this.chain.then(operation, operation).catch(error => { + this.options.logger.error(`[language-services] operation-failed error=${this.message(error)}`); + }); + return this.chain; + } + + private async startCandidate(generation: number, phase: 'explicit' | 'automatic'): Promise { + if (!this.isCurrent(generation)) { + this.log('stale-start-rejected', {generation, phase}); + return; + } + + const configuration = this.options.configuration(); + const candidate: Candidate = {generation, mode: configuration.mode, subscriptions: []}; + const startedAt = this.clock.now(); + try { + if (configuration.mode === 'embedded') { + candidate.server = this.options.createServer(configuration); + candidate.subscriptions.push(candidate.server.onExit(event => this.onServerExit(generation, event))); + await candidate.server.start(); + if (!this.isCurrent(generation)) { + await this.cleanupCandidate(candidate, 'stale-after-server-start'); + return; + } + } + + candidate.client = this.options.createClient(configuration); + candidate.subscriptions.push(candidate.client.onUnexpectedClose(() => this.onClientClose(generation))); + await candidate.client.connect(); + if (!this.isCurrent(generation)) { + await this.cleanupCandidate(candidate, 'stale-after-client-connect'); + return; + } + + this.current = candidate; + this.options.setRequestClient(candidate.client, generation); + this.startStableTimer(generation); + this.log('running', { + generation, + mode: configuration.mode, + phase, + attempt: this.automaticAttempt, + port: configuration.port, + pid: candidate.server?.pid, + recoveryDurationMs: this.clock.now() - startedAt, + }); + } catch (error) { + this.log('candidate-failed', { + generation, + mode: configuration.mode, + phase, + attempt: this.automaticAttempt, + error: this.message(error), + }); + await this.cleanupCandidate(candidate, 'candidate-failed'); + if (this.isCurrent(generation)) { + this.scheduleNextAttempt(); + } + } + } + + private onServerExit(generation: number, event: ServerExitEvent): void { + if (event.expected) { + this.log('expected-process-exit', {generation, pid: event.pid, code: event.code, signal: event.signal}); + return; + } + this.handleUnexpectedFailure(generation, 'process', { + pid: event.pid, + code: event.code, + signal: event.signal, + }); + } + + private onClientClose(generation: number): void { + this.handleUnexpectedFailure(generation, 'client', {}); + } + + private handleUnexpectedFailure(generation: number, source: 'process' | 'client', details: Record): void { + if (!this.isCurrent(generation) || this.current?.generation !== generation) { + this.log('stale-failure-rejected', {generation, source, ...details}); + return; + } + + this.cancelStableTimer(); + const failed = this.current; + this.current = undefined; + const recoveryGeneration = ++this.generation; + this.publishUnavailable(recoveryGeneration); + this.log('unexpected-failure', {generation, recoveryGeneration, source, ...details}); + void this.enqueue(async () => { + await this.cleanupCandidate(failed, `unexpected-${source}`); + if (this.isCurrent(recoveryGeneration)) { + this.scheduleNextAttempt(recoveryGeneration); + } + }); + } + + private scheduleNextAttempt(existingGeneration?: number): void { + if (this.disposed) { + return; + } + if (this.automaticAttempt >= RETRY_DELAYS_MS.length) { + void this.terminalize(); + return; + } + + const attempt = ++this.automaticAttempt; + const delayMs = RETRY_DELAYS_MS[attempt - 1]; + const generation = existingGeneration ?? ++this.generation; + if (existingGeneration === undefined) { + this.publishUnavailable(generation); + } + this.cancelRetryTimer(); + this.log('retry-scheduled', {generation, attempt, delayMs}); + this.retryTimer = this.clock.setTimeout(() => { + this.retryTimer = undefined; + if (!this.isCurrent(generation)) { + this.log('stale-retry-rejected', {generation, attempt}); + return; + } + void this.enqueue(() => this.startCandidate(generation, 'automatic')); + }, delayMs); + } + + private startStableTimer(generation: number): void { + this.cancelStableTimer(); + this.stableTimer = this.clock.setTimeout(() => { + this.stableTimer = undefined; + if (!this.isCurrent(generation) || this.current?.generation !== generation) { + this.log('stale-stability-reset-rejected', {generation}); + return; + } + this.automaticAttempt = 0; + this.terminalNotified = false; + this.log('stability-budget-reset', {generation, stableMs: STABLE_WINDOW_MS}); + }, STABLE_WINDOW_MS); + } + + private async terminalize(): Promise { + if (this.disposed || this.terminalNotified) { + return; + } + this.terminalNotified = true; + this.cancelTimers(); + const mode = this.options.configuration().mode; + this.log('terminal-exhaustion', {generation: this.generation, mode, attempts: this.automaticAttempt}); + const action = await this.options.notifyTerminal(mode); + if (action === 'restart') { + await this.restart('terminal notification: Restart Now'); + } else if (action === 'output') { + this.options.showOutput(); + } else if (action === 'settings') { + this.options.showSettings(); + } + } + + private async cleanupCurrent(reason: string): Promise { + const current = this.current; + this.current = undefined; + await this.cleanupCandidate(current, reason); + } + + private async cleanupCandidate(candidate: Candidate | undefined, reason: string): Promise { + if (!candidate) { + return; + } + const results = await Promise.allSettled([ + candidate.client?.disconnect() ?? Promise.resolve(), + candidate.server?.stop() ?? Promise.resolve(), + ]); + candidate.subscriptions.forEach(subscription => subscription.dispose()); + this.log('cleanup', { + generation: candidate.generation, + mode: candidate.mode, + reason, + client: results[0].status, + server: results[1].status, + }); + } + + private publishUnavailable(generation: number): void { + this.options.setRequestClient(this.options.unavailableClient(), generation); + } + + private isCurrent(generation: number): boolean { + return !this.disposed && this.generation === generation; + } + + private cancelTimers(): void { + this.cancelRetryTimer(); + this.cancelStableTimer(); + } + + private cancelRetryTimer(): void { + this.retryTimer?.dispose(); + this.retryTimer = undefined; + } + + private cancelStableTimer(): void { + this.stableTimer?.dispose(); + this.stableTimer = undefined; + } + + private log(event: string, fields: Record): void { + const values = Object.entries(fields) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}=${String(value)}`) + .join(' '); + this.options.logger.info(`[language-services] event=${event}${values ? ` ${values}` : ''}`); + } + + private message(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } + + private reasonCategory(reason: string): string { + if (reason === 'extension activation') { + return 'activation'; + } + if (reason === 'Manual restart command') { + return 'manual'; + } + if (reason === 'Configuration change detected') { + return 'configuration'; + } + if (reason.startsWith('terminal notification:')) { + return 'terminal-action'; + } + return 'executable-change'; + } +} + +export const languageServicesRetryDelays = RETRY_DELAYS_MS; +export const languageServicesStableWindowMs = STABLE_WINDOW_MS; diff --git a/src/test/aspectValidationController.test.ts b/src/test/aspectValidationController.test.ts index 364b140..b5724f0 100644 --- a/src/test/aspectValidationController.test.ts +++ b/src/test/aspectValidationController.test.ts @@ -29,3 +29,77 @@ async function withStubbedRegisterCommand(run: () => void | Promise): Prom }); } } + +suite('AspectValidationController', () => { + test('ignores a stale successful request after client replacement', async () => { + let resolveRequest: ((value: {diagnostics: []}) => void) | undefined; + const request = new Promise<{diagnostics: []}>(resolve => (resolveRequest = resolve)); + const harness = createValidationControllerHarness({request: async () => request as Promise}); + const validation = harness.controller.validateDocument(createValidationDocument('/tmp/model.ttl'), 'manual'); + + harness.controller.setClient({sendRequest: async () => ({diagnostics: []}) as R}, 2); + resolveRequest?.({diagnostics: []}); + + assert.strictEqual(await validation, undefined); + assert.deepStrictEqual(harness.window.infoMessages, []); + assert.deepStrictEqual(harness.window.errorMessages, []); + assert.ok(harness.outputChannel.lines.some(line => line.includes('Ignoring stale result'))); + }); + + test('ignores a stale failed request after client replacement', async () => { + let rejectRequest: ((error: Error) => void) | undefined; + const request = new Promise((_, reject) => (rejectRequest = reject)); + const harness = createValidationControllerHarness({request: async () => request as Promise}); + const validation = harness.controller.validateDocument(createValidationDocument('/tmp/model.ttl'), 'manual'); + + harness.controller.setClient({sendRequest: async () => ({diagnostics: []}) as R}, 2); + rejectRequest?.(new Error('old connection failed')); + + assert.strictEqual(await validation, undefined); + assert.deepStrictEqual(harness.window.errorMessages, []); + assert.ok(harness.outputChannel.lines.some(line => line.includes('Ignoring stale failure'))); + }); + + test('reports the current successful request', async () => { + const harness = createValidationControllerHarness({response: {diagnostics: []}}); + const result = await harness.controller.validateDocument(createValidationDocument('/tmp/model.ttl'), 'manual'); + + assert.deepStrictEqual(result, {diagnostics: []}); + assert.deepStrictEqual(harness.window.infoMessages, ['Aspect validation completed without issues.']); + assert.strictEqual(harness.sentRequests[0].method, VALIDATE_DOCUMENT_REQUEST); + }); + + test('reports the current failed request', async () => { + const harness = createValidationControllerHarness({error: new Error('connection failed')}); + const result = await harness.controller.validateDocument(createValidationDocument('/tmp/model.ttl'), 'manual'); + + assert.strictEqual(result, undefined); + assert.deepStrictEqual(harness.window.errorMessages, ['Aspect validation request failed: connection failed']); + }); + + test('uses status messages rather than notifications for save validation', async () => { + await withStubbedRegisterCommand(async () => { + const harness = createValidationControllerHarness({response: {diagnostics: []}}); + harness.controller.register({subscriptions: []} as unknown as vscode.ExtensionContext); + + await harness.workspace.fireSave(createValidationDocument('/tmp/model.ttl')); + for (let index = 0; index < 8; index++) { + await Promise.resolve(); + } + + assert.deepStrictEqual(harness.window.statusMessages, [ + 'Aspect model validation in progress...', + 'Aspect validation completed without issues.', + ]); + assert.deepStrictEqual(harness.window.infoMessages, []); + assert.deepStrictEqual(harness.window.errorMessages, []); + }); + }); + + test('register remains compatible with the VS Code command surface', async () => { + await withStubbedRegisterCommand(() => { + const harness = createValidationControllerHarness(); + harness.controller.register({subscriptions: []} as unknown as vscode.ExtensionContext); + }); + }); +}); diff --git a/src/test/languageClient.test.ts b/src/test/languageClient.test.ts new file mode 100644 index 0000000..2573afe --- /dev/null +++ b/src/test/languageClient.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +import * as assert from 'assert'; +import {CloseAction, State} from 'vscode-languageclient/node'; +import {createDoNotRestartErrorHandler, TurtleLanguageClient} from '../languageClient'; + +suite('TurtleLanguageClient lifecycle', () => { + test('uses DoNotRestart and reports a duplicate close only once', async () => { + let closeCount = 0; + const handler = createDoNotRestartErrorHandler(() => closeCount++); + + const first = await handler.closed(); + const second = await handler.closed(); + + assert.strictEqual(first.action, CloseAction.DoNotRestart); + assert.strictEqual(second.action, CloseAction.DoNotRestart); + assert.strictEqual(closeCount, 1); + }); + + test('disconnect is idempotent after the client reaches Stopped', async () => { + let stopCount = 0; + const client = { + state: State.Running, + stop: async () => { + stopCount++; + client.state = State.Stopped; + }, + }; + const wrapper = Object.create(TurtleLanguageClient.prototype) as unknown as { + client: typeof client; + disconnecting: boolean; + disconnect(): Promise; + }; + wrapper.client = client; + wrapper.disconnecting = false; + + await wrapper.disconnect(); + await wrapper.disconnect(); + + assert.strictEqual(stopCount, 1); + }); +}); diff --git a/src/test/languageServer.test.ts b/src/test/languageServer.test.ts new file mode 100644 index 0000000..6a1c993 --- /dev/null +++ b/src/test/languageServer.test.ts @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +import * as assert from 'assert'; +import {ChildProcessWithoutNullStreams} from 'node:child_process'; +import {EventEmitter} from 'node:events'; +import {Readable, Writable} from 'node:stream'; +import * as vscode from 'vscode'; +import type {ServerExitEvent} from '../languageServicesSupervisor'; +import {TurtleLanguageServer} from '../languageServer'; +import type {ExtensionLogger} from '../outputChannel'; + +class FakeChild extends EventEmitter { + readonly pid = 4242; + readonly stdout = new Readable({read: () => undefined}); + readonly stderr = new Readable({read: () => undefined}); + readonly stdin = new Writable({write: (_chunk, _encoding, callback) => callback()}); + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + killCount = 0; + + kill(signal: NodeJS.Signals = 'SIGTERM'): boolean { + this.killCount++; + this.emitExit(null, signal); + return true; + } + + emitExit(code: number | null, signal: NodeJS.Signals | null): void { + this.exitCode = code; + this.signalCode = signal; + this.emit('exit', code, signal); + } +} + +class TestLanguageServer extends TurtleLanguageServer { + constructor( + private readonly child: FakeChild, + private readonly readiness: (child: FakeChild) => Promise = async () => undefined, + ) { + const logger: ExtensionLogger = { + trace: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, + }; + super({extensionPath: '/tmp/test-extension'} as vscode.ExtensionContext, logger, '/tmp/test-samm-cli', 19461); + } + + protected override createChildProcess(): ChildProcessWithoutNullStreams { + return this.child as unknown as ChildProcessWithoutNullStreams; + } + + protected override waitForServerPort(): Promise { + return this.readiness(this.child); + } +} + +suite('TurtleLanguageServer lifecycle', () => { + test('reports one immutable unexpected post-ready exit with metadata', async () => { + const child = new FakeChild(); + const server = new TestLanguageServer(child); + const events: ServerExitEvent[] = []; + server.onExit(event => events.push(event)); + await server.start(); + + child.emitExit(134, 'SIGABRT'); + child.emitExit(134, 'SIGABRT'); + + assert.strictEqual(events.length, 1); + assert.deepStrictEqual(events[0], {pid: 4242, code: 134, signal: 'SIGABRT', expected: false}); + assert.ok(Object.isFrozen(events[0])); + assert.strictEqual(server.pid, undefined); + }); + + test('coalesces spawn error and later exit into one event', async () => { + const child = new FakeChild(); + const server = new TestLanguageServer(child); + const events: ServerExitEvent[] = []; + server.onExit(event => events.push(event)); + await server.start(); + + child.emit('error', new Error('spawn failed')); + child.emitExit(1, null); + + assert.deepStrictEqual(events, [{pid: 4242, code: null, signal: null, expected: false}]); + }); + + test('fails immediately when the child exits during readiness', async () => { + const child = new FakeChild(); + const server = new TestLanguageServer(child, async current => { + current.emitExit(1, null); + throw new Error('The Turtle language server exited before it became ready.'); + }); + + await assert.rejects(server.start(), /exited before it became ready/); + assert.strictEqual(server.pid, undefined); + assert.strictEqual(child.killCount, 0); + }); + + test('marks timeout cleanup expected and stop remains idempotent', async () => { + const child = new FakeChild(); + const server = new TestLanguageServer(child, async () => { + throw new Error('Timed out waiting for the Turtle language server to start on port 19461.'); + }); + const events: ServerExitEvent[] = []; + server.onExit(event => events.push(event)); + + await assert.rejects(server.start(), /Timed out/); + await server.stop(); + + assert.strictEqual(child.killCount, 1); + assert.deepStrictEqual(events, [{pid: 4242, code: null, signal: 'SIGTERM', expected: true}]); + }); +}); diff --git a/src/test/languageServicesSupervisor.test.ts b/src/test/languageServicesSupervisor.test.ts new file mode 100644 index 0000000..3a37c40 --- /dev/null +++ b/src/test/languageServicesSupervisor.test.ts @@ -0,0 +1,296 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +import * as assert from 'assert'; +import type {RequestClient} from '../aspectValidation'; +import { + DisposableLike, + LanguageServicesConfiguration, + LanguageServicesSupervisor, + ManagedLanguageClient, + ManagedLanguageServer, + ServerExitEvent, + SupervisorClock, +} from '../languageServicesSupervisor'; +import type {ExtensionLogger} from '../outputChannel'; + +class FakeClock implements SupervisorClock { + currentTime = 0; + scheduledDelays: number[] = []; + private timers: Array<{at: number; callback: () => void; disposed: boolean}> = []; + + now(): number { + return this.currentTime; + } + + setTimeout(callback: () => void, delayMs: number): DisposableLike { + const timer = {at: this.currentTime + delayMs, callback, disposed: false}; + this.scheduledDelays.push(delayMs); + this.timers.push(timer); + return {dispose: () => (timer.disposed = true)}; + } + + advance(delayMs: number): void { + const target = this.currentTime + delayMs; + while (true) { + const next = this.timers.filter(timer => !timer.disposed && timer.at <= target).sort((left, right) => left.at - right.at)[0]; + if (!next) { + break; + } + next.disposed = true; + this.currentTime = next.at; + next.callback(); + } + this.currentTime = target; + } +} + +class FakeServer implements ManagedLanguageServer { + readonly pid: number; + starts = 0; + stops = 0; + private listeners = new Set<(event: ServerExitEvent) => void>(); + + constructor( + pid: number, + private readonly startFailure?: Error, + ) { + this.pid = pid; + } + + async start(): Promise { + this.starts++; + if (this.startFailure) { + throw this.startFailure; + } + } + + async stop(): Promise { + this.stops++; + } + + onExit(listener: (event: ServerExitEvent) => void): DisposableLike { + this.listeners.add(listener); + return {dispose: () => this.listeners.delete(listener)}; + } + + exit(expected = false): void { + this.listeners.forEach(listener => listener({pid: this.pid, code: 1, signal: null, expected})); + } +} + +class FakeClient implements ManagedLanguageClient { + connects = 0; + disconnects = 0; + private listeners = new Set<() => void>(); + + constructor(private readonly connectFailure?: Error) {} + + async connect(): Promise { + this.connects++; + if (this.connectFailure) { + throw this.connectFailure; + } + } + + async disconnect(): Promise { + this.disconnects++; + } + + onUnexpectedClose(listener: () => void): DisposableLike { + this.listeners.add(listener); + return {dispose: () => this.listeners.delete(listener)}; + } + + close(): void { + this.listeners.forEach(listener => listener()); + } + + async sendRequest(): Promise { + return {} as R; + } +} + +type Harness = ReturnType; + +function createHarness(mode: 'embedded' | 'external' = 'embedded', clientFailures: boolean[] = []) { + const clock = new FakeClock(); + const servers: FakeServer[] = []; + const clients: FakeClient[] = []; + const bindings: Array<{client: RequestClient; generation: number}> = []; + const logs: string[] = []; + const notifications: string[] = []; + let serverId = 100; + let clientIndex = 0; + const logger: ExtensionLogger = { + trace: message => logs.push(message), + info: message => logs.push(message), + warn: message => logs.push(message), + error: message => logs.push(String(message)), + }; + const configuration = (): LanguageServicesConfiguration => ({mode, port: 1846}); + const supervisor = new LanguageServicesSupervisor({ + configuration, + createServer: () => { + const server = new FakeServer(++serverId); + servers.push(server); + return server; + }, + createClient: () => { + const client = new FakeClient(clientFailures[clientIndex++] ? new Error('connect failed') : undefined); + clients.push(client); + return client; + }, + setRequestClient: (client, generation) => bindings.push({client, generation}), + unavailableClient: () => ({sendRequest: async () => Promise.reject(new Error('unavailable'))}), + notifyTerminal: async currentMode => { + notifications.push(currentMode); + return undefined; + }, + showOutput: () => undefined, + showSettings: () => undefined, + logger, + clock, + }); + return {supervisor, clock, servers, clients, bindings, logs, notifications}; +} + +async function settle(): Promise { + for (let index = 0; index < 8; index++) { + await Promise.resolve(); + } +} + +async function start(harness: Harness): Promise { + await harness.supervisor.start('test'); + await settle(); +} + +suite('LanguageServicesSupervisor', () => { + test('suppresses an expected process exit', async () => { + const harness = createHarness(); + await start(harness); + harness.servers[0].exit(true); + await settle(); + harness.clock.advance(10_000); + await settle(); + + assert.strictEqual(harness.servers.length, 1); + assert.strictEqual(harness.notifications.length, 0); + await harness.supervisor.dispose(); + }); + + test('coalesces process and client failure signals into one replacement', async () => { + const harness = createHarness(); + await start(harness); + + harness.servers[0].exit(); + harness.clients[0].close(); + await settle(); + harness.clock.advance(999); + await settle(); + assert.strictEqual(harness.servers.length, 1); + + harness.clock.advance(1); + await settle(); + assert.strictEqual(harness.servers.length, 2); + assert.strictEqual(harness.servers[0].stops, 1); + assert.strictEqual(harness.clients[0].disconnects, 1); + await harness.supervisor.dispose(); + }); + + test('uses exact retry delays and emits one terminal notification', async () => { + const harness = createHarness('embedded', [true, true, true, true, true]); + await start(harness); + + for (const delay of [1_000, 2_000, 4_000, 8_000]) { + harness.clock.advance(delay); + await settle(); + } + + assert.deepStrictEqual(harness.clock.scheduledDelays, [1_000, 2_000, 4_000, 8_000]); + assert.strictEqual(harness.notifications.length, 1); + assert.strictEqual(harness.clients.length, 5); + assert.ok(harness.clients.every(client => client.disconnects === 1)); + assert.ok(harness.servers.every(server => server.stops === 1)); + await harness.supervisor.dispose(); + }); + + test('resets the retry budget only after three stable minutes', async () => { + const harness = createHarness(); + await start(harness); + harness.servers[0].exit(); + await settle(); + harness.clock.advance(1_000); + await settle(); + + harness.clock.advance(179_999); + harness.servers[1].exit(); + await settle(); + assert.strictEqual(harness.clock.scheduledDelays.at(-1), 2_000); + harness.clock.advance(2_000); + await settle(); + + harness.clock.advance(180_000); + harness.servers[2].exit(); + await settle(); + assert.strictEqual(harness.clock.scheduledDelays.at(-1), 1_000); + await harness.supervisor.dispose(); + }); + + test('external mode exhausts bounded reconnects without a server and allows manual recovery', async () => { + const harness = createHarness('external', [false, true, true, true, true]); + await start(harness); + assert.strictEqual(harness.servers.length, 0); + harness.clients[0].close(); + await settle(); + + for (const delay of [1_000, 2_000, 4_000, 8_000]) { + harness.clock.advance(delay); + await settle(); + } + + assert.strictEqual(harness.servers.length, 0); + assert.strictEqual(harness.clients.length, 5); + assert.deepStrictEqual(harness.notifications, ['external']); + + await harness.supervisor.restart('manual override'); + assert.strictEqual(harness.servers.length, 0); + assert.strictEqual(harness.clients.length, 6); + await harness.supervisor.dispose(); + }); + + test('manual restart cancels a pending automatic retry', async () => { + const harness = createHarness(); + await start(harness); + harness.servers[0].exit(); + await settle(); + await harness.supervisor.restart('manual override'); + harness.clock.advance(1_000); + await settle(); + assert.strictEqual(harness.servers.length, 2); + await harness.supervisor.dispose(); + }); + + test('deactivation during backoff prevents stale resurrection', async () => { + const harness = createHarness(); + await start(harness); + harness.servers[0].exit(); + await settle(); + await harness.supervisor.dispose(); + harness.clock.advance(10_000); + await settle(); + assert.strictEqual(harness.servers.length, 1); + assert.strictEqual(harness.notifications.length, 0); + }); +}); diff --git a/src/test/validationTestHarness.ts b/src/test/validationTestHarness.ts index cc83f9f..e391bcf 100644 --- a/src/test/validationTestHarness.ts +++ b/src/test/validationTestHarness.ts @@ -20,11 +20,12 @@ import { ValidationWindow, ValidationWorkspace, } from '../aspectValidation'; -import type { ExtensionLogger } from '../outputChannel'; +import type {ExtensionLogger} from '../outputChannel'; type ValidationHarnessOptions = { response?: DiagnosticReport; error?: Error; + request?: (method: string, params?: unknown) => Promise; }; type RecordedRequest = { @@ -44,9 +45,10 @@ type FakeWorkspace = ValidationWorkspace & { fireSave(document: Pick): Promise; }; -type FakeOutputChannel = ValidationOutputChannel & ExtensionLogger & { - lines: string[]; -}; +type FakeOutputChannel = ValidationOutputChannel & + ExtensionLogger & { + lines: string[]; + }; export function createValidationControllerHarness(options: ValidationHarnessOptions = {}) { const sentRequests: RecordedRequest[] = []; @@ -57,6 +59,10 @@ export function createValidationControllerHarness(options: ValidationHarnessOpti sendRequest: async (method: string, params?: unknown) => { sentRequests.push({method, params}); + if (options.request) { + return options.request(method, params); + } + if (options.error) { throw options.error; }