Skip to content

Commit 6396314

Browse files
authored
Fix channel hanging (#93)
1 parent 8c65d63 commit 6396314

File tree

4 files changed

+210
-17
lines changed

4 files changed

+210
-17
lines changed

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -453,9 +453,9 @@
453453
"@gitpod/public-api": "main-gha",
454454
"@gitpod/supervisor-api-grpc": "main-gha",
455455
"@improbable-eng/grpc-web-node-http-transport": "^0.14.0",
456-
"@microsoft/dev-tunnels-ssh": "^3.11.8",
457-
"@microsoft/dev-tunnels-ssh-keys": "^3.11.8",
458-
"@microsoft/dev-tunnels-ssh-tcp": "^3.11.8",
456+
"@microsoft/dev-tunnels-ssh": "^3.11.16",
457+
"@microsoft/dev-tunnels-ssh-keys": "^3.11.16",
458+
"@microsoft/dev-tunnels-ssh-tcp": "^3.11.16",
459459
"@segment/analytics-node": "^1.0.0-beta.24",
460460
"configcat-node": "^8.0.0",
461461
"js-yaml": "^4.1.0",
@@ -476,4 +476,4 @@
476476
"ws": "^8.13.0",
477477
"yazl": "^2.5.1"
478478
}
479-
}
479+
}

