Skip to content

Reconnect after a SOCKS proxy connection failure - #416

Open
Ian-Costa18 wants to merge 1 commit into
kiwiirc:masterfrom
Ian-Costa18:fix/socks-connect-failure-never-reconnects
Open

Reconnect after a SOCKS proxy connection failure#416
Ian-Costa18 wants to merge 1 commit into
kiwiirc:masterfrom
Ian-Costa18:fix/socks-connect-failure-never-reconnects

Conversation

@Ian-Costa18

Copy link
Copy Markdown

The problem

With options.socks set, a failed connection attempt leaves the connection permanently dead: no
close is emitted, no reconnect is scheduled, and no error reaches the consumer. The connection
sits in SOCK_CONNECTING with a null socket and no pending timer, indefinitely. The direct
(non-proxy) path recovers from the same class of failure normally.

Why

In connect(), the SOCKS branch sets this.socket = null and attaches the rejection handler to
the promise:

if (options.socks) {
    this.socket = null;

    SocksClient.createConnection({ ... }).then(info => {
        this.socket = connection;
        this._onSocketCreate(options, connection);   // binds 'close', 'error', 'timeout'
    }).catch(this.onSocketError.bind(this));
}

_onSocketCreate() is what binds the socket handlers, and it only runs in the .then. So on
rejection there is no socket and nothing bound to it. The rejection lands in:

onSocketError(err) {
    this.debugOut('socketError() ' + err.message);
    this.last_socket_error = err;
    // this.emit('error', err);
}

which records the error and emits nothing — the emit('error') is commented out.
Connection.socketClose() is the only place that ever schedules a reconnect, and it is driven
exclusively by the transport's 'close' event, so it never runs.

The non-SOCKS branch assigns this.socket synchronously and calls _onSocketCreate()
immediately, so a failure there emits 'close' and reconnects. The two paths disagree on
behaviour for the same class of failure.

The fix

Emit the close ourselves when there is no socket to emit it for us. onSocketClose() sets
state = SOCK_DISCONNECTED and emits 'close' with last_socket_error — exactly what the
direct path produces — after which the existing logic in Connection.socketClose() applies
unchanged.

The !this.socket guard keeps failures that arrive through the bound 'error' handler from
being double-counted; those have a real socket that will emit its own 'close'. this.socket is
assigned before _onSocketCreate() in both the plain-SOCKS and TLS-over-SOCKS cases, so a failed
TLS handshake correctly takes the bound path.

Tests

Two cases added to the existing onSocketError() block in test/net.transport.test.js — that a
close is emitted when there is no socket, and that one is not emitted when a socket exists.
Reverting the source change alone turns the first from 248 passing into 247 passing and 1
failing. yarn lint and yarn unit-test both clean.

Known gap

If the .then throws after this.socket = connection but before _onSocketCreate() finishes
binding, !this.socket is false and nothing is bound — the same black hole. Testing
this.socket_events.length === 0 would close it precisely. I kept the change minimal instead;
say the word if you would rather have the broader guard.

How it was found

A bouncer running four IRC networks through a SOCKS5 proxy. Networks dropped and were never
retried — confirmed against the proxy's own per-connection log, which showed no further
connection attempts for over eight hours. The failure is silent from the consumer's side: no
error event, no close, nothing in the logs. Restarting the client was the only recovery.

On the SOCKS path connect() sets this.socket = null and hands the
createConnection() rejection to onSocketError(), whose emit('error') is
commented out. Socket handlers are bound in _onSocketCreate(), which only
runs from the .then, so a rejection has nothing bound to it: no 'close' is
emitted, Connection.socketClose() never runs, and no reconnect is ever
scheduled. The connection sits in SOCK_CONNECTING with a null socket and no
timer, indefinitely.

The direct path assigns this.socket synchronously and calls
_onSocketCreate() immediately, so the same class of failure emits 'close'
and reconnects normally.

Emit the close ourselves when there is no socket to emit it for us. The
!this.socket guard leaves failures arriving through the bound 'error'
handler alone, since those have a real socket that will emit its own
'close'.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant