valkey: run a dial that fails before there is a socket through the deferred close
This is the first PR of a stack split out of #37993. The second is #39511. The third is #38281. Each PR stands on its own.
Part of #18895. The stack as a whole fixes it; #38281 closes it.
The problem
Some dials fail before a socket exists:
connect(2)fails at once, for example a unix socket path that does not exist.- The TLS context cannot be built.
Each entry point had its own close path for this case. None of them behaved like a dial that fails later, with a socket.
- A retry that failed this way called
onclosefrom inside the timer callback. Ifonclosedialled again and that failed too, the code recursed. Nothing settled. - A first dial that failed this way rejected at once with ERR_SOCKET_CLOSED_BEFORE_CONNECTION. It did not run
onclose. It ignored the retry policy. It left the rejected promise cached, so a later dial that succeeded settled it a second time. - A TLS context failure ran the close callback from inside
connect().
Separately, a half-received reply stayed in the read buffer after close(). It counted as pending activity and kept the process alive.
The rule this PR sets
A dial that fails before there is a socket takes the same close path as a dial that fails with one. The close path settles the connect() promise, runs onclose, applies the retry policy, and updates the event loop ref.
What changed
All four cases now queue one task on the event loop. The task is a second mode of the existing ValkeyDeferredClose. It runs the close path for the failed dial.
Until the task runs, the client stays in the Connecting state, as it would with a real dial in flight. So a connect() or close() called in the meantime joins or cancels the attempt. It does not dial on top of it, and it is not ignored.
If onclose calls connect() and that dial fails the same way, it queues another task. It does not re-enter the close path on the same stack.
If the VM tears down while the task is still queued, the task releases the client ref and the event loop ref. It does not run script.
The close path now frees the read buffer. A half-received reply cannot outlive its connection.
Visible change
A first dial that fails at once now rejects from the event loop with ERR_REDIS_CONNECTION_CLOSED. It runs onclose and follows the retry policy, like a refused dial. Before, it rejected synchronously with ERR_SOCKET_CLOSED_BEFORE_CONNECTION.
Tests
The tests are in the "Recovering After fail()" block of connection-failures.test.ts. They use unix socket paths that nobody listens on, closed ports, and a net stub. All ten fail on Bun 1.3.14.
worker-terminate-lifetime.test.ts tears a VM down with the task still queued, once with a command queued behind it. It also runs the same close on the main thread, where the process then exits on its own.
Not in this PR
The old synchronous path put the errno in the error message. This path only logs it. Carrying it into the rejection is a follow-up.
Supersedes #32768.