Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import assert from 'assert';
7
import { DeferredPromise } from '../../../../base/common/async.js';
8
import { Emitter, Event } from '../../../../base/common/event.js';
9
import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js';
10
import { URI } from '../../../../base/common/uri.js';
11
import type { IChannel } from '../../../../base/parts/ipc/common/ipc.js';
12
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
13
import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js';
14
import { ILogService, NullLogService } from '../../../log/common/log.js';
15
import { IConfigurationService } from '../../../configuration/common/configuration.js';
16
import { IInstantiationService } from '../../../instantiation/common/instantiation.js';
17
import { ISharedProcessService } from '../../../ipc/electron-browser/services.js';
18
import { IRemoteAgentHostService } from '../../common/remoteAgentHostService.js';
19
import type { IAgentConnection } from '../../common/agentService.js';
20
import type {
21
ISSHAgentHostConfig,
22
ISSHConnectResult,
23
ISSHRelayMessage,
24
ISSHResolvedConfig,
25
} from '../../common/sshRemoteAgentHost.js';
26
import { SSHRemoteAgentHostService } from '../../electron-browser/sshRemoteAgentHostServiceImpl.js';
27
28
/**
29
* In-renderer mock of the shared-process SSH service. Exposes the same
30
* surface that the renderer accesses through ProxyChannel, plus a small
31
* test API to drive close events and inspect calls.
32
*/
33
class MockSSHMainService {
34
private readonly _onDidChangeConnections = new Emitter<void>();
35
readonly onDidChangeConnections = this._onDidChangeConnections.event;
36
37
private readonly _onDidCloseConnection = new Emitter<string>();
38
readonly onDidCloseConnection = this._onDidCloseConnection.event;
39
40
private readonly _onDidReportConnectProgress = new Emitter<{ connectionKey: string; message: string }>();
41
readonly onDidReportConnectProgress = this._onDidReportConnectProgress.event;
42
43
private readonly _onDidRelayMessage = new Emitter<ISSHRelayMessage>();
44
readonly onDidRelayMessage = this._onDidRelayMessage.event;
45
46
private readonly _onDidRelayClose = new Emitter<string>();
47
readonly onDidRelayClose = this._onDidRelayClose.event;
48
49
readonly disconnectCalls: string[] = [];
50
private _nextConnectionId = 1;
51
52
connectResult: Partial<ISSHConnectResult> | undefined;
53
54
async connect(config: ISSHAgentHostConfig): Promise<ISSHConnectResult> {
55
const connectionId = this.connectResult?.connectionId ?? `conn-${this._nextConnectionId++}`;
56
return {
57
connectionId,
58
address: this.connectResult?.address ?? `ssh:${config.host}`,
59
name: config.name,
60
connectionToken: 'test-token',
61
config: { host: config.host, username: config.username, authMethod: config.authMethod, name: config.name, sshConfigHost: config.sshConfigHost },
62
sshConfigHost: config.sshConfigHost,
63
};
64
}
65
66
async reconnect(sshConfigHost: string, name: string): Promise<ISSHConnectResult> {
67
return {
68
connectionId: `conn-${this._nextConnectionId++}`,
69
address: `ssh:${sshConfigHost}`,
70
name,
71
connectionToken: 'test-token',
72
config: { host: sshConfigHost, username: 'u', authMethod: 0 as never, name, sshConfigHost },
73
sshConfigHost,
74
};
75
}
76
77
async relaySend(_connectionId: string, _message: string): Promise<void> { /* no-op */ }
78
79
async disconnect(connectionId: string): Promise<void> {
80
this.disconnectCalls.push(connectionId);
81
}
82
83
async listSSHConfigHosts(): Promise<string[]> { return []; }
84
async ensureUserSSHConfig(): Promise<URI> { return URI.file('/tmp/ssh-config'); }
85
async listSSHConfigFiles(): Promise<URI[]> { return [URI.file('/tmp/ssh-config')]; }
86
async resolveSSHConfig(_host: string): Promise<ISSHResolvedConfig> {
87
return { hostname: '', user: undefined, port: 22, identityFile: [], forwardAgent: false };
88
}
89
90
dispose(): void {
91
this._onDidChangeConnections.dispose();
92
this._onDidCloseConnection.dispose();
93
this._onDidReportConnectProgress.dispose();
94
this._onDidRelayMessage.dispose();
95
this._onDidRelayClose.dispose();
96
}
97
}
98
99
/** Adapt a mock service object to the IChannel surface ProxyChannel expects. */
100
function asChannel(target: object): IChannel {
101
return {
102
call: async <T>(method: string, args?: unknown): Promise<T> => {
103
const fn = (target as Record<string, unknown>)[method];
104
if (typeof fn !== 'function') {
105
throw new Error(`MockChannel: no method ${method}`);
106
}
107
return (fn as (...a: unknown[]) => Promise<T>).apply(target, (args as unknown[]) ?? []);
108
},
109
listen: <T>(event: string): Event<T> => {
110
const ev = (target as Record<string, unknown>)[event];
111
if (typeof ev !== 'function') {
112
throw new Error(`MockChannel: no event ${event}`);
113
}
114
return ev as Event<T>;
115
},
116
};
117
}
118
119
/** Captures addManagedConnection calls so tests can inspect transportDisposable. */
120
class MockRemoteAgentHostService extends Disposable {
121
readonly added: Array<{ address: string; transport?: IDisposable }> = [];
122
private readonly _entries = new Map<string, { transport?: IDisposable; client: { dispose?: () => void } }>();
123
124
async addManagedConnection(entry: { name: string; connection: { address?: string; sshConfigHost?: string } }, client: IAgentConnection, transportDisposable?: IDisposable): Promise<unknown> {
125
const address = entry.connection.address ?? `ssh:${entry.connection.sshConfigHost}`;
126
this.added.push({ address, transport: transportDisposable });
127
this._entries.set(address, { client: client as { dispose?: () => void }, transport: transportDisposable });
128
return { address, name: entry.name, clientId: 'mock', defaultDirectory: undefined, status: 0 };
129
}
130
131
/** Simulate user clicking "Remove Remote": disposes the per-entry store, which runs the transport disposable. */
132
removeEntry(address: string): void {
133
const e = this._entries.get(address);
134
if (!e) {
135
return;
136
}
137
this._entries.delete(address);
138
e.client.dispose?.();
139
e.transport?.dispose();
140
}
141
142
override dispose(): void {
143
// Dispose any still-registered entries (mirrors the per-entry store cleanup
144
// done by the real RemoteAgentHostService when it itself is disposed).
145
for (const [, e] of this._entries) {
146
e.client.dispose?.();
147
e.transport?.dispose();
148
}
149
this._entries.clear();
150
super.dispose();
151
}
152
}
153
154
class MockProtocolClient extends Disposable {
155
readonly clientId = 'mock-protocol-client';
156
readonly onDidClose = Event.None;
157
readonly onDidAction = Event.None;
158
readonly onDidNotification = Event.None;
159
readonly connectDeferred = new DeferredPromise<void>();
160
async connect(): Promise<void> { return this.connectDeferred.p; }
161
registerOwned<T extends IDisposable>(d: T): T { return this._register(d); }
162
}
163
164
class TestConfigurationService {
165
readonly onDidChangeConfiguration = Event.None;
166
getValue(): unknown { return undefined; }
167
}
168
169
suite('SSHRemoteAgentHostService (renderer)', () => {
170
171
const disposables = new DisposableStore();
172
let mainService: MockSSHMainService;
173
let remoteAgentHostService: MockRemoteAgentHostService;
174
let createdClients: MockProtocolClient[];
175
let waitForClient: (index: number) => Promise<MockProtocolClient>;
176
let service: SSHRemoteAgentHostService;
177
178
setup(() => {
179
mainService = new MockSSHMainService();
180
disposables.add({ dispose: () => mainService.dispose() });
181
remoteAgentHostService = disposables.add(new MockRemoteAgentHostService());
182
createdClients = [];
183
184
const sharedProcessService: Partial<ISharedProcessService> = {
185
getChannel: () => asChannel(mainService),
186
};
187
188
const instantiationService = disposables.add(new TestInstantiationService());
189
instantiationService.stub(ILogService, new NullLogService());
190
instantiationService.stub(IConfigurationService, new TestConfigurationService() as Partial<IConfigurationService>);
191
instantiationService.stub(ISharedProcessService, sharedProcessService as ISharedProcessService);
192
instantiationService.stub(IRemoteAgentHostService, remoteAgentHostService as Partial<IRemoteAgentHostService>);
193
194
const clientWaiters: DeferredPromise<MockProtocolClient>[] = [];
195
waitForClient = (index: number): Promise<MockProtocolClient> => {
196
if (createdClients[index]) {
197
return Promise.resolve(createdClients[index]);
198
}
199
return (clientWaiters[index] ??= new DeferredPromise<MockProtocolClient>()).p;
200
};
201
202
const inner: Partial<IInstantiationService> = {
203
createInstance: (_ctor: unknown, ...args: unknown[]) => {
204
const c = new MockProtocolClient();
205
// The real RemoteAgentHostProtocolClient owns the transport disposable
206
// it's constructed with; mirror that here so SSHRelayTransport doesn't leak.
207
const transport = args[1] as IDisposable | undefined;
208
if (transport) {
209
c.registerOwned(transport);
210
}
211
disposables.add(c);
212
const index = createdClients.length;
213
createdClients.push(c);
214
clientWaiters[index]?.complete(c);
215
return c;
216
},
217
};
218
instantiationService.stub(IInstantiationService, inner as Partial<IInstantiationService>);
219
220
service = disposables.add(instantiationService.createInstance(SSHRemoteAgentHostService));
221
});
222
223
teardown(() => disposables.clear());
224
ensureNoDisposablesAreLeakedInTestSuite();
225
226
const sampleConfig: ISSHAgentHostConfig = {
227
host: 'remote.example',
228
username: 'user',
229
authMethod: 0 as never,
230
name: 'My Remote',
231
sshConfigHost: 'remote.example',
232
};
233
234
/** Wait until the renderer has created its protocol client, then resolve its handshake. */
235
async function awaitClientThenResolve(index: number): Promise<void> {
236
const client = await waitForClient(index);
237
client.connectDeferred.complete();
238
}
239
240
test('connect registers a managed connection with a transport disposable', async () => {
241
const connectPromise = service.connect(sampleConfig);
242
await awaitClientThenResolve(0);
243
const handle = await connectPromise;
244
245
assert.strictEqual(remoteAgentHostService.added.length, 1);
246
assert.strictEqual(remoteAgentHostService.added[0].address, 'ssh:remote.example');
247
assert.ok(remoteAgentHostService.added[0].transport, 'a transport disposable is passed so removal can tear down the SSH tunnel');
248
assert.strictEqual(service.connections.length, 1);
249
assert.strictEqual(handle.localAddress, 'ssh:remote.example');
250
});
251
252
test('removing the entry tears down the SSH tunnel and the renderer-side handle', async () => {
253
const connectPromise = service.connect(sampleConfig);
254
await awaitClientThenResolve(0);
255
await connectPromise;
256
257
assert.strictEqual(mainService.disconnectCalls.length, 0);
258
assert.strictEqual(service.connections.length, 1);
259
260
// Simulate the user clicking "Remove Remote": IRemoteAgentHostService
261
// disposes the per-entry store, which runs our transport disposable.
262
remoteAgentHostService.removeEntry('ssh:remote.example');
263
264
assert.deepStrictEqual(mainService.disconnectCalls, ['conn-1'], 'main-process tunnel is told to disconnect');
265
assert.strictEqual(service.connections.length, 0, 'renderer-side handle is dropped');
266
});
267
268
test('connect after removal does not reuse the previous handle', async () => {
269
// First connect → entry registered, then removed.
270
const c1 = service.connect(sampleConfig);
271
await awaitClientThenResolve(0);
272
await c1;
273
remoteAgentHostService.removeEntry('ssh:remote.example');
274
assert.strictEqual(service.connections.length, 0);
275
276
// Second connect → main returns a new connectionId; renderer creates
277
// a fresh handle and registers a new managed entry.
278
mainService.connectResult = { connectionId: 'conn-2', address: 'ssh:remote.example' };
279
const c2 = service.connect(sampleConfig);
280
await awaitClientThenResolve(1);
281
await c2;
282
283
assert.strictEqual(service.connections.length, 1);
284
assert.strictEqual(remoteAgentHostService.added.length, 2, 'each connect produces a fresh managed-connection registration');
285
});
286
287
test('main-process onDidCloseConnection cleans up renderer handle without double-disconnecting', async () => {
288
const connectPromise = service.connect(sampleConfig);
289
await awaitClientThenResolve(0);
290
await connectPromise;
291
assert.strictEqual(service.connections.length, 1);
292
293
// Simulate main process closing the connection on its own (e.g. SSH dropped).
294
// We can't directly fire on the wrapped emitter through the channel because
295
// ProxyChannel is one-directional; instead we trigger via the mock service
296
// emitter that the renderer subscribed to.
297
(mainService as unknown as { _onDidCloseConnection: Emitter<string> })._onDidCloseConnection.fire('conn-1');
298
299
assert.strictEqual(service.connections.length, 0, 'handle dropped on main close');
300
// Removing the (already-gone) entry shouldn't trigger another disconnect call.
301
remoteAgentHostService.removeEntry('ssh:remote.example');
302
// One disconnect from the transport disposable is fine; we just want to make
303
// sure we're not at risk of issuing a second one against a stale id.
304
assert.ok(mainService.disconnectCalls.length <= 1, 'no duplicate disconnect against a stale connectionId');
305
});
306
});
307
308