Skip to content
This repository was archived by the owner on May 31, 2022. It is now read-only.

fix: Wait for the tunnel to close before finishing client close COMPASS-4474 #352

Merged
merged 9 commits into from
Mar 4, 2021

Conversation

gribnoysup
Copy link
Contributor

@gribnoysup gribnoysup commented Feb 24, 2021

Replaces ssh-tunnel implementation with @mongodb-js/ssh-tunnel and safely overrides driver close* to always wait for tunnel to close before resolving the callback. That way we can always be sure that tunnel is completely stopped before allowing for another connection to be open through a tunnel with the same port in e.g., Compass

* - I considered other options how to achieve that (for example, exposing tunnel with the client from the connect callback and then awaiting explicitly on it in data-service), but overriding close on client still feels like a better balance between how much stuff is exposed from the connection-model and "hackiness" of the implementation

@gribnoysup gribnoysup force-pushed the compass-4474-refactor-ssh-tunnel branch 2 times, most recently from ce94374 to 905c181 Compare February 24, 2021 16:24
@gribnoysup gribnoysup force-pushed the compass-4474-refactor-ssh-tunnel branch 2 times, most recently from e97ccc6 to e4c4576 Compare February 26, 2021 08:46
lib/connect.js Outdated
let maybeTunnelError;

