/******************************************************************************** * 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 ********************************************************************************/ import { ChannelLogger, ConnectionReporter, type ConnectionAttempt, type ConnectionTarget } from '@hydranium/client-theia/browser'; import { Clock } from '@hydranium/client-theia/common'; import { DATA_SERVER_CONNECT_FAILED, DATA_SERVER_NOT_READY, renderFrameworkMessage, SystemClock, 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'; import { inject, injectable, optional } from '@theia/core/shared/inversify'; import { WorkspaceService } from '@theia/workspace/lib/browser'; import type { MessageConnection } from 'vscode-jsonrpc'; import { type ChannelConnectionHandle, openChannelConnection } from './channel-connection'; import { whenWorkspaceOpen } from './workspace-gate'; /** * A {@link DataPort} over a Theia frontend channel. * * Subclasses supply {@link servicePath}; everything above the port — * `DataConnection`, its sessions, and whatever model a widget drives — is * host-neutral and shared with the VS Code and browser shells. * * Bind one per service path and in singleton scope. Theia keys a frontend * channel by its path and refuses a second on a path already open, and the * throw escapes the `openChannelConnection` the loser is awaiting, leaving that * promise unsettled rather than rejected. */ @injectable() 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) readonly logger!: ChannelLogger; /** Times the channel's reconnect escalation: the container's {@link Clock}, or a `SystemClock`. */ @inject(Clock) @optional() protected readonly clock: Clock = new SystemClock(); /** Frontend service path the backend forwarder for this head is registered under. */ protected abstract readonly servicePath: string; /** * Re-open the channel when the current connection is lost. Default `true` — * re-opening is the only thing that recovers a restarted language server, * which binds new ephemeral ports. Turn it off for a frontend that tears * itself down on transport loss instead. */ protected readonly reconnectOnConnectionLoss: boolean = true; protected readonly disposeEmitter = new Emitter(); readonly onDispose: Event = this.disposeEmitter.event; protected handle?: ChannelConnectionHandle; /** Set by {@link dispose}, after which {@link connect} rejects. */ protected disposed = false; /** 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. * * Gated because the backend forwarder discovers the head's port by executing * a command the language server answers, and the language server only * launches once there is a workspace to launch it for. Asking earlier polls * a command nobody has registered. * * Rejects once the port is disposed: the consumer reconnects when the * dispose fires {@link onDispose}, and a channel opened then is closed by * nothing. */ connect(): Promise { if (this.disposed) { return Promise.reject(new Error(`${this.constructor.name}: the port is disposed`)); } // Read per call rather than cached: `current` is repointed at a fresh // promise every time the channel is re-opened, so reading through the // handle is what makes a later generation reach the live server. if (!this.handle) { this.handle = openChannelConnection(this.connectionProvider, this.servicePath, { whenReady: whenWorkspaceOpen(this.workspaceService), logger: this.logger, clock: this.clock, reconnect: this.reconnectOnConnectionLoss }); // A relaunched server binds new ephemeral ports; the handle re-opens // and the forwarder rediscovers, but `DataConnection` caches its // generation and would go on addressing the dead one. Its signal to // drop that generation is this event. this.handle.onDidLoseConnection(() => this.disposeEmitter.fire(undefined)); } return this.handle.current; } /** * Surface a transport or write failure as a Theia notification. * * Swallowing it is the failure this exists to prevent: a dead connection and * an empty document are indistinguishable in a widget. * * `reported` is already a complete sentence with the detail interpolated, so * wrapping it in a sentence of the host's own would nest one owner's clause * inside another's and leave no translator in control of the whole. */ reportError(_error: unknown, reported: ResolvedMessage): void { 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.disposed = true; this.settleAttempt(); this.handle?.dispose(); this.handle = undefined; this.disposeEmitter.fire(undefined); this.disposeEmitter.dispose(); } }