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-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-diagram.spec.mts b/examples/order-flow/theia-app/test/e2e/order-flow-diagram.spec.mts index dd27e493..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 @@ -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. * @@ -112,22 +98,9 @@ test.describe.serial('Order-flow diagram in Theia', () => { 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); + 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/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-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/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-data-connection.ts b/examples/order-flow/theia/src/browser/order-flow-data-connection.ts index be668e4f..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 @@ -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, @@ -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/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/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/src/node/abstract-socket-forwarding-connection-handler.ts b/packages/client-theia/src/node/abstract-socket-forwarding-connection-handler.ts index 1cf252af..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 @@ -117,31 +117,67 @@ 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); + 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(); } } - protected async findPort(): Promise { + /** 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(); 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); - 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..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 @@ -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,16 @@ 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(); + } + /** Reach the protected dial under test. */ connect(channel: Channel, port: number): Promise { return this.connectToServer(channel, port); @@ -85,6 +95,82 @@ 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'")); + }); + + /** + * 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(); + }); + + /** + * 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/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..e3c2d0b3 100644 --- a/packages/data-client-theia/README.md +++ b/packages/data-client-theia/README.md @@ -29,8 +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`) and 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`, @@ -83,8 +85,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..20aa4feb 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,30 @@ 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}. */ + 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 +122,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 dd885e9a..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` @@ -47,10 +49,20 @@ 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. 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 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 @@ -85,7 +97,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..367de9f8 100644 --- a/packages/glsp-client-theia/src/browser/client-contribution.ts +++ b/packages/glsp-client-theia/src/browser/client-contribution.ts @@ -7,94 +7,283 @@ * SPDX-License-Identifier: MIT ********************************************************************************/ -import { type GLSPClient } 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 { 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 { OutputChannelManager } from '@theia/output/lib/browser/output-channel'; import { WorkspaceService } from '@theia/workspace/lib/browser'; +import { HydraniumGlspClient } from './glsp-client'; -/** 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 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(OutputChannelManager) protected outputChannelManager!: OutputChannelManager; @inject(WorkspaceService) protected readonly workspaceService!: WorkspaceService; + @inject(ConnectionReporter) protected readonly connectionReporter!: ConnectionReporter; + @inject(ChannelLogger) protected readonly logger!: ChannelLogger; readonly id: string; - protected readonly channelName: string; - protected readonly readyMarker: 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(); this.id = options.languageContributionId; - this.channelName = options.channelName; - this.readyMarker = options.readyMarker; - } - - /** 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)) { + 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; + } + + /** 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.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(); + if (this.disposed) { return; } - 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(); + 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( + 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) => { + 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(); + } + }); + } + ); + 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); + } + } + + /** 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.`); } }); - return deferred.promise; } + /** 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 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 + * 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; + 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(); + } + + 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)); } - await this.waitForBackendConnected(); - return super.start(glspClient); + } + + 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.') + }); + } + /** 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.'); + } + + 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..a2ac8c4a 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'; @@ -36,23 +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. * - * **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}. * * Bound unconditionally by `AbstractHydraniumGlspTheiaFrontendModule`. To opt out, * override {@link showLoadingOverlay} to a no-op; to change what is rendered, @@ -64,6 +55,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 +73,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 +120,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 +150,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-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/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 ced942e5..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,16 @@ * 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 } 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 { 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'; /** * `GLSPDiagramManager` subclass that derives the language-correlated getters @@ -47,6 +51,10 @@ 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(); + override get fileExtensions(): string[] { return [...this.diagramLanguage.fileExtensions]; } @@ -67,6 +75,81 @@ export abstract class AbstractHydraniumGlspDiagramManager extends GLSPDiagramMan return this.managerLabel; } + override async createWidget(options?: unknown): Promise { + this.listenToClient(); + const widget = await super.createWidget(options); + if (widget instanceof HydraniumGlspDiagramWidget) { + widget.onDidRequestReopen(() => void this.reopen(widget)); + } + 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. + * 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/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..71eae15a 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'; @@ -21,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/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..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,103 +7,406 @@ * 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, 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'); + 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('@hydranium/client-theia/lib/browser', () => ({ + ChannelLogger: class ChannelLogger {}, + ConnectionReporter: Symbol('ConnectionReporter') })); vi.mock('@theia/workspace/lib/browser', () => ({ WorkspaceService: class WorkspaceService {} })); -import { describe, expect, it, vi } from 'vitest'; -import { HydraniumGlspClientContribution } from '../../src/browser/client-contribution'; +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 { afterEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_GLSP_CLIENT_STARTUP_TIMEOUT_MS, HydraniumGlspClientContribution } from '../../src/browser/client-contribution'; -interface FakeOutputChannel { - text: string; - listeners: Array<() => void>; - resource: { textModel: { getValue(): string } }; - onContentChange(cb: () => void): { dispose: () => void }; +interface FakeConnection { + onDispose(): void; + onClose(listener: () => void): void; + close(): void; } -function makeFakeChannel(initialText = ''): FakeOutputChannel { +function makeConnection(): FakeConnection { 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 { + onDispose: () => undefined, + onClose: listener => listeners.push(listener), + close: () => listeners.forEach(listener => listener()) }; - return channel; } +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 + * decides when, and whether, each one arrives. */ class TestContribution extends HydraniumGlspClientContribution { - constructor(options: ConstructorParameters[0]) { - super(options); + readonly connections: Array> = []; + readonly clients: FakeClient[] = []; + readonly attempts: ReportedAttempt[] = []; + readonly infos = vi.fn(); + /** Whether each client's `initializeServer` never answers. */ + initializeHangs = false; + + constructor(startupTimeoutMs?: number) { + super({ languageContributionId: 'test', startupTimeoutMs }); + Object.assign(this, { + restartDelaysMs: [1], + workspaceService: { tryGetRoots: () => [{}] }, + 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 }) + }; + } + } + }); } - exposeWaitForBackend(): Promise { - return this.waitForBackendConnected(); + + get startupTimeout(): number { + return this.glspClientStartupTimeout; + } + + 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 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, + 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; } } -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); - }); - - 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' +describe('HydraniumGlspClientContribution', () => { + const contributions: TestContribution[] = []; + const make = (startupTimeoutMs?: number): TestContribution => { + const contribution = new TestContribution(startupTimeoutMs); + contributions.push(contribution); + return contribution; + }; + afterEach(() => contributions.splice(0).forEach(contribution => contribution.dispose())); + + it('bounds a start by 30 s unless the options say otherwise', () => { + expect(make().startupTimeout).toBe(DEFAULT_GLSP_CLIENT_STARTUP_TIMEOUT_MS); + expect(DEFAULT_GLSP_CLIENT_STARTUP_TIMEOUT_MS).toBe(30_000); + 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, 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.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 = make(); + 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.attempts[0].message).toBe('Could not connect to the diagram server.')); + }); + + /** 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.attempts.map(attempt => attempt.outcome)).toEqual(['failed', 'connected'])); + const client = await contribution.glspClient; + expect(client).toBe(contribution.clients[0]); + }); + + /** 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(); + + contribution.nextConnection().resolve(makeConnection()); + const client = await contribution.glspClient; + expect(client).toBe(contribution.clients[0]); + }); + + /** 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[1]); + await vi.waitFor(() => expect(contribution.attempts.map(attempt => attempt.outcome)).toEqual(['connected', 'connected'])); + }); + + /** 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; + 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); + }); + + /** 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)); + + 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 = make(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(makeConnection()); + 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'); + }); + + /** 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 make().closeChannel({ close } as unknown as Channel); + expect(close).toHaveBeenCalledTimes(1); + }); + + /** 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 vi.waitFor(() => expect(contribution.attempts).toHaveLength(1)); + + contribution.dispose(); + + await vi.waitFor(() => expect(contribution.attempts).toEqual([{ outcome: 'cancelled' }])); + }); + + /** + * 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 = make(); + 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-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/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 3414d1ab..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,14 @@ * 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'; /** @@ -34,12 +38,34 @@ 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; + } + }, + BaseGLSPClientContribution: class {}, + 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 {}, + ConnectionReporter: Symbol('ConnectionReporter') +})); +vi.mock('@theia/workspace/lib/browser', () => ({ WorkspaceService: class WorkspaceService {} })); +vi.mock('../../src/browser/glsp-saveable', () => ({ HydraniumGlspSaveable: class HydraniumGlspSaveable {} })); const LANGUAGE: GLSPDiagramLanguage = { diagramType: 'test-diagram', @@ -97,3 +123,178 @@ 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(); + Object.assign(manager, { diagramServiceProvider: { getGLSPClientContribution: () => undefined } }); + 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(); + }); +}); + +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'; 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(); + }); +}); 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..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 @@ -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,10 +83,13 @@ 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'); }); - 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', @@ -96,16 +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() }) + onMessage: 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); }); }); 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();