@hydranium/data-client-theia 1.0.0-next.6 → 1.0.0-next.61
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/README.md +3 -3
- package/lib/browser/channel-data-port.d.ts +65 -0
- package/lib/browser/channel-data-port.d.ts.map +1 -0
- package/lib/browser/channel-data-port.js +115 -0
- package/lib/browser/channel-data-port.js.map +1 -0
- package/lib/browser/data-service-frontend.d.ts +39 -97
- package/lib/browser/data-service-frontend.d.ts.map +1 -1
- package/lib/browser/data-service-frontend.js +77 -98
- package/lib/browser/data-service-frontend.js.map +1 -1
- package/lib/browser/index.d.ts +1 -0
- package/lib/browser/index.d.ts.map +1 -1
- package/lib/browser/index.js +1 -0
- package/lib/browser/index.js.map +1 -1
- package/lib/common/emitter-data-client.d.ts +9 -1
- package/lib/common/emitter-data-client.d.ts.map +1 -1
- package/lib/common/emitter-data-client.js +12 -0
- package/lib/common/emitter-data-client.js.map +1 -1
- package/lib/node/data-server-connection-handler.d.ts +10 -0
- package/lib/node/data-server-connection-handler.d.ts.map +1 -1
- package/lib/node/data-server-connection-handler.js +1 -1
- package/lib/node/data-server-connection-handler.js.map +1 -1
- package/lib/node/socket-channel-forwarder.d.ts.map +1 -1
- package/lib/node/socket-channel-forwarder.js +6 -1
- package/lib/node/socket-channel-forwarder.js.map +1 -1
- package/package.json +13 -12
- package/src/browser/channel-data-port.ts +100 -0
- package/src/browser/data-service-frontend.ts +94 -125
- package/src/browser/index.ts +1 -0
- package/src/common/emitter-data-client.ts +18 -0
- package/src/node/data-server-connection-handler.ts +11 -1
- package/src/node/socket-channel-forwarder.ts +6 -1
|
@@ -7,11 +7,17 @@
|
|
|
7
7
|
* SPDX-License-Identifier: MIT
|
|
8
8
|
********************************************************************************/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
type DataPort,
|
|
12
|
+
type ReadyServer,
|
|
13
|
+
type ResolvedMessage,
|
|
14
|
+
RpcConnection,
|
|
15
|
+
type RpcProxy,
|
|
16
|
+
renderFrameworkMessage
|
|
17
|
+
} from '@hydranium/protocol';
|
|
18
|
+
import { Emitter, nls, type MessageService } from '@theia/core';
|
|
11
19
|
import type { ServiceConnectionProvider } from '@theia/core/lib/browser';
|
|
12
|
-
import { Deferred } from '@theia/core/lib/common/promise-util';
|
|
13
20
|
import type { WorkspaceService } from '@theia/workspace/lib/browser';
|
|
14
|
-
import type { MessageConnection } from 'vscode-jsonrpc';
|
|
15
21
|
import { type ChannelConnectionHandle, openChannelConnection } from './channel-connection';
|
|
16
22
|
import { whenWorkspaceOpen } from './workspace-gate';
|
|
17
23
|
|
|
@@ -28,138 +34,116 @@ import { whenWorkspaceOpen } from './workspace-gate';
|
|
|
28
34
|
* the local notification target `TClient`. A subclass supplies the abstract
|
|
29
35
|
* members below, calls {@link start} from its `@postConstruct`, and awaits
|
|
30
36
|
* {@link ensureConnected} before its first `this.server.*` call.
|
|
37
|
+
*
|
|
38
|
+
* The lifecycle is `RpcConnection`'s — the same proxy, readiness gate and
|
|
39
|
+
* reconnect generation the host-neutral tier uses. What stays here is the Theia
|
|
40
|
+
* half: the channel, the workspace gate and the notification sink.
|
|
31
41
|
*/
|
|
32
|
-
export abstract class AbstractDataServiceFrontend<TServer extends
|
|
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. */
|
|
42
|
+
export abstract class AbstractDataServiceFrontend<TServer extends ReadyServer, TClient extends object> {
|
|
58
43
|
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
44
|
protected abstract readonly workspaceService?: WorkspaceService;
|
|
66
|
-
/** Local inbound-notification target bound on the connection (the `localTarget`). */
|
|
67
45
|
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
|
-
*/
|
|
46
|
+
/** Frontend service path the backend forwarder for this head is registered under. */
|
|
83
47
|
protected abstract readonly servicePath: string;
|
|
84
|
-
/** Wire namespace the server + client methods are addressed under. */
|
|
85
48
|
protected abstract readonly methodNamespace: string;
|
|
86
49
|
/** Allowlist of {@link client} methods to bind as inbound handlers. */
|
|
87
50
|
protected abstract readonly clientMethods: readonly (keyof TClient & string)[];
|
|
88
51
|
|
|
52
|
+
/** Surfaces a transport failure. Optional: a frontend with no UI of its own omits it. */
|
|
53
|
+
protected readonly messageService?: MessageService;
|
|
54
|
+
|
|
89
55
|
/**
|
|
90
|
-
* Rebuild the connection
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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.
|
|
56
|
+
* Rebuild the connection when the current one is lost. Defaults to `true` —
|
|
57
|
+
* re-opening the channel is the only thing that recovers a restarted
|
|
58
|
+
* language server, which binds new ephemeral ports. Turn it off for a
|
|
59
|
+
* frontend that would rather tear itself down than show its warm-up twice.
|
|
100
60
|
*/
|
|
101
61
|
protected readonly reconnectOnConnectionLoss: boolean = true;
|
|
102
62
|
|
|
63
|
+
protected channel?: ChannelConnectionHandle;
|
|
64
|
+
protected connection?: RpcConnection<TServer, TClient>;
|
|
65
|
+
protected readonly lossEmitter = new Emitter<void>();
|
|
66
|
+
|
|
103
67
|
/**
|
|
104
68
|
* Readiness gate for the connection — the channel opens only once the
|
|
105
69
|
* 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).
|
|
70
|
+
* {@link workspaceService} is provided, otherwise opens immediately.
|
|
109
71
|
*/
|
|
110
72
|
protected connectionReadyGate(): Promise<void> | undefined {
|
|
111
73
|
return this.workspaceService ? whenWorkspaceOpen(this.workspaceService) : undefined;
|
|
112
74
|
}
|
|
113
75
|
|
|
76
|
+
/** A connection generation is opening, including on each reconnect. */
|
|
77
|
+
protected onConnecting(): void {
|
|
78
|
+
// nothing by default
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The server's readiness gate has settled for a generation. */
|
|
82
|
+
protected onReady(): void {
|
|
83
|
+
// nothing by default
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A generation failed; the awaiting caller still rejects. */
|
|
87
|
+
protected onFailed(_error: unknown): void {
|
|
88
|
+
// nothing by default
|
|
89
|
+
}
|
|
90
|
+
|
|
114
91
|
/**
|
|
115
|
-
* Open the connection
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* `@postConstruct`). Outbound calls + inbound notifications queue over the
|
|
119
|
-
* connection promise until the channel is live.
|
|
92
|
+
* Open the channel and build the connection over it. Call once, typically
|
|
93
|
+
* from the adopter's `@postConstruct`. Outbound calls and inbound
|
|
94
|
+
* notifications queue until the channel is live.
|
|
120
95
|
*/
|
|
121
96
|
protected start(): void {
|
|
122
97
|
this.channel = openChannelConnection(this.connectionProvider, this.servicePath, {
|
|
123
98
|
whenReady: this.connectionReadyGate(),
|
|
124
99
|
reconnect: this.reconnectOnConnectionLoss
|
|
125
100
|
});
|
|
126
|
-
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
101
|
+
// The LOSS, not the replacement's arrival: the connection drops its
|
|
102
|
+
// generation here, so a request made during the gap waits for the fresh
|
|
103
|
+
// one instead of addressing the dead one and never settling.
|
|
104
|
+
this.channel.onDidLoseConnection(() => this.lossEmitter.fire(undefined));
|
|
105
|
+
this.connection = new RpcConnection<TServer, TClient>(this.channelPort(), this.client, {
|
|
106
|
+
methodNamespace: this.methodNamespace,
|
|
107
|
+
clientMethods: this.clientMethods,
|
|
108
|
+
lifecycle: {
|
|
109
|
+
onConnecting: () => this.onConnecting(),
|
|
110
|
+
onReady: () => this.onReady(),
|
|
111
|
+
onFailed: error => this.onFailed(error)
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The channel as a {@link DataPort} — the whole Theia-specific half. */
|
|
117
|
+
protected channelPort(): DataPort {
|
|
118
|
+
return {
|
|
119
|
+
// Read per call: `current` is repointed on every re-open, so reaching
|
|
120
|
+
// through the handle is what makes a later generation find the live
|
|
121
|
+
// server.
|
|
122
|
+
connect: () => this.requireChannel().current,
|
|
123
|
+
reportError: (_error, reported) => this.reportError(reported),
|
|
124
|
+
onDispose: this.lossEmitter.event
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Surface a transport failure the way this host does. */
|
|
129
|
+
protected reportError(reported: ResolvedMessage): void {
|
|
130
|
+
this.messageService?.error(renderFrameworkMessage(reported, nls.localization?.translations));
|
|
132
131
|
}
|
|
133
132
|
|
|
134
133
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
134
|
+
* The server proxy. Calls queue against the connection, so await
|
|
135
|
+
* {@link ensureConnected} first wherever the server's readiness matters.
|
|
137
136
|
*/
|
|
138
|
-
protected
|
|
139
|
-
|
|
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
|
-
});
|
|
137
|
+
protected get server(): RpcProxy<TServer> {
|
|
138
|
+
return this.requireConnection().server;
|
|
148
139
|
}
|
|
149
140
|
|
|
150
141
|
/**
|
|
151
|
-
*
|
|
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.
|
|
142
|
+
* Await the connection and the server's readiness gate, shared across
|
|
143
|
+
* concurrent callers and re-run once per connection generation.
|
|
159
144
|
*/
|
|
160
|
-
protected
|
|
161
|
-
this.
|
|
162
|
-
this.bindConnection();
|
|
145
|
+
protected async ensureConnected(): Promise<void> {
|
|
146
|
+
await this.requireConnection().connected();
|
|
163
147
|
}
|
|
164
148
|
|
|
165
149
|
/**
|
|
@@ -170,39 +154,24 @@ export abstract class AbstractDataServiceFrontend<TServer extends { waitForReady
|
|
|
170
154
|
* lifecycle of its own.
|
|
171
155
|
*/
|
|
172
156
|
dispose(): void {
|
|
157
|
+
this.connection?.dispose();
|
|
158
|
+
this.connection = undefined;
|
|
173
159
|
this.channel?.dispose();
|
|
174
160
|
this.channel = undefined;
|
|
175
|
-
this.
|
|
161
|
+
this.lossEmitter.dispose();
|
|
176
162
|
}
|
|
177
163
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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);
|
|
164
|
+
protected requireConnection(): RpcConnection<TServer, TClient> {
|
|
165
|
+
if (!this.connection) {
|
|
166
|
+
throw new Error('the connection is not open: call start() first');
|
|
187
167
|
}
|
|
188
|
-
return this.
|
|
168
|
+
return this.connection;
|
|
189
169
|
}
|
|
190
170
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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)));
|
|
171
|
+
protected requireChannel(): ChannelConnectionHandle {
|
|
172
|
+
if (!this.channel) {
|
|
173
|
+
throw new Error('the channel is not open: call start() first');
|
|
206
174
|
}
|
|
175
|
+
return this.channel;
|
|
207
176
|
}
|
|
208
177
|
}
|
package/src/browser/index.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
********************************************************************************/
|
|
9
9
|
|
|
10
10
|
export * from './channel-connection';
|
|
11
|
+
export * from './channel-data-port';
|
|
11
12
|
export * from './data-service-frontend';
|
|
12
13
|
export * from './diagnostics-data-service-frontend';
|
|
13
14
|
export * from './host-diagnostics-frontend';
|
|
@@ -12,7 +12,9 @@ import type {
|
|
|
12
12
|
Project,
|
|
13
13
|
ProjectsChangedEvent,
|
|
14
14
|
TransferDiagnostic,
|
|
15
|
+
TransferDocumentDeletedEvent,
|
|
15
16
|
TransferDocumentSavedEvent,
|
|
17
|
+
TransferDocumentsBuiltEvent,
|
|
16
18
|
TransferDocumentUpdatedEvent,
|
|
17
19
|
TransferElement
|
|
18
20
|
} from '@hydranium/protocol';
|
|
@@ -46,6 +48,14 @@ export class EmitterDataClient<
|
|
|
46
48
|
/** Fires for each inbound {@link onDocumentSaved} notification. */
|
|
47
49
|
readonly onDidSaveDocument: Event<TransferDocumentSavedEvent<TTransfer, TDiagnostic>> = this.onDocumentSavedEmitter.event;
|
|
48
50
|
|
|
51
|
+
protected readonly onDocumentDeletedEmitter = new Emitter<TransferDocumentDeletedEvent>();
|
|
52
|
+
/** Fires for each inbound {@link onDocumentDeleted} notification. */
|
|
53
|
+
readonly onDidDeleteDocument: Event<TransferDocumentDeletedEvent> = this.onDocumentDeletedEmitter.event;
|
|
54
|
+
|
|
55
|
+
protected readonly onDocumentsBuiltEmitter = new Emitter<TransferDocumentsBuiltEvent>();
|
|
56
|
+
/** Fires for each inbound {@link onDocumentsBuilt} notification. */
|
|
57
|
+
readonly onDidBuildDocuments: Event<TransferDocumentsBuiltEvent> = this.onDocumentsBuiltEmitter.event;
|
|
58
|
+
|
|
49
59
|
protected readonly onProjectsChangedEmitter = new Emitter<ProjectsChangedEvent<TProject>>();
|
|
50
60
|
/** Fires for each inbound {@link onProjectsChanged} notification. */
|
|
51
61
|
readonly onDidChangeProjects: Event<ProjectsChangedEvent<TProject>> = this.onProjectsChangedEmitter.event;
|
|
@@ -58,6 +68,14 @@ export class EmitterDataClient<
|
|
|
58
68
|
this.onDocumentSavedEmitter.fire(event);
|
|
59
69
|
}
|
|
60
70
|
|
|
71
|
+
onDocumentDeleted(event: TransferDocumentDeletedEvent): void {
|
|
72
|
+
this.onDocumentDeletedEmitter.fire(event);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
onDocumentsBuilt(event: TransferDocumentsBuiltEvent): void {
|
|
76
|
+
this.onDocumentsBuiltEmitter.fire(event);
|
|
77
|
+
}
|
|
78
|
+
|
|
61
79
|
onProjectsChanged(event: ProjectsChangedEvent<TProject>): void {
|
|
62
80
|
this.onProjectsChangedEmitter.fire(event);
|
|
63
81
|
}
|
|
@@ -30,6 +30,16 @@ export interface DataServerConnectionHandlerOptions {
|
|
|
30
30
|
* Defaults to the framework `DATA_SERVER_PORT_COMMAND`; override to match
|
|
31
31
|
* an adopter's established id. */
|
|
32
32
|
readonly portCommand?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Product name for the connect-failure dialog this handler raises.
|
|
35
|
+
*
|
|
36
|
+
* It reaches the adopter's UI verbatim, so the framework default leaks a
|
|
37
|
+
* framework noun into a product that is not ours. Not a translation concern —
|
|
38
|
+
* routing it through a catalogue would ask an adopter to "translate" English
|
|
39
|
+
* into their own product name, and would make their branding
|
|
40
|
+
* locale-dependent.
|
|
41
|
+
*/
|
|
42
|
+
readonly serverName?: string;
|
|
33
43
|
readonly findPortTimeout?: number;
|
|
34
44
|
readonly findPortAttempts?: number;
|
|
35
45
|
readonly connectTimeoutMs?: number;
|
|
@@ -62,7 +72,7 @@ export class DataServerConnectionHandler extends AbstractSocketForwardingConnect
|
|
|
62
72
|
path: options.servicePath ?? DATA_SERVER_PATH,
|
|
63
73
|
portCommand: options.portCommand ?? DATA_SERVER_PORT_COMMAND,
|
|
64
74
|
logComponent: 'DataServer',
|
|
65
|
-
serverName: 'Model Server',
|
|
75
|
+
serverName: options.serverName ?? 'Model Server',
|
|
66
76
|
findPortTimeout: options.findPortTimeout,
|
|
67
77
|
findPortAttempts: options.findPortAttempts,
|
|
68
78
|
connectTimeoutMs: options.connectTimeoutMs,
|
|
@@ -34,8 +34,13 @@ export class SocketChannelForwarder implements Disposable {
|
|
|
34
34
|
const reader = new SocketMessageReader(socket);
|
|
35
35
|
const writer = new SocketMessageWriter(socket);
|
|
36
36
|
const connection = createMessageConnection(reader, writer);
|
|
37
|
+
// Nothing here destroys the socket, and adding it back would be dead
|
|
38
|
+
// code in both directions. `SocketMessageWriter.dispose()` destroys it
|
|
39
|
+
// itself, which covers the dispose path; and `connection.onClose` fires
|
|
40
|
+
// only from the reader's or writer's own close, which for a socket means
|
|
41
|
+
// the socket has already gone — so a destroy handler there is downstream
|
|
42
|
+
// of the effect it would be trying to cause.
|
|
37
43
|
this.toDispose.pushAll([
|
|
38
|
-
connection.onClose(() => socket.destroy()),
|
|
39
44
|
reader.listen(message => this.writeToChannel(message)),
|
|
40
45
|
channel.onMessage(provider => void writer.write(this.decodeChannelMessage(provider))),
|
|
41
46
|
channel.onClose(() => connection.dispose()),
|