From b26f367559fca3e78b4e07efde57dcc729d97c2a Mon Sep 17 00:00:00 2001 From: Martin Fleck Date: Wed, 30 Sep 2026 10:13:36 +0200 Subject: [PATCH 1/6] fix(client-theia): retry a port command that answers without a port The socket-forwarding handler retried its port command only when it threw. A command that resolved with no port neither resolved the lookup nor asked again, so the head never connected and nothing was logged. - An answer without a port fails the attempt like a throw does: it is asked again after findPortTimeout and counts against findPortAttempts - Once the attempts run out, the lookup rejects with an error naming the command, which the handler logs at error - Each failed attempt is logged at debug with the command and reason Fixes #226 --- ...ct-socket-forwarding-connection-handler.ts | 10 +++++-- ...cket-forwarding-connection-handler.test.ts | 26 ++++++++++++++++++- .../glsp-server-connection-handler.test.ts | 7 +++-- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/packages/client-theia/src/node/abstract-socket-forwarding-connection-handler.ts b/packages/client-theia/src/node/abstract-socket-forwarding-connection-handler.ts index 1cf252af..46e58134 100644 --- a/packages/client-theia/src/node/abstract-socket-forwarding-connection-handler.ts +++ b/packages/client-theia/src/node/abstract-socket-forwarding-connection-handler.ts @@ -137,11 +137,17 @@ export abstract class AbstractSocketForwardingConnectionHandler implements Conne setTimeout(async () => { try { const port = await this.commandService.executeCommand(this.portCommand); - if (port) { - pendingContent.resolve(port); + // An empty answer fails the attempt like a throw does: left + // unanswered, it neither resolves nor re-queues, and the lookup + // stays pending with nothing logged. + if (!port) { + throw new Error(`Port command '${this.portCommand}' answered without a port.`); } + pendingContent.resolve(port); } catch (error) { counter++; + const message = error instanceof Error ? error.message : String(error); + this.logger.debug(`[${this.logComponent}] Port command '${this.portCommand}' attempt ${counter} failed: ${message}`); if (this.findPortAttempts >= 0 && counter > this.findPortAttempts) { pendingContent.reject(error); } else { diff --git a/packages/client-theia/test/abstract-socket-forwarding-connection-handler.test.ts b/packages/client-theia/test/abstract-socket-forwarding-connection-handler.test.ts index 77fc85d3..3623f7dc 100644 --- a/packages/client-theia/test/abstract-socket-forwarding-connection-handler.test.ts +++ b/packages/client-theia/test/abstract-socket-forwarding-connection-handler.test.ts @@ -9,7 +9,7 @@ import 'reflect-metadata'; import { describe, expect, it, vi } from 'vitest'; -import { type Channel, Disposable, type ILogger } from '@theia/core'; +import { type Channel, type CommandService, Disposable, type ILogger } from '@theia/core'; import { ForwardingChannel } from '@theia/core/lib/common/message-rpc/channel'; import type { MessageProvider } from '@theia/core/lib/common/message-rpc/channel'; import type * as net from 'node:net'; @@ -30,6 +30,11 @@ class TestHandler extends AbstractSocketForwardingConnectionHandler { this.replayBufferedMessages(channel, buffered); } + /** Reach the protected port lookup under test. */ + lookUpPort(): Promise { + return this.findPort(); + } + /** Reach the protected dial under test. */ connect(channel: Channel, port: number): Promise { return this.connectToServer(channel, port); @@ -85,6 +90,25 @@ describe('AbstractSocketForwardingConnectionHandler', () => { expect(handler.config.connectTimeoutMs).toBe(1234); }); + /** + * A host's port command may answer before its language client is ready, and + * nothing obliges it to throw then rather than return nothing. An empty + * answer that neither resolves nor re-queues leaves the lookup pending for + * good and the head never connects, with nothing logged. + */ + it('counts a port command that answers without a port as a failed attempt', async () => { + const handler = new TestHandler({ ...baseOptions(), findPortTimeout: 1, findPortAttempts: 2 }); + const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as unknown as ILogger; + const executeCommand = vi.fn(async () => undefined); + Object.assign(handler, { logger, commandService: { executeCommand } as unknown as CommandService }); + + await expect(handler.lookUpPort()).rejects.toThrow(/'test:port'/); + + expect(executeCommand).toHaveBeenCalledTimes(3); + expect(logger.debug).toHaveBeenCalledTimes(3); + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("'test:port'")); + }); + /** * The heads bind `127.0.0.1`, so the dial has to name that address rather * than leave Node to resolve its `localhost` default: on a dual-stack machine diff --git a/packages/glsp-client-theia/test/node/glsp-server-connection-handler.test.ts b/packages/glsp-client-theia/test/node/glsp-server-connection-handler.test.ts index 6a2e3adc..7f3257ea 100644 --- a/packages/glsp-client-theia/test/node/glsp-server-connection-handler.test.ts +++ b/packages/glsp-client-theia/test/node/glsp-server-connection-handler.test.ts @@ -11,9 +11,9 @@ import { describe, expect, it, vi } from 'vitest'; import { type Channel, type CommandService, type ILogger, type MessageService } from '@theia/core'; import { GlspServerConnectionHandler } from '../../src/node/glsp-server-connection-handler'; -/** Backend `ILogger` stub — the handler logs connect/error lines through it. */ +/** Backend `ILogger` stub — the handler logs port attempts and connect/error lines through it. */ function stubLogger(): ILogger { - return { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as unknown as ILogger; + return { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as unknown as ILogger; } class TestHandler extends GlspServerConnectionHandler { @@ -46,6 +46,7 @@ describe('GlspServerConnectionHandler', () => { executeCommand: vi.fn<() => Promise>().mockResolvedValue(5007) } as unknown as CommandService; handler['messageService'] = {} as MessageService; + (handler as unknown as { logger: ILogger }).logger = stubLogger(); await expect(handler.exposeFindPort()).resolves.toBe(5007); }); @@ -66,6 +67,7 @@ describe('GlspServerConnectionHandler', () => { }) } as unknown as CommandService; handler['messageService'] = {} as MessageService; + (handler as unknown as { logger: ILogger }).logger = stubLogger(); await expect(handler.exposeFindPort()).resolves.toBe(5008); expect(attempts).toBe(3); }); @@ -81,6 +83,7 @@ describe('GlspServerConnectionHandler', () => { executeCommand: vi.fn<() => Promise>().mockRejectedValue(new Error('port unavailable')) } as unknown as CommandService; handler['messageService'] = {} as MessageService; + (handler as unknown as { logger: ILogger }).logger = stubLogger(); await expect(handler.exposeFindPort()).rejects.toThrow('port unavailable'); }); From 3db94db352b7390b427a87fc57cebc0a833d03e6 Mon Sep 17 00:00:00 2001 From: Martin Fleck Date: Wed, 30 Sep 2026 14:02:52 +0200 Subject: [PATCH 2/6] fix(client-theia): stop a port lookup once its channel closes - Poll the port command only while the frontend's channel is open, so a frontend that gives up and reopens leaves no poll loop behind that dials a dead channel once the port is published - Log a lookup the frontend ended at info, without an error notification --- ...ct-socket-forwarding-connection-handler.ts | 28 ++++++++++++-- ...cket-forwarding-connection-handler.test.ts | 37 +++++++++++++++++++ .../glsp-server-connection-handler.test.ts | 3 +- 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/packages/client-theia/src/node/abstract-socket-forwarding-connection-handler.ts b/packages/client-theia/src/node/abstract-socket-forwarding-connection-handler.ts index 46e58134..cc790717 100644 --- a/packages/client-theia/src/node/abstract-socket-forwarding-connection-handler.ts +++ b/packages/client-theia/src/node/abstract-socket-forwarding-connection-handler.ts @@ -117,24 +117,46 @@ export abstract class AbstractSocketForwardingConnectionHandler implements Conne // window. const buffered: MessageProvider[] = []; const bufferSub = channel.onMessage(provider => buffered.push(provider)); + const closed = new AbortController(); + const closeSub = channel.onClose(() => closed.abort()); try { - const port = await this.findPort(); + const port = await this.findPort(closed.signal); + closed.signal.throwIfAborted(); this.logger.info(`[${this.logComponent}] Connecting to ${this.serverName} on port ${port}...`); await this.connectToServer(channel, port, { bufferSub, buffered }); this.logger.info(`[${this.logComponent}] Connected to ${this.serverName} on port ${port}.`); } catch (error) { bufferSub.dispose(); + if (closed.signal.aborted) { + this.logger.info(`[${this.logComponent}] Stopped connecting to ${this.serverName}: the frontend closed the channel.`); + return; + } const message = error && typeof error === 'object' && 'message' in error ? String(error.message) : String(error); this.logger.error(`[${this.logComponent}] Could not connect to ${this.serverName}: ${message}`); this.messageService.error(`Could not connect to ${this.serverName}: ` + message); + } finally { + closeSub.dispose(); } } - protected async findPort(): Promise { + /** Poll the port command until it answers, its attempts run out, or `signal` aborts. */ + protected async findPort(signal?: AbortSignal): Promise { const pendingContent = new Deferred(); let counter = 0; + let timer: ReturnType | undefined; + const stop = (): void => { + clearTimeout(timer); + pendingContent.reject(signal?.reason); + }; + if (signal?.aborted) { + stop(); + } + signal?.addEventListener('abort', stop, { once: true }); const tryQueryingPort = (): void => { - setTimeout(async () => { + if (signal?.aborted) { + return; + } + timer = setTimeout(async () => { try { const port = await this.commandService.executeCommand(this.portCommand); // An empty answer fails the attempt like a throw does: left diff --git a/packages/client-theia/test/abstract-socket-forwarding-connection-handler.test.ts b/packages/client-theia/test/abstract-socket-forwarding-connection-handler.test.ts index 3623f7dc..4b1ef31a 100644 --- a/packages/client-theia/test/abstract-socket-forwarding-connection-handler.test.ts +++ b/packages/client-theia/test/abstract-socket-forwarding-connection-handler.test.ts @@ -30,6 +30,11 @@ class TestHandler extends AbstractSocketForwardingConnectionHandler { this.replayBufferedMessages(channel, buffered); } + /** Reach the protected connection setup under test. */ + initialize(channel: Channel): Promise { + return this.initializeServerConnection(channel); + } + /** Reach the protected port lookup under test. */ lookUpPort(): Promise { return this.findPort(); @@ -109,6 +114,38 @@ describe('AbstractSocketForwardingConnectionHandler', () => { expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("'test:port'")); }); + /** + * A frontend that gave up opens a fresh channel, and the default lookup + * polls forever: left running for the closed one, each give-up adds a poll + * loop, and every loop dials once the port is published. + */ + it('stops looking up the port once the frontend closes the channel', async () => { + const handler = new TestHandler({ ...baseOptions(), findPortTimeout: 1 }); + const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as unknown as ILogger; + // Slower than the poll interval, so the close lands while a query is in flight. + const executeCommand = vi.fn(() => new Promise(resolve => setTimeout(() => resolve(undefined), 5))); + const messageService = { error: vi.fn() }; + Object.assign(handler, { logger, messageService, commandService: { executeCommand } as unknown as CommandService }); + const channel = new ForwardingChannel( + 'test', + () => {}, + () => { + throw new Error('write buffer not needed for this test'); + } + ); + + const initialized = handler.initialize(channel); + await vi.waitFor(() => expect(executeCommand).toHaveBeenCalled()); + channel.onCloseEmitter.fire({ reason: 'closed by the frontend' }); + await initialized; + const queries = executeCommand.mock.calls.length; + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(executeCommand).toHaveBeenCalledTimes(queries); + expect(logger.error).not.toHaveBeenCalled(); + expect(messageService.error).not.toHaveBeenCalled(); + }); + /** * The heads bind `127.0.0.1`, so the dial has to name that address rather * than leave Node to resolve its `localhost` default: on a dual-stack machine diff --git a/packages/glsp-client-theia/test/node/glsp-server-connection-handler.test.ts b/packages/glsp-client-theia/test/node/glsp-server-connection-handler.test.ts index 7f3257ea..f0248793 100644 --- a/packages/glsp-client-theia/test/node/glsp-server-connection-handler.test.ts +++ b/packages/glsp-client-theia/test/node/glsp-server-connection-handler.test.ts @@ -106,7 +106,8 @@ describe('GlspServerConnectionHandler', () => { // reached, so the buffer-subscription added by the race-fix never sees // any messages. const stubChannel = { - onMessage: vi.fn().mockReturnValue({ dispose: vi.fn() }) + onMessage: vi.fn().mockReturnValue({ dispose: vi.fn() }), + onClose: vi.fn().mockReturnValue({ dispose: vi.fn() }) } as unknown as Channel; await handler.exposeInitialize(stubChannel); expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('boom')); From 3054f544e7d8dd7b46035f062542d61faf558ebd Mon Sep 17 00:00:00 2001 From: Martin Fleck Date: Wed, 30 Sep 2026 14:12:36 +0200 Subject: [PATCH 3/6] fix(glsp-client-theia): load a diagram at every server log level The Theia GLSP client waited for a line the server logs at info to appear in an Output channel before it started, with no bound. Above info the line was never logged, so the diagram stayed at "Loading diagram..." and nothing reported a failure. Start - The client starts once a workspace is open and sends its first request at once; the backend handler already holds it until its socket to the server is up - A start that has not finished after startupTimeoutMs (30 s by default) fails, and a dispose ends a start without a report - When the backend handler gives up, it closes the channel, so the frontend fails at once instead of waiting out the bound - The first channel to arrive serves the latest start, so a start after a websocket outage does not hang behind an older held open Reporting and retry - A start still running after 3 s shows a progress notification and ends in one notification: an error with Retry, or a success - A failed diagram keeps its overlay with the error and a Retry, which reopens it as a fresh widget in the same tab position; loading again in place would register GLSP's model source handlers twice, and GLSP's status overlay does not show the loader's report - Reading the client after a failed start starts a fresh one over a fresh channel, so reopening a diagram recovers too - The GLSP backend handler logs a failure without a notification of its own, and the contribution's messages speak of "the diagram server" New public names - ClientContributionOptions.startupTimeoutMs and DEFAULT_GLSP_CLIENT_STARTUP_TIMEOUT_MS - HydraniumGlspClientContribution.restart - AbstractHydraniumGlspDiagramManager.reopen and HydraniumGlspDiagramWidget.onDidRequestReopen - The protected AbstractSocketForwardingConnectionHandler .reportConnectFailure and HydraniumGlspDiagramWidget.retryLoad and createRetryButton Breaking changes - ClientContributionOptions drops readyMarker and channelName, and the contribution drops waitForBackendConnected and its outputChannelManager; @theia/output is no longer a peer - The contribution's start no longer settles glspClient, and its initialize raises no notification - GlspServerConnectionHandler raises no notification, and its serverName defaults to "Diagram Server" - The diagram widget shows every load failure itself, whatever DiagramLoadFailure.surfaced says Fixes #227 --- .../test/e2e/order-flow-browser.spec.mts | 11 + examples/order-flow/theia-app/README.md | 2 +- .../test/e2e/order-flow-diagram.spec.mts | 33 -- .../test/e2e/order-flow-log-level.spec.mts | 54 +++ examples/order-flow/theia/README.md | 6 +- .../order-flow-glsp-client-contribution.ts | 25 +- ...er-flow-process-diagram-frontend-module.ts | 2 +- .../src/common/order-flow-diagram-language.ts | 17 +- .../vscode-servers/src/language-client.ts | 2 +- package-lock.json | 2 - ...ct-socket-forwarding-connection-handler.ts | 10 +- ...cket-forwarding-connection-handler.test.ts | 25 ++ packages/glsp-client-theia/README.md | 12 +- packages/glsp-client-theia/package.json | 2 - .../src/browser/client-contribution.ts | 229 +++++++++--- .../src/browser/diagram-loader.ts | 12 +- .../src/browser/diagram-widget.ts | 46 ++- .../src/browser/glsp-diagram-manager.ts | 51 ++- .../node/glsp-server-connection-handler.ts | 16 +- .../style/diagram-loading.css | 7 +- .../test/browser/client-contribution.test.ts | 353 ++++++++++++++---- .../test/browser/diagram-loader.test.ts | 2 - .../test/browser/diagram-widget.test.ts | 54 ++- .../test/browser/glsp-diagram-manager.test.ts | 143 +++++++ .../glsp-server-connection-handler.test.ts | 21 +- 25 files changed, 865 insertions(+), 272 deletions(-) create mode 100644 examples/order-flow/theia-app/test/e2e/order-flow-log-level.spec.mts diff --git a/examples/order-flow/browser/test/e2e/order-flow-browser.spec.mts b/examples/order-flow/browser/test/e2e/order-flow-browser.spec.mts index 0eeeab3d..1a48a372 100644 --- a/examples/order-flow/browser/test/e2e/order-flow-browser.spec.mts +++ b/examples/order-flow/browser/test/e2e/order-flow-browser.spec.mts @@ -1058,6 +1058,17 @@ test.describe('order-flow in a web worker', () => { await expect(page.locator('#log-level')).toHaveValue('debug'); }); + /** + * The level filters what the server logs, so a host that waits for a logged + * line before connecting its diagram never connects above `info`. Nothing on + * this page does, and this keeps it so. + */ + test('the diagram loads with the level above info from startup', async ({ page }) => { + await page.goto('/?log=warn'); + await expect(page.locator('[data-report="glsp-head"]')).toHaveAttribute('title', RENDERED_REPORT); + await expect(page.locator('#log').getByText('Log level info → warn (setting)', { exact: false })).toHaveCount(1); + }); + test('the log-level picker reaches the server threshold over LSP configuration', async ({ page }) => { await page.goto('/'); await expect(page.locator('[data-report="glsp-head"]')).toHaveAttribute('title', RENDERED_REPORT); diff --git a/examples/order-flow/theia-app/README.md b/examples/order-flow/theia-app/README.md index 8480c581..9c45b78e 100644 --- a/examples/order-flow/theia-app/README.md +++ b/examples/order-flow/theia-app/README.md @@ -19,7 +19,7 @@ data and GLSP heads reached over their own sockets. | Spec | What it observes | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `order-flow-properties.spec.mts` | a panel write reaching the shared Langium workspace — renaming a `.process` root raises a diagnostic on its `.layout`, which is cross-document and cross-grammar | -| `order-flow-diagram.spec.mts` | the diagram loading, with the ready-marker handshake read out of the **server's own log** rather than the UI, which cannot tell "never printed" from "never received" | +| `order-flow-diagram.spec.mts` | the diagram loading, and its loading overlay coming down | | `order-flow-diagnostics.spec.mts` | the extension's _second_ channel to the data head answering at all | | `order-flow-restart.spec.mts` | recovery after the language server is killed underneath the panel | diff --git a/examples/order-flow/theia-app/test/e2e/order-flow-diagram.spec.mts b/examples/order-flow/theia-app/test/e2e/order-flow-diagram.spec.mts index dd27e493..c1802c2c 100644 --- a/examples/order-flow/theia-app/test/e2e/order-flow-diagram.spec.mts +++ b/examples/order-flow/theia-app/test/e2e/order-flow-diagram.spec.mts @@ -15,29 +15,15 @@ * the data head, the diagram to the GLSP head — and a diagram that never * finishes loading would otherwise take the panel's passing assertions down with * it, or worse, be masked by them. - * - * The ready-marker test reads the LANGUAGE SERVER's own log rather than the UI, - * and that is the point of it: `HydraniumGlspClientContribution.waitForBackendConnected` - * gates the client on a server-printed marker arriving in a Theia Output - * channel, so when the diagram hangs there are two very different causes — the - * server never printed the marker, or it printed it and the Theia side never saw - * it. The UI cannot tell them apart. The captured log can, and reading a file - * perturbs nothing, whereas opening the Output view to look would itself change - * the state under test. */ import { expect } from '@playwright/test'; -import { resolveServerLogPath } from '@hydranium/core/testing/playwright'; import { type TheiaApp, TheiaExplorerView } from '@theia/playwright'; -import { readFileSync } from 'node:fs'; import { loadOrderFlowApp, test } from './order-flow-app.mjs'; /** Class contract published by `@hydranium/glsp-client-theia`'s diagram widget. */ const LOADING_OVERLAY_CLASS = 'hydranium-diagram-loading'; -/** Must match `ORDER_FLOW_GLSP_READY_MARKER` in `order-flow-theia`. */ -const GLSP_READY_MARKER = 'Starting GLSP server connection'; - /** * Watch for the loading overlay from *before* the diagram is opened. * @@ -111,23 +97,4 @@ test.describe.serial('Order-flow diagram in Theia', () => { expect(await loadingOverlayWasSeen(app)).toBe(true); await expect(app.page.locator(`.${LOADING_OVERLAY_CLASS}`)).toHaveCount(0); }); - - test('the GLSP ready marker is emitted on the language server connection', async () => { - // Diagnostic, and ordered last on purpose: it runs after the diagram test - // so it reports on that attempt, and it reads a file rather than the UI so - // it cannot itself resolve the very wait it is investigating. - // - // The marker is `JsonRpcGLSPServerLauncher.configureClientConnection`'s - // log line, i.e. it is printed when a client CONNECTS to the GLSP socket, - // not when the socket starts listening. It reaches this log through - // `GlspClientLogger`, the same sink that carries it to the Theia Output - // channel the client contribution tails — so its presence here means the - // server side of that handshake happened and the frontend's failure to see - // it is a delivery problem, and its absence means no client ever reached - // the socket. - const logPath = resolveServerLogPath(app.workspace.path); - expect(logPath, 'server-log capture is off; run with HYDRANIUM_SERVER_LOG_DIR set').toBeDefined(); - const log = readFileSync(logPath!, 'utf-8'); - expect(log).toContain(GLSP_READY_MARKER); - }); }); diff --git a/examples/order-flow/theia-app/test/e2e/order-flow-log-level.spec.mts b/examples/order-flow/theia-app/test/e2e/order-flow-log-level.spec.mts new file mode 100644 index 00000000..14da475b --- /dev/null +++ b/examples/order-flow/theia-app/test/e2e/order-flow-log-level.spec.mts @@ -0,0 +1,54 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available in the project root. + * + * SPDX-License-Identifier: MIT + ********************************************************************************/ + +/** + * The order-flow diagram with the server log level above `info`. + * + * The level filters what the server writes to the Output channel, so a client + * gate that waits for a line in that channel never opens at `warn`: the diagram + * stays at its loading overlay and nothing reports a failure. A separate spec + * because the level is a workspace setting, read once at startup, and every + * other spec runs at the default. + */ + +import { expect } from '@playwright/test'; +import { type TheiaApp, TheiaExplorerView } from '@theia/playwright'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { loadOrderFlowApp, test } from './order-flow-app.mjs'; + +/** A workspace overlay that raises the server log level to `warn`. */ +function warnLevelOverlay(): string { + const overlay = mkdtempSync(path.join(tmpdir(), 'order-flow-log-warn-')); + mkdirSync(path.join(overlay, '.theia')); + writeFileSync(path.join(overlay, '.theia', 'settings.json'), JSON.stringify({ 'order-flow.log.level': 'warn' })); + return overlay; +} + +test.describe('Order-flow diagram with the server log level at warn', () => { + let app: TheiaApp; + + test.beforeAll(async ({ playwright, browser }) => { + app = await loadOrderFlowApp({ playwright, browser }, [warnLevelOverlay()]); + }); + + test.afterAll(async () => { + await app.page.close(); + }); + + test('opens fulfillment.process as a GLSP diagram', async () => { + const explorer = await app.openView(TheiaExplorerView); + await explorer.waitForVisibleFileNodes(); + await explorer.clickContextMenuItem('orders/fulfillment.process', ['Open']); + + await expect(app.page.locator('.sprotty').getByText('Pay', { exact: false }).first()).toBeVisible({ timeout: 30_000 }); + await expect(app.page.locator('.sprotty').getByText('Ship', { exact: false }).first()).toBeVisible(); + }); +}); diff --git a/examples/order-flow/theia/README.md b/examples/order-flow/theia/README.md index 86f8f4c0..0f408a74 100644 --- a/examples/order-flow/theia/README.md +++ b/examples/order-flow/theia/README.md @@ -46,9 +46,9 @@ wiring: the navigator, the tab bar and the open GLSP diagram all publish a selection this provider can read. - **`OrderFlowGlspClientContribution` holds the client back until a workspace is - open, then tails an Output channel for a server-printed ready marker.** In the - Theia deployment the server is launched by a _sideloaded VS Code extension_, - so connecting eagerly would dial a server that has not started. + open.** In the Theia deployment the server is launched by a _sideloaded VS Code + extension_; the backend forwarder holds the client's first request until the + server publishes its port. ## The port commands, and the one silent failure diff --git a/examples/order-flow/theia/src/browser/order-flow-glsp-client-contribution.ts b/examples/order-flow/theia/src/browser/order-flow-glsp-client-contribution.ts index f410eb70..43c819da 100644 --- a/examples/order-flow/theia/src/browser/order-flow-glsp-client-contribution.ts +++ b/examples/order-flow/theia/src/browser/order-flow-glsp-client-contribution.ts @@ -9,31 +9,18 @@ import { HydraniumGlspClientContribution } from '@hydranium/glsp-client-theia/lib/browser'; import { injectable } from '@theia/core/shared/inversify'; -import { - ORDER_FLOW_DIAGRAM_LANGUAGE_ID, - ORDER_FLOW_GLSP_READY_MARKER, - ORDER_FLOW_OUTPUT_CHANNEL -} from '../common/order-flow-diagram-language'; +import { ORDER_FLOW_DIAGRAM_LANGUAGE_ID } from '../common/order-flow-diagram-language'; /** - * Pins this shell's GLSP contribution id, Output channel and ready marker. + * Pins this shell's GLSP contribution id. * - * The framework base supplies what a plugin-hosted server needs: it holds the - * client back until a workspace is open, then tails the Output channel until the - * server prints the ready marker. Without that, the client would try to connect - * before the sideloaded VS Code extension has even launched the server. - * - * Deliberately the thin wrapper it looks like: these values are the only - * per-adopter part of the arrangement, so anything more here means the base is - * being worked around rather than configured. + * Deliberately the thin wrapper it looks like: the id is the only per-adopter + * part of the arrangement, so anything more here means the base is being + * worked around rather than configured. */ @injectable() export class OrderFlowGlspClientContribution extends HydraniumGlspClientContribution { constructor() { - super({ - languageContributionId: ORDER_FLOW_DIAGRAM_LANGUAGE_ID, - channelName: ORDER_FLOW_OUTPUT_CHANNEL, - readyMarker: ORDER_FLOW_GLSP_READY_MARKER - }); + super({ languageContributionId: ORDER_FLOW_DIAGRAM_LANGUAGE_ID }); } } diff --git a/examples/order-flow/theia/src/browser/order-flow-process-diagram-frontend-module.ts b/examples/order-flow/theia/src/browser/order-flow-process-diagram-frontend-module.ts index 5278c06b..9bff00eb 100644 --- a/examples/order-flow/theia/src/browser/order-flow-process-diagram-frontend-module.ts +++ b/examples/order-flow/theia/src/browser/order-flow-process-diagram-frontend-module.ts @@ -33,7 +33,7 @@ export class OrderFlowProcessDiagramModule extends AbstractHydraniumGlspTheiaFro protected readonly diagramManager = OrderFlowProcessDiagramManager; protected override readonly logLevelPreference = ORDER_FLOW_LOG_LEVEL_PREFERENCE; - // The workspace-deferred start + ready-marker tail; see the contribution. + // The workspace-deferred start; see the contribution. protected override bindClientContribution(): typeof OrderFlowGlspClientContribution { return OrderFlowGlspClientContribution; } diff --git a/examples/order-flow/theia/src/common/order-flow-diagram-language.ts b/examples/order-flow/theia/src/common/order-flow-diagram-language.ts index 2c67cfb7..ffcbecd2 100644 --- a/examples/order-flow/theia/src/common/order-flow-diagram-language.ts +++ b/examples/order-flow/theia/src/common/order-flow-diagram-language.ts @@ -62,25 +62,10 @@ export const ORDER_FLOW_HOST_PORT_COMMANDS = { * * Must match the `name` the VS Code extension passes to `new LanguageClient` * (`'Order Flow'`), because that is the channel `vscode-languageclient` creates - * and the one `plugin-ext` surfaces to Theia. The GLSP server's own logs land - * here too — they route through `GlspClientLogger` onto the LSP connection — - * which is what lets the client contribution tail this one channel for - * {@link ORDER_FLOW_GLSP_READY_MARKER}. + * and the one `plugin-ext` surfaces to Theia. */ export const ORDER_FLOW_OUTPUT_CHANNEL = 'Order Flow'; -/** - * Server-printed marker the client contribution tails before connecting. - * - * The string is `@eclipse-glsp/server`'s own, logged by its JSON-RPC launcher - * when a client CONNECTS to the GLSP socket — not when the socket starts - * listening, which is the earlier and less useful moment. It reaches this - * channel because `startGlspServer` binds the container's logger onto the - * adopter's `createLogger`, which routes over the LSP connection. Changing it - * means changing what the launcher prints, so it is taken as given. - */ -export const ORDER_FLOW_GLSP_READY_MARKER = 'Starting GLSP server connection'; - /** * Preference driving the framework log threshold, applied once at startup. * diff --git a/examples/order-flow/vscode-servers/src/language-client.ts b/examples/order-flow/vscode-servers/src/language-client.ts index 3133d5d7..ca57b0e9 100644 --- a/examples/order-flow/vscode-servers/src/language-client.ts +++ b/examples/order-flow/vscode-servers/src/language-client.ts @@ -33,7 +33,7 @@ import { LanguageClient, type LanguageClientOptions, type ServerOptions, Transpo const LANGUAGE_IDS = ['order-flow-domain', 'order-flow-process', 'order-flow-layout'] as const; /** The client's name, which is also the Output channel `vscode-languageclient` - * creates. Theia-side integrations tail that channel by this exact string. */ + * creates. Theia-side integrations name that channel by this exact string. */ export const ORDER_FLOW_LANGUAGE_CLIENT_NAME = 'Order Flow'; /** Host command ids the two socket-head ports are reachable under. diff --git a/package-lock.json b/package-lock.json index 72af0538..fe095f8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23543,7 +23543,6 @@ "@hydranium/glsp-server": "1.0.0-next", "@hydranium/protocol": "1.0.0-next", "@theia/core": "^1.70.0", - "@theia/output": "^1.70.0", "@theia/process": "^1.70.0", "@theia/workspace": "^1.70.0", "@types/node": "^22.0.0", @@ -23562,7 +23561,6 @@ "@hydranium/client-theia": "^1.0.0-next", "@hydranium/protocol": "^1.0.0-next", "@theia/core": "^1.70.0", - "@theia/output": "^1.70.0", "@theia/process": "^1.70.0", "@theia/workspace": "^1.70.0", "inversify": "^6.0.0", diff --git a/packages/client-theia/src/node/abstract-socket-forwarding-connection-handler.ts b/packages/client-theia/src/node/abstract-socket-forwarding-connection-handler.ts index cc790717..b4afd986 100644 --- a/packages/client-theia/src/node/abstract-socket-forwarding-connection-handler.ts +++ b/packages/client-theia/src/node/abstract-socket-forwarding-connection-handler.ts @@ -133,12 +133,20 @@ export abstract class AbstractSocketForwardingConnectionHandler implements Conne } const message = error && typeof error === 'object' && 'message' in error ? String(error.message) : String(error); this.logger.error(`[${this.logComponent}] Could not connect to ${this.serverName}: ${message}`); - this.messageService.error(`Could not connect to ${this.serverName}: ` + message); + this.reportConnectFailure(message); + // Closed so the frontend learns of it: left open, the channel has no + // server behind it, and every request on it waits for good. + channel.close(); } finally { closeSub.dispose(); } } + /** Tell the user this handler gave up; `message` is the reason it logged. */ + protected reportConnectFailure(message: string): void { + this.messageService.error(`Could not connect to ${this.serverName}: ` + message); + } + /** Poll the port command until it answers, its attempts run out, or `signal` aborts. */ protected async findPort(signal?: AbortSignal): Promise { const pendingContent = new Deferred(); diff --git a/packages/client-theia/test/abstract-socket-forwarding-connection-handler.test.ts b/packages/client-theia/test/abstract-socket-forwarding-connection-handler.test.ts index 4b1ef31a..6a47fb09 100644 --- a/packages/client-theia/test/abstract-socket-forwarding-connection-handler.test.ts +++ b/packages/client-theia/test/abstract-socket-forwarding-connection-handler.test.ts @@ -146,6 +146,31 @@ describe('AbstractSocketForwardingConnectionHandler', () => { expect(messageService.error).not.toHaveBeenCalled(); }); + /** + * A frontend learns that the backend gave up only from its channel closing. + * Left open, the channel has no server behind it and the frontend's first + * request waits for good. + */ + it('closes the channel when it gives up connecting', async () => { + const handler = new TestHandler({ ...baseOptions(), findPortTimeout: 1, findPortAttempts: 0 }); + const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as unknown as ILogger; + const executeCommand = vi.fn(async () => undefined); + Object.assign(handler, { + logger, + commandService: { executeCommand } as unknown as CommandService, + messageService: { error: vi.fn() } + }); + const close = vi.fn(); + const channel = new ForwardingChannel('test', close, () => { + throw new Error('write buffer not needed for this test'); + }); + + await handler.initialize(channel); + + expect(close).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("'test:port'")); + }); + /** * The heads bind `127.0.0.1`, so the dial has to name that address rather * than leave Node to resolve its `localhost` default: on a dual-stack machine diff --git a/packages/glsp-client-theia/README.md b/packages/glsp-client-theia/README.md index dd885e9a..1b1a517d 100644 --- a/packages/glsp-client-theia/README.md +++ b/packages/glsp-client-theia/README.md @@ -47,10 +47,13 @@ if you are mounting a hydranium GLSP diagram in a Theia application. `NoOpExternalMarkerManager` instead: markers still decorate the diagram, but Theia's Problems view stops double-listing them. **`AbstractHydraniumGlspDiagramManager`** derives the language-correlated - getters from one descriptor plus a label. -- **`HydraniumGlspClientContribution`** — for a server that starts late: it tails - the Output channel for a server-printed ready marker and defers `start` until a - workspace is open. + getters from one descriptor plus a label, and `reopen` replaces a diagram + with a fresh widget in the same tab position, as the Retry of a failed load + does. +- **`HydraniumGlspClientContribution`** — for a server that starts late: it + defers `start` until a workspace is open, fails a start that takes longer than + `startupTimeoutMs` (30 s by default), and offers a Retry that starts a fresh + client. - **`HydraniumGlspSaveable`** — the diagram widget's saveable. Each save is a `RequestSaveModelAction` the server answers: the save resolves on its own response, and rejects on its own rejection or after 10 s rather than GLSP's @@ -85,7 +88,6 @@ hydranium GLSP server. The declared peer dependencies are: | `@hydranium/client-theia` | `^1.0.0-next` | | `@hydranium/protocol` | `^1.0.0-next` | | `@theia/core` | `^1.70.0` | -| `@theia/output` | `^1.70.0` | | `@theia/process` | `^1.70.0` | | `@theia/workspace` | `^1.70.0` | | `inversify` | `^6.0.0` | diff --git a/packages/glsp-client-theia/package.json b/packages/glsp-client-theia/package.json index 08c2ff6f..7f27b22a 100644 --- a/packages/glsp-client-theia/package.json +++ b/packages/glsp-client-theia/package.json @@ -84,7 +84,6 @@ "@hydranium/glsp-server": "1.0.0-next", "@hydranium/protocol": "1.0.0-next", "@theia/core": "^1.70.0", - "@theia/output": "^1.70.0", "@theia/process": "^1.70.0", "@theia/workspace": "^1.70.0", "@types/node": "^22.0.0", @@ -100,7 +99,6 @@ "@hydranium/client-theia": "^1.0.0-next", "@hydranium/protocol": "^1.0.0-next", "@theia/core": "^1.70.0", - "@theia/output": "^1.70.0", "@theia/process": "^1.70.0", "@theia/workspace": "^1.70.0", "inversify": "^6.0.0", diff --git a/packages/glsp-client-theia/src/browser/client-contribution.ts b/packages/glsp-client-theia/src/browser/client-contribution.ts index ca3adf2a..64e11899 100644 --- a/packages/glsp-client-theia/src/browser/client-contribution.ts +++ b/packages/glsp-client-theia/src/browser/client-contribution.ts @@ -7,35 +7,34 @@ * SPDX-License-Identifier: MIT ********************************************************************************/ -import { type GLSPClient } from '@eclipse-glsp/client'; +import { type GLSPClient, type InitializeResult } from '@eclipse-glsp/client'; import { BaseGLSPClientContribution } from '@eclipse-glsp/theia-integration'; +import { createChannelConnection, GLSPContribution } from '@eclipse-glsp/theia-integration/lib/common'; +import { type Channel, Disposable, Event, nls, type Progress } from '@theia/core'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { inject, injectable, unmanaged } from '@theia/core/shared/inversify'; -import { OutputChannelManager } from '@theia/output/lib/browser/output-channel'; import { WorkspaceService } from '@theia/workspace/lib/browser'; -/** Options for `HydraniumGlspClientContribution`. The channel name is the - * Theia Output channel both the LSP and GLSP sides write to (so log lines - * interleave); the ready marker is a server-printed substring that signals - * "the GLSP server is accepting client connections". */ +export const DEFAULT_GLSP_CLIENT_STARTUP_TIMEOUT_MS = 30_000; + +/** Options for `HydraniumGlspClientContribution`. */ export interface ClientContributionOptions { readonly languageContributionId: string; - readonly channelName: string; - readonly readyMarker: string; + /** How long a start may take, from the workspace opening to the server + * answering `initializeServer`, before it fails; `0` or less waits for good. */ + readonly startupTimeoutMs?: number; } /** * Theia GLSP client contribution for adopters whose GLSP server runs in a * sideloaded VS Code extension process (or any other deferred-start setup). - * The behaviours adopters reliably need are lifted: * - * 1. `waitForBackendConnected`: tail the OutputChannel for a server-printed - * ready marker. While a socket connection might open earlier, the GLSP - * server typically does internal initialisation before it can accept - * client connections — we wait for it to say so. - * 2. `start`: defer until a workspace is opened. If Theia starts without a - * workspace, this prevents the frontend-backend connection (and its - * "connecting" progress spinner) from firing prematurely. + * The client starts once a workspace is open and sends its first request at + * once; the backend handler holds it until its socket to the server is up. A + * start still running after three seconds shows its progress, and ends in one + * notification. A failed start rejects {@link glspClient}, and its + * notification's Retry, like reading {@link glspClient} again, starts a fresh + * client. * * A `GLSPClient` override filtering inbound messages by clientId is * deliberately NOT provided: upstream's `BaseJsonrpcGLSPClient.onActionMessage` @@ -44,57 +43,183 @@ export interface ClientContributionOptions { */ @injectable() export class HydraniumGlspClientContribution extends BaseGLSPClientContribution { - @inject(OutputChannelManager) protected outputChannelManager!: OutputChannelManager; @inject(WorkspaceService) protected readonly workspaceService!: WorkspaceService; readonly id: string; - protected readonly channelName: string; - protected readonly readyMarker: string; + + /** The attempt waiting for a channel, if any. */ + protected channelRequest?: Deferred; + protected disposed = false; constructor(@unmanaged() options: ClientContributionOptions) { super(); this.id = options.languageContributionId; - this.channelName = options.channelName; - this.readyMarker = options.readyMarker; + this.glspClientStartupTimeout = options.startupTimeoutMs ?? DEFAULT_GLSP_CLIENT_STARTUP_TIMEOUT_MS; + } + + /** The client every diagram load awaits; after a failed start, a fresh one. */ + override get glspClient(): Promise { + return this.glspClientDeferred.state === 'rejected' ? this.restart() : this.glspClientDeferred.promise; } - /** Wait for the server to print its ready marker into the OutputChannel. - * Resolves immediately if the marker is already present (server started - * before the client contribution mounted). */ - protected async waitForBackendConnected(): Promise { - const channel = this.outputChannelManager.getChannel(this.channelName); - const channelText = - (channel as unknown as { resource?: { textModel?: { getValue(): string } } }).resource?.textModel?.getValue() ?? ''; - if (channelText.includes(this.readyMarker)) { - return; + /** Start a fresh client over a fresh channel if the last start failed. */ + restart(): Promise { + if (!this.disposed && this.glspClientDeferred.state === 'rejected') { + this.toDispose.dispose(); + // Upstream refuses to open a channel while the collection is disposed. + this.toDispose.push(Disposable.NULL); + this.glspClientDeferred = new Deferred(); + void this.activateClient(); } - const deferred = new Deferred(); - const channelListener = channel.onContentChange(() => { - const text = (channel as unknown as { resource?: { textModel?: { getValue(): string } } }).resource?.textModel?.getValue() ?? ''; - if (text.includes(this.readyMarker)) { - channelListener.dispose(); - deferred.resolve(); + return this.glspClientDeferred.promise; + } + + /** Settles the promise current when it began, so a start a restart overtook + * cannot settle its successor. */ + protected override async activateClient(): Promise { + const pending = this.glspClientDeferred; + await this.workspaceOpened(); + let progress: Promise | undefined; + const notice = setTimeout(() => (progress = this.showConnecting()), this.connectingNoticeDelayMs); + const timeout = + this.glspClientStartupTimeout > 0 + ? setTimeout(() => pending.reject(new Error(this.startupTimeoutMessage())), this.glspClientStartupTimeout) + : undefined; + pending.promise.then( + () => { + if (!this.disposed) { + this.reportStarted(progress); + } + }, + (error: unknown) => { + if (!this.disposed) { + void this.reportStartFailure(error, progress); + } } - }); - return deferred.promise; + ); + void pending.promise + .finally(() => { + clearTimeout(notice); + clearTimeout(timeout); + }) + .catch(() => undefined); + try { + const connection = await this.createConnection(); + // Ahead of the client's own listener, whose teardown rejects with a + // transport message instead. + connection.onClose(() => pending.reject(new Error(this.unreachableMessage()))); + const client = await this.createGLSPClient(connection); + connection.onDispose(() => client.stop()); + await this.start(client); + pending.resolve(client); + } catch (error: unknown) { + pending.reject(error); + } } + /** Start the client and initialize its server, rejecting on failure; the + * caller settles {@link glspClient}. */ protected override async start(glspClient: GLSPClient): Promise { - // Defer starting the GLSP client until a workspace is opened. If Theia - // starts without a workspace, this prevents creating the frontend- - // backend connection (and showing the "connecting" progress) prematurely. + await glspClient.start(); + await this.initialize(glspClient); + } + + /** Without upstream's own error notification, which would duplicate the result one. */ + protected override async initialize(glspClient: GLSPClient): Promise { + return glspClient.initializeServer(await this.createInitializeParameters()); + } + + /** + * Whichever channel arrives goes to the attempt waiting now, and one that + * arrives with none waiting is closed. Theia holds each open until its + * websocket is back, and after an outage every held open past the first + * throws "already open" without reaching its handler, so the first to arrive + * has to serve the latest attempt. Without Theia's replay, which would be a + * second opener of the same path. + */ + protected override async createChannelConnection(): ReturnType { + this.channelRequest?.reject(new Error('A newer start took over the channel request.')); + const request = new Deferred(); + this.channelRequest = request; + this.connectionProvider.listen( + GLSPContribution.getPath(this), + (_path, channel) => { + const waiting = this.channelRequest; + this.channelRequest = undefined; + if (waiting && !this.disposed) { + waiting.resolve(channel); + } else { + channel.close(); + } + }, + false + ); + const channel = await request.promise; + const connection = createChannelConnection(channel); + this.toDispose.push(Disposable.create(() => this.disposeChannel(connection, channel))); + return connection; + } + + /** Also closes the channel: upstream leaves a Theia channel open, and the + * next one on the same path then cannot open. */ + protected override async disposeChannel( + connection: Parameters[0], + channel: Channel + ): Promise { + await super.disposeChannel(connection, channel); + channel.close(); + } + + /** Also fails a start in flight, so nothing reports it after this. */ + override dispose(): void { + this.disposed = true; + this.glspClientDeferred.promise.catch(() => undefined); + this.glspClientDeferred.reject(new Error('The diagram client was disposed.')); + super.dispose(); + } + + protected async workspaceOpened(): Promise { const roots = this.workspaceService.tryGetRoots(); - if (!roots || roots.length === 0) { - await new Promise(resolve => { - const disposable = this.workspaceService.onWorkspaceChanged(changedRoots => { - if (changedRoots && changedRoots.length > 0) { - disposable.dispose(); - resolve(); - } - }); - }); + if (roots.length === 0) { + await Event.toPromise(Event.filter(this.workspaceService.onWorkspaceChanged, changedRoots => changedRoots.length > 0)); + } + } + + /** A start that takes longer than this shows a progress notification. */ + protected readonly connectingNoticeDelayMs: number = 3_000; + + protected showConnecting(): Promise { + return this.messageService.showProgress({ + text: nls.localize('hydranium/glsp-client-theia/diagram-server-connecting', 'Connecting to the diagram server…') + }); + } + + /** A start that showed progress ends in a notification; a quick one stays silent. */ + protected reportStarted(progress: Promise | undefined): void { + if (progress) { + void progress.then(shown => shown.cancel()); + this.messageService.info(nls.localize('hydranium/glsp-client-theia/diagram-server-connected', 'Connected to the diagram server.')); + } + } + + protected async reportStartFailure(error: unknown, progress: Promise | undefined): Promise { + void progress?.then(shown => shown.cancel()); + const retry = nls.localize('hydranium/glsp-client-theia/diagram-retry', 'Retry'); + const choice = await this.messageService.error(error instanceof Error ? error.message : String(error), retry); + if (choice === retry) { + void this.restart(); } - await this.waitForBackendConnected(); - return super.start(glspClient); + } + + protected unreachableMessage(): string { + return nls.localize('hydranium/glsp-client-theia/diagram-server-unreachable', 'Could not connect to the diagram server.'); + } + + protected startupTimeoutMessage(): string { + return nls.localize( + 'hydranium/glsp-client-theia/diagram-server-timeout', + 'The diagram server did not answer within {0} seconds.', + String(Math.round(this.glspClientStartupTimeout / 1000)) + ); } } diff --git a/packages/glsp-client-theia/src/browser/diagram-loader.ts b/packages/glsp-client-theia/src/browser/diagram-loader.ts index b7685922..09fad944 100644 --- a/packages/glsp-client-theia/src/browser/diagram-loader.ts +++ b/packages/glsp-client-theia/src/browser/diagram-loader.ts @@ -32,8 +32,7 @@ export interface DiagramLoadFailure { readonly error: unknown; /** * Whether the `severity: 'ERROR'` {@link StatusAction} reached the action - * dispatcher — i.e. whether GLSP's `StatusOverlay` is now displaying this - * failure. + * dispatcher. It says nothing about whether a status overlay shows it. * * `false` means the report itself failed, which happens when the action * dispatcher is the thing that could not initialize. A consumer that covers @@ -102,10 +101,7 @@ export class HydraniumDiagramLoader extends DiagramLoader { await super.load(options); this.settle({ status: 'loaded' }); } catch (err) { - // Report first, settle second, and carry whether the report landed: a - // consumer that uncovers the canvas on settle then finds the error - // already on the status overlay — or learns that it has to render the - // failure itself because nothing else can. + // Report first, settle second, and carry whether the report landed. const surfaced = await this.reportLoadFailure(err); this.settle({ status: 'failed', error: err, surfaced }); } @@ -158,9 +154,7 @@ export class HydraniumDiagramLoader extends DiagramLoader { /** * The sentence a failed load is reported with, on every surface that reports * it. Public because the canvas overlay is one of those surfaces and lives in - * another class: it renders this failure exactly when the `StatusAction` did - * not land, so a second copy of the sentence there could only ever drift - * unseen. + * another class, where a second copy of the sentence would drift. * * **The seam and the catalogue key answer different questions, so it carries * both.** Overriding this method changes the WORDING for every user of one diff --git a/packages/glsp-client-theia/src/browser/diagram-widget.ts b/packages/glsp-client-theia/src/browser/diagram-widget.ts index b8dcf4e4..397d9234 100644 --- a/packages/glsp-client-theia/src/browser/diagram-widget.ts +++ b/packages/glsp-client-theia/src/browser/diagram-widget.ts @@ -12,6 +12,7 @@ import { GLSPDiagramWidget, type GLSPDiagramWidgetOptions } from '@eclipse-glsp/ // Type-only: the `@theia/core/lib/browser` barrel touches DOM globals at module // load, which the node-environment unit tests cannot provide. import { type Message } from '@theia/core/lib/browser'; +import { Emitter, type Event, nls } from '@theia/core'; import { type Container, injectable } from '@theia/core/shared/inversify'; import { type DiagramLoadOutcome, HydraniumDiagramLoader } from './diagram-loader'; import { HydraniumGlspSaveable } from './glsp-saveable'; @@ -47,12 +48,10 @@ export const DIAGRAM_LOADING_FAILED_CLASS = `${DIAGRAM_LOADING_CLASS}-failed`; * whether the canvas is pending and the two mechanisms compose instead of * competing. * - * **The one case where this overlay reports the failure itself.** When - * `DiagramLoadFailure.surfaced` is `false` the loader could not dispatch its - * `StatusAction` — the action dispatcher was what failed — so the status overlay - * shows nothing. Uncovering the canvas would then leave a blank diagram whose only - * explanation is a line in the Output channel. In that case, and only that case, - * the overlay stays up and swaps the spinner for the error text. + * A failed load keeps the overlay up with the error and a Retry, which asks for + * a fresh widget through {@link onDidRequestReopen}. Uncovering the canvas + * instead leaves it blank: GLSP's status overlay does not show the loader's + * report in this host. * * Bound unconditionally by `AbstractHydraniumGlspTheiaFrontendModule`. To opt out, * override {@link showLoadingOverlay} to a no-op; to change what is rendered, @@ -64,6 +63,13 @@ export const DIAGRAM_LOADING_FAILED_CLASS = `${DIAGRAM_LOADING_CLASS}-failed`; @injectable() export class HydraniumGlspDiagramWidget extends GLSPDiagramWidget { protected loadingOverlay?: HTMLElement; + protected readonly reopenRequestEmitter = new Emitter(); + + /** Fires when this diagram asks to be replaced by a fresh widget, as its + * Retry does; `AbstractHydraniumGlspDiagramManager` reopens it. A load is + * not repeated in place, since GLSP's model source registers its handlers + * once per load. */ + readonly onDidRequestReopen: Event = this.reopenRequestEmitter.event; /** * Replaces the saveable GLSP's `configure` builds inline, with no factory @@ -75,6 +81,7 @@ export class HydraniumGlspDiagramWidget extends GLSPDiagramWidget { this.saveable.dispose(); this.saveable = this.createSaveable(); this.toDispose.push(this.saveable); + this.toDispose.push(this.reopenRequestEmitter); } /** The widget's saveable. Override to change how saves and dirty state behave. */ @@ -121,15 +128,9 @@ export class HydraniumGlspDiagramWidget extends GLSPDiagramWidget { ); } - /** - * Take the overlay down, or keep it as the failure's only reporter. - * - * The overlay is retained ONLY for a failure the loader could not surface; - * anything else uncovers the canvas, so a load that failed *and was reported* - * reveals GLSP's status overlay rather than double-reporting on top of it. - */ + /** Take the overlay down, or keep it as the failure's report. */ protected onLoadSettled(outcome: DiagramLoadOutcome): void { - if (outcome.status === 'failed' && !outcome.surfaced) { + if (outcome.status === 'failed') { this.showLoadFailure(outcome.error); return; } @@ -157,6 +158,23 @@ export class HydraniumGlspDiagramWidget extends GLSPDiagramWidget { if (label) { label.textContent = loader.loadFailureLabel(error); } + overlay.appendChild(this.createRetryButton()); + } + + /** Ask for a fresh widget, after a failed load. */ + protected retryLoad(): void { + if (this.hydraniumDiagramLoader?.loadOutcome?.status === 'failed') { + this.reopenRequestEmitter.fire(); + } + } + + /** Build the failure overlay's Retry button, which calls {@link retryLoad}. */ + protected createRetryButton(): HTMLElement { + const button = document.createElement('button'); + button.className = `theia-button ${DIAGRAM_LOADING_CLASS}-retry`; + button.textContent = nls.localize('hydranium/glsp-client-theia/diagram-load-retry', 'Retry'); + button.addEventListener('click', () => this.retryLoad()); + return button; } protected hideLoadingOverlay(): void { diff --git a/packages/glsp-client-theia/src/browser/glsp-diagram-manager.ts b/packages/glsp-client-theia/src/browser/glsp-diagram-manager.ts index ced942e5..631fe04e 100644 --- a/packages/glsp-client-theia/src/browser/glsp-diagram-manager.ts +++ b/packages/glsp-client-theia/src/browser/glsp-diagram-manager.ts @@ -10,9 +10,10 @@ import { codiconCSSString } from '@eclipse-glsp/client'; import { GLSPDiagramManager } from '@eclipse-glsp/theia-integration'; import { type GLSPDiagramLanguage } from '@eclipse-glsp/theia-integration/lib/common'; -import { type GLSPDiagramWidget } from '@eclipse-glsp/theia-integration/lib/browser'; +import { type GLSPDiagramWidget, type GLSPWidgetOpenerOptions } from '@eclipse-glsp/theia-integration/lib/browser'; import { type WidgetOpenerOptions } from '@theia/core/lib/browser'; import { injectable } from '@theia/core/shared/inversify'; +import { HydraniumGlspDiagramWidget } from './diagram-widget'; /** * `GLSPDiagramManager` subclass that derives the language-correlated getters @@ -47,6 +48,9 @@ export abstract class AbstractHydraniumGlspDiagramManager extends GLSPDiagramMan /** Optional icon-class override; takes precedence over the language's `iconClass`. */ protected readonly customIconClass?: string; + /** The reopens asked for so far, which run one at a time. */ + protected reopening: Promise = Promise.resolve(); + override get fileExtensions(): string[] { return [...this.diagramLanguage.fileExtensions]; } @@ -67,6 +71,51 @@ export abstract class AbstractHydraniumGlspDiagramManager extends GLSPDiagramMan return this.managerLabel; } + override async createWidget(options?: unknown): Promise { + const widget = await super.createWidget(options); + if (widget instanceof HydraniumGlspDiagramWidget) { + widget.onDidRequestReopen(() => void this.reopen(widget)); + } + return widget; + } + + /** + * Replace `widget` with a fresh one for the same diagram, in the same tab + * position and with its viewport: a new container, client id and load. + * Reopens run one at a time, since each places its replacement next to a + * neighbour that a reopen running alongside could take out of the layout. + */ + reopen(widget: GLSPDiagramWidget): Promise { + const reopened = this.reopening.then(() => this.replaceWidget(widget)); + this.reopening = reopened.catch(() => undefined); + return reopened; + } + + /** Taken down as a close does, but without its save prompt, since a dirty + * diagram whose server is gone has nothing to save to. */ + protected async replaceWidget(widget: GLSPDiagramWidget): Promise { + if (widget.isDisposed) { + return; + } + const tabBar = this.shell.getTabBarFor(widget); + const titles = tabBar?.titles ?? []; + const index = titles.indexOf(widget.title); + // ponytail: a diagram alone in its tab bar reopens where a new one opens, + // since its split closes with it; restore the dock layout if that matters. + const neighbour = index > 0 ? titles[index - 1].owner : titles[index + 1]?.owner; + const mode = this.shell.activeWidget === widget ? 'activate' : tabBar?.currentTitle === widget.title ? 'reveal' : 'open'; + // Detached before the dispose, as a close does: the widget stores its + // viewport on detach, and its dispose empties the container it reads. + widget.parent = null; + widget.dispose(); + const options: GLSPWidgetOpenerOptions = { + mode, + editMode: widget.options.editMode, + widgetOptions: neighbour ? { ref: neighbour, mode: index > 0 ? 'tab-after' : 'tab-before' } : undefined + }; + await this.open(widget.uri, options); + } + /** * Drops a `selection` key that is present but `undefined` before the base * class reads it. diff --git a/packages/glsp-client-theia/src/node/glsp-server-connection-handler.ts b/packages/glsp-client-theia/src/node/glsp-server-connection-handler.ts index bbe9cf57..ecd6ecaa 100644 --- a/packages/glsp-client-theia/src/node/glsp-server-connection-handler.ts +++ b/packages/glsp-client-theia/src/node/glsp-server-connection-handler.ts @@ -19,15 +19,7 @@ import type * as net from 'net'; export interface GlspServerConnectionHandlerOptions { readonly languageContributionId: string; readonly portCommand: string; - /** - * Product name for the connect-failure dialog this handler raises. - * - * It reaches the adopter's UI verbatim, so the framework default leaks a - * framework noun into a product that is not ours. Not a translation concern — - * routing it through a catalogue would ask an adopter to "translate" English - * into their own product name, and would make their branding - * locale-dependent. - */ + /** Name of the server in this handler's log lines. */ readonly serverName?: string; readonly findPortTimeout?: number; readonly findPortAttempts?: number; @@ -61,11 +53,15 @@ export class GlspServerConnectionHandler extends AbstractSocketForwardingConnect path: GLSPContribution.servicePath + '/' + options.languageContributionId, portCommand: options.portCommand, logComponent: 'GLSP', - serverName: options.serverName ?? 'Graphical Server', + serverName: options.serverName ?? 'Diagram Server', findPortTimeout: options.findPortTimeout, findPortAttempts: options.findPortAttempts, connectTimeoutMs: options.connectTimeoutMs, onSocketCreated: options.onSocketCreated }); } + + /** Logged only: the frontend contribution reports the failed start, and a + * second notification would duplicate it. */ + protected override reportConnectFailure(): void {} } diff --git a/packages/glsp-client-theia/style/diagram-loading.css b/packages/glsp-client-theia/style/diagram-loading.css index 8139a359..37b45f90 100644 --- a/packages/glsp-client-theia/style/diagram-loading.css +++ b/packages/glsp-client-theia/style/diagram-loading.css @@ -49,11 +49,8 @@ } } -/* Failure state: set only when the loader could NOT dispatch its error - StatusAction, so this overlay is the single remaining surface for the message. - The spinner goes (nothing is pending any more) and the text takes the error - colour. A load that failed and WAS reported never reaches this state — the - overlay is removed and GLSP's own status overlay shows it instead. */ +/* Failure state: the spinner goes, since nothing is pending any more, and the + text takes the error colour. */ .hydranium-diagram-loading-failed .hydranium-diagram-loading-spinner { display: none; } diff --git a/packages/glsp-client-theia/test/browser/client-contribution.test.ts b/packages/glsp-client-theia/test/browser/client-contribution.test.ts index cd42ea28..05aa3a7c 100644 --- a/packages/glsp-client-theia/test/browser/client-contribution.test.ts +++ b/packages/glsp-client-theia/test/browser/client-contribution.test.ts @@ -7,103 +7,294 @@ * SPDX-License-Identifier: MIT ********************************************************************************/ -// Stub Theia OutputChannel (DOM-pulling) + BaseGLSPClientContribution (which -// transitively imports from Theia's monaco-coupled chain). Both are used as -// the SUT's runtime base class / @inject target only — stubs are enough for -// the deferred-marker tests. -vi.mock('@theia/output/lib/browser/output-channel', () => ({ - OutputChannelManager: class OutputChannelManager {}, - OutputChannel: class OutputChannel {} -})); -vi.mock('@eclipse-glsp/theia-integration', () => ({ - BaseGLSPClientContribution: class BaseGLSPClientContribution { - protected async start(): Promise { - // upstream sets up the GLSP wire — stubbed for the deferred-start tests. +// The real base class pulls Theia's monaco-coupled chain. The stand-in carries +// only the members the contribution reads, with upstream's defaults. +vi.mock('@eclipse-glsp/theia-integration', async () => { + const { Deferred } = await import('@theia/core/lib/common/promise-util'); + const { DisposableCollection } = await import('@theia/core'); + return { + BaseGLSPClientContribution: class BaseGLSPClientContribution { + glspClientDeferred = new Deferred(); + toDispose = new DisposableCollection(); + glspClientStartupTimeout = 15_000; + get glspClient(): Promise { + return this.glspClientDeferred.promise; + } + async createInitializeParameters(): Promise { + return {}; + } + async disposeChannel(): Promise {} + dispose(): void { + this.toDispose.dispose(); + } } - } -})); + }; +}); vi.mock('@theia/workspace/lib/browser', () => ({ WorkspaceService: class WorkspaceService {} })); +import { type Channel } from '@theia/core'; +import { ForwardingChannel } from '@theia/core/lib/common/message-rpc/channel'; +import { Deferred } from '@theia/core/lib/common/promise-util'; import { describe, expect, it, vi } from 'vitest'; -import { HydraniumGlspClientContribution } from '../../src/browser/client-contribution'; - -interface FakeOutputChannel { - text: string; - listeners: Array<() => void>; - resource: { textModel: { getValue(): string } }; - onContentChange(cb: () => void): { dispose: () => void }; -} +import { DEFAULT_GLSP_CLIENT_STARTUP_TIMEOUT_MS, HydraniumGlspClientContribution } from '../../src/browser/client-contribution'; -function makeFakeChannel(initialText = ''): FakeOutputChannel { - const listeners: Array<() => void> = []; - const channel: FakeOutputChannel = { - text: initialText, - listeners, - resource: { - textModel: { - getValue: () => channel.text - } - }, - onContentChange(cb: () => void): { dispose: () => void } { - listeners.push(cb); - return { - dispose: () => { - const idx = listeners.indexOf(cb); - if (idx >= 0) { - listeners.splice(idx, 1); - } - } - }; - } - }; - return channel; +interface FakeClient { + readonly name: string; + start(): Promise; + initializeServer(): Promise; + stop(): void; } +/** Each start takes its connection from `connections`, in order, so a test + * decides when, and whether, each one arrives. */ class TestContribution extends HydraniumGlspClientContribution { - constructor(options: ConstructorParameters[0]) { - super(options); + readonly connections: Array> = []; + readonly clients: FakeClient[] = []; + readonly errors = vi.fn(async (_message: string, ..._actions: string[]): Promise => undefined); + readonly infos = vi.fn(); + readonly progress = { cancel: vi.fn() }; + readonly showProgress = vi.fn(async () => this.progress); + /** Whether each client's `initializeServer` never answers. */ + initializeHangs = false; + + constructor(startupTimeoutMs?: number, connectingNoticeDelayMs = 1_000) { + super({ languageContributionId: 'test', startupTimeoutMs }); + Object.assign(this, { + connectingNoticeDelayMs, + workspaceService: { tryGetRoots: () => [{}] }, + messageService: { error: this.errors, info: this.infos, showProgress: this.showProgress } + }); + } + + get startupTimeout(): number { + return this.glspClientStartupTimeout; } - exposeWaitForBackend(): Promise { - return this.waitForBackendConnected(); + + begin(): Promise { + return this.activateClient(); + } + + /** Queue the connection the next start receives. */ + nextConnection(): Deferred { + const connection = new Deferred(); + this.connections.push(connection); + return connection; + } + + closeChannel(channel: Channel): Promise { + return this.disposeChannel({} as never, channel); + } + + openChannel(): Promise { + return this.createChannelConnection(); + } + + protected override createConnection(): never { + const next = this.connections.shift(); + if (!next) { + throw new Error('no connection queued'); + } + return next.promise as never; + } + + protected override async createGLSPClient(): Promise { + const client: FakeClient = { + name: `client ${this.clients.length + 1}`, + start: async () => undefined, + initializeServer: () => (this.initializeHangs ? new Promise(() => undefined) : Promise.resolve({})), + stop: () => undefined + }; + this.clients.push(client); + return client as never; } } -describe('HydraniumGlspClientContribution.waitForBackendConnected', () => { - it('resolves immediately when the ready marker is already in the channel', async () => { - const channel = makeFakeChannel('boot...\nStarting GLSP server connection\n'); - const contribution = new TestContribution({ - languageContributionId: 'foo', - channelName: 'Foo', - readyMarker: 'Starting GLSP server connection' - }); - contribution['outputChannelManager'] = { - getChannel: () => channel - } as never; - await expect(contribution.exposeWaitForBackend()).resolves.toBeUndefined(); - expect(channel.listeners).toHaveLength(0); +interface FakeConnection { + onDispose(): void; + onClose(listener: () => void): void; + close(): void; +} + +function makeConnection(): FakeConnection { + const listeners: Array<() => void> = []; + return { + onDispose: () => undefined, + onClose: listener => listeners.push(listener), + close: () => listeners.forEach(listener => listener()) + }; +} + +const connection = makeConnection(); + +describe('HydraniumGlspClientContribution', () => { + it('bounds a start by 30 s unless the options say otherwise', () => { + expect(new TestContribution().startupTimeout).toBe(DEFAULT_GLSP_CLIENT_STARTUP_TIMEOUT_MS); + expect(DEFAULT_GLSP_CLIENT_STARTUP_TIMEOUT_MS).toBe(30_000); + expect(new TestContribution(5).startupTimeout).toBe(5); + }); + + /** + * A server that never answers leaves every diagram load waiting on the + * client, with nothing reported. + */ + it('fails a start that runs out of time and offers a Retry', async () => { + const contribution = new TestContribution(10); + contribution.nextConnection(); + void contribution.begin(); + + await expect(contribution.glspClient).rejects.toThrow('The diagram server did not answer within 0 seconds.'); + await vi.waitFor(() => expect(contribution.errors).toHaveBeenCalledTimes(1)); + expect(contribution.errors).toHaveBeenCalledWith('The diagram server did not answer within 0 seconds.', 'Retry'); + }); + + it('fails a start whose connection closes before the server answers', async () => { + const contribution = new TestContribution(); + contribution.initializeHangs = true; + const closing = makeConnection(); + contribution.nextConnection().resolve(closing); + void contribution.begin(); + await vi.waitFor(() => expect(contribution.clients).toHaveLength(1)); + closing.close(); + + await expect(contribution.glspClient).rejects.toThrow('Could not connect to the diagram server.'); + await vi.waitFor(() => expect(contribution.errors).toHaveBeenCalledWith('Could not connect to the diagram server.', 'Retry')); + }); + + it('shows progress while a start is slow, then one notification with the result', async () => { + const contribution = new TestContribution(undefined, 1); + const arriving = contribution.nextConnection(); + void contribution.begin(); + await vi.waitFor(() => expect(contribution.showProgress).toHaveBeenCalledTimes(1)); + expect(contribution.showProgress).toHaveBeenCalledWith({ text: 'Connecting to the diagram server…' }); + + arriving.resolve(connection); + await vi.waitFor(() => expect(contribution.infos).toHaveBeenCalledWith('Connected to the diagram server.')); + expect(contribution.progress.cancel).toHaveBeenCalledTimes(1); + expect(contribution.errors).not.toHaveBeenCalled(); + }); + + it('replaces the progress with the failure when a slow start fails', async () => { + const contribution = new TestContribution(20, 1); + contribution.nextConnection(); + void contribution.begin(); + await expect(contribution.glspClient).rejects.toThrow(); + + await vi.waitFor(() => expect(contribution.progress.cancel).toHaveBeenCalledTimes(1)); + expect(contribution.errors).toHaveBeenCalledTimes(1); + expect(contribution.infos).not.toHaveBeenCalled(); + }); + + it('resolves the client once it is started and initialized', async () => { + const contribution = new TestContribution(); + contribution.nextConnection().resolve(connection); + await contribution.begin(); + + const client = await contribution.glspClient; + expect(client).toBe(contribution.clients[0]); + // Quick enough that no progress showed, so nothing is announced either. + expect(contribution.showProgress).not.toHaveBeenCalled(); + expect(contribution.infos).not.toHaveBeenCalled(); + expect(contribution.errors).not.toHaveBeenCalled(); + }); + + /** Every diagram load reads the client, so reading it is what lets a diagram + * retry, or a reopened tab, recover from a failed start. */ + it('starts a fresh client when the client is read after a failed start', async () => { + const contribution = new TestContribution(10); + contribution.nextConnection(); + void contribution.begin(); + await expect(contribution.glspClient).rejects.toThrow(); + + contribution.nextConnection().resolve(connection); + const client = await contribution.glspClient; + expect(client).toBe(contribution.clients[0]); + }); + + it('restarts from the notification’s Retry', async () => { + const contribution = new TestContribution(10); + contribution.errors.mockResolvedValueOnce('Retry'); + contribution.nextConnection(); + const retried = contribution.nextConnection(); + void contribution.begin(); + await expect(contribution.glspClient).rejects.toThrow(); + + await vi.waitFor(() => expect(contribution.connections).toHaveLength(0)); + retried.resolve(connection); + const client = await contribution.glspClient; + expect(client).toBe(contribution.clients[0]); + }); + + it('keeps a start that a restart overtook from settling its successor', async () => { + const contribution = new TestContribution(10); + const late = contribution.nextConnection(); + void contribution.begin(); + await expect(contribution.glspClient).rejects.toThrow(); + + // Unbounded from here, so only the overtaken start could settle the successor. + Object.assign(contribution, { glspClientStartupTimeout: 0 }); + contribution.nextConnection(); + const successor = contribution.glspClient; + late.resolve(connection); + await vi.waitFor(() => expect(contribution.clients).toHaveLength(1)); + + const outcome = await Promise.race([ + successor.then(() => 'settled'), + new Promise(resolve => setTimeout(() => resolve('pending'), 5)) + ]); + expect(outcome).toBe('pending'); }); - it('subscribes and resolves once the marker appears in the channel', async () => { - const channel = makeFakeChannel('boot...\n'); - const contribution = new TestContribution({ - languageContributionId: 'foo', - channelName: 'Foo', - readyMarker: 'READY' + /** Upstream disposes a channel only when it is a `Disposable`, which Theia's + * are not, and the next channel on the same path then cannot open. */ + it('closes the channel it tears down', async () => { + const close = vi.fn(); + await new TestContribution().closeChannel({ close } as unknown as Channel); + expect(close).toHaveBeenCalledTimes(1); + }); + + /** A start disposed with it would otherwise still time out and report. */ + it('reports nothing for a start that a dispose ended', async () => { + const contribution = new TestContribution(10, 5); + contribution.nextConnection(); + void contribution.begin(); + await Promise.resolve(); + + contribution.dispose(); + await new Promise(resolve => setTimeout(resolve, 30)); + + expect(contribution.errors).not.toHaveBeenCalled(); + expect(contribution.showProgress).not.toHaveBeenCalled(); + }); + + /** + * Theia holds each open until its websocket is back, and every held open + * past the first then throws "already open" without reaching its handler. + */ + it('hands the first channel to arrive to the latest start, and closes one nobody waits for', async () => { + const contribution = new TestContribution(); + const opens: Array<{ handler: (path: string, channel: Channel) => void; reconnect: boolean }> = []; + Object.assign(contribution, { + connectionProvider: { + listen: (_path: string, handler: (path: string, channel: Channel) => void, reconnect: boolean) => + opens.push({ handler, reconnect }) + } }); - contribution['outputChannelManager'] = { - getChannel: () => channel - } as never; - const waiter = contribution.exposeWaitForBackend(); - // Simulate a content append that doesn't include the marker yet. - channel.text += 'still booting\n'; - channel.listeners.forEach(cb => cb()); - // Now write the marker. - channel.text += 'READY\n'; - channel.listeners.forEach(cb => cb()); - await expect(waiter).resolves.toBeUndefined(); - // Listener disposed itself once the marker fired. - expect(channel.listeners).toHaveLength(0); + const makeChannel = (close = vi.fn()): ForwardingChannel => + new ForwardingChannel('test', close, () => { + throw new Error('write buffer not needed for this test'); + }); + + const overtaken = contribution.openChannel(); + const latest = contribution.openChannel(); + opens[0].handler('path', makeChannel()); + + await expect(latest).resolves.toBeDefined(); + await expect(overtaken).rejects.toThrow(); + const unwanted = vi.fn(); + opens[1].handler('path', makeChannel(unwanted)); + expect(unwanted).toHaveBeenCalledTimes(1); + expect(opens.map(open => open.reconnect)).toEqual([false, false]); }); }); diff --git a/packages/glsp-client-theia/test/browser/diagram-loader.test.ts b/packages/glsp-client-theia/test/browser/diagram-loader.test.ts index 6da3931d..66fe00a0 100644 --- a/packages/glsp-client-theia/test/browser/diagram-loader.test.ts +++ b/packages/glsp-client-theia/test/browser/diagram-loader.test.ts @@ -154,8 +154,6 @@ describe('HydraniumDiagramLoader', () => { const err = new Error('connection refused'); loader.makeSuperLoadThrow(err); await loader.load(); - // `surfaced: true` tells a canvas-covering consumer that GLSP's status - // overlay has the message, so it should uncover rather than double-report. expect(loader.loadOutcome).toEqual({ status: 'failed', error: err, surfaced: true }); }); diff --git a/packages/glsp-client-theia/test/browser/diagram-widget.test.ts b/packages/glsp-client-theia/test/browser/diagram-widget.test.ts index 92b7249a..387628f3 100644 --- a/packages/glsp-client-theia/test/browser/diagram-widget.test.ts +++ b/packages/glsp-client-theia/test/browser/diagram-widget.test.ts @@ -20,6 +20,7 @@ vi.mock('@hydranium/client-theia/lib/browser', () => ({ vi.mock('@eclipse-glsp/theia-integration', () => ({ GLSPDiagramWidget: class GLSPDiagramWidget { onAfterAttachCalls = 0; + initializeDiagramCalls = 0; disposeCalls = 0; saveable?: { dispose(): void }; readonly toDispose = { @@ -38,6 +39,9 @@ vi.mock('@eclipse-glsp/theia-integration', () => ({ protected onAfterAttach(): void { this.onAfterAttachCalls++; } + protected async initializeDiagram(): Promise { + this.initializeDiagramCalls++; + } dispose(): void { this.disposeCalls++; } @@ -67,12 +71,14 @@ interface OverlayHost { appendChild(child: FakeElement): void; } /** Enough of an element for the overlay lifecycle: a class list, the label child - * `showLoadFailure` looks up, and removal from the host. */ + * `showLoadFailure` looks up, appended children, and removal from the host. */ interface FakeElement { className: string; readonly classes: Set; readonly label: { textContent: string }; + readonly appended: unknown[]; classList: { add(token: string): void }; + appendChild(child: unknown): void; querySelector(selector: string): { textContent: string } | undefined; remove(): void; } @@ -120,6 +126,14 @@ class TestableWidget extends HydraniumGlspDiagramWidget { return this.loadingOverlay as unknown as FakeElement | undefined; } + retry(): void { + this.retryLoad(); + } + + protected override createRetryButton(): HTMLElement { + return { kind: 'retry button' } as unknown as HTMLElement; + } + protected override get hydraniumDiagramLoader(): HydraniumDiagramLoader | undefined { return this.loader; } @@ -128,11 +142,14 @@ class TestableWidget extends HydraniumGlspDiagramWidget { this.createdOverlays++; const classes = new Set([DIAGRAM_LOADING_CLASS]); const label = { textContent: this.loadingLabel }; + const appended: unknown[] = []; const element: FakeElement = { className: DIAGRAM_LOADING_CLASS, classes, label, + appended, classList: { add: token => classes.add(token) }, + appendChild: child => appended.push(child), querySelector: selector => (selector === `.${DIAGRAM_LOADING_CLASS}-label` ? label : undefined), remove: () => { const index = this.overlayHost.children.indexOf(element); @@ -189,14 +206,39 @@ describe('HydraniumGlspDiagramWidget', () => { expect(widget.currentOverlay()).toBeUndefined(); }); - it('removes the overlay for a reported failure, revealing the error status underneath', async () => { - // The overlay is opaque and covers the widget node, while the loader reports - // the failure on GLSP's status overlay *inside* the base div. `surfaced: true` - // means that message is already there, so staying up would double-report. + /** The loader's report reaching the dispatcher does not put it on screen: + * uncovering the canvas leaves it blank. */ + it('keeps the overlay with the error and a Retry for a reported failure too', async () => { widget.attach(); loader.settleNow({ status: 'failed', error: new Error('connection refused'), surfaced: true }); await flush(); - expect(widget.overlayHost.children).toHaveLength(0); + + expect(widget.overlayHost.children).toHaveLength(1); + const overlay = widget.overlayHost.children[0]; + expect(overlay.label.textContent).toBe('Diagram failed to load: connection refused'); + expect(overlay.appended).toEqual([{ kind: 'retry button' }]); + }); + + /** A second load in the same container registers the model source's handlers twice. */ + it('asks to be reopened on Retry rather than loading again in place', async () => { + const requests = vi.fn(); + widget.onDidRequestReopen(requests); + widget.attach(); + loader.settleNow({ status: 'failed', error: 'boom', surfaced: true }); + await flush(); + + widget.retry(); + + expect(requests).toHaveBeenCalledTimes(1); + expect((widget as unknown as { initializeDiagramCalls: number }).initializeDiagramCalls).toBe(0); + }); + + it('ignores Retry unless the load failed', () => { + const requests = vi.fn(); + widget.onDidRequestReopen(requests); + widget.attach(); + widget.retry(); + expect(requests).not.toHaveBeenCalled(); }); it('keeps the overlay and shows the error when the failure could not be surfaced', async () => { diff --git a/packages/glsp-client-theia/test/browser/glsp-diagram-manager.test.ts b/packages/glsp-client-theia/test/browser/glsp-diagram-manager.test.ts index 3414d1ab..bf20c851 100644 --- a/packages/glsp-client-theia/test/browser/glsp-diagram-manager.test.ts +++ b/packages/glsp-client-theia/test/browser/glsp-diagram-manager.test.ts @@ -11,6 +11,7 @@ import { type GLSPDiagramWidget } from '@eclipse-glsp/theia-integration/lib/brow import { type GLSPDiagramLanguage } from '@eclipse-glsp/theia-integration/lib/common'; import { type WidgetOpenerOptions } from '@theia/core/lib/browser'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { HydraniumGlspDiagramWidget } from '../../src/browser/diagram-widget.js'; import { AbstractHydraniumGlspDiagramManager } from '../../src/browser/glsp-diagram-manager.js'; /** @@ -34,12 +35,31 @@ let received: Array; // running app. vi.mock('@eclipse-glsp/theia-integration', () => ({ GLSPDiagramManager: class { + createdWidget?: unknown; protected handleNavigations(_widget: unknown, options?: WidgetOpenerOptions): boolean { received.push(options); return options !== undefined && 'selection' in options; } + async createWidget(): Promise { + return this.createdWidget; + } + }, + GLSPDiagramWidget: class { + events: string[] = []; + /** Lumino's widget detaches, and GLSP's stores its viewport, when its parent is cleared. */ + set parent(_parent: unknown) { + this.events.push('detach'); + } + dispose(): void { + this.events.push('dispose'); + } } })); +// Its browser barrel pulls `@theia/output`, which touches DOM globals at load. +vi.mock('@hydranium/client-theia/lib/browser', () => ({ + ChannelLogger: class ChannelLogger {} +})); +vi.mock('../../src/browser/glsp-saveable', () => ({ HydraniumGlspSaveable: class HydraniumGlspSaveable {} })); const LANGUAGE: GLSPDiagramLanguage = { diagramType: 'test-diagram', @@ -97,3 +117,126 @@ describe('AbstractHydraniumGlspDiagramManager.handleNavigations', () => { expect(received[0]).toBeUndefined(); }); }); + +describe('AbstractHydraniumGlspDiagramManager.reopen', () => { + const title = (name: string): { owner: unknown } => ({ owner: { name } }); + + class ReopenTestManager extends TestDiagramManager { + readonly opened: Array<{ uri: unknown; options?: WidgetOpenerOptions }> = []; + override async open(uri: GLSPDiagramWidget['uri'], options?: WidgetOpenerOptions): Promise { + this.opened.push({ uri, options }); + events.push('open'); + return {} as GLSPDiagramWidget; + } + } + + let events: string[]; + let manager: ReopenTestManager; + let widget: HydraniumGlspDiagramWidget; + + const placeIn = (titles: unknown[], active = false): void => { + Object.assign(manager, { + shell: { + getTabBarFor: () => ({ titles, currentTitle: widget.title }), + activeWidget: active ? widget : undefined + } + }); + }; + + beforeEach(() => { + manager = new ReopenTestManager(); + widget = new HydraniumGlspDiagramWidget(); + events = (widget as unknown as { events: string[] }).events; + Object.defineProperties(widget, { + title: { value: title('diagram') }, + uri: { value: 'file:///orders/fulfillment.process' }, + options: { value: { editMode: 'editable' } } + }); + }); + + it("reopens on the widget's request, after its left neighbour, detaching it before the dispose", async () => { + const left = title('left'); + placeIn([left, widget.title, title('right')]); + Object.assign(manager, { createdWidget: widget }); + await manager.createWidget({}); + + (widget as unknown as { reopenRequestEmitter: { fire(): void } }).reopenRequestEmitter.fire(); + await vi.waitFor(() => expect(manager.opened).toHaveLength(1)); + + // Detached first, so the viewport is stored while the container still + // resolves; disposed before the open, since the fresh widget takes its id. + expect(events).toEqual(['detach', 'dispose', 'open']); + expect(manager.opened[0]).toEqual({ + uri: 'file:///orders/fulfillment.process', + options: { mode: 'reveal', editMode: 'editable', widgetOptions: { ref: left.owner, mode: 'tab-after' } } + }); + }); + + it('reopens a first tab before its right neighbour, and activates an active one', async () => { + const right = title('right'); + placeIn([widget.title, right], true); + + await manager.reopen(widget); + + expect(manager.opened[0].options).toMatchObject({ mode: 'activate', widgetOptions: { ref: right.owner, mode: 'tab-before' } }); + }); + + /** Each reopen places its replacement next to a neighbour, and a reopen + * running alongside would take that neighbour out of the layout. */ + it('reopens one diagram at a time', async () => { + const log: string[] = []; + const second = new HydraniumGlspDiagramWidget(); + Object.defineProperties(second, { + title: { value: title('second') }, + uri: { value: 'file:///orders/returns.process' }, + options: { value: { editMode: 'editable' } } + }); + Object.defineProperty(widget, 'parent', { set: () => log.push('detach first') }); + Object.defineProperty(second, 'parent', { set: () => log.push('detach second') }); + let release!: () => void; + const released = new Promise(resolve => (release = resolve)); + Object.assign(manager, { + open: async (uri: string) => { + log.push(`open ${uri}`); + await released; + log.push(`opened ${uri}`); + } + }); + placeIn([widget.title, second.title]); + + const both = Promise.all([manager.reopen(widget), manager.reopen(second)]); + await vi.waitFor(() => expect(log).toContain('open file:///orders/fulfillment.process')); + expect(log).not.toContain('detach second'); + release(); + await both; + + expect(log).toEqual([ + 'detach first', + 'open file:///orders/fulfillment.process', + 'opened file:///orders/fulfillment.process', + 'detach second', + 'open file:///orders/returns.process', + 'opened file:///orders/returns.process' + ]); + }); + + /** A Retry can land while a batch still holds the same diagram. */ + it('skips a diagram that a queued reopen already replaced', async () => { + let disposed = false; + Object.defineProperty(widget, 'isDisposed', { get: () => disposed }); + Object.assign(widget, { dispose: () => (disposed = true) }); + placeIn([widget.title]); + + await Promise.all([manager.reopen(widget), manager.reopen(widget)]); + + expect(manager.opened).toHaveLength(1); + }); + + it('reopens a diagram alone in its tab bar where a new one opens', async () => { + placeIn([widget.title]); + + await manager.reopen(widget); + + expect(manager.opened[0].options?.widgetOptions).toBeUndefined(); + }); +}); diff --git a/packages/glsp-client-theia/test/node/glsp-server-connection-handler.test.ts b/packages/glsp-client-theia/test/node/glsp-server-connection-handler.test.ts index f0248793..0df1f327 100644 --- a/packages/glsp-client-theia/test/node/glsp-server-connection-handler.test.ts +++ b/packages/glsp-client-theia/test/node/glsp-server-connection-handler.test.ts @@ -87,7 +87,9 @@ describe('GlspServerConnectionHandler', () => { await expect(handler.exposeFindPort()).rejects.toThrow('port unavailable'); }); - it('initializeServerConnection surfaces failures via MessageService.error', async () => { + /** The frontend contribution reports the failed start; a toast here would be + * a second notification for it. */ + it('logs a failed connection and closes the channel without a notification', async () => { const handler = new TestHandler({ languageContributionId: 'foo', portCommand: 'foo/port', @@ -99,17 +101,20 @@ describe('GlspServerConnectionHandler', () => { } as unknown as CommandService; const errorSpy = vi.fn(); handler['messageService'] = { error: errorSpy } as unknown as MessageService; + const logger = stubLogger(); // `logger` is a readonly injected field, so override it through a cast. - (handler as unknown as { logger: ILogger }).logger = stubLogger(); - // Don't actually open a socket — pass a stub channel whose `onMessage` - // returns a no-op disposable; findPort rejects before connectToServer is - // reached, so the buffer-subscription added by the race-fix never sees - // any messages. + (handler as unknown as { logger: ILogger }).logger = logger; + // Don't actually open a socket — findPort rejects before connectToServer + // is reached, so the buffer subscription never sees any messages. + const close = vi.fn(); const stubChannel = { onMessage: vi.fn().mockReturnValue({ dispose: vi.fn() }), - onClose: vi.fn().mockReturnValue({ dispose: vi.fn() }) + onClose: vi.fn().mockReturnValue({ dispose: vi.fn() }), + close } as unknown as Channel; await handler.exposeInitialize(stubChannel); - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('boom')); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('boom')); + expect(errorSpy).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledTimes(1); }); }); From 269253837222844df810689117e3bce85e863614 Mon Sep 17 00:00:00 2001 From: Martin Fleck Date: Wed, 30 Sep 2026 14:28:53 +0200 Subject: [PATCH 4/6] feat(client-theia): report both heads' connections through one slot The diagram and the data head reported connection trouble differently: the GLSP client started once and never again, and the data head raised a backend toast per failed connect. Each head now keeps reconnecting on its own and reports every attempt through one slot adopters can replace, or leave silent. Reporting - ConnectionReporter is the slot each head reports an attempt through; DefaultConnectionReporter shows progress for an attempt still running after 3 s and ends it in at most one notification, and reports a failure once until the head connects again - An attempt a dispose or a newer attempt ends is cancelled, which takes its progress down without a notification - Neither backend handler raises a notification of its own; both log the failure GLSP - A failed start, and a connection lost after one succeeded, start a fresh client after a delay that grows while clients keep failing and starts over once one stayed up for 30 s - Open diagrams reopen as fresh widgets once their client is lost, and failed ones once a client starts, so a diagram survives a language-server restart - The client is HydraniumGlspClient, upstream's base client without the notifications its Theia subclass raises, which ends a session without the server once the connection is gone instead of throwing, so a diagram reopened after a loss logs no error - Each client's start and loss is logged with its number and the restart delay, beside upstream's line that its client will not be restarted Data - ChannelDataPort.connectionLifecycle reports each connection through the slot, one not ready after 30 s included, and takes over the connection failures the port would otherwise raise itself, logging them with their detail Example - The order-flow data connection passes the port's lifecycle, and a new restart spec shows an open diagram reopening, and taking an edit once, after the language server is killed New public names - ConnectionReporter, ConnectionAttempt, ConnectionTarget, DefaultConnectionReporter and bindConnectionReporter - ChannelDataPort.connectionLifecycle - HydraniumGlspClientContribution.onDidStartClient and onDidLoseClient - HydraniumGlspClient Breaking changes - HydraniumGlspClientContribution and ChannelDataPort inject ConnectionReporter, so a module binding either calls bindConnectionReporter; the GLSP frontend module does it already - HydraniumGlspClientContribution and ChannelDataPort inject ChannelLogger, so a module binding either calls bindChannelLogger at application scope, as bindConnectionDiagnostics already asks - DataServerConnectionHandler raises no notification, and its serverName defaults to "Data Server" - A DataConnection built without the port's connectionLifecycle reports no progress and no 30 s notice --- .../theia-app/test/e2e/order-flow-app.mts | 32 ++ .../e2e/order-flow-diagram-restart.spec.mts | 130 ++++++++ .../test/e2e/order-flow-restart.spec.mts | 46 +-- .../src/browser/order-flow-data-connection.ts | 4 +- packages/client-theia/README.md | 8 +- .../src/browser/connection-reporter.ts | 121 ++++++++ packages/client-theia/src/browser/index.ts | 1 + .../test/browser/connection-reporter.test.ts | 114 +++++++ packages/data-client-theia/README.md | 13 +- .../src/browser/channel-data-port.ts | 81 ++++- .../node/data-server-connection-handler.ts | 16 +- .../test/channel-data-port.test.ts | 117 +++++++ .../data-server-connection-handler.test.ts | 36 ++- packages/glsp-client-theia/README.md | 13 +- .../src/browser/client-contribution.ts | 166 ++++++---- .../src/browser/glsp-client.ts | 20 ++ .../src/browser/glsp-diagram-manager.ts | 36 ++- .../src/browser/glsp-theia-frontend-module.ts | 3 +- .../glsp-client-theia/src/browser/index.ts | 1 + .../test/browser/client-contribution.test.ts | 288 ++++++++++++------ .../test/browser/glsp-client.test.ts | 33 ++ .../test/browser/glsp-diagram-manager.test.ts | 60 +++- .../glsp-theia-frontend-module.test.ts | 12 +- 23 files changed, 1153 insertions(+), 198 deletions(-) create mode 100644 examples/order-flow/theia-app/test/e2e/order-flow-diagram-restart.spec.mts create mode 100644 packages/client-theia/src/browser/connection-reporter.ts create mode 100644 packages/client-theia/test/browser/connection-reporter.test.ts create mode 100644 packages/data-client-theia/test/channel-data-port.test.ts create mode 100644 packages/glsp-client-theia/src/browser/glsp-client.ts create mode 100644 packages/glsp-client-theia/test/browser/glsp-client.test.ts diff --git a/examples/order-flow/theia-app/test/e2e/order-flow-app.mts b/examples/order-flow/theia-app/test/e2e/order-flow-app.mts index 926fbf4d..6c529511 100644 --- a/examples/order-flow/theia-app/test/e2e/order-flow-app.mts +++ b/examples/order-flow/theia-app/test/e2e/order-flow-app.mts @@ -19,6 +19,8 @@ import { forwardBrowserConsole, serverLogFixtures, type ServerLogFixtures } from '@hydranium/core/testing/playwright'; import { expect, test as base, type Browser, type PlaywrightWorkerArgs } from '@playwright/test'; import { type TheiaApp, TheiaAppLoader, TheiaExplorerView, TheiaView, TheiaWorkspace } from '@theia/playwright'; +import { execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; import * as path from 'node:path'; /** @@ -238,3 +240,33 @@ async function pickQuickInputItem(app: TheiaApp, filter: string, expected = filt await expect(focused).toHaveText(expected); await app.page.keyboard.press('Enter'); } + +/** + * Matches the forked language server, and nothing else on the machine. + * + * RESOLVED, not written out. The VS Code extension launches the server with + * exactly this specifier, and `require.resolve` reports the realpath — so this + * is the absolute path that appears in the child's argv, derived the same way + * the launcher derives it. A hand-written fragment of that path instead rots + * silently in BOTH directions, and both have happened here: after the example + * moved one directory deeper the old fragment matched nothing, and because + * `pgrep` exits 1 on no match and {@link languageServerPids} maps that to an + * empty list, the spec stops testing restart recovery rather than failing. The + * mirror image is worse, because it looks like a pass: a fragment loose enough + * to match a leftover process from a previous layout makes the guard below + * succeed and `pkill` kill something the test never started. + */ +export const SERVER_PROCESS_PATTERN = createRequire(import.meta.url).resolve('@hydranium/example-order-flow-server/lib/main.js'); + +/** PIDs of the running language servers. Empty is a legitimate answer. */ +export function languageServerPids(): string[] { + try { + return execFileSync('pgrep', ['-f', SERVER_PROCESS_PATTERN], { encoding: 'utf-8' }) + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0); + } catch { + // `pgrep` exits 1 when nothing matches. + return []; + } +} diff --git a/examples/order-flow/theia-app/test/e2e/order-flow-diagram-restart.spec.mts b/examples/order-flow/theia-app/test/e2e/order-flow-diagram-restart.spec.mts new file mode 100644 index 00000000..ae8dcedf --- /dev/null +++ b/examples/order-flow/theia-app/test/e2e/order-flow-diagram-restart.spec.mts @@ -0,0 +1,130 @@ +/******************************************************************************** + * Copyright (c) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available in the project root. + * + * SPDX-License-Identifier: MIT + ********************************************************************************/ + +/** + * What an open diagram does when the language server is killed underneath it. + * + * The GLSP server lives in the language server's process, so the kill takes the + * diagram's server with it. The client contribution starts a fresh client over + * a fresh channel, the backend forwarder finds the replacement's new port, and + * the diagram reopens in its tab as a fresh widget. + * + * A diagram that did not recover still shows its old nodes, so they prove + * nothing on their own. The loading overlay coming back after the kill says the + * tab reopened, a GLSP connection in the server log after the kill says it + * reached the replacement server, and an edit applied exactly once says the + * diagram is editable again rather than only drawn. + * + * **Runs alone, with one worker**, for the reason the data-head restart spec + * gives: it kills a process by command-line pattern. + */ + +import { expect } from '@playwright/test'; +import { resolveServerLogPath } from '@hydranium/core/testing/playwright'; +import { type TheiaApp, TheiaExplorerView } from '@theia/playwright'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { languageServerPids, loadOrderFlowApp, SERVER_PROCESS_PATTERN, test } from './order-flow-app.mjs'; + +/** Class contract published by `@hydranium/glsp-client-theia`'s diagram widget. */ +const LOADING_OVERLAY_CLASS = 'hydranium-diagram-loading'; + +/** Logged by the GLSP server when a client connects to its socket. */ +const GLSP_CONNECTION_LINE = 'Starting GLSP server connection'; + +test.describe.serial('Order-flow diagram across a language-server restart', { tag: '@restart' }, () => { + let app: TheiaApp; + + test.beforeAll(async ({ playwright, browser }) => { + app = await loadOrderFlowApp({ playwright, browser }); + }); + + test.afterAll(async () => { + await app.page.close(); + }); + + test('the diagram loads before the restart', async () => { + const explorer = await app.openView(TheiaExplorerView); + await explorer.waitForVisibleFileNodes(); + await explorer.clickContextMenuItem('orders/fulfillment.process', ['Open']); + await expect(app.page.locator('.sprotty').getByText('Pay', { exact: false }).first()).toBeVisible({ timeout: 30_000 }); + await expect(app.page.locator(`.${LOADING_OVERLAY_CLASS}`)).toHaveCount(0); + expect(languageServerPids().length, 'no language server to restart').toBeGreaterThan(0); + }); + + test('the diagram loads again on the replacement server', async () => { + test.setTimeout(120_000); + // Watched from before the kill: the reload's overlay can come and go + // between two polls. + await app.page.evaluate(overlayClass => { + const globals = window as unknown as { __hydraniumReloadSeen?: boolean }; + globals.__hydraniumReloadSeen = false; + new MutationObserver(records => { + for (const record of records) { + for (const node of Array.from(record.addedNodes)) { + if (node instanceof HTMLElement && node.classList.contains(overlayClass)) { + globals.__hydraniumReloadSeen = true; + } + } + } + }).observe(document.body, { childList: true, subtree: true }); + }, LOADING_OVERLAY_CLASS); + + const before = languageServerPids(); + execFileSync('pkill', ['-f', SERVER_PROCESS_PATTERN]); + await expect + .poll(() => languageServerPids().filter(pid => !before.includes(pid)).length, { + message: 'the language server did not restart under a new pid', + timeout: 60_000 + }) + .toBeGreaterThan(0); + + await expect + .poll(() => app.page.evaluate(() => (window as unknown as { __hydraniumReloadSeen?: boolean }).__hydraniumReloadSeen === true), { + message: 'the diagram never reloaded after the kill', + timeout: 60_000 + }) + .toBe(true); + await expect(app.page.locator(`.${LOADING_OVERLAY_CLASS}`)).toHaveCount(0, { timeout: 60_000 }); + await expect(app.page.locator('.sprotty').getByText('Pay', { exact: false }).first()).toBeVisible(); + + const log = resolveServerLogPath(app.workspace.path); + expect(log, 'no server log to read').toBeDefined(); + const text = readFileSync(log!, 'utf-8'); + const sinceTheKill = text.slice(text.lastIndexOf('===== START: the diagram loads again')); + expect(sinceTheKill, 'no client reached the replacement GLSP server').toContain(GLSP_CONNECTION_LINE); + }); + + /** A diagram loaded again in the same container registers GLSP's model + * source handlers twice, and then sends every edit twice: a second task. */ + test('an edit after the restart applies once', async () => { + const diagram = app.page.locator('#theia-main-content-panel .sprotty').first(); + // A second task would be proposed under the next free name that starts with `NewTask`. + const newTasks = diagram.locator('svg.sprotty-graph').getByText(/^NewTask/); + const taskTool = app.page.locator('#theia-main-content-panel .tool-button', { hasText: 'Task' }); + const box = await diagram.boundingBox(); + expect(box, 'the diagram has no box to click in').not.toBeNull(); + // Retried because a model update right after the load disarms the + // palette's tool; a retry follows only a click that created nothing. + await expect(async () => { + await taskTool.click(); + await expect(taskTool).toHaveClass(/clicked/); + // Low and left of centre, where the fitted process leaves empty canvas. + await diagram.click({ position: { x: box!.width * 0.4, y: box!.height * 0.85 } }); + await expect(newTasks).not.toHaveCount(0, { timeout: 5_000 }); + }).toPass({ timeout: 30_000 }); + // The create opens the new node's label editor, which would keep the keyboard. + await app.page.keyboard.press('Escape'); + + await expect(newTasks).toHaveCount(1); + // A second application would land right after the first. + await app.page.waitForTimeout(2_000); + await expect(newTasks).toHaveCount(1); + }); +}); diff --git a/examples/order-flow/theia-app/test/e2e/order-flow-restart.spec.mts b/examples/order-flow/theia-app/test/e2e/order-flow-restart.spec.mts index b9772d79..11ca6f74 100644 --- a/examples/order-flow/theia-app/test/e2e/order-flow-restart.spec.mts +++ b/examples/order-flow/theia-app/test/e2e/order-flow-restart.spec.mts @@ -32,10 +32,18 @@ import { expect } from '@playwright/test'; import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; -import { createRequire } from 'node:module'; import { resolveServerLogPath } from '@hydranium/core/testing/playwright'; import type { TheiaApp } from '@theia/playwright'; -import { loadOrderFlowApp, openPropertiesPanel, PROPERTIES_PANEL as PANEL, runCommand, selectFile, test } from './order-flow-app.mjs'; +import { + languageServerPids, + loadOrderFlowApp, + openPropertiesPanel, + PROPERTIES_PANEL as PANEL, + runCommand, + SERVER_PROCESS_PATTERN, + selectFile, + test +} from './order-flow-app.mjs'; /** * Two diagnostics commands as the palette lists them (`category: title`), from @@ -60,36 +68,6 @@ function diagnosticsToast(app: TheiaApp, summary: string): ReturnType line.trim()) - .filter(line => line.length > 0); - } catch { - // `pgrep` exits 1 when nothing matches. - return []; - } -} - /** * Re-open the panel on `filePath`, forcing a real load. * @@ -217,8 +195,10 @@ test.describe.serial('Order-flow data connection across a language-server restar }; await expect .poll(sinceTheKill, { message: 'the panel never registered again on the new server', timeout: 90_000 }) + // The diagram the explorer opened recovers too, and whichever of the + // two reaches the document first opens it; the other attaches. .toMatch( - /Session started: order-flow-theia-properties#[\s\S]*fulfillment\.process\] Open document: .* by order-flow-theia-properties#/ + /Session started: order-flow-theia-properties#[\s\S]*fulfillment\.process\] (?:Open document: .* by|Attach client:) order-flow-theia-properties#/ ); }); diff --git a/examples/order-flow/theia/src/browser/order-flow-data-connection.ts b/examples/order-flow/theia/src/browser/order-flow-data-connection.ts index be668e4f..c8fcc5f0 100644 --- a/examples/order-flow/theia/src/browser/order-flow-data-connection.ts +++ b/examples/order-flow/theia/src/browser/order-flow-data-connection.ts @@ -7,6 +7,7 @@ * SPDX-License-Identifier: MIT ********************************************************************************/ +import { bindConnectionReporter } from '@hydranium/client-theia/lib/browser'; import { DataSessionStopContribution } from '@hydranium/data-client-theia/lib/browser'; import { DataConnectionWithEvents, @@ -50,7 +51,7 @@ export interface OrderFlowDataServer extends DataServerProtocol { constructor(@inject(OrderFlowTheiaDataPort) port: OrderFlowTheiaDataPort) { - super(port); + super(port, port.connectionLifecycle); } } @@ -67,6 +68,7 @@ export function bindOrderFlowDataConnection(bind: interfaces.Bind, isBound: inte if (isBound(OrderFlowTheiaDataPort)) { return; } + bindConnectionReporter(bind, isBound); bind(OrderFlowTheiaDataPort).toSelf().inSingletonScope(); bind(OrderFlowDataConnection) .toSelf() diff --git a/packages/client-theia/README.md b/packages/client-theia/README.md index fba96d35..c9c026cd 100644 --- a/packages/client-theia/README.md +++ b/packages/client-theia/README.md @@ -18,6 +18,12 @@ hydranium server; a non-Theia host does not need it. sides of the conversation read as one transcript. The same call also binds `ChannelTracer`, the `Tracer` token frontend services inject when they time something. +- **`ConnectionReporter`** with `bindConnectionReporter` — the slot every head + reports its connection attempts through, reconnects included. + `DefaultConnectionReporter` shows a progress notification for an attempt still + running after 3 s and ends it in at most one notification, raising a failure + once until the head connects again. Rebind the slot to report differently, or + not at all; the heads keep their retries either way. - **`LogLevelPreferenceContribution`** with `bindLogLevelPreference` — applies the framework log threshold from a Theia preference once per application, and keeps it in sync on change. It is a `FrontendApplicationContribution` on purpose: the @@ -115,7 +121,7 @@ from here: | Subpath | Holds | Environment | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | | `.` | Nothing. Deliberately empty, so an environment-specific import cannot reach the wrong bundle through a barrel. | browser-neutral (gated) | -| `./browser` | `ChannelLogger`, `ChannelTracer`, `LogLevelPreferenceContribution`, `MemoryDiagnosticsContribution`, `EditorDiskSync`, `HydraniumFileService`, the `bind*` helpers, `captureBrowserRuntime` | browser / Theia frontend (gated) | +| `./browser` | `ChannelLogger`, `ChannelTracer`, `ConnectionReporter`, `DefaultConnectionReporter`, `LogLevelPreferenceContribution`, `MemoryDiagnosticsContribution`, `EditorDiskSync`, `HydraniumFileService`, the `bind*` helpers, `captureBrowserRuntime` | browser / Theia frontend (gated) | | `./common` | `Clock`, the framed socket write buffer and the connection-resilience options | browser-neutral (gated) | | `./node` | `AbstractSocketForwardingConnectionHandler` and its options, `SocketChannelForwarder` — imports `node:net` | Node / Theia backend | | `./testing` | `makeStubOutputChannelManager`, `makeStubInversifyContext` | browser-neutral (gated) | diff --git a/packages/client-theia/src/browser/connection-reporter.ts b/packages/client-theia/src/browser/connection-reporter.ts new file mode 100644 index 00000000..d3e41176 --- /dev/null +++ b/packages/client-theia/src/browser/connection-reporter.ts @@ -0,0 +1,121 @@ +/******************************************************************************** + * Copyright (c) 2026 CrossBreeze, EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available in the project root. + * + * SPDX-License-Identifier: MIT + ********************************************************************************/ + +import { MessageService, nls, type Progress } from '@theia/core'; +import { inject, injectable, type interfaces } from '@theia/core/shared/inversify'; + +/** What a head connects to, as the whole sentences reported about it. */ +export interface ConnectionTarget { + readonly connectingMessage: string; + readonly connectedMessage: string; +} + +/** One connection attempt; it reports exactly one end. */ +export interface ConnectionAttempt { + connected(): void; + /** Ends the attempt with nothing to report, as when its head is disposed + * or a newer attempt takes over. */ + cancelled(): void; + /** + * `message` is the whole sentence to report. `retry`, when given, starts a + * fresh attempt at once; a head without one keeps retrying on its own. + */ + failed(message: string, retry?: () => void): void; +} + +/** + * How a head's connection attempts reach the user. A head calls + * {@link connecting} for every attempt, a reconnect included, and keeps the + * attempt's retries and bounds itself; this decides only what is shown. Rebind + * the slot to show nothing, or something other than notifications. + */ +export interface ConnectionReporter { + connecting(target: ConnectionTarget): ConnectionAttempt; +} +export const ConnectionReporter = Symbol('ConnectionReporter'); + +/** + * Reports through Theia notifications: an attempt still running after + * {@link connectingNoticeDelayMs} shows its progress, and an attempt ends in at + * most one notification. A failure is shown once per target until it connects + * again, so a head that keeps retrying does not raise one per attempt. + */ +@injectable() +export class DefaultConnectionReporter implements ConnectionReporter { + @inject(MessageService) protected readonly messageService!: MessageService; + + protected readonly connectingNoticeDelayMs: number = 3_000; + /** Targets whose last reported attempt failed. */ + protected readonly failing = new Set(); + + connecting(target: ConnectionTarget): ConnectionAttempt { + let progress: Promise | undefined; + const notice = this.failing.has(target) + ? undefined + : setTimeout(() => (progress = this.showConnecting(target)), this.connectingNoticeDelayMs); + let settled = false; + const settle = (): boolean => { + if (settled) { + return false; + } + settled = true; + clearTimeout(notice); + void progress?.then(shown => shown.cancel()); + return true; + }; + return { + connected: () => { + if (settle() && (this.failing.delete(target) || progress)) { + this.showConnected(target); + } + }, + cancelled: () => { + settle(); + }, + failed: (message, retry) => { + if (settle() && !this.failing.has(target)) { + this.failing.add(target); + void this.showFailed(target, message, retry); + } + } + }; + } + + protected showConnecting(target: ConnectionTarget): Promise { + return this.messageService.showProgress({ text: target.connectingMessage }); + } + + protected showConnected(target: ConnectionTarget): void { + this.messageService.info(target.connectedMessage); + } + + protected async showFailed(target: ConnectionTarget, message: string, retry?: () => void): Promise { + if (!retry) { + this.messageService.error(message); + return; + } + const label = this.retryLabel(); + if ((await this.messageService.error(message, label)) === label) { + // The retried attempt reports afresh, progress included. + this.failing.delete(target); + retry(); + } + } + + protected retryLabel(): string { + return nls.localize('hydranium/client-theia/connection-retry', 'Retry'); + } +} + +/** Bind {@link DefaultConnectionReporter} unless the slot is bound already; every head calls this. */ +export function bindConnectionReporter(bind: interfaces.Bind, isBound: interfaces.IsBound): void { + if (!isBound(ConnectionReporter)) { + bind(ConnectionReporter).to(DefaultConnectionReporter).inSingletonScope(); + } +} diff --git a/packages/client-theia/src/browser/index.ts b/packages/client-theia/src/browser/index.ts index 94c90f39..b69d53a5 100644 --- a/packages/client-theia/src/browser/index.ts +++ b/packages/client-theia/src/browser/index.ts @@ -14,5 +14,6 @@ export * from './browser-capture'; export * from './memory-diagnostics-contribution'; export * from './session-aware-connection-source'; export * from './connection-diagnostics-contribution'; +export * from './connection-reporter'; export * from './editor-disk-sync'; export * from './hydranium-file-service'; diff --git a/packages/client-theia/test/browser/connection-reporter.test.ts b/packages/client-theia/test/browser/connection-reporter.test.ts new file mode 100644 index 00000000..c6a1c1ec --- /dev/null +++ b/packages/client-theia/test/browser/connection-reporter.test.ts @@ -0,0 +1,114 @@ +/******************************************************************************** + * Copyright (c) 2026 CrossBreeze, EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available in the project root. + * + * SPDX-License-Identifier: MIT + ********************************************************************************/ + +import 'reflect-metadata'; +import { type MessageService } from '@theia/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { type ConnectionTarget, DefaultConnectionReporter } from '../../src/browser/connection-reporter'; + +const target: ConnectionTarget = { connectingMessage: 'Connecting…', connectedMessage: 'Connected.' }; + +function makeReporter(): { + reporter: DefaultConnectionReporter; + progress: { cancel: ReturnType }; + messages: { showProgress: ReturnType; info: ReturnType; error: ReturnType }; +} { + const progress = { cancel: vi.fn() }; + const messages = { + showProgress: vi.fn(async () => progress), + info: vi.fn(), + error: vi.fn(async (): Promise => undefined) + }; + const reporter = new DefaultConnectionReporter(); + Object.assign(reporter, { messageService: messages as unknown as MessageService }); + return { reporter, progress, messages }; +} + +describe('DefaultConnectionReporter', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('stays silent for an attempt that connects quickly', async () => { + const { reporter, messages } = makeReporter(); + reporter.connecting(target).connected(); + await vi.advanceTimersByTimeAsync(10_000); + + expect(messages.showProgress).not.toHaveBeenCalled(); + expect(messages.info).not.toHaveBeenCalled(); + }); + + /** An attempt dropped without an end would otherwise leave its progress up for good. */ + it('ends a cancelled attempt without a notification, taking down its progress', async () => { + const { reporter, progress, messages } = makeReporter(); + const early = reporter.connecting(target); + early.cancelled(); + const late = reporter.connecting(target); + await vi.advanceTimersByTimeAsync(3_000); + late.cancelled(); + await vi.advanceTimersByTimeAsync(10_000); + + expect(messages.showProgress).toHaveBeenCalledTimes(1); + expect(progress.cancel).toHaveBeenCalledTimes(1); + expect(messages.info).not.toHaveBeenCalled(); + expect(messages.error).not.toHaveBeenCalled(); + }); + + /** Silence through a long wait reads as a hang, and a notice on every quick + * start is noise. */ + it('shows progress for a slow attempt, then one result', async () => { + const { reporter, progress, messages } = makeReporter(); + const attempt = reporter.connecting(target); + await vi.advanceTimersByTimeAsync(3_000); + expect(messages.showProgress).toHaveBeenCalledWith({ text: 'Connecting…' }); + + attempt.connected(); + await vi.advanceTimersByTimeAsync(0); + expect(progress.cancel).toHaveBeenCalledTimes(1); + expect(messages.info).toHaveBeenCalledWith('Connected.'); + expect(messages.error).not.toHaveBeenCalled(); + }); + + it('reports a failure once while the target keeps failing, then its recovery', async () => { + const { reporter, messages } = makeReporter(); + reporter.connecting(target).failed('Could not connect.'); + const retried = reporter.connecting(target); + await vi.advanceTimersByTimeAsync(10_000); + retried.failed('Could not connect.'); + expect(messages.error).toHaveBeenCalledTimes(1); + // A target that is failing shows no progress for its automatic retries. + expect(messages.showProgress).not.toHaveBeenCalled(); + + reporter.connecting(target).connected(); + expect(messages.info).toHaveBeenCalledWith('Connected.'); + }); + + it('offers a Retry when the head can retry at once, and reports that attempt afresh', async () => { + const { reporter, messages } = makeReporter(); + const retry = vi.fn(); + messages.error.mockResolvedValueOnce('Retry'); + reporter.connecting(target).failed('Could not connect.', retry); + await vi.advanceTimersByTimeAsync(0); + + expect(messages.error).toHaveBeenCalledWith('Could not connect.', 'Retry'); + expect(retry).toHaveBeenCalledTimes(1); + reporter.connecting(target).failed('Could not connect.'); + expect(messages.error).toHaveBeenCalledTimes(2); + }); + + it('keeps only the first end an attempt reports', () => { + const { reporter, messages } = makeReporter(); + const attempt = reporter.connecting(target); + attempt.failed('Could not connect.'); + attempt.connected(); + attempt.failed('Could not connect.'); + + expect(messages.error).toHaveBeenCalledTimes(1); + expect(messages.info).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/data-client-theia/README.md b/packages/data-client-theia/README.md index 12be472e..59b09c62 100644 --- a/packages/data-client-theia/README.md +++ b/packages/data-client-theia/README.md @@ -29,8 +29,11 @@ text edits. with a `servicePath`; it supplies the channel, the workspace gate, the reconnect signal and the `MessageService` error sink. Bind one per service path in singleton scope. Hand it to `DataConnectionWithEvents` (from - `@hydranium/protocol`) and the connection, its sessions and its event fan-out - are the host-neutral ones every other shell uses. + `@hydranium/protocol`) with its `connectionLifecycle` — + `new DataConnectionWithEvents(port, port.connectionLifecycle)` — and each + connection reports through the `ConnectionReporter`, a data server not ready + after 30 s included; the connection, its sessions and its event fan-out are the + host-neutral ones every other shell uses. - **`EmitterDataClient`** (on `./common`, not `./browser`) — the default client-side implementation of the data protocol's inbound notifications, fanning each one out to a Theia `Event`: `onDidUpdateDocument`, @@ -83,8 +86,10 @@ extension builds on. That extension's `package.json` declares the entries, and each entry names one frontend/backend module pair: - the **frontend** module binds your `ChannelDataPort` subclass and the - connection over it, both in singleton scope (and, for the diagnostics commands, - calls `bindHostDiagnostics`); + connection over it, both in singleton scope, and calls + `bindConnectionReporter` and `bindChannelLogger` from `@hydranium/client-theia` + (the port logs the connection failures it leaves to the reporter there), and, + for the diagnostics commands, `bindHostDiagnostics`; - the **backend** module is typically a one-liner: `export default createDataServerConnectionContainerModule(MyHandler)`, where `MyHandler` extends `DataServerConnectionHandler`. diff --git a/packages/data-client-theia/src/browser/channel-data-port.ts b/packages/data-client-theia/src/browser/channel-data-port.ts index 107888f1..83229b27 100644 --- a/packages/data-client-theia/src/browser/channel-data-port.ts +++ b/packages/data-client-theia/src/browser/channel-data-port.ts @@ -7,7 +7,15 @@ * SPDX-License-Identifier: MIT ********************************************************************************/ -import { renderFrameworkMessage, type DataPort, type ResolvedMessage } from '@hydranium/protocol'; +import { ChannelLogger, ConnectionReporter, type ConnectionAttempt, type ConnectionTarget } from '@hydranium/client-theia/lib/browser'; +import { + DATA_SERVER_CONNECT_FAILED, + DATA_SERVER_NOT_READY, + renderFrameworkMessage, + type DataPort, + type ResolvedMessage, + type RpcConnectionLifecycle +} from '@hydranium/protocol'; import { Emitter, MessageService, nls, type Event } from '@theia/core'; import { type ServiceConnectionProvider } from '@theia/core/lib/browser'; import { RemoteConnectionProvider } from '@theia/core/lib/browser/messaging/service-connection-provider'; @@ -34,6 +42,8 @@ export abstract class ChannelDataPort implements DataPort { @inject(RemoteConnectionProvider) protected readonly connectionProvider!: ServiceConnectionProvider; @inject(WorkspaceService) protected readonly workspaceService!: WorkspaceService; @inject(MessageService) protected readonly messageService!: MessageService; + @inject(ConnectionReporter) protected readonly connectionReporter!: ConnectionReporter; + @inject(ChannelLogger) protected readonly logger!: ChannelLogger; /** Frontend service path the backend forwarder for this head is registered under. */ protected abstract readonly servicePath: string; @@ -51,6 +61,34 @@ export abstract class ChannelDataPort implements DataPort { protected handle?: ChannelConnectionHandle; + /** How long a connection may take before it is reported as failed; it keeps trying after. */ + protected readonly connectFailureNoticeMs: number = 30_000; + protected attempt?: ConnectionAttempt; + protected attemptTimer?: ReturnType; + /** Set once {@link connectionLifecycle} reports, which then owns the connection failures. */ + protected reportsConnections = false; + + /** + * Reports each connection generation through the {@link ConnectionReporter}. + * Pass it to the `DataConnection` built over this port; without it the + * connection failures are raised as notifications of their own. + */ + readonly connectionLifecycle: RpcConnectionLifecycle = { + onConnecting: () => { + this.reportsConnections = true; + this.settleAttempt(); + const attempt = this.connectionReporter.connecting(this.connectionTarget); + this.attempt = attempt; + this.attemptTimer = setTimeout(() => { + attempt.failed(this.connectTimeoutMessage()); + // The generation keeps waiting; a follow-up attempt reports it connecting. + this.attempt = this.connectionReporter.connecting(this.connectionTarget); + }, this.connectFailureNoticeMs); + }, + onReady: () => this.settleAttempt(attempt => attempt.connected()), + onFailed: () => this.settleAttempt(attempt => attempt.failed(this.unreachableMessage())) + }; + /** * Open the workspace-gated channel and hand back its listening connection. * @@ -88,10 +126,49 @@ export abstract class ChannelDataPort implements DataPort { * inside another's and leave no translator in control of the whole. */ reportError(_error: unknown, reported: ResolvedMessage): void { - this.messageService.error(renderFrameworkMessage(reported, nls.localization?.translations)); + const connectionFailure = reported.code === DATA_SERVER_CONNECT_FAILED.code || reported.code === DATA_SERVER_NOT_READY.code; + const message = renderFrameworkMessage(reported, nls.localization?.translations); + if (connectionFailure && this.reportsConnections) { + // The reporter shows the failure without its detail, so it is kept here. + this.logger.warn(message); + return; + } + this.messageService.error(message); + } + + /** End the current attempt through `settle`, or cancel it. */ + protected settleAttempt(settle: (attempt: ConnectionAttempt) => void = attempt => attempt.cancelled()): void { + clearTimeout(this.attemptTimer); + const attempt = this.attempt; + this.attempt = undefined; + if (attempt) { + settle(attempt); + } + } + + protected get connectionTarget(): ConnectionTarget { + return (this.cachedConnectionTarget ??= { + connectingMessage: nls.localize('hydranium/data-client-theia/data-server-connecting', 'Connecting to the data server…'), + connectedMessage: nls.localize('hydranium/data-client-theia/data-server-connected', 'Connected to the data server.') + }); + } + /** One object per port: the reporter keys what it has shown on it. */ + protected cachedConnectionTarget?: ConnectionTarget; + + protected unreachableMessage(): string { + return nls.localize('hydranium/data-client-theia/data-server-unreachable', 'Could not connect to the data server.'); + } + + protected connectTimeoutMessage(): string { + return nls.localize( + 'hydranium/data-client-theia/data-server-timeout', + 'The data server did not answer within {0} seconds.', + String(Math.round(this.connectFailureNoticeMs / 1000)) + ); } dispose(): void { + this.settleAttempt(); this.handle?.dispose(); this.handle = undefined; this.disposeEmitter.fire(undefined); diff --git a/packages/data-client-theia/src/node/data-server-connection-handler.ts b/packages/data-client-theia/src/node/data-server-connection-handler.ts index e27f561d..52a914dd 100644 --- a/packages/data-client-theia/src/node/data-server-connection-handler.ts +++ b/packages/data-client-theia/src/node/data-server-connection-handler.ts @@ -28,15 +28,7 @@ export interface DataServerConnectionHandlerOptions { * Defaults to the framework `DATA_SERVER_PORT_COMMAND`; override to match * an adopter's established id. */ readonly portCommand?: string; - /** - * Product name for the connect-failure dialog this handler raises. - * - * It reaches the adopter's UI verbatim, so the framework default leaks a - * framework noun into a product that is not ours. Not a translation concern — - * routing it through a catalogue would ask an adopter to "translate" English - * into their own product name, and would make their branding - * locale-dependent. - */ + /** Name of the server in this handler's log lines. */ readonly serverName?: string; readonly findPortTimeout?: number; readonly findPortAttempts?: number; @@ -67,11 +59,15 @@ export class DataServerConnectionHandler extends AbstractSocketForwardingConnect path: options.servicePath ?? DATA_SERVER_PATH, portCommand: options.portCommand ?? DATA_SERVER_PORT_COMMAND, logComponent: 'DataServer', - serverName: options.serverName ?? 'Model Server', + serverName: options.serverName ?? 'Data Server', findPortTimeout: options.findPortTimeout, findPortAttempts: options.findPortAttempts, connectTimeoutMs: options.connectTimeoutMs, onSocketCreated: options.onSocketCreated }); } + + /** Logged only: the frontend port reports the connection, and a second + * notification would duplicate it. */ + protected override reportConnectFailure(): void {} } diff --git a/packages/data-client-theia/test/channel-data-port.test.ts b/packages/data-client-theia/test/channel-data-port.test.ts new file mode 100644 index 00000000..016aa8ea --- /dev/null +++ b/packages/data-client-theia/test/channel-data-port.test.ts @@ -0,0 +1,117 @@ +/******************************************************************************** + * Copyright (c) 2026 CrossBreeze, EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available in the project root. + * + * SPDX-License-Identifier: MIT + ********************************************************************************/ + +// These browser modules touch `document` at load; the port only uses them as +// injection tokens. +vi.mock('@hydranium/client-theia/lib/browser', () => ({ + ChannelLogger: class ChannelLogger {}, + ConnectionReporter: Symbol('ConnectionReporter') +})); +vi.mock('@theia/workspace/lib/browser', () => ({ + WorkspaceService: class WorkspaceService {} +})); +vi.mock('@theia/core/lib/browser/messaging/service-connection-provider', () => ({ + RemoteConnectionProvider: Symbol('RemoteConnectionProvider') +})); + +import 'reflect-metadata'; +import { DATA_SERVER_CONNECT_FAILED, DATA_SERVER_NOT_READY, resolve } from '@hydranium/protocol'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ChannelDataPort } from '../src/browser/channel-data-port'; + +/** What the reporter was told, one entry per attempt. */ +interface ReportedAttempt { + outcome?: 'connected' | 'failed' | 'cancelled'; + message?: string; +} + +class TestPort extends ChannelDataPort { + protected readonly servicePath = '/services/test'; + readonly attempts: ReportedAttempt[] = []; + readonly errors = vi.fn(); + readonly warnings = vi.fn(); + + constructor() { + super(); + Object.assign(this, { + messageService: { error: this.errors }, + logger: { warn: this.warnings }, + connectionReporter: { + connecting: () => { + const attempt: ReportedAttempt = {}; + this.attempts.push(attempt); + return { + connected: () => (attempt.outcome = 'connected'), + cancelled: () => (attempt.outcome = 'cancelled'), + failed: (message: string) => Object.assign(attempt, { outcome: 'failed', message }) + }; + } + } + }); + } +} + +describe('ChannelDataPort', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('reports a generation that becomes ready', () => { + const port = new TestPort(); + port.connectionLifecycle.onConnecting?.(); + port.connectionLifecycle.onReady?.(); + expect(port.attempts).toEqual([{ outcome: 'connected' }]); + }); + + /** The connection keeps waiting for a server that is not there yet, with + * nothing reported unless a bound says so. */ + it('reports a generation not ready after 30 s, and then its recovery', async () => { + const port = new TestPort(); + port.connectionLifecycle.onConnecting?.(); + await vi.advanceTimersByTimeAsync(30_000); + expect(port.attempts[0]).toEqual({ outcome: 'failed', message: 'The data server did not answer within 30 seconds.' }); + + port.connectionLifecycle.onReady?.(); + expect(port.attempts[1]).toEqual({ outcome: 'connected' }); + }); + + it('reports a generation that fails', () => { + const port = new TestPort(); + port.connectionLifecycle.onConnecting?.(); + port.connectionLifecycle.onFailed?.(new Error('boom')); + expect(port.attempts).toEqual([{ outcome: 'failed', message: 'Could not connect to the data server.' }]); + }); + + /** The attempt is the failure's one notification; the protocol's own report + * of it would be a second. */ + it('leaves the connection failures to the reporter once the lifecycle is in use', () => { + const port = new TestPort(); + port.connectionLifecycle.onConnecting?.(); + port.reportError(new Error('boom'), resolve(DATA_SERVER_CONNECT_FAILED, { detail: 'boom' })); + port.reportError(new Error('boom'), resolve(DATA_SERVER_NOT_READY, { detail: 'boom' })); + expect(port.errors).not.toHaveBeenCalled(); + // The reporter's sentence has no detail, so the log keeps it. + expect(port.warnings).toHaveBeenCalledTimes(2); + expect(port.warnings).toHaveBeenCalledWith('Could not connect to the data server: boom'); + }); + + /** An attempt dropped without an end would leave its progress up for good. */ + it('cancels an attempt a newer one takes over, and the one open at dispose', () => { + const port = new TestPort(); + port.connectionLifecycle.onConnecting?.(); + port.connectionLifecycle.onConnecting?.(); + port.dispose(); + expect(port.attempts).toEqual([{ outcome: 'cancelled' }, { outcome: 'cancelled' }]); + }); + + it('still raises the connection failures when no lifecycle reports them', () => { + const port = new TestPort(); + port.reportError(new Error('boom'), resolve(DATA_SERVER_CONNECT_FAILED, { detail: 'boom' })); + expect(port.errors).toHaveBeenCalledWith('Could not connect to the data server: boom'); + }); +}); diff --git a/packages/data-client-theia/test/data-server-connection-handler.test.ts b/packages/data-client-theia/test/data-server-connection-handler.test.ts index 74e6b4fb..d40cdc38 100644 --- a/packages/data-client-theia/test/data-server-connection-handler.test.ts +++ b/packages/data-client-theia/test/data-server-connection-handler.test.ts @@ -8,7 +8,8 @@ ********************************************************************************/ import 'reflect-metadata'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { type Channel, type CommandService, type ILogger, type MessageService } from '@theia/core'; import { DATA_SERVER_PATH, DATA_SERVER_PORT_COMMAND } from '@hydranium/protocol'; import { DataServerConnectionHandler, type DataServerConnectionHandlerOptions } from '../src/node/data-server-connection-handler'; @@ -23,6 +24,12 @@ class TestHandler extends DataServerConnectionHandler { get resolvedPortCommand(): string { return this.portCommand; } + get resolvedServerName(): string { + return this.serverName; + } + initialize(channel: Channel): Promise { + return this.initializeServerConnection(channel); + } } describe('DataServerConnectionHandler', () => { @@ -32,6 +39,33 @@ describe('DataServerConnectionHandler', () => { expect(handler.resolvedPortCommand).toBe(DATA_SERVER_PORT_COMMAND); }); + it('names its server "Data Server" in its logs by default', () => { + expect(new TestHandler().resolvedServerName).toBe('Data Server'); + }); + + /** The frontend port reports the connection; a toast here would be a second + * notification for it. */ + it('logs a failed connection without a notification', async () => { + const handler = new TestHandler({ findPortTimeout: 0, findPortAttempts: 0 }); + const error = vi.fn(); + const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as unknown as ILogger; + Object.assign(handler, { + logger, + messageService: { error } as unknown as MessageService, + commandService: { executeCommand: vi.fn().mockRejectedValue(new Error('boom')) } as unknown as CommandService + }); + const channel = { + onMessage: () => ({ dispose: () => undefined }), + onClose: () => ({ dispose: () => undefined }), + close: vi.fn() + } as unknown as Channel; + + await handler.initialize(channel); + + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('boom')); + expect(error).not.toHaveBeenCalled(); + }); + it('uses explicit servicePath and portCommand when provided', () => { const handler = new TestHandler({ servicePath: '/services/custom', portCommand: 'custom:port' }); expect(handler.path).toBe('/services/custom'); diff --git a/packages/glsp-client-theia/README.md b/packages/glsp-client-theia/README.md index 1b1a517d..807dab23 100644 --- a/packages/glsp-client-theia/README.md +++ b/packages/glsp-client-theia/README.md @@ -49,11 +49,18 @@ if you are mounting a hydranium GLSP diagram in a Theia application. **`AbstractHydraniumGlspDiagramManager`** derives the language-correlated getters from one descriptor plus a label, and `reopen` replaces a diagram with a fresh widget in the same tab position, as the Retry of a failed load - does. + does. It reopens every diagram when their client is lost, and a failed one + when a client starts. - **`HydraniumGlspClientContribution`** — for a server that starts late: it defers `start` until a workspace is open, fails a start that takes longer than - `startupTimeoutMs` (30 s by default), and offers a Retry that starts a fresh - client. + `startupTimeoutMs` (30 s by default), and starts a fresh client after a failed + start or a lost connection, after a delay that grows while clients keep + failing, reporting each attempt through `client-theia`'s + `ConnectionReporter`. `onDidStartClient` and `onDidLoseClient` announce each + client, and each start and loss is logged to the application-scope + `ChannelLogger` an adopter binds with `bindChannelLogger`. Its client is + `HydraniumGlspClient`, which ends a session without the server once the + connection is gone. - **`HydraniumGlspSaveable`** — the diagram widget's saveable. Each save is a `RequestSaveModelAction` the server answers: the save resolves on its own response, and rejects on its own rejection or after 10 s rather than GLSP's diff --git a/packages/glsp-client-theia/src/browser/client-contribution.ts b/packages/glsp-client-theia/src/browser/client-contribution.ts index 64e11899..367de9f8 100644 --- a/packages/glsp-client-theia/src/browser/client-contribution.ts +++ b/packages/glsp-client-theia/src/browser/client-contribution.ts @@ -7,13 +7,15 @@ * SPDX-License-Identifier: MIT ********************************************************************************/ -import { type GLSPClient, type InitializeResult } from '@eclipse-glsp/client'; +import { ClientState, type GLSPClient, type InitializeResult } from '@eclipse-glsp/client'; import { BaseGLSPClientContribution } from '@eclipse-glsp/theia-integration'; import { createChannelConnection, GLSPContribution } from '@eclipse-glsp/theia-integration/lib/common'; -import { type Channel, Disposable, Event, nls, type Progress } from '@theia/core'; +import { ChannelLogger, ConnectionReporter, type ConnectionTarget } from '@hydranium/client-theia/lib/browser'; +import { type Channel, Disposable, Emitter, Event, nls } from '@theia/core'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { inject, injectable, unmanaged } from '@theia/core/shared/inversify'; import { WorkspaceService } from '@theia/workspace/lib/browser'; +import { HydraniumGlspClient } from './glsp-client'; export const DEFAULT_GLSP_CLIENT_STARTUP_TIMEOUT_MS = 30_000; @@ -31,25 +33,46 @@ export interface ClientContributionOptions { * * The client starts once a workspace is open and sends its first request at * once; the backend handler holds it until its socket to the server is up. A - * start still running after three seconds shows its progress, and ends in one - * notification. A failed start rejects {@link glspClient}, and its - * notification's Retry, like reading {@link glspClient} again, starts a fresh - * client. + * start that fails, and a connection lost after one succeeded, start a fresh + * client after a backoff, for as long as the contribution lives. Each start is + * reported through the {@link ConnectionReporter}, and announced through + * {@link onDidStartClient} and {@link onDidLoseClient}. * * A `GLSPClient` override filtering inbound messages by clientId is * deliberately NOT provided: upstream's `BaseJsonrpcGLSPClient.onActionMessage` - * already filters by clientId, so the `TheiaJsonrpcGLSPClient` returned by - * `BaseGLSPClientContribution.createGLSPClient` is correct as-is. + * already filters by clientId. */ @injectable() export class HydraniumGlspClientContribution extends BaseGLSPClientContribution { @inject(WorkspaceService) protected readonly workspaceService!: WorkspaceService; + @inject(ConnectionReporter) protected readonly connectionReporter!: ConnectionReporter; + @inject(ChannelLogger) protected readonly logger!: ChannelLogger; readonly id: string; + /** Delay before the restart following each consecutive failed start or lost + * client; the last repeats. */ + protected readonly restartDelaysMs: readonly number[] = [1_000, 2_000, 4_000, 8_000]; + /** How long a client must stay up before the restart delays start over, so + * a server that fails right after starting is not restarted at once. */ + protected readonly restartEscalationResetMs: number = 30_000; + protected consecutiveFailures = 0; + protected restartTimer?: ReturnType; + /** When the current client started; `0` once a restart has taken it into account. */ + protected startedAt = 0; + /** Clients started so far, which numbers them in the log: they share {@link id}. */ + protected startedClients = 0; /** The attempt waiting for a channel, if any. */ protected channelRequest?: Deferred; protected disposed = false; + protected readonly clientStartedEmitter = new Emitter(); + protected readonly clientLostEmitter = new Emitter(); + + /** Fires with each client once it has started and its server has answered. */ + readonly onDidStartClient: Event = this.clientStartedEmitter.event; + /** Fires when the started client stops; {@link glspClient} already waits + * for its replacement then. */ + readonly onDidLoseClient: Event = this.clientLostEmitter.event; constructor(@unmanaged() options: ClientContributionOptions) { super(); @@ -62,47 +85,63 @@ export class HydraniumGlspClientContribution extends BaseGLSPClientContribution return this.glspClientDeferred.state === 'rejected' ? this.restart() : this.glspClientDeferred.promise; } - /** Start a fresh client over a fresh channel if the last start failed. */ + /** Start a fresh client over a fresh channel at once, if the last start failed. */ restart(): Promise { if (!this.disposed && this.glspClientDeferred.state === 'rejected') { - this.toDispose.dispose(); - // Upstream refuses to open a channel while the collection is disposed. - this.toDispose.push(Disposable.NULL); - this.glspClientDeferred = new Deferred(); + this.replaceClient(); void this.activateClient(); } return this.glspClientDeferred.promise; } + /** Point {@link glspClient} at a fresh client still to start, and tear down + * the old one's channel. */ + protected replaceClient(): void { + clearTimeout(this.restartTimer); + // Replaced before the old channel goes, so the old client stopping is + // not taken for a loss of the new one. + this.glspClientDeferred = new Deferred(); + this.toDispose.dispose(); + // Upstream refuses to open a channel while the collection is disposed. + this.toDispose.push(Disposable.NULL); + } + /** Settles the promise current when it began, so a start a restart overtook * cannot settle its successor. */ protected override async activateClient(): Promise { const pending = this.glspClientDeferred; await this.workspaceOpened(); - let progress: Promise | undefined; - const notice = setTimeout(() => (progress = this.showConnecting()), this.connectingNoticeDelayMs); + if (this.disposed) { + return; + } + const attempt = this.connectionReporter.connecting(this.connectionTarget); const timeout = this.glspClientStartupTimeout > 0 ? setTimeout(() => pending.reject(new Error(this.startupTimeoutMessage())), this.glspClientStartupTimeout) : undefined; pending.promise.then( - () => { - if (!this.disposed) { - this.reportStarted(progress); - } + client => { + clearTimeout(timeout); + attempt.connected(); + this.startedAt = Date.now(); + this.logger.info(`[${this.id}] Diagram client ${++this.startedClients} started.`); + this.clientStartedEmitter.fire(client); + this.restartOnLoss(client); }, (error: unknown) => { - if (!this.disposed) { - void this.reportStartFailure(error, progress); + clearTimeout(timeout); + if (this.disposed) { + attempt.cancelled(); + return; } + attempt.failed(error instanceof Error ? error.message : String(error), () => void this.restart()); + this.scheduleRestart(() => { + if (this.glspClientDeferred === pending) { + void this.restart(); + } + }); } ); - void pending.promise - .finally(() => { - clearTimeout(notice); - clearTimeout(timeout); - }) - .catch(() => undefined); try { const connection = await this.createConnection(); // Ahead of the client's own listener, whose teardown rejects with a @@ -117,6 +156,37 @@ export class HydraniumGlspClientContribution extends BaseGLSPClientContribution } } + /** Run `restart` after the delay for the failures so far, and return that delay. */ + protected scheduleRestart(restart: () => void): number { + if (this.startedAt > 0 && Date.now() - this.startedAt >= this.restartEscalationResetMs) { + this.consecutiveFailures = 0; + } + this.startedAt = 0; + const delay = this.restartDelaysMs[Math.min(this.consecutiveFailures, this.restartDelaysMs.length - 1)]; + this.consecutiveFailures++; + clearTimeout(this.restartTimer); + this.restartTimer = setTimeout(restart, delay); + return delay; + } + + /** Once `client` stops, replace it and start its replacement after the + * restart delay, unless a dispose stopped it. */ + protected restartOnLoss(client: GLSPClient): void { + const listener = client.onCurrentStateChanged(state => { + if (state !== ClientState.ServerError && state !== ClientState.Stopped) { + return; + } + listener.dispose(); + if (!this.disposed) { + this.replaceClient(); + this.clientLostEmitter.fire(); + const delay = this.scheduleRestart(() => void this.activateClient()); + // Upstream's client has just logged that it will not be restarted, which holds for it alone. + this.logger.info(`[${this.id}] Diagram client ${this.startedClients} lost; starting a fresh one in ${delay} ms.`); + } + }); + } + /** Start the client and initialize its server, rejecting on failure; the * caller settles {@link glspClient}. */ protected override async start(glspClient: GLSPClient): Promise { @@ -124,11 +194,19 @@ export class HydraniumGlspClientContribution extends BaseGLSPClientContribution await this.initialize(glspClient); } - /** Without upstream's own error notification, which would duplicate the result one. */ + /** Without upstream's own error notification, which would duplicate the reporter's. */ protected override async initialize(glspClient: GLSPClient): Promise { return glspClient.initializeServer(await this.createInitializeParameters()); } + /** Upstream's base client, without the notifications of its Theia subclass, + * which would duplicate the reporter's. */ + protected override async createGLSPClient( + connectionProvider: Parameters[0] + ): Promise { + return new HydraniumGlspClient({ id: this.id, connectionProvider }); + } + /** * Whichever channel arrives goes to the attempt waiting now, and one that * arrives with none waiting is closed. Theia holds each open until its @@ -173,8 +251,11 @@ export class HydraniumGlspClientContribution extends BaseGLSPClientContribution /** Also fails a start in flight, so nothing reports it after this. */ override dispose(): void { this.disposed = true; + clearTimeout(this.restartTimer); this.glspClientDeferred.promise.catch(() => undefined); this.glspClientDeferred.reject(new Error('The diagram client was disposed.')); + this.clientStartedEmitter.dispose(); + this.clientLostEmitter.dispose(); super.dispose(); } @@ -185,31 +266,14 @@ export class HydraniumGlspClientContribution extends BaseGLSPClientContribution } } - /** A start that takes longer than this shows a progress notification. */ - protected readonly connectingNoticeDelayMs: number = 3_000; - - protected showConnecting(): Promise { - return this.messageService.showProgress({ - text: nls.localize('hydranium/glsp-client-theia/diagram-server-connecting', 'Connecting to the diagram server…') + protected get connectionTarget(): ConnectionTarget { + return (this.cachedConnectionTarget ??= { + connectingMessage: nls.localize('hydranium/glsp-client-theia/diagram-server-connecting', 'Connecting to the diagram server…'), + connectedMessage: nls.localize('hydranium/glsp-client-theia/diagram-server-connected', 'Connected to the diagram server.') }); } - - /** A start that showed progress ends in a notification; a quick one stays silent. */ - protected reportStarted(progress: Promise | undefined): void { - if (progress) { - void progress.then(shown => shown.cancel()); - this.messageService.info(nls.localize('hydranium/glsp-client-theia/diagram-server-connected', 'Connected to the diagram server.')); - } - } - - protected async reportStartFailure(error: unknown, progress: Promise | undefined): Promise { - void progress?.then(shown => shown.cancel()); - const retry = nls.localize('hydranium/glsp-client-theia/diagram-retry', 'Retry'); - const choice = await this.messageService.error(error instanceof Error ? error.message : String(error), retry); - if (choice === retry) { - void this.restart(); - } - } + /** One object per contribution: the reporter keys what it has shown on it. */ + protected cachedConnectionTarget?: ConnectionTarget; protected unreachableMessage(): string { return nls.localize('hydranium/glsp-client-theia/diagram-server-unreachable', 'Could not connect to the diagram server.'); diff --git a/packages/glsp-client-theia/src/browser/glsp-client.ts b/packages/glsp-client-theia/src/browser/glsp-client.ts new file mode 100644 index 00000000..1d408c63 --- /dev/null +++ b/packages/glsp-client-theia/src/browser/glsp-client.ts @@ -0,0 +1,20 @@ +/******************************************************************************** + * Copyright (c) 2026 CrossBreeze, EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available in the project root. + * + * SPDX-License-Identifier: MIT + ********************************************************************************/ + +import { BaseJsonrpcGLSPClient, type DisposeClientSessionParameters } from '@eclipse-glsp/client'; + +/** The GLSP client of `HydraniumGlspClientContribution`. */ +export class HydraniumGlspClient extends BaseJsonrpcGLSPClient { + /** Resolves at once when the connection is gone: the session went with its + * server, and upstream would throw, which a diagram disposed after its + * client was lost logs as an error. */ + override disposeClientSession(params: DisposeClientSessionParameters): Promise { + return this.isConnectionActive() ? super.disposeClientSession(params) : Promise.resolve(); + } +} diff --git a/packages/glsp-client-theia/src/browser/glsp-diagram-manager.ts b/packages/glsp-client-theia/src/browser/glsp-diagram-manager.ts index 631fe04e..906a0aac 100644 --- a/packages/glsp-client-theia/src/browser/glsp-diagram-manager.ts +++ b/packages/glsp-client-theia/src/browser/glsp-diagram-manager.ts @@ -7,12 +7,15 @@ * SPDX-License-Identifier: MIT ********************************************************************************/ -import { codiconCSSString } from '@eclipse-glsp/client'; +import { codiconCSSString, DiagramLoader } from '@eclipse-glsp/client'; import { GLSPDiagramManager } from '@eclipse-glsp/theia-integration'; import { type GLSPDiagramLanguage } from '@eclipse-glsp/theia-integration/lib/common'; import { type GLSPDiagramWidget, type GLSPWidgetOpenerOptions } from '@eclipse-glsp/theia-integration/lib/browser'; import { type WidgetOpenerOptions } from '@theia/core/lib/browser'; +import { type Disposable, DisposableCollection } from '@theia/core'; import { injectable } from '@theia/core/shared/inversify'; +import { HydraniumGlspClientContribution } from './client-contribution'; +import { HydraniumDiagramLoader } from './diagram-loader'; import { HydraniumGlspDiagramWidget } from './diagram-widget'; /** @@ -48,6 +51,7 @@ export abstract class AbstractHydraniumGlspDiagramManager extends GLSPDiagramMan /** Optional icon-class override; takes precedence over the language's `iconClass`. */ protected readonly customIconClass?: string; + protected clientListeners?: Disposable; /** The reopens asked for so far, which run one at a time. */ protected reopening: Promise = Promise.resolve(); @@ -72,6 +76,7 @@ export abstract class AbstractHydraniumGlspDiagramManager extends GLSPDiagramMan } override async createWidget(options?: unknown): Promise { + this.listenToClient(); const widget = await super.createWidget(options); if (widget instanceof HydraniumGlspDiagramWidget) { widget.onDidRequestReopen(() => void this.reopen(widget)); @@ -79,6 +84,35 @@ export abstract class AbstractHydraniumGlspDiagramManager extends GLSPDiagramMan return widget; } + /** + * Reopen every diagram once its client is lost, since none of them has a + * server behind it any more, and a failed one once a client starts. + */ + protected listenToClient(): void { + if (this.clientListeners) { + return; + } + const contribution = this.diagramServiceProvider.getGLSPClientContribution(this.contributionId); + this.clientListeners = + contribution instanceof HydraniumGlspClientContribution + ? new DisposableCollection( + contribution.onDidLoseClient(() => this.reopenAll(() => true)), + contribution.onDidStartClient(() => this.reopenAll(widget => this.loadFailed(widget))) + ) + : new DisposableCollection(); + } + + protected reopenAll(which: (widget: GLSPDiagramWidget) => boolean): void { + for (const widget of this.all.filter(which)) { + void this.reopen(widget); + } + } + + protected loadFailed(widget: GLSPDiagramWidget): boolean { + const loader = widget.diContainer.get(DiagramLoader); + return loader instanceof HydraniumDiagramLoader && loader.loadOutcome?.status === 'failed'; + } + /** * Replace `widget` with a fresh one for the same diagram, in the same tab * position and with its viewport: a new container, client id and load. diff --git a/packages/glsp-client-theia/src/browser/glsp-theia-frontend-module.ts b/packages/glsp-client-theia/src/browser/glsp-theia-frontend-module.ts index aa5c5178..d7579c41 100644 --- a/packages/glsp-client-theia/src/browser/glsp-theia-frontend-module.ts +++ b/packages/glsp-client-theia/src/browser/glsp-theia-frontend-module.ts @@ -17,7 +17,7 @@ import { registerDiagramManager } from '@eclipse-glsp/theia-integration'; import { type GLSPDiagramLanguage } from '@eclipse-glsp/theia-integration/lib/common'; -import { bindLogLevelPreference } from '@hydranium/client-theia/lib/browser'; +import { bindConnectionReporter, bindLogLevelPreference } from '@hydranium/client-theia/lib/browser'; import type { interfaces } from '@theia/core/shared/inversify'; import { HydraniumGlspDiagramWidget } from './diagram-widget'; @@ -106,6 +106,7 @@ export abstract class AbstractHydraniumGlspTheiaFrontendModule extends GLSPTheia */ override initialize(context: ContainerContext): void { super.initialize(context); + bindConnectionReporter(context.bind, context.isBound); if (this.logLevelPreference) { bindLogLevelPreference(context.bind, this.logLevelPreference); } diff --git a/packages/glsp-client-theia/src/browser/index.ts b/packages/glsp-client-theia/src/browser/index.ts index b0c487d7..5dcf033d 100644 --- a/packages/glsp-client-theia/src/browser/index.ts +++ b/packages/glsp-client-theia/src/browser/index.ts @@ -14,6 +14,7 @@ export * from './client-contribution'; export * from './diagram-loader'; export * from './diagram-only-marker-manager'; export * from './diagram-widget'; +export * from './glsp-client'; export * from './glsp-client-theia-module'; export * from './glsp-diagram-manager'; export * from './glsp-message-service'; diff --git a/packages/glsp-client-theia/test/browser/client-contribution.test.ts b/packages/glsp-client-theia/test/browser/client-contribution.test.ts index 05aa3a7c..494bc892 100644 --- a/packages/glsp-client-theia/test/browser/client-contribution.test.ts +++ b/packages/glsp-client-theia/test/browser/client-contribution.test.ts @@ -7,8 +7,9 @@ * SPDX-License-Identifier: MIT ********************************************************************************/ -// The real base class pulls Theia's monaco-coupled chain. The stand-in carries -// only the members the contribution reads, with upstream's defaults. +// The real base class pulls Theia's monaco-coupled chain, and the real +// client-theia browser barrel touches `document`. The stand-ins carry only what +// the contribution reads, with upstream's defaults. vi.mock('@eclipse-glsp/theia-integration', async () => { const { Deferred } = await import('@theia/core/lib/common/promise-util'); const { DisposableCollection } = await import('@theia/core'); @@ -30,21 +31,50 @@ vi.mock('@eclipse-glsp/theia-integration', async () => { } }; }); +vi.mock('@hydranium/client-theia/lib/browser', () => ({ + ChannelLogger: class ChannelLogger {}, + ConnectionReporter: Symbol('ConnectionReporter') +})); vi.mock('@theia/workspace/lib/browser', () => ({ WorkspaceService: class WorkspaceService {} })); +import { ClientState } from '@eclipse-glsp/client'; import { type Channel } from '@theia/core'; import { ForwardingChannel } from '@theia/core/lib/common/message-rpc/channel'; import { Deferred } from '@theia/core/lib/common/promise-util'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_GLSP_CLIENT_STARTUP_TIMEOUT_MS, HydraniumGlspClientContribution } from '../../src/browser/client-contribution'; +interface FakeConnection { + onDispose(): void; + onClose(listener: () => void): void; + close(): void; +} + +function makeConnection(): FakeConnection { + const listeners: Array<() => void> = []; + return { + onDispose: () => undefined, + onClose: listener => listeners.push(listener), + close: () => listeners.forEach(listener => listener()) + }; +} + interface FakeClient { readonly name: string; start(): Promise; initializeServer(): Promise; stop(): void; + onCurrentStateChanged(listener: (state: ClientState) => void): { dispose(): void }; + setState(state: ClientState): void; +} + +/** What the reporter was told, one entry per attempt. */ +interface ReportedAttempt { + outcome?: 'connected' | 'failed' | 'cancelled'; + message?: string; + retry?: () => void; } /** Each start takes its connection from `connections`, in order, so a test @@ -52,19 +82,28 @@ interface FakeClient { class TestContribution extends HydraniumGlspClientContribution { readonly connections: Array> = []; readonly clients: FakeClient[] = []; - readonly errors = vi.fn(async (_message: string, ..._actions: string[]): Promise => undefined); + readonly attempts: ReportedAttempt[] = []; readonly infos = vi.fn(); - readonly progress = { cancel: vi.fn() }; - readonly showProgress = vi.fn(async () => this.progress); /** Whether each client's `initializeServer` never answers. */ initializeHangs = false; - constructor(startupTimeoutMs?: number, connectingNoticeDelayMs = 1_000) { + constructor(startupTimeoutMs?: number) { super({ languageContributionId: 'test', startupTimeoutMs }); Object.assign(this, { - connectingNoticeDelayMs, + restartDelaysMs: [1], workspaceService: { tryGetRoots: () => [{}] }, - messageService: { error: this.errors, info: this.infos, showProgress: this.showProgress } + logger: { info: this.infos }, + connectionReporter: { + connecting: () => { + const attempt: ReportedAttempt = {}; + this.attempts.push(attempt); + return { + connected: () => (attempt.outcome = 'connected'), + cancelled: () => (attempt.outcome = 'cancelled'), + failed: (message: string, retry?: () => void) => Object.assign(attempt, { outcome: 'failed', message, retry }) + }; + } + } }); } @@ -100,57 +139,65 @@ class TestContribution extends HydraniumGlspClientContribution { } protected override async createGLSPClient(): Promise { + const listeners: Array<(state: ClientState) => void> = []; const client: FakeClient = { name: `client ${this.clients.length + 1}`, start: async () => undefined, initializeServer: () => (this.initializeHangs ? new Promise(() => undefined) : Promise.resolve({})), - stop: () => undefined + stop: () => undefined, + onCurrentStateChanged: listener => { + listeners.push(listener); + return { dispose: () => listeners.splice(listeners.indexOf(listener), 1) }; + }, + setState: state => [...listeners].forEach(listener => listener(state)) }; this.clients.push(client); return client as never; } } -interface FakeConnection { - onDispose(): void; - onClose(listener: () => void): void; - close(): void; -} - -function makeConnection(): FakeConnection { - const listeners: Array<() => void> = []; - return { - onDispose: () => undefined, - onClose: listener => listeners.push(listener), - close: () => listeners.forEach(listener => listener()) +describe('HydraniumGlspClientContribution', () => { + const contributions: TestContribution[] = []; + const make = (startupTimeoutMs?: number): TestContribution => { + const contribution = new TestContribution(startupTimeoutMs); + contributions.push(contribution); + return contribution; }; -} - -const connection = makeConnection(); + afterEach(() => contributions.splice(0).forEach(contribution => contribution.dispose())); -describe('HydraniumGlspClientContribution', () => { it('bounds a start by 30 s unless the options say otherwise', () => { - expect(new TestContribution().startupTimeout).toBe(DEFAULT_GLSP_CLIENT_STARTUP_TIMEOUT_MS); + expect(make().startupTimeout).toBe(DEFAULT_GLSP_CLIENT_STARTUP_TIMEOUT_MS); expect(DEFAULT_GLSP_CLIENT_STARTUP_TIMEOUT_MS).toBe(30_000); - expect(new TestContribution(5).startupTimeout).toBe(5); + expect(make(5).startupTimeout).toBe(5); + }); + + it('reports a start that connects', async () => { + const contribution = make(); + contribution.nextConnection().resolve(makeConnection()); + await contribution.begin(); + + const client = await contribution.glspClient; + expect(client).toBe(contribution.clients[0]); + expect(contribution.attempts).toEqual([{ outcome: 'connected' }]); }); /** * A server that never answers leaves every diagram load waiting on the * client, with nothing reported. */ - it('fails a start that runs out of time and offers a Retry', async () => { - const contribution = new TestContribution(10); + it('fails a start that runs out of time, with a Retry', async () => { + const contribution = make(10); contribution.nextConnection(); void contribution.begin(); await expect(contribution.glspClient).rejects.toThrow('The diagram server did not answer within 0 seconds.'); - await vi.waitFor(() => expect(contribution.errors).toHaveBeenCalledTimes(1)); - expect(contribution.errors).toHaveBeenCalledWith('The diagram server did not answer within 0 seconds.', 'Retry'); + await vi.waitFor(() => expect(contribution.attempts[0].outcome).toBe('failed')); + expect(contribution.attempts[0].message).toBe('The diagram server did not answer within 0 seconds.'); + expect(contribution.attempts[0].retry).toBeTypeOf('function'); }); it('fails a start whose connection closes before the server answers', async () => { - const contribution = new TestContribution(); + const contribution = make(); contribution.initializeHangs = true; const closing = makeConnection(); contribution.nextConnection().resolve(closing); @@ -159,75 +206,142 @@ describe('HydraniumGlspClientContribution', () => { closing.close(); await expect(contribution.glspClient).rejects.toThrow('Could not connect to the diagram server.'); - await vi.waitFor(() => expect(contribution.errors).toHaveBeenCalledWith('Could not connect to the diagram server.', 'Retry')); + await vi.waitFor(() => expect(contribution.attempts[0].message).toBe('Could not connect to the diagram server.')); }); - it('shows progress while a start is slow, then one notification with the result', async () => { - const contribution = new TestContribution(undefined, 1); - const arriving = contribution.nextConnection(); + /** The server can come up after the first attempt gave up; waiting for a + * user to act would leave every diagram failed until then. */ + it('starts again on its own after a failed start', async () => { + const contribution = make(10); + contribution.nextConnection(); + contribution.nextConnection().resolve(makeConnection()); void contribution.begin(); - await vi.waitFor(() => expect(contribution.showProgress).toHaveBeenCalledTimes(1)); - expect(contribution.showProgress).toHaveBeenCalledWith({ text: 'Connecting to the diagram server…' }); - arriving.resolve(connection); - await vi.waitFor(() => expect(contribution.infos).toHaveBeenCalledWith('Connected to the diagram server.')); - expect(contribution.progress.cancel).toHaveBeenCalledTimes(1); - expect(contribution.errors).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(contribution.attempts.map(attempt => attempt.outcome)).toEqual(['failed', 'connected'])); + const client = await contribution.glspClient; + expect(client).toBe(contribution.clients[0]); }); - it('replaces the progress with the failure when a slow start fails', async () => { - const contribution = new TestContribution(20, 1); + /** Every diagram load reads the client, so reading it is what lets a + * diagram's retry, or a reopened tab, recover from a failed start. */ + it('starts a fresh client when the client is read after a failed start', async () => { + const contribution = make(10); contribution.nextConnection(); void contribution.begin(); await expect(contribution.glspClient).rejects.toThrow(); - await vi.waitFor(() => expect(contribution.progress.cancel).toHaveBeenCalledTimes(1)); - expect(contribution.errors).toHaveBeenCalledTimes(1); - expect(contribution.infos).not.toHaveBeenCalled(); + contribution.nextConnection().resolve(makeConnection()); + const client = await contribution.glspClient; + expect(client).toBe(contribution.clients[0]); }); - it('resolves the client once it is started and initialized', async () => { - const contribution = new TestContribution(); - contribution.nextConnection().resolve(connection); + /** A language-server restart takes the GLSP server with it; without a + * restart the diagrams have no server until the window reloads. */ + it('starts a fresh client when a started one loses its connection', async () => { + const contribution = make(); + contribution.nextConnection().resolve(makeConnection()); await contribution.begin(); + await contribution.glspClient; + + contribution.nextConnection().resolve(makeConnection()); + contribution.clients[0].setState(ClientState.ServerError); const client = await contribution.glspClient; - expect(client).toBe(contribution.clients[0]); - // Quick enough that no progress showed, so nothing is announced either. - expect(contribution.showProgress).not.toHaveBeenCalled(); - expect(contribution.infos).not.toHaveBeenCalled(); - expect(contribution.errors).not.toHaveBeenCalled(); + expect(client).toBe(contribution.clients[1]); + await vi.waitFor(() => expect(contribution.attempts.map(attempt => attempt.outcome)).toEqual(['connected', 'connected'])); }); - /** Every diagram load reads the client, so reading it is what lets a diagram - * retry, or a reopened tab, recover from a failed start. */ - it('starts a fresh client when the client is read after a failed start', async () => { - const contribution = new TestContribution(10); - contribution.nextConnection(); - void contribution.begin(); - await expect(contribution.glspClient).rejects.toThrow(); - - contribution.nextConnection().resolve(connection); + /** A dispose stops the client too, and that stop is not a loss to recover from. */ + it('does not replace a client that a dispose stopped', async () => { + const contribution = make(); + contribution.nextConnection().resolve(makeConnection()); + await contribution.begin(); const client = await contribution.glspClient; - expect(client).toBe(contribution.clients[0]); + const lost = vi.fn(); + contribution.onDidLoseClient(lost); + + contribution.dispose(); + contribution.clients[0].setState(ClientState.Stopped); + + expect(lost).not.toHaveBeenCalled(); + expect(await contribution.glspClient).toBe(client); }); - it('restarts from the notification’s Retry', async () => { - const contribution = new TestContribution(10); - contribution.errors.mockResolvedValueOnce('Retry'); - contribution.nextConnection(); - const retried = contribution.nextConnection(); - void contribution.begin(); - await expect(contribution.glspClient).rejects.toThrow(); + /** A listener reopening its diagrams then loads them on the replacement, not the lost client. */ + it('announces a lost client once the client it hands out is the replacement', async () => { + const contribution = make(); + contribution.nextConnection().resolve(makeConnection()); + await contribution.begin(); + const started = vi.fn(); + contribution.onDidStartClient(started); + await contribution.glspClient; + let afterLoss: Promise | undefined; + contribution.onDidLoseClient(() => (afterLoss = contribution.glspClient)); - await vi.waitFor(() => expect(contribution.connections).toHaveLength(0)); - retried.resolve(connection); - const client = await contribution.glspClient; - expect(client).toBe(contribution.clients[0]); + contribution.nextConnection().resolve(makeConnection()); + contribution.clients[0].setState(ClientState.ServerError); + + expect(await afterLoss).toBe(contribution.clients[1]); + await vi.waitFor(() => expect(started).toHaveBeenLastCalledWith(contribution.clients[1])); + }); + + /** Upstream's client logs that it will not be restarted, and its id names the server, not the client. */ + it('logs each client it starts and loses, by number', async () => { + const contribution = make(); + contribution.nextConnection().resolve(makeConnection()); + contribution.nextConnection().resolve(makeConnection()); + await contribution.begin(); + await contribution.glspClient; + + contribution.clients[0].setState(ClientState.ServerError); + await vi.waitFor(() => expect(contribution.infos).toHaveBeenCalledTimes(3)); + + expect(contribution.infos.mock.calls.map(call => call[0])).toEqual([ + '[test] Diagram client 1 started.', + '[test] Diagram client 1 lost; starting a fresh one in 1 ms.', + '[test] Diagram client 2 started.' + ]); + }); + + /** A server that fails right after it starts would otherwise be restarted as fast as it can fail. */ + it('backs off while clients are lost soon after starting', async () => { + const contribution = make(); + Object.assign(contribution, { restartDelaysMs: [1, 1_000] }); + contribution.nextConnection().resolve(makeConnection()); + contribution.nextConnection().resolve(makeConnection()); + contribution.nextConnection().resolve(makeConnection()); + await contribution.begin(); + await contribution.glspClient; + + contribution.clients[0].setState(ClientState.ServerError); + await vi.waitFor(() => expect(contribution.clients).toHaveLength(2)); + await contribution.glspClient; + contribution.clients[1].setState(ClientState.ServerError); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(contribution.clients).toHaveLength(2); + }); + + it('starts the delays over once a client stayed up', async () => { + const contribution = make(); + Object.assign(contribution, { restartDelaysMs: [1, 1_000], restartEscalationResetMs: 10 }); + contribution.nextConnection().resolve(makeConnection()); + contribution.nextConnection().resolve(makeConnection()); + contribution.nextConnection().resolve(makeConnection()); + await contribution.begin(); + await contribution.glspClient; + + contribution.clients[0].setState(ClientState.ServerError); + await vi.waitFor(() => expect(contribution.clients).toHaveLength(2)); + await contribution.glspClient; + await new Promise(resolve => setTimeout(resolve, 20)); + contribution.clients[1].setState(ClientState.ServerError); + + await vi.waitFor(() => expect(contribution.clients).toHaveLength(3), { timeout: 200 }); }); it('keeps a start that a restart overtook from settling its successor', async () => { - const contribution = new TestContribution(10); + const contribution = make(10); const late = contribution.nextConnection(); void contribution.begin(); await expect(contribution.glspClient).rejects.toThrow(); @@ -236,7 +350,7 @@ describe('HydraniumGlspClientContribution', () => { Object.assign(contribution, { glspClientStartupTimeout: 0 }); contribution.nextConnection(); const successor = contribution.glspClient; - late.resolve(connection); + late.resolve(makeConnection()); await vi.waitFor(() => expect(contribution.clients).toHaveLength(1)); const outcome = await Promise.race([ @@ -250,22 +364,20 @@ describe('HydraniumGlspClientContribution', () => { * are not, and the next channel on the same path then cannot open. */ it('closes the channel it tears down', async () => { const close = vi.fn(); - await new TestContribution().closeChannel({ close } as unknown as Channel); + await make().closeChannel({ close } as unknown as Channel); expect(close).toHaveBeenCalledTimes(1); }); - /** A start disposed with it would otherwise still time out and report. */ - it('reports nothing for a start that a dispose ended', async () => { - const contribution = new TestContribution(10, 5); + /** Left running, a start the dispose ended keeps its progress up. */ + it('cancels the report of a start that a dispose ended', async () => { + const contribution = make(0); contribution.nextConnection(); void contribution.begin(); - await Promise.resolve(); + await vi.waitFor(() => expect(contribution.attempts).toHaveLength(1)); contribution.dispose(); - await new Promise(resolve => setTimeout(resolve, 30)); - expect(contribution.errors).not.toHaveBeenCalled(); - expect(contribution.showProgress).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(contribution.attempts).toEqual([{ outcome: 'cancelled' }])); }); /** @@ -273,7 +385,7 @@ describe('HydraniumGlspClientContribution', () => { * past the first then throws "already open" without reaching its handler. */ it('hands the first channel to arrive to the latest start, and closes one nobody waits for', async () => { - const contribution = new TestContribution(); + const contribution = make(); const opens: Array<{ handler: (path: string, channel: Channel) => void; reconnect: boolean }> = []; Object.assign(contribution, { connectionProvider: { diff --git a/packages/glsp-client-theia/test/browser/glsp-client.test.ts b/packages/glsp-client-theia/test/browser/glsp-client.test.ts new file mode 100644 index 00000000..d34c5fe2 --- /dev/null +++ b/packages/glsp-client-theia/test/browser/glsp-client.test.ts @@ -0,0 +1,33 @@ +/******************************************************************************** + * Copyright (c) 2026 CrossBreeze, EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available in the project root. + * + * SPDX-License-Identifier: MIT + ********************************************************************************/ + +import { describe, expect, it, vi } from 'vitest'; +import { HydraniumGlspClient } from '../../src/browser/glsp-client'; + +const params = { clientSessionId: 'test_0' }; + +describe('HydraniumGlspClient', () => { + /** A diagram disposed after its client was lost ends its session; upstream + * throws "not ready" there, which the teardown logs as an error. */ + it('ends a session without the server once the connection is gone', async () => { + const client = new HydraniumGlspClient({ id: 'test', connectionProvider: {} as never }); + + await expect(client.disposeClientSession(params)).resolves.toBeUndefined(); + }); + + it('still asks a connected server to end the session', async () => { + const client = new HydraniumGlspClient({ id: 'test', connectionProvider: {} as never }); + const sendRequest = vi.fn(async () => undefined); + Object.assign(client, { isConnectionActive: () => true, resolvedConnection: { sendRequest } }); + + await client.disposeClientSession(params); + + expect(sendRequest).toHaveBeenCalledWith(expect.anything(), params); + }); +}); diff --git a/packages/glsp-client-theia/test/browser/glsp-diagram-manager.test.ts b/packages/glsp-client-theia/test/browser/glsp-diagram-manager.test.ts index bf20c851..72818649 100644 --- a/packages/glsp-client-theia/test/browser/glsp-diagram-manager.test.ts +++ b/packages/glsp-client-theia/test/browser/glsp-diagram-manager.test.ts @@ -7,10 +7,13 @@ * SPDX-License-Identifier: MIT ********************************************************************************/ +import { DiagramLoader } from '@eclipse-glsp/client'; import { type GLSPDiagramWidget } from '@eclipse-glsp/theia-integration/lib/browser'; import { type GLSPDiagramLanguage } from '@eclipse-glsp/theia-integration/lib/common'; import { type WidgetOpenerOptions } from '@theia/core/lib/browser'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { HydraniumGlspClientContribution } from '../../src/browser/client-contribution.js'; +import { HydraniumDiagramLoader } from '../../src/browser/diagram-loader.js'; import { HydraniumGlspDiagramWidget } from '../../src/browser/diagram-widget.js'; import { AbstractHydraniumGlspDiagramManager } from '../../src/browser/glsp-diagram-manager.js'; @@ -44,6 +47,7 @@ vi.mock('@eclipse-glsp/theia-integration', () => ({ return this.createdWidget; } }, + BaseGLSPClientContribution: class {}, GLSPDiagramWidget: class { events: string[] = []; /** Lumino's widget detaches, and GLSP's stores its viewport, when its parent is cleared. */ @@ -57,8 +61,10 @@ vi.mock('@eclipse-glsp/theia-integration', () => ({ })); // Its browser barrel pulls `@theia/output`, which touches DOM globals at load. vi.mock('@hydranium/client-theia/lib/browser', () => ({ - ChannelLogger: class ChannelLogger {} + ChannelLogger: class ChannelLogger {}, + ConnectionReporter: Symbol('ConnectionReporter') })); +vi.mock('@theia/workspace/lib/browser', () => ({ WorkspaceService: class WorkspaceService {} })); vi.mock('../../src/browser/glsp-saveable', () => ({ HydraniumGlspSaveable: class HydraniumGlspSaveable {} })); const LANGUAGE: GLSPDiagramLanguage = { @@ -145,6 +151,7 @@ describe('AbstractHydraniumGlspDiagramManager.reopen', () => { beforeEach(() => { manager = new ReopenTestManager(); + Object.assign(manager, { diagramServiceProvider: { getGLSPClientContribution: () => undefined } }); widget = new HydraniumGlspDiagramWidget(); events = (widget as unknown as { events: string[] }).events; Object.defineProperties(widget, { @@ -240,3 +247,54 @@ describe('AbstractHydraniumGlspDiagramManager.reopen', () => { expect(manager.opened[0].options?.widgetOptions).toBeUndefined(); }); }); + +describe('AbstractHydraniumGlspDiagramManager on client events', () => { + class EventTestManager extends TestDiagramManager { + readonly reopened: unknown[] = []; + widgets: GLSPDiagramWidget[] = []; + override get all(): GLSPDiagramWidget[] { + return this.widgets; + } + override async reopen(widget: GLSPDiagramWidget): Promise { + this.reopened.push(widget); + } + } + + const diagram = (status?: 'loaded' | 'failed'): GLSPDiagramWidget => { + const loader = new HydraniumDiagramLoader(); + Object.assign(loader, { outcome: status && { status } }); + return { diContainer: { get: (id: unknown) => (id === DiagramLoader ? loader : undefined) } } as unknown as GLSPDiagramWidget; + }; + + const setUp = (): { manager: EventTestManager; contribution: HydraniumGlspClientContribution } => { + const manager = new EventTestManager(); + const contribution = new HydraniumGlspClientContribution({ languageContributionId: 'test-contribution' }); + Object.assign(manager, { diagramServiceProvider: { getGLSPClientContribution: () => contribution } }); + return { manager, contribution }; + }; + const fire = (contribution: HydraniumGlspClientContribution, emitter: 'clientLostEmitter' | 'clientStartedEmitter'): void => + (contribution as unknown as Record)[emitter].fire({}); + + /** None of them has a server behind it any more. */ + it('reopens every diagram once the client is lost', async () => { + const { manager, contribution } = setUp(); + await manager.createWidget({}); + manager.widgets = [diagram('loaded'), diagram(), diagram('failed')]; + + fire(contribution, 'clientLostEmitter'); + + expect(manager.reopened).toEqual(manager.widgets); + }); + + /** A diagram whose load failed stays failed otherwise, though a client is up now. */ + it('reopens only the failed diagrams once a client starts', async () => { + const { manager, contribution } = setUp(); + await manager.createWidget({}); + const failed = diagram('failed'); + manager.widgets = [diagram('loaded'), diagram(), failed]; + + fire(contribution, 'clientStartedEmitter'); + + expect(manager.reopened).toEqual([failed]); + }); +}); diff --git a/packages/glsp-client-theia/test/browser/glsp-theia-frontend-module.test.ts b/packages/glsp-client-theia/test/browser/glsp-theia-frontend-module.test.ts index a9a8eb46..1596ce01 100644 --- a/packages/glsp-client-theia/test/browser/glsp-theia-frontend-module.test.ts +++ b/packages/glsp-client-theia/test/browser/glsp-theia-frontend-module.test.ts @@ -17,13 +17,15 @@ // `super.bindGLSPClientContribution` binds the token itself, so "this override // bound it" and "the base bound it" would produce the same observable and every // assertion below would pass whatever the override did. -const { bindLogLevelPreferenceMock, registerDiagramManagerMock, superCalls } = vi.hoisted(() => ({ +const { bindConnectionReporterMock, bindLogLevelPreferenceMock, registerDiagramManagerMock, superCalls } = vi.hoisted(() => ({ + bindConnectionReporterMock: vi.fn(), bindLogLevelPreferenceMock: vi.fn(), registerDiagramManagerMock: vi.fn(), superCalls: { initialize: 0, bindGLSPClientContribution: 0, bindDiagramWidgetFactory: 0 } })); vi.mock('@hydranium/client-theia/lib/browser', () => ({ + bindConnectionReporter: bindConnectionReporterMock, bindLogLevelPreference: bindLogLevelPreferenceMock, ChannelLogger: class ChannelLogger {} })); @@ -171,6 +173,14 @@ describe('AbstractHydraniumGlspTheiaFrontendModule', () => { expect(bindLogLevelPreferenceMock).not.toHaveBeenCalled(); }); + /** The client contribution reports through the slot, so a module that + * forgets it fails at the contribution's construction. */ + it('binds the connection reporter the client contribution reports through', () => { + new ModuleUnderTest().initialize(recorder); + + expect(bindConnectionReporterMock).toHaveBeenCalledWith(recorder.bind, recorder.isBound); + }); + it('binds the log-level preference on top of the base wiring when one is named', () => { class WithPreference extends ModuleUnderTest { protected override readonly logLevelPreference = 'lang-one.log.level'; From 586060ef77b9bd1fdb2f3ba9ff6d78fa8454666d Mon Sep 17 00:00:00 2001 From: Martin Fleck Date: Wed, 30 Sep 2026 11:56:34 +0200 Subject: [PATCH 5/6] feat(protocol): call a port's own connection lifecycle by default A host that reports its connections through its port had every connection built over it pass the port's lifecycle on, and a connection that did not reported nothing. - DataPort.connectionLifecycle is an optional member a host fills in; RpcConnection calls it for each generation, before the lifecycle its options pass, so an adopter's own hooks add to the port's rather than replacing them - A lifecycle passed both ways is called once - ChannelDataPort's lifecycle reaches every connection over it, so the order-flow data connection no longer passes it --- .../src/browser/order-flow-data-connection.ts | 2 +- packages/data-client-theia/README.md | 9 +++-- .../src/browser/channel-data-port.ts | 6 +--- packages/protocol/src/client/data-port.ts | 10 +++++- .../protocol/src/client/rpc-connection.ts | 12 ++++++- .../test/client/rpc-connection.test.ts | 33 ++++++++++++++++++- 6 files changed, 58 insertions(+), 14 deletions(-) diff --git a/examples/order-flow/theia/src/browser/order-flow-data-connection.ts b/examples/order-flow/theia/src/browser/order-flow-data-connection.ts index c8fcc5f0..9d9a5d2c 100644 --- a/examples/order-flow/theia/src/browser/order-flow-data-connection.ts +++ b/examples/order-flow/theia/src/browser/order-flow-data-connection.ts @@ -51,7 +51,7 @@ export interface OrderFlowDataServer extends DataServerProtocol { constructor(@inject(OrderFlowTheiaDataPort) port: OrderFlowTheiaDataPort) { - super(port, port.connectionLifecycle); + super(port); } } diff --git a/packages/data-client-theia/README.md b/packages/data-client-theia/README.md index 59b09c62..e3c2d0b3 100644 --- a/packages/data-client-theia/README.md +++ b/packages/data-client-theia/README.md @@ -29,11 +29,10 @@ text edits. with a `servicePath`; it supplies the channel, the workspace gate, the reconnect signal and the `MessageService` error sink. Bind one per service path in singleton scope. Hand it to `DataConnectionWithEvents` (from - `@hydranium/protocol`) with its `connectionLifecycle` — - `new DataConnectionWithEvents(port, port.connectionLifecycle)` — and each - connection reports through the `ConnectionReporter`, a data server not ready - after 30 s included; the connection, its sessions and its event fan-out are the - host-neutral ones every other shell uses. + `@hydranium/protocol`) and each connection reports through the + `ConnectionReporter`, a data server not ready after 30 s included; the + connection, its sessions and its event fan-out are the host-neutral ones every + other shell uses. - **`EmitterDataClient`** (on `./common`, not `./browser`) — the default client-side implementation of the data protocol's inbound notifications, fanning each one out to a Theia `Event`: `onDidUpdateDocument`, diff --git a/packages/data-client-theia/src/browser/channel-data-port.ts b/packages/data-client-theia/src/browser/channel-data-port.ts index 83229b27..20aa4feb 100644 --- a/packages/data-client-theia/src/browser/channel-data-port.ts +++ b/packages/data-client-theia/src/browser/channel-data-port.ts @@ -68,11 +68,7 @@ export abstract class ChannelDataPort implements DataPort { /** Set once {@link connectionLifecycle} reports, which then owns the connection failures. */ protected reportsConnections = false; - /** - * Reports each connection generation through the {@link ConnectionReporter}. - * Pass it to the `DataConnection` built over this port; without it the - * connection failures are raised as notifications of their own. - */ + /** Reports each connection generation through the {@link ConnectionReporter}. */ readonly connectionLifecycle: RpcConnectionLifecycle = { onConnecting: () => { this.reportsConnections = true; diff --git a/packages/protocol/src/client/data-port.ts b/packages/protocol/src/client/data-port.ts index 6bc6989e..c408687b 100644 --- a/packages/protocol/src/client/data-port.ts +++ b/packages/protocol/src/client/data-port.ts @@ -9,6 +9,7 @@ import type { Event, MessageConnection } from 'vscode-jsonrpc'; import type { ResolvedMessage } from '../messages/primitives'; +import type { RpcConnectionLifecycle } from './rpc-connection'; /** * The one thing a host has to supply for the data head: a live JSON-RPC @@ -28,7 +29,7 @@ import type { ResolvedMessage } from '../messages/primitives'; * method allowlists, which cannot drift. Everything a form or a tree actually * does — the wire contract, the open/watch/update/close sequence, `basedOn` * conflict handling, echo filtering by `sourceClientId` — is host-invariant and - * lives above this interface. What varies between hosts is exactly the four + * lives above this interface. What varies between hosts is exactly the * members below. * * **Why the transport hop and not merely the protocol.** In a Theia frontend @@ -90,4 +91,11 @@ export interface DataPort { * `Disposable` on the *wire* would not be. */ readonly onDispose: Event; + + /** + * Called for each connection generation, beside the lifecycle the + * connection's options pass, so a host reporting its connections itself + * does so without every connection built over it passing this on. + */ + readonly connectionLifecycle?: RpcConnectionLifecycle; } diff --git a/packages/protocol/src/client/rpc-connection.ts b/packages/protocol/src/client/rpc-connection.ts index 569dd65e..f8737ba6 100644 --- a/packages/protocol/src/client/rpc-connection.ts +++ b/packages/protocol/src/client/rpc-connection.ts @@ -55,6 +55,16 @@ export interface RpcConnectionLifecycle { readonly onFailed?: (error: unknown) => void; } +/** Calls each distinct lifecycle's hooks in order; one passed twice is called once. */ +function composeLifecycles(...lifecycles: (RpcConnectionLifecycle | undefined)[]): RpcConnectionLifecycle { + const present = [...new Set(lifecycles)].filter((lifecycle): lifecycle is RpcConnectionLifecycle => lifecycle !== undefined); + return { + onConnecting: () => present.forEach(lifecycle => lifecycle.onConnecting?.()), + onReady: () => present.forEach(lifecycle => lifecycle.onReady?.()), + onFailed: error => present.forEach(lifecycle => lifecycle.onFailed?.(error)) + }; +} + /** Everything {@link RpcConnection} needs once a subclass has resolved its defaults. */ export interface ResolvedRpcConnectionOptions { readonly methodNamespace: string; @@ -109,7 +119,7 @@ export class RpcConnection ) { this.methodNamespace = options.methodNamespace; this.clientMethods = options.clientMethods; - this.lifecycle = options.lifecycle; + this.lifecycle = composeLifecycles(port.connectionLifecycle, options.lifecycle); this.portDisposeListener = this.port.onDispose(() => this.dropGeneration()); } diff --git a/packages/protocol/test/client/rpc-connection.test.ts b/packages/protocol/test/client/rpc-connection.test.ts index 01c3c19b..7a304621 100644 --- a/packages/protocol/test/client/rpc-connection.test.ts +++ b/packages/protocol/test/client/rpc-connection.test.ts @@ -85,7 +85,7 @@ interface Harness { dispose(): void; } -function harness(lifecycle: RpcConnectionLifecycle = {}): Harness { +function harness(lifecycle: RpcConnectionLifecycle = {}, portLifecycle?: RpcConnectionLifecycle): Harness { const pairs: DuplexConnectionPair[] = []; const servers: ServerDouble[] = []; const client = new RecordingClient(); @@ -97,6 +97,7 @@ function harness(lifecycle: RpcConnectionLifecycle = {}): Harness { return pair.right; } }); + Object.assign(port, { connectionLifecycle: portLifecycle }); const rpc = new ProbeRpcConnection(port, client, { methodNamespace: WIRE_PREFIX, clientMethods: CLIENT_METHODS, @@ -115,6 +116,36 @@ function harness(lifecycle: RpcConnectionLifecycle = {}): Harness { }; } +describe('RpcConnection lifecycle', () => { + /** A host reports its connections through its port; a connection that had + * to be handed the port's hooks as well would report nothing when not. */ + it("calls the port's lifecycle beside the one its options pass", async () => { + const calls: string[] = []; + const test = harness( + { onConnecting: () => calls.push('options connecting'), onReady: () => calls.push('options ready') }, + { onConnecting: () => calls.push('port connecting'), onReady: () => calls.push('port ready') } + ); + try { + await test.rpc.connected(); + expect(calls).toEqual(['port connecting', 'options connecting', 'port ready', 'options ready']); + } finally { + test.dispose(); + } + }); + + it('calls a lifecycle passed both ways once', async () => { + let connecting = 0; + const lifecycle: RpcConnectionLifecycle = { onConnecting: () => connecting++ }; + const test = harness(lifecycle, lifecycle); + try { + await test.rpc.connected(); + expect(connecting).toBe(1); + } finally { + test.dispose(); + } + }); +}); + describe('RpcConnection reconnect', () => { it('builds a fresh generation after the port disposes', async () => { const test = harness(); From 1eca9638ffda2cb211cdd7f7db7493cc2ee6a94b Mon Sep 17 00:00:00 2001 From: Martin Fleck Date: Wed, 30 Sep 2026 12:07:52 +0200 Subject: [PATCH 6/6] fix(glsp-client-theia): keep GLSP's status overlay on the page GLSP's status overlay inserts its element into the diagram's base div when the diagram starts, and sprotty's first render then replaces that div with its own. The element was left detached, so no status a server or the loader raised was ever seen in a Theia diagram. - HydraniumStatusOverlay puts a detached element back into the current base div before it shows a status or becomes visible - The diagram container binds it in place of upstream's overlay New public names - HydraniumStatusOverlay --- .../test/e2e/order-flow-diagram.spec.mts | 6 +++ packages/glsp-client-theia/README.md | 4 +- .../src/browser/diagram-widget.ts | 18 +++----- .../src/browser/glsp-client-theia-module.ts | 4 +- .../glsp-client-theia/src/browser/index.ts | 1 + .../src/browser/status-overlay.ts | 43 ++++++++++++++++++ .../browser/glsp-client-theia-module.test.ts | 7 ++- .../test/browser/status-overlay.test.ts | 45 +++++++++++++++++++ 8 files changed, 112 insertions(+), 16 deletions(-) create mode 100644 packages/glsp-client-theia/src/browser/status-overlay.ts create mode 100644 packages/glsp-client-theia/test/browser/status-overlay.test.ts diff --git a/examples/order-flow/theia-app/test/e2e/order-flow-diagram.spec.mts b/examples/order-flow/theia-app/test/e2e/order-flow-diagram.spec.mts index c1802c2c..a6851eeb 100644 --- a/examples/order-flow/theia-app/test/e2e/order-flow-diagram.spec.mts +++ b/examples/order-flow/theia-app/test/e2e/order-flow-diagram.spec.mts @@ -97,4 +97,10 @@ test.describe.serial('Order-flow diagram in Theia', () => { expect(await loadingOverlayWasSeen(app)).toBe(true); await expect(app.page.locator(`.${LOADING_OVERLAY_CLASS}`)).toHaveCount(0); }); + + test("GLSP's status overlay is on the page, where a server status can show", async () => { + // It is inserted into the diagram's base div before sprotty's first render + // replaces that div; left there, it is never on the page at all. + await expect(app.page.locator('.sprotty-status')).toHaveCount(1); + }); }); diff --git a/packages/glsp-client-theia/README.md b/packages/glsp-client-theia/README.md index 807dab23..50239ddf 100644 --- a/packages/glsp-client-theia/README.md +++ b/packages/glsp-client-theia/README.md @@ -21,7 +21,9 @@ if you are mounting a hydranium GLSP diagram in a Theia application. - **`createGlspClientTheiaModule(context, options)`** — the standard per-diagram bindings, all unconditional: the cross-head `ChannelLogger`, `HydraniumGlspActionDispatcher`, `HydraniumDiagramLoader`, - `HydraniumHiddenBoundsUpdater`, and `HydraniumGlspMessageService`. Each + `HydraniumHiddenBoundsUpdater`, `HydraniumGlspMessageService`, and + `HydraniumStatusOverlay`, which keeps GLSP's status overlay on the page after + sprotty's first render replaces the diagram's base div. Each replaces a GLSP default with a strict superset of its behaviour, so a head that wants the original rebinds that one token back. - **Loading feedback that cannot silently vanish.** `HydraniumDiagramLoader` diff --git a/packages/glsp-client-theia/src/browser/diagram-widget.ts b/packages/glsp-client-theia/src/browser/diagram-widget.ts index 397d9234..a2ac8c4a 100644 --- a/packages/glsp-client-theia/src/browser/diagram-widget.ts +++ b/packages/glsp-client-theia/src/browser/diagram-widget.ts @@ -37,21 +37,13 @@ export const DIAGRAM_LOADING_FAILED_CLASS = `${DIAGRAM_LOADING_CLASS}-failed`; * model to read as a broken editor rather than a slow one. * * **How the pending state is sourced.** From {@link HydraniumDiagramLoader}, not - * from `actionDispatcher.onceModelInitialized()`. The loader settles on failure - * as well as on success, and it does so even when its own error-reporting - * dispatch throws; `onceModelInitialized()` simply never settles on a failed - * load. That distinction is not academic here: this overlay is opaque and covers - * the widget node, while GLSP's `StatusOverlay` — where the loader reports the - * failure — mounts *inside* the diagram's base div. Keyed on model - * initialization, a failed load would leave a spinner turning on top of the - * error message explaining it. Keyed on the loader, exactly one component decides - * whether the canvas is pending and the two mechanisms compose instead of - * competing. + * from `actionDispatcher.onceModelInitialized()`: the loader settles on failure + * as well as on success, even when its own error report throws, while + * `onceModelInitialized()` never settles on a failed load and would leave the + * spinner turning for good. * * A failed load keeps the overlay up with the error and a Retry, which asks for - * a fresh widget through {@link onDidRequestReopen}. Uncovering the canvas - * instead leaves it blank: GLSP's status overlay does not show the loader's - * report in this host. + * a fresh widget through {@link onDidRequestReopen}. * * Bound unconditionally by `AbstractHydraniumGlspTheiaFrontendModule`. To opt out, * override {@link showLoadingOverlay} to a no-op; to change what is rendered, diff --git a/packages/glsp-client-theia/src/browser/glsp-client-theia-module.ts b/packages/glsp-client-theia/src/browser/glsp-client-theia-module.ts index f2bc50b6..db0efea0 100644 --- a/packages/glsp-client-theia/src/browser/glsp-client-theia-module.ts +++ b/packages/glsp-client-theia/src/browser/glsp-client-theia-module.ts @@ -8,9 +8,10 @@ ********************************************************************************/ import { bindChannelLogger, type ChannelLoggerOptions } from '@hydranium/client-theia/lib/browser'; -import { type BindingContext, DiagramLoader, GLSPActionDispatcher, GLSPHiddenBoundsUpdater } from '@eclipse-glsp/client'; +import { type BindingContext, DiagramLoader, GLSPActionDispatcher, GLSPHiddenBoundsUpdater, StatusOverlay } from '@eclipse-glsp/client'; import { HydraniumGlspActionDispatcher } from './action-dispatcher'; import { HydraniumDiagramLoader } from './diagram-loader'; +import { HydraniumStatusOverlay } from './status-overlay'; import { bindHydraniumGlspMessageService } from './glsp-message-service'; import { HydraniumHiddenBoundsUpdater } from './hidden-bounds-updater'; @@ -50,5 +51,6 @@ export function createGlspClientTheiaModule(context: BindingContext, options: Gl rebind(GLSPActionDispatcher).toService(HydraniumGlspActionDispatcher); rebind(DiagramLoader).to(HydraniumDiagramLoader).inSingletonScope(); rebind(GLSPHiddenBoundsUpdater).to(HydraniumHiddenBoundsUpdater).inSingletonScope(); + rebind(StatusOverlay).to(HydraniumStatusOverlay).inSingletonScope(); bindHydraniumGlspMessageService(bind, isBound, rebind); } diff --git a/packages/glsp-client-theia/src/browser/index.ts b/packages/glsp-client-theia/src/browser/index.ts index 5dcf033d..71eae15a 100644 --- a/packages/glsp-client-theia/src/browser/index.ts +++ b/packages/glsp-client-theia/src/browser/index.ts @@ -22,6 +22,7 @@ export * from './glsp-saveable'; export * from './glsp-theia-frontend-module'; export * from './hidden-bounds-updater'; export * from './hydranium-glsp-diagram-configuration'; +export * from './status-overlay'; // Upstream's root entry is its browser tier and re-exports no `lib/common`, so the // type our abstract `diagramLanguage` members are declared with has no bare-specifier diff --git a/packages/glsp-client-theia/src/browser/status-overlay.ts b/packages/glsp-client-theia/src/browser/status-overlay.ts new file mode 100644 index 00000000..95612dae --- /dev/null +++ b/packages/glsp-client-theia/src/browser/status-overlay.ts @@ -0,0 +1,43 @@ +/******************************************************************************** + * Copyright (c) 2026 CrossBreeze, EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available in the project root. + * + * SPDX-License-Identifier: MIT + ********************************************************************************/ + +import { type StatusAction, StatusOverlay } from '@eclipse-glsp/client'; +import { injectable } from '@theia/core/shared/inversify'; + +/** + * GLSP's status overlay, kept on the page. + * + * It inserts its element into the diagram's base div when the diagram starts, + * and sprotty's first render then replaces that div with its own, so the element + * is left detached and no status it shows is ever seen. This puts the element + * back into the current base div before it shows anything. + */ +@injectable() +export class HydraniumStatusOverlay extends StatusOverlay { + override handle(action: StatusAction): void { + this.reattach(); + super.handle(action); + } + + override show(...args: Parameters): void { + super.show(...args); + this.reattach(); + } + + protected reattach(): void { + const container = this.containerElement as HTMLElement | undefined; + if (!container || container.isConnected) { + return; + } + const parent = this.getParentContainer(); + if (parent) { + this.insertContainerIntoParent(container, parent); + } + } +} diff --git a/packages/glsp-client-theia/test/browser/glsp-client-theia-module.test.ts b/packages/glsp-client-theia/test/browser/glsp-client-theia-module.test.ts index bb26372b..6b712573 100644 --- a/packages/glsp-client-theia/test/browser/glsp-client-theia-module.test.ts +++ b/packages/glsp-client-theia/test/browser/glsp-client-theia-module.test.ts @@ -26,7 +26,7 @@ vi.mock('@eclipse-glsp/theia-integration', () => ({ TheiaGLSPMessageService: class TheiaGLSPMessageService {} })); -import { DiagramLoader, GLSPActionDispatcher, GLSPHiddenBoundsUpdater } from '@eclipse-glsp/client'; +import { DiagramLoader, GLSPActionDispatcher, GLSPHiddenBoundsUpdater, StatusOverlay } from '@eclipse-glsp/client'; import { TheiaGLSPMessageService } from '@eclipse-glsp/theia-integration'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { HydraniumGlspActionDispatcher } from '../../src/browser/action-dispatcher'; @@ -34,6 +34,7 @@ import { HydraniumDiagramLoader } from '../../src/browser/diagram-loader'; import { createGlspClientTheiaModule, type GlspClientTheiaModuleOptions } from '../../src/browser/glsp-client-theia-module'; import { HydraniumGlspMessageService } from '../../src/browser/glsp-message-service'; import { HydraniumHiddenBoundsUpdater } from '../../src/browser/hidden-bounds-updater'; +import { HydraniumStatusOverlay } from '../../src/browser/status-overlay'; import { type BindRecorder, type BindRecorderOptions, makeBindRecorder } from '../../src/testing/bind-recorder'; const CHANNEL = { channelName: 'Test' }; @@ -76,6 +77,10 @@ describe('createGlspClientTheiaModule', () => { ]); }); + it('rebinds StatusOverlay to the one that keeps its element on the page', () => { + expect(record().find('rebind', StatusOverlay)?.chain).toEqual([`to(<${HydraniumStatusOverlay.name}>)`, 'inSingletonScope()']); + }); + it('rebinds the Theia message service so the duplicate model-loading toast is dropped', () => { expect(record().find('rebind', TheiaGLSPMessageService)?.chain).toEqual([ `to(<${HydraniumGlspMessageService.name}>)`, diff --git a/packages/glsp-client-theia/test/browser/status-overlay.test.ts b/packages/glsp-client-theia/test/browser/status-overlay.test.ts new file mode 100644 index 00000000..b6b27d86 --- /dev/null +++ b/packages/glsp-client-theia/test/browser/status-overlay.test.ts @@ -0,0 +1,45 @@ +/******************************************************************************** + * Copyright (c) 2026 CrossBreeze, EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available in the project root. + * + * SPDX-License-Identifier: MIT + ********************************************************************************/ + +import 'reflect-metadata'; +import { StatusAction } from '@eclipse-glsp/client'; +import { describe, expect, it, vi } from 'vitest'; +import { HydraniumStatusOverlay } from '../../src/browser/status-overlay'; + +/** An overlay whose element and parent are stand-ins, so no DOM is needed. */ +class TestOverlay extends HydraniumStatusOverlay { + readonly parent = { insertBefore: vi.fn(), firstChild: undefined }; + readonly element = { isConnected: false }; + + constructor() { + super(); + Object.assign(this, { containerElement: this.element }); + } + + protected override getParentContainer(): HTMLElement { + return this.parent as unknown as HTMLElement; + } +} + +describe('HydraniumStatusOverlay', () => { + /** Sprotty's first render replaces the base div the element was inserted + * into, and a status shown in a detached element is never seen. */ + it('puts a detached element back into the base div before showing a status', () => { + const overlay = new TestOverlay(); + overlay.handle(StatusAction.create('Initializing...', { severity: 'INFO' })); + expect(overlay.parent.insertBefore).toHaveBeenCalledWith(overlay.element, undefined); + }); + + it('leaves an element that is on the page where it is', () => { + const overlay = new TestOverlay(); + overlay.element.isConnected = true; + overlay.handle(StatusAction.create('Initializing...', { severity: 'INFO' })); + expect(overlay.parent.insertBefore).not.toHaveBeenCalled(); + }); +});