src/local-ssh/pipeSession.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { CancellationToken, PromiseCompletionSource, SessionRequestFailureMessage, SessionRequestMessage, SessionRequestSuccessMessage, SshChannel, SshChannelClosedEventArgs, SshChannelError, SshChannelOpeningEventArgs, SshMessage, SshRequestEventArgs, SshSession, SshSessionClosedEventArgs, SshTraceEventIds, TraceLevel } from "@microsoft/dev-tunnels-ssh";
2+
import { ChannelFailureMessage, ChannelMessage, ChannelOpenConfirmationMessage, ChannelOpenFailureMessage, ChannelRequestMessage, ChannelSuccessMessage, SshChannelOpenFailureReason } from "@microsoft/dev-tunnels-ssh/messages/connectionMessages";
3+
4+
/**
5+
* Extension methods for piping sessions and channels.
6+
*
7+
* Note this class is not exported from the package. Instead, the piping APIs are exposed via
8+
* public methods on the `SshSession` and `SshChannel` classes. See those respective methods
9+
* for API documentation.
10+
*/
11+
export class PipeExtensions {
12+
public static async pipeSession(session: SshSession, toSession: SshSession): Promise<void> {
13+
if (!session) throw new TypeError('Session is required.');
14+
if (!toSession) throw new TypeError('Target session is required');
15+
16+
const endCompletion = new PromiseCompletionSource<Promise<void>>();
17+
18+
session.onRequest((e) => {
19+
e.responsePromise = PipeExtensions.forwardSessionRequest(e, toSession, e.cancellation);
20+
});
21+
toSession.onRequest((e) => {
22+
e.responsePromise = PipeExtensions.forwardSessionRequest(e, session, e.cancellation);
23+
});
24+
25+
session.onChannelOpening((e) => {
26+
if (e.isRemoteRequest) {
27+
e.openingPromise = PipeExtensions.forwardChannel(e, toSession, e.cancellation);
28+
}
29+
});
30+
toSession.onChannelOpening((e) => {
31+
if (e.isRemoteRequest) {
32+
e.openingPromise = PipeExtensions.forwardChannel(e, session, e.cancellation);
33+
}
34+
});
35+
36+
session.onClosed((e) => {
37+
endCompletion.resolve(PipeExtensions.forwardSessionClose(toSession, e));
38+
});
39+
toSession.onClosed((e) => {
40+
endCompletion.resolve(PipeExtensions.forwardSessionClose(session, e));
41+
});
42+
43+
const endPromise = await endCompletion.promise;
44+
await endPromise;
45+
}
46+
47+
public static async pipeChannel(channel: SshChannel, toChannel: SshChannel): Promise<void> {
48+
if (!channel) throw new TypeError('Channel is required.');
49+
if (!toChannel) throw new TypeError('Target channel is required');
50+
51+
const endCompletion = new PromiseCompletionSource<Promise<void>>();
52+
let closed = false;
53+
54+
channel.onRequest((e) => {
55+
e.responsePromise = PipeExtensions.forwardChannelRequest(e, toChannel, e.cancellation);
56+
});
57+
toChannel.onRequest((e) => {
58+
e.responsePromise = PipeExtensions.forwardChannelRequest(e, channel, e.cancellation);
59+
});
60+
61+
channel.onDataReceived((data) => {
62+
void PipeExtensions.forwardData(channel, toChannel, data).catch();
63+
});
64+
toChannel.onDataReceived((data) => {
65+
void PipeExtensions.forwardData(toChannel, channel, data).catch();
66+
});
67+
68+
channel.onEof(() => {
69+
void PipeExtensions.forwardData(channel, toChannel, Buffer.alloc(0)).catch();
70+
});
71+
toChannel.onEof(() => {
72+
void PipeExtensions.forwardData(toChannel, channel, Buffer.alloc(0)).catch();
73+
});
74+
75+
channel.onClosed((e) => {
76+
if (!closed) {
77+
closed = true;
78+
endCompletion.resolve(PipeExtensions.forwardChannelClose(channel, toChannel, e));
79+
}
80+
});
81+
toChannel.onClosed((e) => {
82+
if (!closed) {
83+
closed = true;
84+
endCompletion.resolve(PipeExtensions.forwardChannelClose(toChannel, channel, e));
85+
}
86+
});
87+
88+
const endTask = await endCompletion.promise;
89+
await endTask;
90+
}
91+
92+
private static async forwardSessionRequest(
93+
e: SshRequestEventArgs<SessionRequestMessage>,
94+
toSession: SshSession,
95+
cancellation?: CancellationToken,
96+
): Promise<SshMessage> {
97+
// `SshSession.requestResponse()` always set `wantReply` to `true` internally and waits for a
98+
// response, but since the message buffer is cached the updated `wantReply` value is not sent.
99+
// Anyway, it's better to forward a no-reply message as another no-reply message, using
100+
// `SshSession.request()` instead.
101+
if (!e.request.wantReply) {
102+
return toSession
103+
.request(e.request, cancellation)
104+
.then(() => new SessionRequestSuccessMessage());
105+
}
106+
return toSession.requestResponse(
107+
e.request,
108+
SessionRequestSuccessMessage,
109+
SessionRequestFailureMessage,
110+
cancellation,
111+
);
112+
}
113+
114+
private static async forwardChannel(
115+
e: SshChannelOpeningEventArgs,
116+
toSession: SshSession,
117+
cancellation?: CancellationToken,
118+
): Promise<ChannelMessage> {
119+
try {
120+
const toChannel = await toSession.openChannel(e.request, null, cancellation);
121+
void PipeExtensions.pipeChannel(e.channel, toChannel).catch();
122+
return new ChannelOpenConfirmationMessage();
123+
} catch (err) {
124+
if (!(err instanceof Error)) throw err;
125+
126+
const failureMessage = new ChannelOpenFailureMessage();
127+
if (err instanceof SshChannelError) {
128+
failureMessage.reasonCode = err.reason ?? SshChannelOpenFailureReason.connectFailed;
129+
} else {
130+
failureMessage.reasonCode = SshChannelOpenFailureReason.connectFailed;
131+
}
132+
133+
failureMessage.description = err.message;
134+
return failureMessage;
135+
}
136+
}
137+
138+
private static async forwardChannelRequest(
139+
e: SshRequestEventArgs<ChannelRequestMessage>,
140+
toChannel: SshChannel,
141+
cancellation?: CancellationToken,
142+
): Promise<SshMessage> {
143+
e.request.recipientChannel = toChannel.remoteChannelId;
144+
const result = await toChannel.request(e.request, cancellation);
145+
return result ? new ChannelSuccessMessage() : new ChannelFailureMessage();
146+
}
147+
148+
private static async forwardSessionClose(
149+
session: SshSession,
150+
e: SshSessionClosedEventArgs,
151+
): Promise<void> {
152+
return session.close(e.reason, e.message, e.error ?? undefined);
153+
}
154+
155+
private static async forwardData(
156+
channel: SshChannel,
157+
toChannel: SshChannel,
158+
data: Buffer,
159+
): Promise<void> {
160+
// Make a copy of the buffer before sending because SshChannel.send() is an async operation
161+
// (it may need to wait for the window to open), while the buffer will be re-used for the
162+
// next message as sson as this task yields.
163+
const buffer = Buffer.alloc(data.length);
164+
data.copy(buffer);
165+
const promise = toChannel.send(buffer, CancellationToken.None);
166+
channel.adjustWindow(buffer.length);
167+
return promise;
168+
}
169+
170+
private static async forwardChannelClose(
171+
fromChannel: SshChannel,
172+
toChannel: SshChannel,
173+
e: SshChannelClosedEventArgs,
174+
): Promise<void> {
175+
const message =
176+
`Piping channel closure.\n` +
177+
`Source: ${fromChannel.session} ${fromChannel}\n` +
178+
`Destination: ${toChannel.session} ${toChannel}\n`;
179+
toChannel.session.trace(TraceLevel.Verbose, SshTraceEventIds.channelClosed, message);
180+
181+
if (e.error) {
182+
toChannel.close(e.error as any);
183+
return Promise.resolve();
184+
} else if (e.exitSignal) {
185+
return toChannel.close(e.exitSignal, e.errorMessage);
186+
} else if (typeof e.exitStatus === 'number') {
187+
return toChannel.close(e.exitStatus);
188+
} else {
189+
return toChannel.close();
190+
}
191+
}
192+
}

src/local-ssh/proxy.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import * as stream from 'stream';
8282
import { ILogService } from '../services/logService';
8383
import { ITelemetryService, UserFlowTelemetryProperties } from '../common/telemetry';
8484
import { LocalSSHMetricsReporter } from '../services/localSSHMetrics';
85+
import { PipeExtensions } from './pipeSession';
8586

8687
// This public key is safe to be public since we only use it to verify local-ssh connections.
8788
const HOST_KEY = 'LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JR0hBZ0VBTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEJHMHdhd0lCQVFRZ1QwcXg1eEJUVmc4TUVJbUUKZmN4RXRZN1dmQVVsM0JYQURBK2JYREsyaDZlaFJBTkNBQVJlQXo0RDVVZXpqZ0l1SXVOWXpVL3BCWDdlOXoxeApvZUN6UklqcGdCUHozS0dWRzZLYXV5TU5YUm95a21YSS9BNFpWaW9nd2Vjb0FUUjRUQ2FtWm1ScAotLS0tLUVORCBQUklWQVRFIEtFWS0tLS0tCg==';
@@ -190,7 +191,7 @@ class WebSocketSSHProxy {
190191
e.authenticationPromise = this.authenticateClient(e.username ?? '')
191192
.then(async pipeSession => {
192193
this.sendUserStatusFlow('connected');
193-
pipePromise = session.pipe(pipeSession);
194+
pipePromise = PipeExtensions.pipeSession(session, pipeSession);
194195
return {};
195196
}).catch(async err => {
196197
this.logService.error('failed to authenticate proxy with username: ' + e.username ?? '', err);

yarn.lock

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -246,24 +246,24 @@
246246
semver "^7.3.5"
247247
tar "^6.1.11"
248248

249-
"@microsoft/dev-tunnels-ssh-keys@^3.11.8":
250-
version "3.11.8"
251-
resolved "https://registry.yarnpkg.com/@microsoft/dev-tunnels-ssh-keys/-/dev-tunnels-ssh-keys-3.11.8.tgz#4d70ff5999e1f247f628c24e345090bbb34b819f"
252-
integrity sha512-ijYyKnE0osRS9olUJWuGumXO3Efjp97eXq/MVH4GNs9HZ4ACjLGs+gECXZKFM4Q6g2EfINXLla7HyK+FKeMUBA==
249+
"@microsoft/dev-tunnels-ssh-keys@^3.11.16":
250+
version "3.11.16"
251+
resolved "https://registry.yarnpkg.com/@microsoft/dev-tunnels-ssh-keys/-/dev-tunnels-ssh-keys-3.11.16.tgz#ba8197d6264ef2d33790c3c59cef794e2717a050"
252+
integrity sha512-GaPKaewjnpCyLv3947FOjJluakVkAKVNh6+vDDGAG+ZWFPhYAgcEiOHTZOfJlmoUhYXFpCrQ5z8fiTDw5mW3Mw==
253253
dependencies:
254254
"@microsoft/dev-tunnels-ssh" "~3.11"
255255

256-
"@microsoft/dev-tunnels-ssh-tcp@^3.11.8":
257-
version "3.11.8"
258-
resolved "https://registry.yarnpkg.com/@microsoft/dev-tunnels-ssh-tcp/-/dev-tunnels-ssh-tcp-3.11.8.tgz#e56865d352a58ae157e3bed91cb03c121c78a42d"
259-
integrity sha512-ERTR4pPd4fLZjHziStT0V1D/9QC4+mkfax3eTj42fBgVZks9pQIoGy67o8vJGCnaF1/V56z5KIctIriN/wG+gQ==
256+
"@microsoft/dev-tunnels-ssh-tcp@^3.11.16":
257+
version "3.11.16"
258+
resolved "https://registry.yarnpkg.com/@microsoft/dev-tunnels-ssh-tcp/-/dev-tunnels-ssh-tcp-3.11.16.tgz#ea4f96686362913d2466d846502c50b321e04881"
259+
integrity sha512-12D5sVDkI6kjoSUfBswsPYPuPJLbMoTAC+K8g+j4Yf9DeabbhR/wkdYyboIGPt2Tpb0SkAwVnuZLpPffAq4N/g==
260260
dependencies:
261261
"@microsoft/dev-tunnels-ssh" "~3.11"
262262

263-
"@microsoft/dev-tunnels-ssh@^3.11.8", "@microsoft/dev-tunnels-ssh@~3.11":
264-
version "3.11.8"
265-
resolved "https://registry.yarnpkg.com/@microsoft/dev-tunnels-ssh/-/dev-tunnels-ssh-3.11.8.tgz#2a0e25bca006f1f097a2df63cdf4684d28d1ddca"
266-
integrity sha512-71GmOBXy7JjDpL6tkVW4XI1+G4s7SdvMRvYPaXK1MBhBKm8sGLXqBb2xoOpjbY9/Y/gUzADEuwB7OyGlEAyWYw==
263+
"@microsoft/dev-tunnels-ssh@^3.11.16", "@microsoft/dev-tunnels-ssh@~3.11":
264+
version "3.11.16"
265+
resolved "https://registry.yarnpkg.com/@microsoft/dev-tunnels-ssh/-/dev-tunnels-ssh-3.11.16.tgz#53e15306181801ffee87f1bb6440beb346d5255e"
266+
integrity sha512-OUvAhudpOS9Ms1C2Y3WkUpCRBsNvZJl6IQE04Ud9ZGg/VaF04QJQFWeXt3pYryJJtv3zy1V2aH9JnzMMvBjXfA==
267267
dependencies:
268268
buffer "^5.2.1"
269269
debug "^4.1.1"

0 commit comments

Comments
 (0)