Skip to content

redis: close the socket when the connection fails - #33479

Open
robobun wants to merge 1 commit into
mainfrom
farm/8d9df4c3/redis-fail-closes-socket
Open

redis: close the socket when the connection fails#33479
robobun wants to merge 1 commit into
mainfrom
farm/8d9df4c3/redis-fail-closes-socket

Conversation

@robobun

@robobun robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

What

Bun.RedisClient can end up permanently half-alive: flags.failed is set, every command rejects with ERR_REDIS_CONNECTION_CLOSED, but the socket is still open, .connected still reports true, and onclose never fires. The application is never told the connection is dead.

ValkeyClient::fail() is the single entry point for every connection-level failure: a RESP frame the parser can't read, a non-OK SELECT, the idle timeout, an unrecognised RESP3 push. On an established connection none of them closed the socket.

Repro

A scripted RESP3 server that answers HELLO and then sends a byte that is not a RESP type:

const client = new Bun.RedisClient(url, { autoReconnect: false });
client.onclose = () => console.log("onclose fired");

await client.connect();
try { await client.ping(); } catch (e) { console.log("ping ->", e.code); }

console.log({ connected: client.connected });
ping -> ERR_REDIS_INVALID_RESPONSE_TYPE
{ connected: true }          <- socket still open, onclose never fired

Every later command rejects, forever, and nothing ever tells the app.

Cause

// src/runtime/valkey_jsc/valkey.rs
self.flags.failed = true;
let val = Self::reject_all_pending_commands(..);

if !self.connection_ready() {           // is_authenticated && !is_selecting_db_internal
    self.flags.is_manually_closed = true;
    self.close();
}

The socket was only closed while the handshake was still in flight. Once is_authenticated flipped, fail() rejected the queues and returned, leaving a live socket attached to a client that refuses to use it. status stays Connected because only the socket's on_close callback moves it to Disconnected, and that callback is the one thing that runs the onclose / reconnect policy.

Fix

Close the socket, and mark the close as deliberate:

self.flags.is_manually_closed = true;
self.close();

Deliberate, rather than letting it fall into auto-reconnect, because every failure that reaches fail() is the client's own and recurs on reconnect. handle_hello_response() resets retry_attempts = 0 on each successful HELLO, so a post-handshake failure can never reach maxRetries — it would re-dial forever. Measured, with the reconnecting variant of this patch and redis://host/99 (bad DB index): 22 handshakes in 1.5s, onclose never fired. With the patch as it stands: one handshake, one onclose, connected === false.

What does not change:

  • A socket the peer drops never goes through fail()on_close()'s reconnect branch handles it — so autoReconnect still covers the case it exists for. Verified against a scripted server that destroys the connection: 2 handshakes, client reconnects, identical to main.
  • connect() revives a failed client (it clears failed and is_manually_closed), which is what test/regression/issue/29925.test.ts already pins.
  • close() is idempotent (it early-returns on a detached or already-closed socket), so the callers that had closed the socket before reaching fail() (on_close itself, on_connect_error, fail_handshake, the TLS-context failure) are unaffected.

fail() callers, and what each does now

caller before after
handle_hello_response (bad auth, RESP2 server) close, terminal unchanged
authenticate (OOM writing HELLO/SELECT) close, terminal unchanged
on_close (3 branches), on_connect_error socket already gone, no-op unchanged
fail_handshake, TLS-context failure close, terminal unchanged
connection timeout (on_timer, pre-auth) close, terminal unchanged
on_data parse error (post-auth) stranded close, onclose, terminal
SELECT failure (error / non-OK / wrong type) stranded close, onclose, terminal
idle timeout (on_timer, post-auth) stranded close, onclose, terminal
unrecognised RESP3 push on a subscriber stranded close, onclose, terminal

Verification

test/js/valkey/reliability/connection-teardown.test.ts drives both branches of the policy against an in-process RESP3 server (no Redis needed). Both tests hang on main (the close never arrives) and pass with the fix. The autoReconnect: true test asserts onclose fires — which only happens on the terminal branch of on_close, never on the reconnecting one — and that exactly 2 handshakes are seen, pinning the absence of a re-dial storm.

test/js/valkey/ is green, and test/regression/issue/29925.test.ts (which spawns a real redis-server and exercises flags.failed through close()/connect()) still passes.

Relationship to #32858

This is the other half of the same report. #32858 fixes the trigger in the subscriber path — a pmessage/smessage/invalidate push made the client call fail() on itself — and rewrites the push-kind dispatch so those frames stop failing anything. It deliberately leaves fail() alone.

This PR fixes what fail() does once it is reached, from any of its callers, so no code path can strand a client in failed && connected. The two touch different functions and do not conflict.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2fe5c66f-57f0-49fa-98da-b7af88d90988

📥 Commits

Reviewing files that changed from the base of the PR and between 48ff9eb and b20c3d5.

📒 Files selected for processing (2)
  • src/runtime/valkey_jsc/valkey.rs
  • test/js/valkey/reliability/connection-teardown.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:17 PM PT - Jul 6th, 2026

@robobun, your commit b20c3d5 has some failures in Build #69222 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 33479

That installs a local version of the PR into your bun-33479 executable, so you can run:

bun-33479 --bun

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Found 4 issues this PR may fix:

  1. RedisClient.onClose does not get invoked #22812 - PR directly fixes the condition where onclose never fires after a post-authentication failure because the socket was never closed
  2. redis.connect behave very wrong when failed to connect (see description) #18895 - Reporter describes commands failing with "connection closed" while redis.onclose was never called and idleTimeout failures left the client in a broken state
  3. node-redis Pub/Sub silently fails to reconnect in rare cases #21622 - Reporter describes a client entering a "limbo" state after a network failure: no errors emitted, appears connected but non-functional, and .unsubscribe() hangs
  4. Connection to redis fails on prod 7.4 redis redislabs server (local redis works fine) #23467 - Reporter gets ERR_REDIS_CONNECTION_CLOSED connecting to a RedisLabs production server; if the handshake completes but a subsequent response triggers fail(), the socket stays open in the broken half-alive state

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #22812
Fixes #18895
Fixes #21622
Fixes #23467

🤖 Generated with Claude Code

Comment thread src/runtime/valkey_jsc/valkey.rs Outdated
Comment thread src/runtime/valkey_jsc/valkey.rs
@robobun
robobun force-pushed the farm/8d9df4c3/redis-fail-closes-socket branch from 83cb50d to b5c4ae4 Compare July 6, 2026 12:22
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Checked the four issues the find-issues bot suggested. None of them are fixed by this PR — please don't paste that Fixes #N block into the description.

The bot seems to have matched on the words "onclose never fires" and "connection closed" rather than on the code path.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks — making is_manually_closed unconditional resolves both issues I raised (the SELECT reconnect loop and the unarmed timeout). Leaving the final call to a human since this makes every fail() terminal even with autoReconnect: true (the PR description's "the client reconnects and is usable again" bullet is now stale), which is a reasonable design but a user-visible semantics change worth a maintainer nod.

Extended reasoning...

Overview

The PR changes ValkeyClient::fail_with_js_value() in src/runtime/valkey_jsc/valkey.rs to unconditionally set is_manually_closed = true and call close(), replacing the previous if !connection_ready() guard that only closed during the handshake. A new test file test/js/valkey/reliability/connection-teardown.test.ts spins up an in-process RESP3 mock server and asserts that a post-auth protocol error (a) closes the socket and fires onclose with autoReconnect: false, and (b) is terminal (no re-dial) with autoReconnect: true, with connect() able to revive the client.

Change since my last review

My earlier 🔴 comment was against a prior revision where is_manually_closed stayed guarded by !connection_ready() and only close() was hoisted out — that version would have reconnect-looped forever on a bad DB index. The current revision sets is_manually_closed = true unconditionally, which resolves that (no reconnect ever runs from fail()), and also moots the 🟡 nit about the unarmed connection-timeout on the reconnect path. The commit message explicitly cites the retry_attempts reset in handle_hello_response() as motivation, matching my earlier analysis.

Security risks

None. No new input parsing, no auth/crypto surface, no untrusted data handling changed. The mock RESP server in the test is bound to 127.0.0.1 on port 0.

Level of scrutiny

Medium. The Rust change is 9 lines in one function, but it sits at the heart of the Redis client's connection-lifecycle state machine and encodes a design decision: any failure that reaches fail() (parser error, idle timeout, bad SELECT, unrecognised push) is now terminal regardless of autoReconnect, and only peer-initiated socket closes still auto-reconnect. That's defensible and well-argued in the code comment and commit message, and it's a strict improvement over main (stranded → terminal + revivable), but it is a user-visible semantics change to autoReconnect that a maintainer should sign off on.

Other factors

  • The PR description still describes the earlier revision's behavior ("autoReconnect: true → the client reconnects and is usable again"), which now contradicts both the code and the second test ("a protocol error is terminal even with autoReconnect enabled"). Worth updating before merge to avoid confusing future readers.
  • Idle timeout going through fail() means an idled connection is now terminal rather than auto-reconnected — probably fine (it was stranded before anyway), but worth a maintainer confirming that's the intended semantics for users who set both idleTimeout and autoReconnect: true.
  • Tests are well-constructed: hermetic (in-process TCP server, no real Redis), await conditions rather than sleeping, and cover both branches of the on_close policy.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks. On the stale description: it was rewritten in b5c4ae4 and no longer claims the client reconnects, so the review just caught an older snapshot of the body. The current text spells out the terminal policy and tabulates what each fail() caller does before and after.

You're right that the semantics change is the thing worth a maintainer's nod, so to make that call cheap, here is the whole decision:

Option A (this PR). Every fail() is terminal: close, onclose, connected === false, and connect() revives. Only a peer-initiated close still auto-reconnects, because that path never calls fail().

Option B. Let post-handshake failures fall into auto-reconnect. This needs two more fixes before it is even safe:

  1. handle_hello_response() sets retry_attempts = 0 on every successful HELLO, so maxRetries can never cap a failure that happens after the handshake. Without fixing that, Option B is an unbounded re-dial loop. I measured it: the reconnecting variant of this patch, pointed at redis://host/9 against a server that rejects SELECT, did 22 handshakes in 1.5s with maxRetries: 3 and never fired onclose.
  2. Idle timeout cannot take that path at all. A reconnect re-arms the idle timer on a connection that is, by construction, idle, so it would close and re-dial every idleTimeout ms forever. Terminal is the only coherent answer there regardless of which option wins.

So Option B is strictly more machinery for a behaviour that is wrong for at least one of its callers. That's why I went with A.

On your idleTimeout + autoReconnect: true question specifically: today that combination strands the client (connected === true, every command rejecting, no onclose). After this PR the app gets an onclose and can re-dial on its own terms. What it still does not get is a lazy reconnect on the next command, because nothing in the client does that today for a Disconnected socket either. That's a separate gap, not one this PR opens or closes.

Happy to flip to B (plus the two fixes above) if a maintainer prefers it.

fail_with_js_value() only closed the socket when the handshake had not
completed. A failure on an established connection set flags.failed,
rejected every pending command, and then left the socket open: .connected
kept reporting true, onclose never fired, and every later command rejected
with ERR_REDIS_CONNECTION_CLOSED forever, with nothing to tell the app.

Close the socket, and mark the close as deliberate. The failures that
reach here are the client's own (a frame the parser cannot read, a
non-OK SELECT, an idle timeout, a rejected handshake), so they recur on
reconnect; worse, handle_hello_response() resets retry_attempts on every
successful HELLO, so a post-handshake failure would never reach maxRetries
and would re-dial forever. A socket the peer drops never goes through
fail(), so autoReconnect still covers it, and connect() revives a failed
client. close() is idempotent, so the callers that had already closed the
socket are unaffected.
@robobun
robobun force-pushed the farm/8d9df4c3/redis-fail-closes-socket branch from b5c4ae4 to b20c3d5 Compare July 6, 2026 18:53
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

CI status (build 69222, sha b20c3d5)

Diff is green. The rebase onto 48ff9eb cleared the earlier cookie-map.test.ts failures (those were a broken merge-base: the runtime emitted IMF-fixdate Expires while the test still asserted the old format, fixed on main by #33425). This build: 280 jobs passed, clippy / Format / Lint JavaScript all green, and every linux and windows test-bun shard passed — including the new connection-teardown.test.ts.

The only red is darwin infra, unrelated to this change:

  • darwin 26 aarch64 - test-bun failed before any test ran, on buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun' (could not fetch the built binary).
  • 4× darwin jobs stuck in scheduled (no agent picked them up).
  • 1× broken :eyes: manual step.

I'm not pushing a ci: retrigger: the failures are an artifact-download timeout plus darwin agent starvation, so a re-roll into the same pool would very likely time out identically. Happy to rebase again or re-trigger if a maintainer would like a fully green board once darwin capacity recovers.

The diff itself is ready for review; the one open call is the design question above (should fail() be terminal even with autoReconnect: true).

Jarred-Sumner pushed a commit that referenced this pull request Jul 18, 2026
## What

A RESP simple string (`+`) or error (`-`) reply longer than 512KB is
rejected with `ERR_REDIS_INVALID_RESPONSE "Failed to read data (buffer
path)"`, after which the client rejects every subsequent command with
`ERR_REDIS_CONNECTION_CLOSED` while still reporting `connected: true`.
1.3.14 resolved the same reply fine.

## Repro

```js
// against redis-server 8.4.0
await client.send("EVAL", ["return redis.status_reply(string.rep('x', 600000))", "0"]);
// 1.3.14: resolves, length 600000; subsequent ping: PONG
// 1.4:    rejects ERR_REDIS_INVALID_RESPONSE "Failed to read data (buffer path)"
//         subsequent ping: rejects ERR_REDIS_CONNECTION_CLOSED
// boundary: 524288 OK, 524289 fails
```

## Cause

`read_until_crlf` in `src/valkey/valkey_protocol.rs` was capped at
`MAX_LINE_LEN = 512 * 1024` by a later hardening pass; the 1.3.14 Zig
parser (`readUntilCRLF`) scanned the whole buffer. The cap backs every
line-terminated RESP type (`+ - : _ , # (`). The RESP spec places no
length limit on these, and a real Redis server emits arbitrary-length
`+` / `-` lines for Lua `redis.status_reply()` / `redis.error_reply()`.
Bulk strings (`$`) carry a length prefix and were never affected.

## Fix

Raise `MAX_LINE_LEN` to match `MAX_BULK_LEN` (512MB, the server-side
`proto-max-bulk-len` default), so line-terminated replies get the same
buffer-growth bound as length-prefixed blobs. The scan window and
`LineTooLong` check are kept so a server that streams an unterminated
line still cannot grow the read buffer without bound. The incremental
`ReplyScanner`'s `crlf_skip` bookkeeping keeps the scan linear across
socket reads.

The second half of the report (a protocol error latches `flags.failed`
without closing the socket, leaving a zombie client) is #33479; this PR
only removes the trigger that made valid replies hit that path.

## Verification

`test/js/valkey/reliability/resp-nesting-depth.test.ts` gains three
cases driven by an in-process mock server (no Docker): a 600KB `+` reply
resolves and the client serves a follow-up `PING`; the 524289-byte
boundary resolves; a 600KB `-` reply rejects with the server's original
error text and the client survives. All three fail on the released bun
with `ERR_REDIS_INVALID_RESPONSE "Failed to read data (buffer path)"`
and pass with the fix.

`test/js/valkey/valkey-gc.test.ts`'s "line terminator never arrives"
case is updated: 600KB unterminated followed by socket close is now
under the cap, so the pending command is rejected with
`ERR_REDIS_CONNECTION_CLOSED` rather than `ERR_REDIS_INVALID_RESPONSE`.

<!-- robobun:evidence:begin -->

---

**[review]** gate passed · iteration 1 · 4 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 4 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/valkey/reliability/resp-nesting-depth.test.ts test/js/valkey/valkey-gc.test.ts test/js/valkey/valkey-incremental-scan.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (0ea8158)

test/js/valkey/valkey-gc.test.ts:
(pass) RedisClient survives GC after a command throws during argument validation [692.15ms]
173 |       });
174 |       try {
175 |         await client.send("PING", []);
176 |         expect.unreachable();
177 |       } catch (error: any) {
178 |         expect(error.code).toBe("ERR_REDIS_CONNECTION_CLOSED");
                                 ^
error: expect(received).toBe(expected)

Expected: "ERR_REDIS_CONNECTION_CLOSED"
Received: "ERR_REDIS_INVALID_RESPONSE"

      at <anonymous> (/workspace/bun/test/js/valkey/valkey-gc.test.ts:178:28)
(fail) rejects a RESP simple-string reply whose lin
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (af1fbb4)

test/js/valkey/valkey-gc.test.ts:
(pass) RedisClient survives GC after a command throws during argument validation [29.77ms]
(pass) custom setter with a foreign receiver throws instead of corrupting the heap [19.23ms]
(pass) RedisClient survives GC across many short-lived instances [18.26ms]
(pass) rejects a RESP simple-string reply whose line terminator never arrives [27.35ms]
(pass) getBuffer replies survive GC with adopted backing stores intact [64.13ms]

test/js/valkey/valkey-incremental-scan.test.ts:
(pass) Valkey reply torn across socket reads > BulkString ($) torn at byte 5 decodes (baseline) [5.31ms]
(pass) Valkey reply torn across socket reads > BulkString ($) torn at byte 10 decodes (baseline) [4.22ms]
(pass) Valkey reply torn across socket reads > BulkString ($) torn at byte 19 decodes (baseline) [4.12ms]
(pass) Valkey reply torn across socket reads > BulkString ($) torn at byte 21 decodes (baseline) [4.06ms]
(pass) Valkey reply torn across socket reads > VerbatimString (=) torn at byte 5 decodes instead of failing the connection [4.01ms]
(pass) Valkey reply torn across socket reads > VerbatimString (=) torn at byte 1
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/valkey/reliability/resp-nesting-depth.test.ts test/js/valkey/valkey-gc.test.ts test/js/valkey/valkey-incremental-scan.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (0ea8158)

test/js/valkey/valkey-gc.test.ts:
(pass) custom setter with a foreign receiver throws instead of corrupting the heap [536.19ms]
(pass) rejects a RESP simple-string reply whose line terminator never arrives [530.92ms]
(pass) RedisClient survives GC after a command throws during argument validation [636.70ms]
(pass) RedisClient survives GC across many short-lived instances [799.75ms]
(pass) getBuffer replies survive GC with adopted backing stores intact [2322.86ms]

test/js/valkey/valkey-incremental-scan.test.ts:
(pass) Valkey reply torn across socket reads > BulkString ($) torn at byte 5 decodes (baseline) [104.02ms]
(pass)
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 13660ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/90] gen ErrorCode+*.h
[2/47] gen cpp.rs (cppbind)
[3/47] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (13 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /w
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/valkey/valkey_protocol.rs                      |  6 +-
 .../valkey/reliability/resp-nesting-depth.test.ts  | 70 ++++++++++++++++++++++
 test/js/valkey/valkey-gc.test.ts                   | 21 +++----
 test/js/valkey/valkey-incremental-scan.test.ts     |  5 +-
 4 files changed, 84 insertions(+), 18 deletions(-)
```

</details>

**gate history** · 1 passed · 1 rejected · iteration 1

<details><summary>evidence per changed file</summary>

```
file                                                   reads  edits  tests
src/valkey/valkey_protocol.rs                              2      3      0
test/js/valkey/reliability/resp-nesting-depth.test.ts      1      1      0
test/js/valkey/valkey-gc.test.ts                           3      3      0
test/js/valkey/valkey-incremental-scan.test.ts             1      1      0
```

</details>

<!-- robobun:evidence:end -->

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #37993, which makes the same fail() change together with the related onclose/status fixes; this one can be closed once that lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant