websocket-rs 0.7.2__tar.gz → 0.7.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/.github/workflows/test.yml +4 -1
  2. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/CHANGELOG.md +32 -0
  3. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/Cargo.lock +1 -1
  4. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/Cargo.toml +1 -1
  5. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/PKG-INFO +1 -1
  6. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/README.md +22 -7
  7. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/pyproject.toml +1 -1
  8. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/src/native_client.rs +90 -53
  9. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/src/sync_client.rs +241 -52
  10. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/tests/diagnose_server_pattern.py +1 -3
  11. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/tests/test_compatibility.py +70 -0
  12. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/tests/test_native_features.py +1 -1
  13. websocket_rs-0.7.4/tests/test_timeout_and_errors.py +477 -0
  14. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/websocket_rs/__init__.py +4 -1
  15. websocket_rs-0.7.2/tests/test_timeout_and_errors.py +0 -235
  16. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/.github/pull_request_template.md +0 -0
  17. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/.github/workflows/pr.yml +0 -0
  18. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/.github/workflows/release.yml +0 -0
  19. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/.gitignore +0 -0
  20. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/LICENSE +0 -0
  21. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/Makefile +0 -0
  22. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/README.pypi.md +0 -0
  23. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/README.zh-TW.md +0 -0
  24. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/docs/API.md +0 -0
  25. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/docs/BENCHMARKS.md +0 -0
  26. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/docs/BENCHMARKS.zh-TW.md +0 -0
  27. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/docs/CONTRIBUTING.md +0 -0
  28. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/docs/MIGRATION.md +0 -0
  29. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/docs/OPTIMIZATION_RESEARCH.md +0 -0
  30. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/docs/PUBLISHING.md +0 -0
  31. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/monitor_and_merge.sh +0 -0
  32. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/pytest.ini +0 -0
  33. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/scripts/build-pgo.sh +0 -0
  34. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/scripts/pre-check.sh +0 -0
  35. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/scripts/prepare-release.sh +0 -0
  36. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/scripts/tag-release.sh +0 -0
  37. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/scripts/test.sh +0 -0
  38. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/src/async_client.rs +0 -0
  39. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/src/bin/ws_echo_fastws.rs +0 -0
  40. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/src/bin/ws_echo_server.rs +0 -0
  41. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/src/bin/ws_echo_server_tls.rs +0 -0
  42. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/src/lib.rs +0 -0
  43. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/tests/bench_socks5_handshake.py +0 -0
  44. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/tests/benchmark_picows_parity.py +0 -0
  45. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/tests/benchmark_three_servers.py +0 -0
  46. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/tests/benchmark_tls_parity.py +0 -0
  47. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/tests/perf_pipelined_64k.py +0 -0
  48. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/uv.lock +0 -0
  49. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/websocket_rs/__init__.pyi +0 -0
  50. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/websocket_rs/async_client.pyi +0 -0
  51. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/websocket_rs/native_client.pyi +0 -0
  52. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/websocket_rs/py.typed +0 -0
  53. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/websocket_rs/sync/__init__.pyi +0 -0
  54. {websocket_rs-0.7.2 → websocket_rs-0.7.4}/websocket_rs/sync/client.pyi +0 -0
@@ -55,7 +55,10 @@ jobs:
55
55
  uv venv --python ${{ matrix.python-version }}
56
56
 
57
57
  - name: Install dependencies with uv
58
- run: uv pip install maturin pytest websockets
58
+ # uvloop has no Windows wheels; without it the uvloop half of the
59
+ # peer-close matrix silently drops, and uvloop+wss is a combination
60
+ # that actually regressed once.
61
+ run: uv pip install maturin pytest websockets "uvloop; sys_platform != 'win32'"
59
62
 
60
63
  - name: Build
61
64
  run: uv run maturin develop --release
@@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.7.4] - 2026-07-29
9
+
10
+ ### Fixed
11
+
12
+ - **`__version__` reported 0.7.1 (#TBD)**: the Python package hard-coded a version literal that was never bumped, so `websocket_rs.__version__` read `0.7.1` through the 0.7.2 and 0.7.3 releases while the installed distribution said otherwise. It now derives from the compiled module's `CARGO_PKG_VERSION`, leaving `Cargo.toml` as the single place a version is written by hand (`pyproject.toml` still mirrors it for the build backend).
13
+
14
+ - **`eof_received` on the native protocol (#TBD)**: `NativeClient` implements asyncio's protocol callbacks by hand — it is a pyclass and inherits nothing from `asyncio.Protocol`, so the base class's default was never available — and `eof_received` was missing. When the peer half-closes, the transport calls it unguarded and the resulting `AttributeError` was raised inside asyncio's own callback: CPython routes it into `_fatal_error` ("Fatal error: protocol.eof_received() call failed."), so it surfaced as a connection error rather than a warning.
15
+
16
+ Affects every combination except plain TCP under uvloop, whose `_on_eof` is the only call site that guards with `try/except AttributeError`. Both TLS stacks (CPython `sslproto._call_eof_received`, uvloop `sslproto.pyx` `_call_eof_received`) call it directly, so `wss://` was affected under both event loops. Client-closes-first tests never reach this path, which is why it went unnoticed.
17
+
18
+ `NativeClientBuffered` inherits the fix. The regression test lives in `tests/test_timeout_and_errors.py` (one of the two files CI actually runs) and covers the loop × transport matrix; CI now installs uvloop everywhere it has wheels, so `uvloop + wss` is exercised there too. After the peer closes, a second `recv()` must fail immediately rather than hang to the timeout, which is what proves `connection_lost` still fails pending receives. The TLS cases mint a self-signed cert into a pytest temp dir.
19
+
20
+ ## [0.7.3] - 2026-07-24
21
+
22
+ ### Performance
23
+
24
+ - **Sync receive without the intermediate copy (#29)**: tungstenite's owned `Bytes` payload now moves through the synchronous receive path instead of being copied into an intermediate `Vec<u8>`. Measured medians on an idle host: plain 1 MiB +9.1%, TLS 1 MiB +27.9%.
25
+
26
+ - **Sync send borrows exact `bytes` (#30)**: exact CPython `bytes` payloads are sent through an owner-backed `Bytes` with no extraction copy; `bytearray`, `memoryview`, other buffer-protocol objects, and `bytes` subclasses keep the existing copy path. Pooled 7-round median: plain 1 MiB +7.2%.
27
+
28
+ - **Interned Future-delivery identifiers (#31)**: `done` / `set_result` lookups in the hot async receive path use interned strings. Merged under the documented micro-optimization exception — the +2% performance gate was **not** met (~+1% on TLS 256 B, 7/7 positive rounds); this entry must not be cited as a perf-gate pass.
29
+
30
+ ### Fixed
31
+
32
+ - **Interrupted socket I/O in the sync client (#34)**: blocking read/write/flush now follow PEP 475 — on `EINTR` the client checks pending Python signals first (so Ctrl-C still raises `KeyboardInterrupt`) and then retries, and an overall receive deadline is enforced across retries so `receive_timeout` is never extended by signal storms. Previously any signal delivered during a blocking `recv()` surfaced `RuntimeError: Interrupted system call`.
33
+
34
+ ### Internal
35
+
36
+ - **Owner invariants compiler-enforced (#32)**: the sync-send `PyBytes` owner moved into a private module with a single constructor, making its safety invariants unconstructible-wrong; mutate-after-send tripwire tests added for mutable input types.
37
+
38
+ - **Peer-close unified behind one seam (#33)**: close parsing and effects flow through `parse_close_payload` + `begin_peer_close` / `apply_peer_close` on every path (slow-path event sink, all three fast-path adapters, `connection_lost`, client `close()`). The remaining future-failure and transport calls made under an active `RefCell` borrow were eliminated — the same reentrancy discipline the 0.7.2 event sink introduced — and `handle_close_frame` / `fail_all_pending` were deleted outright.
39
+
8
40
  ## [0.7.2] - 2026-07-24
9
41
 
10
42
  ### Fixed
@@ -1519,7 +1519,7 @@ dependencies = [
1519
1519
 
1520
1520
  [[package]]
1521
1521
  name = "websocket_rs"
1522
- version = "0.7.2"
1522
+ version = "0.7.4"
1523
1523
  dependencies = [
1524
1524
  "base64 0.22.1",
1525
1525
  "bytes",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "websocket_rs"
3
- version = "0.7.2"
3
+ version = "0.7.4"
4
4
  edition = "2021"
5
5
  authors = ["coseto6125 <enorenor@gmail.com>"]
6
6
  description = "High-performance WebSocket client library for Python using Rust"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: websocket-rs
3
- Version: 0.7.2
3
+ Version: 0.7.4
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: Programming Language :: Python :: Implementation :: PyPy
@@ -15,7 +15,7 @@ Fastest Python WebSocket client on plain TCP — wins **12/12** benchmark cells
15
15
  | Plain Python script / CLI tool (no asyncio) | **ws-rs sync** | picows is async-only; 1.3–7× over websocket-client (size-dependent) |
16
16
  | asyncio app, sequential RR ≤ 8 KB | **ws-rs sync** via `to_thread` | +13–28% over picows; sync bypasses asyncio overhead |
17
17
  | asyncio app, pipelined / streaming | **ws-rs async** | mean RPS +14–21% over picows on best path |
18
- | asyncio app, ≥ 1 MB payloads | **ws-rs async** **picows** | tied within 5%; server-side ceiling |
18
+ | asyncio app, ≥ 1 MB payloads | **ws-rs async** | +15–20% over picows (RR, two servers, 7-round median; v0.7.3 zero-copy recv) |
19
19
  | RR latency-sensitive, payload ≥ 16 KB | **picows** | 3–5 µs lower per-message RTT (Cython cdef vs PyO3) |
20
20
  | Cross-network (real server, real RTT) | **Either** | network RT dominates; library choice < 1% of total latency |
21
21
 
@@ -34,10 +34,26 @@ Neutral Rust echo server (tokio-tungstenite), pinned cores, uvloop, 1 s discarde
34
34
  | 256 B | **15.0k** | 12.8k | 13.5k | 12.0k | 9.9k | 11.7k |
35
35
  | 8 KB | **15.1k** | 13.4k | 13.2k | 11.8k | 9.6k | 10.1k |
36
36
  | 100 KB | **10.8k** | 10.3k | 10.5k | 9.4k | 8.1k | 4.5k |
37
- | 1 MB | 4.0k | **4.2k** | **4.2k** | 3.4k | 3.0k | 509 |
37
+ | 1 MB | 4.0k | **4.2k** | 3.4k | 3.4k | 3.0k | 509 |
38
+
39
+ > The legacy multi-client table above predates picows 2.1.1 and the v0.7.3 zero-copy path; the async-vs-picows section below supersedes its `ws-rs async` / `picows` columns. Its 1 MB `picows` cell is corrected here (was tied at 4.2k, now 3.4k on picows 2.1.1); the aiohttp / websockets / websocket-client columns are the older single-round figures pending a decision-grade re-measure.
38
40
 
39
41
  ws-rs wins or ties **12/12 cells** across three server architectures (tokio-tungstenite, fastwebsockets, picows-server).
40
42
 
43
+ #### Apples-to-apples: async vs picows (v0.7.3, decision-grade)
44
+
45
+ Both are `asyncio.Protocol` peers, so this is the fair head-to-head. Measured against **picows 2.1.1** (latest) on an AMD Ryzen 9 9950X / WSL2, single-connection plain-TCP RR loopback, one discarded warm-up round + **7 interleaved scored rounds**, pooled median. Cross-checked on two independent servers so the result isn't tied to one server implementation:
46
+
47
+ | Payload | tokio-tungstenite server | picows-exact-echo server | verdict |
48
+ |---------|---:|---:|---|
49
+ | 256 B | 100.4% | ~parity | tie |
50
+ | 8 KB | 102.5% | 102.4% | +2.4% |
51
+ | 100 KB | 102.6% | 100.7% | ~parity–small win |
52
+ | 1 MB | **120.0%** | **115.5%** | **+15–20%** |
53
+ | **geomean** | **+6.1%** | **+5.2%** | ws-rs ahead |
54
+
55
+ The 1 MB win is the direct payoff of the v0.7.3 zero-copy receive path. Scope: these numbers are specific to this machine and a request-response workload — they do **not** cover TLS, pipelining, multiple concurrent connections, or streaming throughput.
56
+
41
57
  ### Request-Response Throughput — TLS / wss:// (rustls)
42
58
 
43
59
  | Payload | **ws-rs sync** | ws-rs async | picows | aiohttp | websockets | websocket-client |
@@ -55,12 +71,11 @@ Sync wins TLS 256 B–100 KB by 30–60%. At 1 MB picows leads by ~7%; ws-rs tie
55
71
 
56
72
  📊 **[Full benchmarks — all 3 servers, TCP & TLS, latency distributions, compression](docs/BENCHMARKS.md)** | 📝 **[Optimization Research](docs/OPTIMIZATION_RESEARCH.md)**
57
73
 
58
- ## ✨ What's New in v0.7.2
74
+ ## ✨ What's New in v0.7.3
59
75
 
60
- - **Timeout correctness**: async `connect()` now honors its `connect_timeout` / `receive_timeout` kwargs (previously dropped); native `connect_timeout=None` applies the 10 s default across all clients instead of hanging indefinitely. `receive_timeout=None` stays unlimited by design.
61
- - **Stability**: receiving a Ping on the buffered slow path under transport backpressure no longer panics protocol decisions and transport effects are fully decoupled.
62
- - **SOCKS5**: proxy handshake replies split across TCP segments no longer fail the connection.
63
- - **Internals**: the buffered state machine is now a pure sans-I/O `ProtocolCore` with an allocation-free event sink, and the three fast-path frame walks share one monomorphized scanner — receive-path performance held or improved (several parity cells +3–11%).
76
+ - **Faster sync client**: both directions of the synchronous client drop their per-message payload copy large-message throughput up 7–28% (plain 1 MiB recv +9.1%, TLS 1 MiB recv +27.9%, plain 1 MiB send +7.2%; medians on an idle host).
77
+ - **Signal-safe sync I/O**: blocking read/write now follow PEP 475 signals during a blocking `recv()` no longer surface `Interrupted system call` errors, Ctrl-C still raises `KeyboardInterrupt`, and `receive_timeout` deadlines hold across retries.
78
+ - **Internals**: peer-close handling unified behind one seam with all transport effects outside internal borrows; the sync-send buffer owner's safety invariants are now compiler-enforced.
64
79
 
65
80
  Full details in [CHANGELOG.md](CHANGELOG.md).
66
81
 
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "websocket-rs"
7
- version = "0.7.2"
7
+ version = "0.7.4"
8
8
  description = "High-performance WebSocket client library for Python using Rust"
9
9
  readme = "README.pypi.md"
10
10
  authors = [
@@ -159,6 +159,13 @@ fn parse_header(buf: &[u8]) -> Option<(bool, bool, u8, usize, usize)> {
159
159
  Some((fin, rsv1, opcode, plen, hdr))
160
160
  }
161
161
 
162
+ /// Close-frame payload → (code, reason). Empty/short payloads carry neither.
163
+ fn parse_close_payload(payload: &[u8]) -> (Option<u16>, Option<String>) {
164
+ let code = (payload.len() >= 2).then(|| u16::from_be_bytes([payload[0], payload[1]]));
165
+ let reason = (payload.len() > 2).then(|| String::from_utf8_lossy(&payload[2..]).into_owned());
166
+ (code, reason)
167
+ }
168
+
162
169
  struct FastFrame<'a> {
163
170
  opcode: u8,
164
171
  payload: &'a [u8],
@@ -350,10 +357,7 @@ impl ProtocolCore<'_> {
350
357
  OP_CLOSE => {
351
358
  self.buf.advance(hdr);
352
359
  let payload = self.buf.split_to(plen);
353
- let code =
354
- (payload.len() >= 2).then(|| u16::from_be_bytes([payload[0], payload[1]]));
355
- let reason = (payload.len() > 2)
356
- .then(|| String::from_utf8_lossy(&payload[2..]).into_owned());
360
+ let (code, reason) = parse_close_payload(&payload);
357
361
  return Ok(Some(ProtocolEvent::Close { code, reason }));
358
362
  }
359
363
  OP_PING => {
@@ -1025,11 +1029,28 @@ impl NativeClient {
1025
1029
  Ok(())
1026
1030
  }
1027
1031
 
1032
+ /// The peer half-closed (TCP FIN, or TLS close_notify). No further frame can
1033
+ /// arrive, so let the transport close itself and let `connection_lost` do the
1034
+ /// cleanup: returning true would mean "the protocol keeps this open", which is
1035
+ /// wrong for a WebSocket client and is ignored under TLS anyway.
1036
+ ///
1037
+ /// asyncio.Protocol supplies a default, but this class is a pyclass and is not
1038
+ /// a subclass of it, so the method has to exist here. Both call sites are
1039
+ /// unconditional and unguarded (`sslproto._call_eof_received`,
1040
+ /// `_SelectorSocketTransport._read_ready__data_received`), and the TLS one
1041
+ /// routes a missing attribute into `_fatal_error`.
1042
+ fn eof_received(&self) -> bool {
1043
+ false
1044
+ }
1045
+
1028
1046
  fn connection_lost(&self, py: Python<'_>, _exc: Py<PyAny>) {
1029
- let mut state = self.state.borrow_mut();
1030
- state.closed = true;
1031
- Self::fail_all_pending(py, &mut state, "Connection lost");
1032
- state.transport = None;
1047
+ let pending = {
1048
+ let mut state = self.state.borrow_mut();
1049
+ state.closed = true;
1050
+ state.transport = None;
1051
+ std::mem::take(&mut state.pending_recv)
1052
+ };
1053
+ Self::fail_pending(py, pending, "Connection lost");
1033
1054
  }
1034
1055
 
1035
1056
  // ---- User-facing API ----
@@ -1347,8 +1368,9 @@ impl NativeClient {
1347
1368
  state.create_future = None;
1348
1369
  state.wait_for = None;
1349
1370
  let write_queue = std::mem::take(&mut state.write_queue);
1350
- Self::fail_all_pending(py, &mut state, "Connection closed by client");
1371
+ let pending = std::mem::take(&mut state.pending_recv);
1351
1372
  drop(state);
1373
+ Self::fail_pending(py, pending, "Connection closed by client");
1352
1374
  // All mutex-guarded references are gone; drop pending writes and then
1353
1375
  // issue the close frame + transport.close() on the surviving transport ref.
1354
1376
  drop(write_queue);
@@ -1388,6 +1410,7 @@ impl NativeClient {
1388
1410
  self.data_received_inner(py, data)?;
1389
1411
  return Ok((data.len(), None));
1390
1412
  }
1413
+ let mut close_effects = None;
1391
1414
  let outcome = walk_frames(data, |frame| -> PyResult<VisitOutcome> {
1392
1415
  match frame.opcode {
1393
1416
  OP_TEXT | OP_BINARY => {
@@ -1397,14 +1420,18 @@ impl NativeClient {
1397
1420
  Self::deliver_message(py, &mut state, msg)?;
1398
1421
  }
1399
1422
  OP_CLOSE => {
1423
+ let (code, reason) = parse_close_payload(frame.payload);
1400
1424
  let mut state = self.state.borrow_mut();
1401
- Self::handle_close_frame(py, &mut state, frame.payload);
1425
+ close_effects = Some(Self::begin_peer_close(py, &mut state, code, reason));
1402
1426
  return Ok(VisitOutcome::Stop);
1403
1427
  }
1404
1428
  _ => {}
1405
1429
  }
1406
1430
  Ok(VisitOutcome::Continue)
1407
1431
  })?;
1432
+ if let Some((pending, transport)) = close_effects {
1433
+ Self::apply_peer_close(py, pending, transport);
1434
+ }
1408
1435
  match outcome {
1409
1436
  ScanOutcome::Exhausted { consumed } | ScanOutcome::Stopped { consumed } => {
1410
1437
  Ok((consumed, None))
@@ -1429,6 +1456,7 @@ impl NativeClient {
1429
1456
  };
1430
1457
  if can_fast_path {
1431
1458
  let mut state = self.state.borrow_mut();
1459
+ let mut close_effects = None;
1432
1460
  let outcome = walk_frames(data, |frame| -> PyResult<VisitOutcome> {
1433
1461
  match frame.opcode {
1434
1462
  OP_TEXT | OP_BINARY => {
@@ -1447,7 +1475,8 @@ impl NativeClient {
1447
1475
  Self::deliver_message(py, &mut state, msg)?;
1448
1476
  }
1449
1477
  OP_CLOSE => {
1450
- Self::handle_close_frame(py, &mut state, frame.payload);
1478
+ let (code, reason) = parse_close_payload(frame.payload);
1479
+ close_effects = Some(Self::begin_peer_close(py, &mut state, code, reason));
1451
1480
  return Ok(VisitOutcome::Stop);
1452
1481
  }
1453
1482
  _ => {}
@@ -1455,7 +1484,13 @@ impl NativeClient {
1455
1484
  Ok(VisitOutcome::Continue)
1456
1485
  })?;
1457
1486
  let consumed = match outcome {
1458
- ScanOutcome::Stopped { .. } => return Ok(()),
1487
+ ScanOutcome::Stopped { .. } => {
1488
+ drop(state);
1489
+ if let Some((pending, transport)) = close_effects {
1490
+ Self::apply_peer_close(py, pending, transport);
1491
+ }
1492
+ return Ok(());
1493
+ }
1459
1494
  ScanOutcome::Exhausted { consumed } if consumed == data.len() => return Ok(()),
1460
1495
  ScanOutcome::Exhausted { consumed }
1461
1496
  | ScanOutcome::Partial { consumed, .. }
@@ -1483,6 +1518,7 @@ impl NativeClient {
1483
1518
  };
1484
1519
  if can_fast_path {
1485
1520
  let mut state = self.state.borrow_mut();
1521
+ let mut close_effects = None;
1486
1522
  let outcome = walk_frames(data, |frame| -> PyResult<VisitOutcome> {
1487
1523
  match frame.opcode {
1488
1524
  OP_TEXT | OP_BINARY => {
@@ -1491,7 +1527,8 @@ impl NativeClient {
1491
1527
  Self::deliver_message(py, &mut state, msg)?;
1492
1528
  }
1493
1529
  OP_CLOSE => {
1494
- Self::handle_close_frame(py, &mut state, frame.payload);
1530
+ let (code, reason) = parse_close_payload(frame.payload);
1531
+ close_effects = Some(Self::begin_peer_close(py, &mut state, code, reason));
1495
1532
  return Ok(VisitOutcome::Stop);
1496
1533
  }
1497
1534
  _ => {}
@@ -1499,7 +1536,13 @@ impl NativeClient {
1499
1536
  Ok(VisitOutcome::Continue)
1500
1537
  })?;
1501
1538
  let consumed = match outcome {
1502
- ScanOutcome::Stopped { .. } => return Ok(()),
1539
+ ScanOutcome::Stopped { .. } => {
1540
+ drop(state);
1541
+ if let Some((pending, transport)) = close_effects {
1542
+ Self::apply_peer_close(py, pending, transport);
1543
+ }
1544
+ return Ok(());
1545
+ }
1503
1546
  ScanOutcome::Exhausted { consumed } if consumed == data.len() => return Ok(()),
1504
1547
  ScanOutcome::Exhausted { consumed }
1505
1548
  | ScanOutcome::Partial { consumed, .. }
@@ -1736,19 +1779,9 @@ impl NativeClient {
1736
1779
  ProtocolEvent::Close { code, reason } => {
1737
1780
  let (pending, transport) = {
1738
1781
  let mut state = self.state.borrow_mut();
1739
- if code.is_some() {
1740
- state.close_code = code;
1741
- state.close_reason = reason;
1742
- }
1743
- state.closed = true;
1744
- let pending = std::mem::take(&mut state.pending_recv);
1745
- let transport = state.transport.as_ref().map(|t| t.clone_ref(py));
1746
- (pending, transport)
1782
+ Self::begin_peer_close(py, &mut state, code, reason)
1747
1783
  };
1748
- Self::fail_pending(py, pending, "Connection closed by peer");
1749
- if let Some(transport) = transport {
1750
- let _ = transport.bind(py).call_method0("close");
1751
- }
1784
+ Self::apply_peer_close(py, pending, transport);
1752
1785
  Ok(EventFlow::Stop)
1753
1786
  }
1754
1787
  ProtocolEvent::ProtocolError(reason) => {
@@ -1799,8 +1832,12 @@ impl NativeClient {
1799
1832
  }
1800
1833
  if let Some(fut) = state.pending_recv.pop_front() {
1801
1834
  let fb = fut.bind(py);
1802
- if !fb.call_method0("done")?.extract::<bool>().unwrap_or(false) {
1803
- fb.call_method1("set_result", (msg,))?;
1835
+ if !fb
1836
+ .call_method0(pyo3::intern!(py, "done"))?
1837
+ .extract::<bool>()
1838
+ .unwrap_or(false)
1839
+ {
1840
+ fb.call_method1(pyo3::intern!(py, "set_result"), (msg,))?;
1804
1841
  }
1805
1842
  } else {
1806
1843
  state.backlog.push_back(msg);
@@ -1808,33 +1845,33 @@ impl NativeClient {
1808
1845
  Ok(())
1809
1846
  }
1810
1847
 
1811
- fn handle_close_frame(py: Python<'_>, state: &mut State, payload: &[u8]) {
1812
- if payload.len() >= 2 {
1813
- state.close_code = Some(u16::from_be_bytes([payload[0], payload[1]]));
1814
- state.close_reason =
1815
- (payload.len() > 2).then(|| String::from_utf8_lossy(&payload[2..]).into_owned());
1848
+ /// Record a peer-initiated close in `state` and hand back the effects the
1849
+ /// caller must apply AFTER releasing the State borrow — the same
1850
+ /// reentrancy discipline as the slow-path event sink.
1851
+ fn begin_peer_close(
1852
+ py: Python<'_>,
1853
+ state: &mut State,
1854
+ code: Option<u16>,
1855
+ reason: Option<String>,
1856
+ ) -> (VecDeque<Py<PyAny>>, Option<Py<PyAny>>) {
1857
+ if code.is_some() {
1858
+ state.close_code = code;
1859
+ state.close_reason = reason;
1816
1860
  }
1817
1861
  state.closed = true;
1818
- Self::fail_all_pending(py, state, "Connection closed by peer");
1819
- if let Some(t) = state.transport.as_ref() {
1820
- let tb = t.bind(py);
1821
- let _ = tb.call_method0("close");
1822
- }
1823
- }
1824
-
1825
- fn fail_all_pending(py: Python<'_>, state: &mut State, msg: &str) {
1826
- while let Some(fut) = state.pending_recv.pop_front() {
1827
- let fb = fut.bind(py);
1828
- if !fb
1829
- .call_method0("done")
1830
- .and_then(|d| d.extract::<bool>())
1831
- .unwrap_or(true)
1832
- {
1833
- let _ = fb.call_method1(
1834
- "set_exception",
1835
- (PyConnectionError::new_err(msg.to_string()),),
1836
- );
1837
- }
1862
+ let pending = std::mem::take(&mut state.pending_recv);
1863
+ let transport = state.transport.as_ref().map(|t| t.clone_ref(py));
1864
+ (pending, transport)
1865
+ }
1866
+
1867
+ fn apply_peer_close(
1868
+ py: Python<'_>,
1869
+ pending: VecDeque<Py<PyAny>>,
1870
+ transport: Option<Py<PyAny>>,
1871
+ ) {
1872
+ Self::fail_pending(py, pending, "Connection closed by peer");
1873
+ if let Some(transport) = transport {
1874
+ let _ = transport.bind(py).call_method0("close");
1838
1875
  }
1839
1876
  }
1840
1877
  }