@hydranium/data-client-theia 1.0.0-next.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +126 -0
- package/lib/browser/channel-connection.d.ts +174 -0
- package/lib/browser/channel-connection.d.ts.map +1 -0
- package/lib/browser/channel-connection.js +264 -0
- package/lib/browser/channel-connection.js.map +1 -0
- package/lib/browser/data-service-frontend.d.ts +154 -0
- package/lib/browser/data-service-frontend.d.ts.map +1 -0
- package/lib/browser/data-service-frontend.js +169 -0
- package/lib/browser/data-service-frontend.js.map +1 -0
- package/lib/browser/diagnostics-data-service-frontend.d.ts +35 -0
- package/lib/browser/diagnostics-data-service-frontend.d.ts.map +1 -0
- package/lib/browser/diagnostics-data-service-frontend.js +54 -0
- package/lib/browser/diagnostics-data-service-frontend.js.map +1 -0
- package/lib/browser/host-diagnostics-frontend.d.ts +19 -0
- package/lib/browser/host-diagnostics-frontend.d.ts.map +1 -0
- package/lib/browser/host-diagnostics-frontend.js +31 -0
- package/lib/browser/host-diagnostics-frontend.js.map +1 -0
- package/lib/browser/index.d.ts +15 -0
- package/lib/browser/index.d.ts.map +1 -0
- package/lib/browser/index.js +31 -0
- package/lib/browser/index.js.map +1 -0
- package/lib/browser/references-data-service-frontend.d.ts +45 -0
- package/lib/browser/references-data-service-frontend.d.ts.map +1 -0
- package/lib/browser/references-data-service-frontend.js +55 -0
- package/lib/browser/references-data-service-frontend.js.map +1 -0
- package/lib/browser/workspace-gate.d.ts +21 -0
- package/lib/browser/workspace-gate.d.ts.map +1 -0
- package/lib/browser/workspace-gate.js +36 -0
- package/lib/browser/workspace-gate.js.map +1 -0
- package/lib/common/emitter-data-client.d.ts +40 -0
- package/lib/common/emitter-data-client.d.ts.map +1 -0
- package/lib/common/emitter-data-client.js +49 -0
- package/lib/common/emitter-data-client.js.map +1 -0
- package/lib/common/index.d.ts +10 -0
- package/lib/common/index.d.ts.map +1 -0
- package/lib/common/index.js +37 -0
- package/lib/common/index.js.map +1 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +11 -0
- package/lib/index.js.map +1 -0
- package/lib/node/connection-container-module.d.ts +34 -0
- package/lib/node/connection-container-module.d.ts.map +1 -0
- package/lib/node/connection-container-module.js +53 -0
- package/lib/node/connection-container-module.js.map +1 -0
- package/lib/node/data-server-connection-handler.d.ts +56 -0
- package/lib/node/data-server-connection-handler.d.ts.map +1 -0
- package/lib/node/data-server-connection-handler.js +63 -0
- package/lib/node/data-server-connection-handler.js.map +1 -0
- package/lib/node/host-diagnostics-server.d.ts +31 -0
- package/lib/node/host-diagnostics-server.d.ts.map +1 -0
- package/lib/node/host-diagnostics-server.js +62 -0
- package/lib/node/host-diagnostics-server.js.map +1 -0
- package/lib/node/index.d.ts +13 -0
- package/lib/node/index.d.ts.map +1 -0
- package/lib/node/index.js +29 -0
- package/lib/node/index.js.map +1 -0
- package/lib/node/socket-channel-forwarder.d.ts +34 -0
- package/lib/node/socket-channel-forwarder.d.ts.map +1 -0
- package/lib/node/socket-channel-forwarder.js +63 -0
- package/lib/node/socket-channel-forwarder.js.map +1 -0
- package/package.json +101 -0
- package/src/browser/channel-connection.ts +413 -0
- package/src/browser/data-service-frontend.ts +208 -0
- package/src/browser/diagnostics-data-service-frontend.ts +70 -0
- package/src/browser/host-diagnostics-frontend.ts +30 -0
- package/src/browser/index.ts +15 -0
- package/src/browser/references-data-service-frontend.ts +70 -0
- package/src/browser/workspace-gate.ts +35 -0
- package/src/common/emitter-data-client.ts +64 -0
- package/src/common/index.ts +21 -0
- package/src/index.ts +18 -0
- package/src/node/connection-container-module.ts +51 -0
- package/src/node/data-server-connection-handler.ts +76 -0
- package/src/node/host-diagnostics-server.ts +57 -0
- package/src/node/index.ts +13 -0
- package/src/node/socket-channel-forwarder.ts +64 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (c) 2026 CrossBreeze, EclipseSource and others.
|
|
3
|
+
*
|
|
4
|
+
* This program and the accompanying materials are made available under the
|
|
5
|
+
* terms of the MIT License which is available in the project root.
|
|
6
|
+
*
|
|
7
|
+
* SPDX-License-Identifier: MIT
|
|
8
|
+
********************************************************************************/
|
|
9
|
+
|
|
10
|
+
import { type Channel, DisposableCollection, Emitter, type Event, type MessageProvider } from '@theia/core';
|
|
11
|
+
import { type ServiceConnectionProvider } from '@theia/core/lib/browser';
|
|
12
|
+
import { Deferred } from '@theia/core/lib/common/promise-util';
|
|
13
|
+
import {
|
|
14
|
+
AbstractMessageReader,
|
|
15
|
+
AbstractMessageWriter,
|
|
16
|
+
createMessageConnection,
|
|
17
|
+
type DataCallback,
|
|
18
|
+
type Disposable,
|
|
19
|
+
type Logger,
|
|
20
|
+
type Message,
|
|
21
|
+
type MessageConnection,
|
|
22
|
+
type MessageReader,
|
|
23
|
+
type MessageWriter
|
|
24
|
+
// The `/browser` subpath, not the package root: `createMessageConnection`'s
|
|
25
|
+
// message queue needs a vscode-jsonrpc runtime abstraction layer, and only the
|
|
26
|
+
// `/node` and `/browser` entrypoints install one. The root resolves to the
|
|
27
|
+
// RAL-less common API, which throws 'No runtime abstraction layer installed'
|
|
28
|
+
// on the first message. This is frontend code, so `/browser`.
|
|
29
|
+
} from 'vscode-jsonrpc/browser';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A `vscode-jsonrpc` {@link MessageReader} that reads JSON-RPC messages off a
|
|
33
|
+
* Theia {@link Channel}. Each Theia channel message carries exactly one
|
|
34
|
+
* JSON-RPC message (the channel does its own length framing); the reader
|
|
35
|
+
* decodes the bytes and fires the parsed message.
|
|
36
|
+
*/
|
|
37
|
+
class ChannelMessageReader extends AbstractMessageReader implements MessageReader {
|
|
38
|
+
protected readonly onMessageEmitter = new Emitter<Message>();
|
|
39
|
+
protected readonly toDispose = new DisposableCollection();
|
|
40
|
+
|
|
41
|
+
constructor(protected readonly channel: Channel) {
|
|
42
|
+
super();
|
|
43
|
+
this.toDispose.push(this.onMessageEmitter);
|
|
44
|
+
this.toDispose.push(channel.onMessage(provider => this.handleMessage(provider)));
|
|
45
|
+
this.toDispose.push(channel.onClose(() => this.fireClose()));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
protected handleMessage(provider: MessageProvider): void {
|
|
49
|
+
const buffer = provider().readBytes();
|
|
50
|
+
const message = JSON.parse(new TextDecoder().decode(buffer)) as Message;
|
|
51
|
+
this.onMessageEmitter.fire(message);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
listen(callback: DataCallback): Disposable {
|
|
55
|
+
return this.onMessageEmitter.event(callback);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
override dispose(): void {
|
|
59
|
+
super.dispose();
|
|
60
|
+
this.toDispose.dispose();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A `vscode-jsonrpc` {@link MessageWriter} that writes JSON-RPC messages onto a
|
|
66
|
+
* Theia {@link Channel} — one channel message per JSON-RPC message, the dual of
|
|
67
|
+
* {@link ChannelMessageReader}. Uses `TextEncoder` (not node `Buffer`) so the
|
|
68
|
+
* writer is browser-native, since the frontend constructs the connection.
|
|
69
|
+
*/
|
|
70
|
+
class ChannelMessageWriter extends AbstractMessageWriter implements MessageWriter {
|
|
71
|
+
protected readonly toDispose: Disposable;
|
|
72
|
+
|
|
73
|
+
constructor(protected readonly channel: Channel) {
|
|
74
|
+
super();
|
|
75
|
+
this.toDispose = channel.onClose(() => this.fireClose());
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
write(message: Message): Promise<void> {
|
|
79
|
+
const writeBuffer = this.channel.getWriteBuffer();
|
|
80
|
+
writeBuffer.writeBytes(new TextEncoder().encode(JSON.stringify(message)));
|
|
81
|
+
writeBuffer.commit();
|
|
82
|
+
return Promise.resolve();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
end(): void {
|
|
86
|
+
this.dispose();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
override dispose(): void {
|
|
90
|
+
super.dispose();
|
|
91
|
+
this.toDispose.dispose();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build a `vscode-jsonrpc` {@link MessageConnection} on top of a Theia
|
|
97
|
+
* {@link Channel}. This is the browser-side transport for the data-server
|
|
98
|
+
* head: the frontend obtains a channel to the backend forwarder (via
|
|
99
|
+
* `connectionProvider.listen`), wraps it here, and drives the typed
|
|
100
|
+
* `createRpcProxy` over the returned connection — so the frontend
|
|
101
|
+
* speaks the same vscode-jsonrpc protocol the model-server speaks, with the
|
|
102
|
+
* backend relaying bytes between the two.
|
|
103
|
+
*
|
|
104
|
+
* Mirrors `@eclipse-glsp/theia-integration`'s `createChannelConnection`, but
|
|
105
|
+
* lives here so the data-server head carries no GLSP dependency.
|
|
106
|
+
*/
|
|
107
|
+
export function createChannelConnection(channel: Channel, logger?: Logger): MessageConnection {
|
|
108
|
+
const reader = new ChannelMessageReader(channel);
|
|
109
|
+
const writer = new ChannelMessageWriter(channel);
|
|
110
|
+
return createMessageConnection(reader, writer, logger);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Options for {@link openChannelConnection}. */
|
|
114
|
+
export interface OpenChannelConnectionOptions {
|
|
115
|
+
/**
|
|
116
|
+
* Resolves when it is safe to open the channel. The handler holds back the
|
|
117
|
+
* `connectionProvider.listen` call until this settles — e.g. pass
|
|
118
|
+
* `whenWorkspaceOpen` so the backend forwarder isn't asked for the
|
|
119
|
+
* model-server port before a workspace (and therefore the LSP launch that
|
|
120
|
+
* publishes the port) exists.
|
|
121
|
+
*/
|
|
122
|
+
whenReady?: Promise<void>;
|
|
123
|
+
/** Optional `vscode-jsonrpc` logger threaded into the connection. */
|
|
124
|
+
logger?: Logger;
|
|
125
|
+
/**
|
|
126
|
+
* Re-establish the connection whenever the live one is lost, by RE-OPENING
|
|
127
|
+
* the channel on `path` — not merely by rebuilding over a channel Theia hands
|
|
128
|
+
* back.
|
|
129
|
+
*
|
|
130
|
+
* **Default `true`, and the default is the point.** Re-opening is the only
|
|
131
|
+
* thing that recovers a restarted language server. Theia replays a `listen`
|
|
132
|
+
* handler solely from `ServiceConnectionProvider.handleChannelCreated`, i.e.
|
|
133
|
+
* when the MAIN frontend-backend channel is (re)created — a page reload, a
|
|
134
|
+
* dropped socket, a backend restart. A language-server restart closes just the
|
|
135
|
+
* multiplexed sub-channel for this service path while the main channel stays
|
|
136
|
+
* open, so no replacement channel ever arrives on its own and a handle that
|
|
137
|
+
* only waits for one holds a connection to the dead process forever: its
|
|
138
|
+
* requests neither answer nor reject.
|
|
139
|
+
*
|
|
140
|
+
* **Why on by default, where the older rebuild-on-a-fresh-channel behaviour
|
|
141
|
+
* was opt-in.** That one had a working alternative to weigh against — the
|
|
142
|
+
* existing connection was fine, and opting in only decided whether to swap to
|
|
143
|
+
* a fresher one, so leaving a consumer's readiness gate (and the progress UI it
|
|
144
|
+
* drives) undisturbed was worth more. A CLOSED channel offers no such choice:
|
|
145
|
+
* the alternative to re-opening is a permanently dead handle. Re-running a
|
|
146
|
+
* readiness gate against a live replacement is what a restarted server should
|
|
147
|
+
* cost.
|
|
148
|
+
*
|
|
149
|
+
* Turn it off for a consumer that treats transport loss as terminal and tears
|
|
150
|
+
* itself down instead. Off means build-once: every channel after the first is
|
|
151
|
+
* ignored and {@link ChannelConnectionHandle.onDidLoseConnection} never fires.
|
|
152
|
+
*/
|
|
153
|
+
reconnect?: boolean;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Delay before the re-open following each CONSECUTIVE connection loss, in ms;
|
|
157
|
+
* the last entry repeats once the schedule is exhausted. Defaults to
|
|
158
|
+
* {@link DEFAULT_RECONNECT_DELAYS}.
|
|
159
|
+
*
|
|
160
|
+
* A delay rather than an immediate re-open, for two independent reasons.
|
|
161
|
+
* Theia's `ChannelMultiplexer.handleClose` fires the close emitter BEFORE it
|
|
162
|
+
* deletes the id from `openChannels`, so a re-open issued from inside the
|
|
163
|
+
* close listener can still see the id as open — and `open()` throws
|
|
164
|
+
* "Another channel with the id '<id>' is already open" then, which
|
|
165
|
+
* `ServiceConnectionProvider.listen` neither catches nor reports: the handler
|
|
166
|
+
* is simply never invoked, and the consumer hangs rather than failing. And a
|
|
167
|
+
* server that is flapping would otherwise be re-opened against at whatever
|
|
168
|
+
* rate it can close a channel, which is what the escalation is for.
|
|
169
|
+
*
|
|
170
|
+
* The escalation resets once a connection has survived
|
|
171
|
+
* {@link RECONNECT_ESCALATION_RESET_MS}, so an hour-long session with
|
|
172
|
+
* occasional restarts does not converge on the longest delay.
|
|
173
|
+
*/
|
|
174
|
+
reconnectDelays?: readonly number[];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Default {@link OpenChannelConnectionOptions.reconnectDelays}.
|
|
179
|
+
*
|
|
180
|
+
* The first entry only has to outlast the synchronous close dispatch, so it is
|
|
181
|
+
* short: the backend forwarder, not this delay, is what absorbs a server that is
|
|
182
|
+
* still down. `AbstractSocketForwardingConnectionHandler` re-runs its port command per
|
|
183
|
+
* connection and retries it indefinitely, so a channel re-opened while the
|
|
184
|
+
* server is restarting parks in `findPort` and connects when the replacement
|
|
185
|
+
* publishes its new port. The later entries exist for the case that machinery
|
|
186
|
+
* cannot absorb — a server that accepts a socket and then dies again.
|
|
187
|
+
*/
|
|
188
|
+
export const DEFAULT_RECONNECT_DELAYS: readonly number[] = [250, 500, 1_000, 2_000, 4_000, 8_000];
|
|
189
|
+
|
|
190
|
+
/** How long a connection must survive before the reconnect escalation resets. */
|
|
191
|
+
export const RECONNECT_ESCALATION_RESET_MS = 30_000;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A live channel connection plus the two things a bare
|
|
195
|
+
* `Promise<MessageConnection>` cannot express: that the connection can be
|
|
196
|
+
* REPLACED, and that it can be released.
|
|
197
|
+
*/
|
|
198
|
+
export interface ChannelConnectionHandle {
|
|
199
|
+
/**
|
|
200
|
+
* The current connection. Read it per use rather than caching it: after a
|
|
201
|
+
* reconnect this returns a NEW promise, and a cached one resolves to the
|
|
202
|
+
* dead connection forever. The same reasoning makes
|
|
203
|
+
* `AbstractDataServiceFrontend` rebuild its proxy rather than hold one.
|
|
204
|
+
*/
|
|
205
|
+
readonly current: Promise<MessageConnection>;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Fires when the live connection is gone and a replacement is on the way.
|
|
209
|
+
* {@link current} has ALREADY been repointed at the replacement (still
|
|
210
|
+
* unresolved) by the time listeners run, so a listener re-derives from
|
|
211
|
+
* `current` and whatever it queues waits for the new channel instead of being
|
|
212
|
+
* addressed at the dead one.
|
|
213
|
+
*
|
|
214
|
+
* **This is the event a consumer wants, not {@link onDidReconnect}.** Anything
|
|
215
|
+
* derived from a connection — an `createRpcProxy`, a readiness gate, a
|
|
216
|
+
* `DataSession` generation — is dead from this moment, and a consumer that
|
|
217
|
+
* waits for the replacement to be live instead keeps sending into the dead one
|
|
218
|
+
* for the whole gap. A `DataPort` implementation translates this into the
|
|
219
|
+
* port's own `onDispose`, which is what makes `DataSession` drop its
|
|
220
|
+
* generation.
|
|
221
|
+
*
|
|
222
|
+
* Never fires when {@link OpenChannelConnectionOptions.reconnect} is off.
|
|
223
|
+
*/
|
|
224
|
+
readonly onDidLoseConnection: Event<void>;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Fires with each connection built AFTER the first, once it is live. For a
|
|
228
|
+
* consumer that needs the connection object itself; for rebuilding what was
|
|
229
|
+
* derived from the previous one, use {@link onDidLoseConnection} instead —
|
|
230
|
+
* it fires at the start of the gap rather than at its end.
|
|
231
|
+
*
|
|
232
|
+
* Never fires when {@link OpenChannelConnectionOptions.reconnect} is off.
|
|
233
|
+
*/
|
|
234
|
+
readonly onDidReconnect: Event<MessageConnection>;
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Dispose the live connection and stop tracking the channel. Idempotent.
|
|
238
|
+
*
|
|
239
|
+
* Without it, a frontend being torn down leaves its connection and its
|
|
240
|
+
* inbound handler bindings attached — a leak that accumulates per cycle in
|
|
241
|
+
* tests and in a reloading workbench.
|
|
242
|
+
*/
|
|
243
|
+
dispose(): void;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Open a Theia channel to a backend forwarder and wrap it as the
|
|
248
|
+
* `vscode-jsonrpc` {@link MessageConnection} the data-server proxy speaks over.
|
|
249
|
+
*
|
|
250
|
+
* Awaits `options.whenReady` (if given), then registers the channel handler via
|
|
251
|
+
* `connectionProvider.listen` and keeps a live connection to `path` for as long
|
|
252
|
+
* as the handle is undisposed — re-opening the channel whenever the current one
|
|
253
|
+
* closes, which {@link OpenChannelConnectionOptions.reconnect} governs. Callers
|
|
254
|
+
* build their `createRpcProxy` / `bindRpcMethods` over
|
|
255
|
+
* {@link ChannelConnectionHandle.current}, which queues outbound calls and
|
|
256
|
+
* inbound bindings until the channel is live.
|
|
257
|
+
*
|
|
258
|
+
* **The `reconnect` argument to Theia's `listen` is deliberately `false`**, in
|
|
259
|
+
* both modes. Passing `true` there registers the handler for replay from
|
|
260
|
+
* `handleChannelCreated`, and this handle then has two independent re-openers for
|
|
261
|
+
* one path: Theia's replay when the main frontend-backend channel is recreated,
|
|
262
|
+
* and the close-driven re-open below — which also fires then, because
|
|
263
|
+
* `ChannelMultiplexer.onUnderlyingChannelClose` closes every sub-channel. Two
|
|
264
|
+
* `open()` calls for one id means the loser throws "Another channel with the id
|
|
265
|
+
* '<id>' is already open" inside a promise `listen` neither awaits nor reports,
|
|
266
|
+
* so the consumer's request is left unsettled: a hang with a clean log, not an
|
|
267
|
+
* error. Owning the re-open here means owning it exclusively.
|
|
268
|
+
*/
|
|
269
|
+
export function openChannelConnection(
|
|
270
|
+
connectionProvider: ServiceConnectionProvider,
|
|
271
|
+
path: string,
|
|
272
|
+
options: OpenChannelConnectionOptions = {}
|
|
273
|
+
): ChannelConnectionHandle {
|
|
274
|
+
const reconnect = options.reconnect ?? true;
|
|
275
|
+
const reconnectDelays = options.reconnectDelays ?? DEFAULT_RECONNECT_DELAYS;
|
|
276
|
+
const reconnectEmitter = new Emitter<MessageConnection>();
|
|
277
|
+
const loseEmitter = new Emitter<void>();
|
|
278
|
+
let deferred = new Deferred<MessageConnection>();
|
|
279
|
+
let live: MessageConnection | undefined;
|
|
280
|
+
let generation = 0;
|
|
281
|
+
let disposed = false;
|
|
282
|
+
/** Set from the close that repointed `deferred`, cleared by the accept that resolves it. */
|
|
283
|
+
let awaitingReplacement = false;
|
|
284
|
+
/** Consecutive losses, indexing `reconnectDelays`. */
|
|
285
|
+
let losses = 0;
|
|
286
|
+
/** When the live connection was built, for the escalation reset. `0` before the first. */
|
|
287
|
+
let liveSince = 0;
|
|
288
|
+
let reopenTimer: ReturnType<typeof setTimeout> | undefined;
|
|
289
|
+
// Own synchronous flag, deliberately NOT `deferred.state`. Theia's
|
|
290
|
+
// `Deferred` sets `state` inside a `.then()` on its own promise, so it is
|
|
291
|
+
// still `'unresolved'` for a microtask after `resolve()` returns. Guarding on
|
|
292
|
+
// it means two channels arriving in the SAME synchronous turn both pass the
|
|
293
|
+
// guard: the second connection gets built and starts listening, the
|
|
294
|
+
// first-wins `resolve` keeps the promise pointed at the first, and the second
|
|
295
|
+
// leaks — attached to a channel, reachable by nobody, never disposed. A test
|
|
296
|
+
// that only checks which connection the promise resolves with cannot see
|
|
297
|
+
// that; it takes counting the connections built per channel.
|
|
298
|
+
let built = false;
|
|
299
|
+
|
|
300
|
+
const scheduleReopen = (): void => {
|
|
301
|
+
if (liveSince > 0 && Date.now() - liveSince >= RECONNECT_ESCALATION_RESET_MS) {
|
|
302
|
+
losses = 0;
|
|
303
|
+
}
|
|
304
|
+
const delay = reconnectDelays[Math.min(losses, reconnectDelays.length - 1)];
|
|
305
|
+
losses++;
|
|
306
|
+
reopenTimer = setTimeout(() => {
|
|
307
|
+
reopenTimer = undefined;
|
|
308
|
+
// `awaitingReplacement` is the guard against re-opening a path that
|
|
309
|
+
// already has a channel: a second `open()` for one id is the "already
|
|
310
|
+
// open" throw, and its symptom is a hang rather than an error.
|
|
311
|
+
if (disposed || !awaitingReplacement) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
requestChannel();
|
|
315
|
+
}, delay);
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The live connection's channel closed. Retire the generation and arm the
|
|
320
|
+
* replacement, in that order — a listener on `onDidLoseConnection` must
|
|
321
|
+
* already see `current` pointing at the replacement.
|
|
322
|
+
*/
|
|
323
|
+
const handleChannelClosed = (): void => {
|
|
324
|
+
if (disposed || awaitingReplacement) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
// Dispose rather than merely drop. vscode-jsonrpc rejects pending
|
|
328
|
+
// responses from `dispose`, never from a reader-side close — so a
|
|
329
|
+
// connection left undisposed keeps every in-flight request unsettled
|
|
330
|
+
// forever, which is the failure this whole path exists to end.
|
|
331
|
+
live?.dispose();
|
|
332
|
+
live = undefined;
|
|
333
|
+
deferred = new Deferred<MessageConnection>();
|
|
334
|
+
awaitingReplacement = true;
|
|
335
|
+
loseEmitter.fire(undefined);
|
|
336
|
+
scheduleReopen();
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const acceptChannel = (channel: Channel): void => {
|
|
340
|
+
if (disposed) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (built && !awaitingReplacement) {
|
|
344
|
+
// Unsolicited: a channel arrived while one is already live and no close
|
|
345
|
+
// asked for a replacement. Exactly one channel is outstanding at a time
|
|
346
|
+
// here, so this is either the build-once case (`reconnect` off) or a
|
|
347
|
+
// handler re-fire, and adopting it would leave the previous connection
|
|
348
|
+
// listening on its own channel, reachable by nobody and never disposed.
|
|
349
|
+
// The close-driven path is the only way a second generation is built.
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const connection = createChannelConnection(channel, options.logger);
|
|
353
|
+
connection.listen();
|
|
354
|
+
live = connection;
|
|
355
|
+
built = true;
|
|
356
|
+
awaitingReplacement = false;
|
|
357
|
+
generation++;
|
|
358
|
+
liveSince = Date.now();
|
|
359
|
+
if (reconnect) {
|
|
360
|
+
// Registered AFTER `createChannelConnection`, so the connection's own
|
|
361
|
+
// reader and writer see the close first and `connection.onClose` still
|
|
362
|
+
// fires for whoever is listening to it. Disposing before they run would
|
|
363
|
+
// put the connection in `Disposed`, from which vscode-jsonrpc
|
|
364
|
+
// deliberately emits no close event at all.
|
|
365
|
+
const acceptedGeneration = generation;
|
|
366
|
+
channel.onClose(() => {
|
|
367
|
+
if (acceptedGeneration === generation) {
|
|
368
|
+
handleChannelClosed();
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
deferred.resolve(connection);
|
|
373
|
+
if (generation > 1) {
|
|
374
|
+
reconnectEmitter.fire(connection);
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
/** Register the channel handler for one open attempt on `path`. */
|
|
379
|
+
function requestChannel(): void {
|
|
380
|
+
connectionProvider.listen(path, (_path, channel) => acceptChannel(channel), false);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const start = async (): Promise<void> => {
|
|
384
|
+
await options.whenReady;
|
|
385
|
+
if (disposed) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
requestChannel();
|
|
389
|
+
};
|
|
390
|
+
start();
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
get current(): Promise<MessageConnection> {
|
|
394
|
+
return deferred.promise;
|
|
395
|
+
},
|
|
396
|
+
onDidLoseConnection: loseEmitter.event,
|
|
397
|
+
onDidReconnect: reconnectEmitter.event,
|
|
398
|
+
dispose(): void {
|
|
399
|
+
if (disposed) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
disposed = true;
|
|
403
|
+
if (reopenTimer !== undefined) {
|
|
404
|
+
clearTimeout(reopenTimer);
|
|
405
|
+
reopenTimer = undefined;
|
|
406
|
+
}
|
|
407
|
+
live?.dispose();
|
|
408
|
+
live = undefined;
|
|
409
|
+
loseEmitter.dispose();
|
|
410
|
+
reconnectEmitter.dispose();
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (c) 2026 CrossBreeze, EclipseSource and others.
|
|
3
|
+
*
|
|
4
|
+
* This program and the accompanying materials are made available under the
|
|
5
|
+
* terms of the MIT License which is available in the project root.
|
|
6
|
+
*
|
|
7
|
+
* SPDX-License-Identifier: MIT
|
|
8
|
+
********************************************************************************/
|
|
9
|
+
|
|
10
|
+
import { createRpcProxy } from '@hydranium/protocol';
|
|
11
|
+
import type { ServiceConnectionProvider } from '@theia/core/lib/browser';
|
|
12
|
+
import { Deferred } from '@theia/core/lib/common/promise-util';
|
|
13
|
+
import type { WorkspaceService } from '@theia/workspace/lib/browser';
|
|
14
|
+
import type { MessageConnection } from 'vscode-jsonrpc';
|
|
15
|
+
import { type ChannelConnectionHandle, openChannelConnection } from './channel-connection';
|
|
16
|
+
import { whenWorkspaceOpen } from './workspace-gate';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Base for a Theia frontend that owns the data-server vscode-jsonrpc
|
|
20
|
+
* connection directly (the data head's "frontend speaks the model-server's
|
|
21
|
+
* protocol over a relayed channel" pattern). Lifts the mechanical wiring —
|
|
22
|
+
* the workspace-gated connection, the combined server proxy + inbound client
|
|
23
|
+
* binding, and the lazy idempotent init gate — leaving the adopter to supply
|
|
24
|
+
* the connection seams and any progress UI / domain caching / request-method
|
|
25
|
+
* delegation on top.
|
|
26
|
+
*
|
|
27
|
+
* Generic over the server protocol `TServer` (must expose `waitForReady`) and
|
|
28
|
+
* the local notification target `TClient`. A subclass supplies the abstract
|
|
29
|
+
* members below, calls {@link start} from its `@postConstruct`, and awaits
|
|
30
|
+
* {@link ensureConnected} before its first `this.server.*` call.
|
|
31
|
+
*/
|
|
32
|
+
export abstract class AbstractDataServiceFrontend<TServer extends { waitForReady(): Promise<void> }, TClient extends object> {
|
|
33
|
+
/**
|
|
34
|
+
* The workspace-gated connection to the backend forwarder. Set by
|
|
35
|
+
* {@link start}, and REPLACED whenever the connection is lost and
|
|
36
|
+
* {@link reconnectOnConnectionLoss} is on — so read it per use and never
|
|
37
|
+
* cache the resolved connection.
|
|
38
|
+
*/
|
|
39
|
+
protected connectionPromise!: Promise<MessageConnection>;
|
|
40
|
+
/**
|
|
41
|
+
* Typed proxy over {@link connectionPromise}, addressing the server under
|
|
42
|
+
* {@link methodNamespace}. Set by {@link start}, and replaced alongside
|
|
43
|
+
* {@link connectionPromise} on reconnect.
|
|
44
|
+
*
|
|
45
|
+
* `createRpcProxy` resolves the connection promise once and binds to it for
|
|
46
|
+
* good, so a reconnect necessarily means a new proxy. Reading
|
|
47
|
+
* `this.server.foo()` per call — rather than hoisting `this.server` into a
|
|
48
|
+
* local or a constructor-time field — is what keeps a subclass correct
|
|
49
|
+
* across one.
|
|
50
|
+
*/
|
|
51
|
+
protected server!: TServer;
|
|
52
|
+
/** The channel handle {@link start} opened; owns reconnect and disposal. */
|
|
53
|
+
protected channel?: ChannelConnectionHandle;
|
|
54
|
+
/** Shared init Deferred so concurrent {@link ensureConnected} callers await one initialization. */
|
|
55
|
+
protected initialized?: Deferred<void>;
|
|
56
|
+
|
|
57
|
+
/** Theia connection provider the channel is opened through. */
|
|
58
|
+
protected abstract readonly connectionProvider: ServiceConnectionProvider;
|
|
59
|
+
/**
|
|
60
|
+
* Optional workspace service. When provided, the default
|
|
61
|
+
* {@link connectionReadyGate} waits for a workspace before opening the
|
|
62
|
+
* channel; a head that is not workspace-scoped omits it (and may override
|
|
63
|
+
* {@link connectionReadyGate} for a different gate).
|
|
64
|
+
*/
|
|
65
|
+
protected abstract readonly workspaceService?: WorkspaceService;
|
|
66
|
+
/** Local inbound-notification target bound on the connection (the `localTarget`). */
|
|
67
|
+
protected abstract readonly client: TClient;
|
|
68
|
+
/**
|
|
69
|
+
* Theia service path the backend forwarder is registered under.
|
|
70
|
+
*
|
|
71
|
+
* **Unique per frontend, not per server.** Theia keys a frontend channel by
|
|
72
|
+
* this path and throws `Another channel with the id '<path>' is already open`
|
|
73
|
+
* on a second opener — so a subclass sharing the framework default with any
|
|
74
|
+
* other consumer of the same head (a host-neutral `DataPort`, a sibling
|
|
75
|
+
* service frontend) breaks whichever opens second. The failure is remote from
|
|
76
|
+
* its cause: the throw escapes an `openChannelConnection` the other consumer
|
|
77
|
+
* awaited, leaving its request permanently unsettled rather than rejected,
|
|
78
|
+
* which presents as a view stuck on its loading state with a clean server
|
|
79
|
+
* log. Give each frontend its own path and register a forwarder per path;
|
|
80
|
+
* they still reach one server, since the shared `portCommand` is what names
|
|
81
|
+
* the process.
|
|
82
|
+
*/
|
|
83
|
+
protected abstract readonly servicePath: string;
|
|
84
|
+
/** Wire namespace the server + client methods are addressed under. */
|
|
85
|
+
protected abstract readonly methodNamespace: string;
|
|
86
|
+
/** Allowlist of {@link client} methods to bind as inbound handlers. */
|
|
87
|
+
protected abstract readonly clientMethods: readonly (keyof TClient & string)[];
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Rebuild the connection and the proxy when the current connection is lost,
|
|
91
|
+
* and re-run initialization against the replacement. Defaults to `true` —
|
|
92
|
+
* see `OpenChannelConnectionOptions.reconnect` for why re-opening the
|
|
93
|
+
* channel is the only thing that recovers a restarted language server, and
|
|
94
|
+
* why a dead connection leaves no alternative worth preserving.
|
|
95
|
+
*
|
|
96
|
+
* The subclass-facing cost is that {@link doInitialize} runs again per
|
|
97
|
+
* connection, so any progress UI it drives reappears. Turn this off for a
|
|
98
|
+
* frontend that would rather show nothing than show its warm-up twice, or
|
|
99
|
+
* that tears itself down on transport loss.
|
|
100
|
+
*/
|
|
101
|
+
protected readonly reconnectOnConnectionLoss: boolean = true;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Readiness gate for the connection — the channel opens only once the
|
|
105
|
+
* returned promise settles. Default: waits for a workspace when
|
|
106
|
+
* {@link workspaceService} is provided, otherwise opens immediately
|
|
107
|
+
* (`undefined`). Override for a different gate (e.g. a fixed model store
|
|
108
|
+
* that is always ready, or a custom warm-up).
|
|
109
|
+
*/
|
|
110
|
+
protected connectionReadyGate(): Promise<void> | undefined {
|
|
111
|
+
return this.workspaceService ? whenWorkspaceOpen(this.workspaceService) : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Open the connection (workspace-gated by default via
|
|
116
|
+
* {@link connectionReadyGate}) and build the combined server proxy +
|
|
117
|
+
* inbound client binding. Call once (typically from the adopter's
|
|
118
|
+
* `@postConstruct`). Outbound calls + inbound notifications queue over the
|
|
119
|
+
* connection promise until the channel is live.
|
|
120
|
+
*/
|
|
121
|
+
protected start(): void {
|
|
122
|
+
this.channel = openChannelConnection(this.connectionProvider, this.servicePath, {
|
|
123
|
+
whenReady: this.connectionReadyGate(),
|
|
124
|
+
reconnect: this.reconnectOnConnectionLoss
|
|
125
|
+
});
|
|
126
|
+
this.bindConnection();
|
|
127
|
+
// The LOSS, not the replacement's arrival: rebinding when the channel
|
|
128
|
+
// closes points `server` at the queueing replacement promise, so a request
|
|
129
|
+
// made during the gap waits for the new server instead of being addressed
|
|
130
|
+
// at the dead one and never settling.
|
|
131
|
+
this.channel.onDidLoseConnection(() => this.handleConnectionLost());
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Point {@link connectionPromise} and {@link server} at the channel's
|
|
136
|
+
* current connection. Called by {@link start} and again per reconnect.
|
|
137
|
+
*/
|
|
138
|
+
protected bindConnection(): void {
|
|
139
|
+
if (!this.channel) {
|
|
140
|
+
throw new Error('bindConnection called before start');
|
|
141
|
+
}
|
|
142
|
+
this.connectionPromise = this.channel.current;
|
|
143
|
+
this.server = createRpcProxy<TServer, TClient>(this.connectionPromise, {
|
|
144
|
+
methodNamespace: this.methodNamespace,
|
|
145
|
+
localTarget: this.client,
|
|
146
|
+
localMethods: this.clientMethods
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Rebind onto the replacement connection and arm initialization to run again.
|
|
152
|
+
*
|
|
153
|
+
* Clearing {@link initialized} is the load-bearing half. A restarted server
|
|
154
|
+
* has an unwarmed workspace, so its `waitForReady` gate has to be awaited
|
|
155
|
+
* afresh; leaving the old resolved Deferred in place would let the first
|
|
156
|
+
* request after a restart through against a server still walking the
|
|
157
|
+
* workspace, and be answered correctly from an empty registry — which reads
|
|
158
|
+
* as data loss rather than as a race.
|
|
159
|
+
*/
|
|
160
|
+
protected handleConnectionLost(): void {
|
|
161
|
+
this.initialized = undefined;
|
|
162
|
+
this.bindConnection();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Release the connection and stop tracking the channel. Idempotent.
|
|
167
|
+
*
|
|
168
|
+
* Subclasses that are Theia `Disposable`s should route their own disposal
|
|
169
|
+
* here; nothing calls it automatically, because the base is not bound to a
|
|
170
|
+
* lifecycle of its own.
|
|
171
|
+
*/
|
|
172
|
+
dispose(): void {
|
|
173
|
+
this.channel?.dispose();
|
|
174
|
+
this.channel = undefined;
|
|
175
|
+
this.initialized = undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Lazily drive initialization, shared across concurrent callers via one
|
|
180
|
+
* {@link Deferred}. Request methods `await this.ensureConnected()` before
|
|
181
|
+
* their first `this.server.*` call.
|
|
182
|
+
*/
|
|
183
|
+
protected ensureConnected(): Promise<void> {
|
|
184
|
+
if (!this.initialized) {
|
|
185
|
+
this.initialized = new Deferred<void>();
|
|
186
|
+
void this.doInitialize(this.initialized);
|
|
187
|
+
}
|
|
188
|
+
return this.initialized.promise;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Default initialization: await the connection, await the server's readiness
|
|
193
|
+
* gate, then resolve the passed Deferred. Initialization completion is
|
|
194
|
+
* observable by awaiting {@link ensureConnected} (which returns this same
|
|
195
|
+
* Deferred's promise) — there is no separate post-init hook. Override
|
|
196
|
+
* wholesale to interleave progress UI / extra warm-up; an override owns
|
|
197
|
+
* resolving/rejecting `initialized` (there is no `super` step to call).
|
|
198
|
+
*/
|
|
199
|
+
protected async doInitialize(initialized: Deferred<void>): Promise<void> {
|
|
200
|
+
try {
|
|
201
|
+
await this.connectionPromise;
|
|
202
|
+
await this.server.waitForReady();
|
|
203
|
+
initialized.resolve();
|
|
204
|
+
} catch (error) {
|
|
205
|
+
initialized.reject(error instanceof Error ? error : new Error(String(error)));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (c) 2026 CrossBreeze, EclipseSource and others.
|
|
3
|
+
*
|
|
4
|
+
* This program and the accompanying materials are made available under the
|
|
5
|
+
* terms of the MIT License which is available in the project root.
|
|
6
|
+
*
|
|
7
|
+
* SPDX-License-Identifier: MIT
|
|
8
|
+
********************************************************************************/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
DataServerDiagnosticsProtocol,
|
|
12
|
+
DumpServerStateArgs,
|
|
13
|
+
LatencyReport,
|
|
14
|
+
StartProfilingArgs,
|
|
15
|
+
StopProfilingArgs,
|
|
16
|
+
WriteServerHeapSnapshotArgs
|
|
17
|
+
} from '@hydranium/protocol';
|
|
18
|
+
import { AbstractDataServiceFrontend } from './data-service-frontend';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* {@link AbstractDataServiceFrontend} specialised for a server that also exposes
|
|
22
|
+
* the framework {@link DataServerDiagnosticsProtocol} (which the framework
|
|
23
|
+
* `DataServer` implements by default). It implements each diagnostics method as
|
|
24
|
+
* a readiness-gated pass-through — `await this.ensureConnected()`, then delegate
|
|
25
|
+
* to `this.server` — so every adopter frontend gets the memory / state /
|
|
26
|
+
* profiling / latency surface without hand-writing identical one-liner bodies.
|
|
27
|
+
*
|
|
28
|
+
* Extend this instead of {@link AbstractDataServiceFrontend} whenever the head's
|
|
29
|
+
* `DataServer` keeps the default diagnostics registration; the `TServer` bound
|
|
30
|
+
* carries `DataServerDiagnosticsProtocol`, so the delegates are type-checked
|
|
31
|
+
* (no casts). A head that dropped the diagnostics methods via
|
|
32
|
+
* `DataServerOptions.excludedMethods` should extend the plain base instead.
|
|
33
|
+
*/
|
|
34
|
+
export abstract class AbstractDiagnosticsDataServiceFrontend<
|
|
35
|
+
TServer extends { waitForReady(): Promise<void> } & DataServerDiagnosticsProtocol,
|
|
36
|
+
TClient extends object
|
|
37
|
+
>
|
|
38
|
+
extends AbstractDataServiceFrontend<TServer, TClient>
|
|
39
|
+
implements DataServerDiagnosticsProtocol
|
|
40
|
+
{
|
|
41
|
+
async dumpServerState(args: DumpServerStateArgs): Promise<string> {
|
|
42
|
+
await this.ensureConnected();
|
|
43
|
+
return this.server.dumpServerState(args);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async writeHeapSnapshot(args: WriteServerHeapSnapshotArgs): Promise<string> {
|
|
47
|
+
await this.ensureConnected();
|
|
48
|
+
return this.server.writeHeapSnapshot(args);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async dumpPodMemory(): Promise<string> {
|
|
52
|
+
await this.ensureConnected();
|
|
53
|
+
return this.server.dumpPodMemory();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async startProfiling(args: StartProfilingArgs): Promise<void> {
|
|
57
|
+
await this.ensureConnected();
|
|
58
|
+
return this.server.startProfiling(args);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async stopProfiling(args: StopProfilingArgs): Promise<string> {
|
|
62
|
+
await this.ensureConnected();
|
|
63
|
+
return this.server.stopProfiling(args);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async getLatency(): Promise<LatencyReport> {
|
|
67
|
+
await this.ensureConnected();
|
|
68
|
+
return this.server.getLatency();
|
|
69
|
+
}
|
|
70
|
+
}
|