if (tunnel) {
tunnel.on('error', (tunnelError) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tunnel.once('error' ?

lib/connect.js Outdated
tunnel.close().then(
() => {
debug('ssh tunnel stopped');
cb(err);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cb(null)?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now i get it, sorry .. this code is quite complex though .. i think we should give the promise version / or a more de-tangled approach a chance

@@ -12,7 +12,7 @@ const {
const { MongoClient } = require('mongodb');
const { parseConnectionString } = require('mongodb/lib/core');
const Connection = require('./extended-model');
const createSSHTunnel = require('./ssh-tunnel');
const { default: SSHTunnel } = require('@mongodb-js/ssh-tunnel');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😍

lib/connect.js Outdated
tunnel.close().then(
() => {
debug('ssh tunnel stopped');
callbackOrPromise(clientError);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why we return an error here? Isn't this what is supposed to happen?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This case is for when client failed to connect. We wait for tunnel to close and only then resolve the callback with the original client error

lib/connect.js Outdated
}
});
}

mongoClient.connect((err, _client) => {
Copy link
Contributor

@mcasimir mcasimir Feb 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

now that the tunnel uses promises, what about having an async function connect() {} that we export with util.callbackify instead than manually converting all of the promises call manually?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would not be a 1 to 1 replacement, connect method synchronously returns state emitter and additionally allows to pass a callback that will evaluate when connection happened, can't really replace this behavior with promises

Copy link
Contributor

@mcasimir mcasimir left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really think we should avoid changing the client, let's investigate first some alternative with client.once('close') for example

@gribnoysup
Copy link
Contributor Author

After discussing F2F we decided to go with the exposing the tunnel from the connect method option

@gribnoysup gribnoysup added the wip label Feb 26, 2021
…rough connect instead of overriding client.close method
@gribnoysup gribnoysup removed the wip label Feb 26, 2021
@gribnoysup gribnoysup requested a review from mcasimir February 26, 2021 17:32
Copy link
Contributor Author

@gribnoysup gribnoysup left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-implemented as discussed, want to give it another look?

Comment on lines +268 to +271
const _client = await Promise.race([
mongoClient.connect(),
waitForTunnelError
]);
Copy link
Contributor Author

@gribnoysup gribnoysup Feb 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really happy with this bit here as we are not really waiting for MongoClient.connect to resolve if tunnel threw (this should not affect anything though, so I think it's okay as is), but that's more or less the only way to do it without doing weird callback stuff as it was in the previous implementation

Copy link
Contributor

@mcasimir mcasimir Mar 1, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point!

Are user actionable errors about the tunnel thrown by .listen?

The one thing I was assuming here is this: all the ssh related errors are thrown on tunnel.listen ie if a cert or password is invalid .... and the only issues catched by on('error') are not caused by user mistakes (ie. they picked a wrong file) but by programming bug or network conditions that are not actionable beyond a retry: exception when the tcp connection is opened by MongoClient.

If that is true i think we can even ignore the race with waitForTunnelError (just use it to make sure tunnel closes), since MongoClient will throw a connection error and we could just give a nice error in the UI: The tunnel was open correctly but the connection to mongodb failed: ${mongoError}.

For sure we could give more info but is super fine, we are making it connect when it should, which now does not, we already improve the user life immensely.

If however instead errors like wrong password can only be catched by waitForTunnelError than is a different story and probably we should wait for that as you did, or better make sure those errors are thrown by listen.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all the ssh related errors are thrown on tunnel.listen ie if a cert or password is invalid

This doesn't happen until first connection happens, tunnel is established for every incoming connection to ssh tunnel server, so things like forward out are just not happening before that. If we want them to be thrown when listen is called, some kind of ping request needs to be issued, which is doable. What do you think, should we add something like this here?

So in current implementation both errors emitted by tunnel or provided in callback can be helpful. But as mentioned, if tunnel emitted one, it probably will have a higher priority in terms of being useful to the user.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

forward out are just not happening before that

When it gets to forward the connection to ssh should already be established, isn't it?

tunnel is established for every incoming connection to ssh

why? can't we just both create a new tunnel and 'forwarding tcp server' on listen instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why? can't we just both create a new tunnel and 'forwarding tcp server' on listen instead?

ssh2 docs don't recommend reusing one client for multiple connections, so @mongodb-js/ssh-tunnel creates new client for every incoming connection, which also means that client is not created until incoming connection is received

Copy link
Contributor

@mcasimir mcasimir left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great! For what i remember race may be returning an array, you may probably want to check that.

Comment on lines +268 to +271
const _client = await Promise.race([
mongoClient.connect(),
waitForTunnelError
]);
Copy link
Contributor

@mcasimir mcasimir Mar 1, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point!

Are user actionable errors about the tunnel thrown by .listen?

The one thing I was assuming here is this: all the ssh related errors are thrown on tunnel.listen ie if a cert or password is invalid .... and the only issues catched by on('error') are not caused by user mistakes (ie. they picked a wrong file) but by programming bug or network conditions that are not actionable beyond a retry: exception when the tcp connection is opened by MongoClient.

If that is true i think we can even ignore the race with waitForTunnelError (just use it to make sure tunnel closes), since MongoClient will throw a connection error and we could just give a nice error in the UI: The tunnel was open correctly but the connection to mongodb failed: ${mongoError}.

For sure we could give more info but is super fine, we are making it connect when it should, which now does not, we already improve the user life immensely.

If however instead errors like wrong password can only be catched by waitForTunnelError than is a different story and probably we should wait for that as you did, or better make sure those errors are thrown by listen.

cb(null, { url: model.driverUrlWithSsh, options: validOptions });
});
try {
const _client = await Promise.race([
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't this: const [_client]? is there a test that verifies if we return a client and a tunnel?

Copy link
Contributor Author

@gribnoysup gribnoysup Mar 1, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Promise.race returns only one value (it races a bunch of them against each other and only one "wins", right?), so it's not an array.

We have a whole suite of tests for connect method and they are still passing, so client is returned correctly, this implementation doesn't change current behavior. I haven't added any tests for returning tunnel because I initially thought that this will require setting up a proper tunnel server to test the whole thing and it seemed like too much for a trivial test like that, but I can mock a bunch of things and write one for returning tunnel, yeah

Copy link
Contributor

@mcasimir mcasimir Mar 2, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! makes a lot of sense :), could have checked myself.

Copy link
Contributor

@addaleax addaleax left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants