websocket-rs 0.7.2__tar.gz → 0.7.3__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 (53) hide show
  1. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/CHANGELOG.md +20 -0
  2. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/Cargo.lock +1 -1
  3. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/Cargo.toml +1 -1
  4. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/PKG-INFO +1 -1
  5. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/README.md +4 -5
  6. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/pyproject.toml +1 -1
  7. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/src/native_client.rs +76 -53
  8. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/src/sync_client.rs +241 -52
  9. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/tests/test_compatibility.py +70 -0
  10. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/tests/test_timeout_and_errors.py +97 -23
  11. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/.github/pull_request_template.md +0 -0
  12. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/.github/workflows/pr.yml +0 -0
  13. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/.github/workflows/release.yml +0 -0
  14. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/.github/workflows/test.yml +0 -0
  15. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/.gitignore +0 -0
  16. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/LICENSE +0 -0
  17. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/Makefile +0 -0
  18. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/README.pypi.md +0 -0
  19. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/README.zh-TW.md +0 -0
  20. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/docs/API.md +0 -0
  21. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/docs/BENCHMARKS.md +0 -0
  22. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/docs/BENCHMARKS.zh-TW.md +0 -0
  23. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/docs/CONTRIBUTING.md +0 -0
  24. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/docs/MIGRATION.md +0 -0
  25. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/docs/OPTIMIZATION_RESEARCH.md +0 -0
  26. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/docs/PUBLISHING.md +0 -0
  27. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/monitor_and_merge.sh +0 -0
  28. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/pytest.ini +0 -0
  29. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/scripts/build-pgo.sh +0 -0
  30. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/scripts/pre-check.sh +0 -0
  31. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/scripts/prepare-release.sh +0 -0
  32. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/scripts/tag-release.sh +0 -0
  33. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/scripts/test.sh +0 -0
  34. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/src/async_client.rs +0 -0
  35. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/src/bin/ws_echo_fastws.rs +0 -0
  36. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/src/bin/ws_echo_server.rs +0 -0
  37. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/src/bin/ws_echo_server_tls.rs +0 -0
  38. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/src/lib.rs +0 -0
  39. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/tests/bench_socks5_handshake.py +0 -0
  40. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/tests/benchmark_picows_parity.py +0 -0
  41. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/tests/benchmark_three_servers.py +0 -0
  42. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/tests/benchmark_tls_parity.py +0 -0
  43. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/tests/diagnose_server_pattern.py +0 -0
  44. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/tests/perf_pipelined_64k.py +0 -0
  45. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/tests/test_native_features.py +0 -0
  46. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/uv.lock +0 -0
  47. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/websocket_rs/__init__.py +0 -0
  48. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/websocket_rs/__init__.pyi +0 -0
  49. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/websocket_rs/async_client.pyi +0 -0
  50. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/websocket_rs/native_client.pyi +0 -0
  51. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/websocket_rs/py.typed +0 -0
  52. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/websocket_rs/sync/__init__.pyi +0 -0
  53. {websocket_rs-0.7.2 → websocket_rs-0.7.3}/websocket_rs/sync/client.pyi +0 -0
@@ -5,6 +5,26 @@ 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.3] - 2026-07-24
9
+
10
+ ### Performance
11
+
12
+ - **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%.
13
+
14
+ - **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%.
15
+
16
+ - **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.
17
+
18
+ ### Fixed
19
+
20
+ - **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`.
21
+
22
+ ### Internal
23
+
24
+ - **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.
25
+
26
+ - **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.
27
+
8
28
  ## [0.7.2] - 2026-07-24
9
29
 
10
30
  ### 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.3"
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.3"
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.3
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: Programming Language :: Python :: Implementation :: PyPy
@@ -55,12 +55,11 @@ Sync wins TLS 256 B–100 KB by 30–60%. At 1 MB picows leads by ~7%; ws-rs tie
55
55
 
56
56
  📊 **[Full benchmarks — all 3 servers, TCP & TLS, latency distributions, compression](docs/BENCHMARKS.md)** | 📝 **[Optimization Research](docs/OPTIMIZATION_RESEARCH.md)**
57
57
 
58
- ## ✨ What's New in v0.7.2
58
+ ## ✨ What's New in v0.7.3
59
59
 
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%).
60
+ - **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).
61
+ - **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.
62
+ - **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
63
 
65
64
  Full details in [CHANGELOG.md](CHANGELOG.md).
66
65
 
@@ -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.3"
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 => {
@@ -1026,10 +1030,13 @@ impl NativeClient {
1026
1030
  }
1027
1031
 
1028
1032
  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;
1033
+ let pending = {
1034
+ let mut state = self.state.borrow_mut();
1035
+ state.closed = true;
1036
+ state.transport = None;
1037
+ std::mem::take(&mut state.pending_recv)
1038
+ };
1039
+ Self::fail_pending(py, pending, "Connection lost");
1033
1040
  }
1034
1041
 
1035
1042
  // ---- User-facing API ----
@@ -1347,8 +1354,9 @@ impl NativeClient {
1347
1354
  state.create_future = None;
1348
1355
  state.wait_for = None;
1349
1356
  let write_queue = std::mem::take(&mut state.write_queue);
1350
- Self::fail_all_pending(py, &mut state, "Connection closed by client");
1357
+ let pending = std::mem::take(&mut state.pending_recv);
1351
1358
  drop(state);
1359
+ Self::fail_pending(py, pending, "Connection closed by client");
1352
1360
  // All mutex-guarded references are gone; drop pending writes and then
1353
1361
  // issue the close frame + transport.close() on the surviving transport ref.
1354
1362
  drop(write_queue);
@@ -1388,6 +1396,7 @@ impl NativeClient {
1388
1396
  self.data_received_inner(py, data)?;
1389
1397
  return Ok((data.len(), None));
1390
1398
  }
1399
+ let mut close_effects = None;
1391
1400
  let outcome = walk_frames(data, |frame| -> PyResult<VisitOutcome> {
1392
1401
  match frame.opcode {
1393
1402
  OP_TEXT | OP_BINARY => {
@@ -1397,14 +1406,18 @@ impl NativeClient {
1397
1406
  Self::deliver_message(py, &mut state, msg)?;
1398
1407
  }
1399
1408
  OP_CLOSE => {
1409
+ let (code, reason) = parse_close_payload(frame.payload);
1400
1410
  let mut state = self.state.borrow_mut();
1401
- Self::handle_close_frame(py, &mut state, frame.payload);
1411
+ close_effects = Some(Self::begin_peer_close(py, &mut state, code, reason));
1402
1412
  return Ok(VisitOutcome::Stop);
1403
1413
  }
1404
1414
  _ => {}
1405
1415
  }
1406
1416
  Ok(VisitOutcome::Continue)
1407
1417
  })?;
1418
+ if let Some((pending, transport)) = close_effects {
1419
+ Self::apply_peer_close(py, pending, transport);
1420
+ }
1408
1421
  match outcome {
1409
1422
  ScanOutcome::Exhausted { consumed } | ScanOutcome::Stopped { consumed } => {
1410
1423
  Ok((consumed, None))
@@ -1429,6 +1442,7 @@ impl NativeClient {
1429
1442
  };
1430
1443
  if can_fast_path {
1431
1444
  let mut state = self.state.borrow_mut();
1445
+ let mut close_effects = None;
1432
1446
  let outcome = walk_frames(data, |frame| -> PyResult<VisitOutcome> {
1433
1447
  match frame.opcode {
1434
1448
  OP_TEXT | OP_BINARY => {
@@ -1447,7 +1461,8 @@ impl NativeClient {
1447
1461
  Self::deliver_message(py, &mut state, msg)?;
1448
1462
  }
1449
1463
  OP_CLOSE => {
1450
- Self::handle_close_frame(py, &mut state, frame.payload);
1464
+ let (code, reason) = parse_close_payload(frame.payload);
1465
+ close_effects = Some(Self::begin_peer_close(py, &mut state, code, reason));
1451
1466
  return Ok(VisitOutcome::Stop);
1452
1467
  }
1453
1468
  _ => {}
@@ -1455,7 +1470,13 @@ impl NativeClient {
1455
1470
  Ok(VisitOutcome::Continue)
1456
1471
  })?;
1457
1472
  let consumed = match outcome {
1458
- ScanOutcome::Stopped { .. } => return Ok(()),
1473
+ ScanOutcome::Stopped { .. } => {
1474
+ drop(state);
1475
+ if let Some((pending, transport)) = close_effects {
1476
+ Self::apply_peer_close(py, pending, transport);
1477
+ }
1478
+ return Ok(());
1479
+ }
1459
1480
  ScanOutcome::Exhausted { consumed } if consumed == data.len() => return Ok(()),
1460
1481
  ScanOutcome::Exhausted { consumed }
1461
1482
  | ScanOutcome::Partial { consumed, .. }
@@ -1483,6 +1504,7 @@ impl NativeClient {
1483
1504
  };
1484
1505
  if can_fast_path {
1485
1506
  let mut state = self.state.borrow_mut();
1507
+ let mut close_effects = None;
1486
1508
  let outcome = walk_frames(data, |frame| -> PyResult<VisitOutcome> {
1487
1509
  match frame.opcode {
1488
1510
  OP_TEXT | OP_BINARY => {
@@ -1491,7 +1513,8 @@ impl NativeClient {
1491
1513
  Self::deliver_message(py, &mut state, msg)?;
1492
1514
  }
1493
1515
  OP_CLOSE => {
1494
- Self::handle_close_frame(py, &mut state, frame.payload);
1516
+ let (code, reason) = parse_close_payload(frame.payload);
1517
+ close_effects = Some(Self::begin_peer_close(py, &mut state, code, reason));
1495
1518
  return Ok(VisitOutcome::Stop);
1496
1519
  }
1497
1520
  _ => {}
@@ -1499,7 +1522,13 @@ impl NativeClient {
1499
1522
  Ok(VisitOutcome::Continue)
1500
1523
  })?;
1501
1524
  let consumed = match outcome {
1502
- ScanOutcome::Stopped { .. } => return Ok(()),
1525
+ ScanOutcome::Stopped { .. } => {
1526
+ drop(state);
1527
+ if let Some((pending, transport)) = close_effects {
1528
+ Self::apply_peer_close(py, pending, transport);
1529
+ }
1530
+ return Ok(());
1531
+ }
1503
1532
  ScanOutcome::Exhausted { consumed } if consumed == data.len() => return Ok(()),
1504
1533
  ScanOutcome::Exhausted { consumed }
1505
1534
  | ScanOutcome::Partial { consumed, .. }
@@ -1736,19 +1765,9 @@ impl NativeClient {
1736
1765
  ProtocolEvent::Close { code, reason } => {
1737
1766
  let (pending, transport) = {
1738
1767
  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)
1768
+ Self::begin_peer_close(py, &mut state, code, reason)
1747
1769
  };
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
- }
1770
+ Self::apply_peer_close(py, pending, transport);
1752
1771
  Ok(EventFlow::Stop)
1753
1772
  }
1754
1773
  ProtocolEvent::ProtocolError(reason) => {
@@ -1799,8 +1818,12 @@ impl NativeClient {
1799
1818
  }
1800
1819
  if let Some(fut) = state.pending_recv.pop_front() {
1801
1820
  let fb = fut.bind(py);
1802
- if !fb.call_method0("done")?.extract::<bool>().unwrap_or(false) {
1803
- fb.call_method1("set_result", (msg,))?;
1821
+ if !fb
1822
+ .call_method0(pyo3::intern!(py, "done"))?
1823
+ .extract::<bool>()
1824
+ .unwrap_or(false)
1825
+ {
1826
+ fb.call_method1(pyo3::intern!(py, "set_result"), (msg,))?;
1804
1827
  }
1805
1828
  } else {
1806
1829
  state.backlog.push_back(msg);
@@ -1808,33 +1831,33 @@ impl NativeClient {
1808
1831
  Ok(())
1809
1832
  }
1810
1833
 
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());
1834
+ /// Record a peer-initiated close in `state` and hand back the effects the
1835
+ /// caller must apply AFTER releasing the State borrow — the same
1836
+ /// reentrancy discipline as the slow-path event sink.
1837
+ fn begin_peer_close(
1838
+ py: Python<'_>,
1839
+ state: &mut State,
1840
+ code: Option<u16>,
1841
+ reason: Option<String>,
1842
+ ) -> (VecDeque<Py<PyAny>>, Option<Py<PyAny>>) {
1843
+ if code.is_some() {
1844
+ state.close_code = code;
1845
+ state.close_reason = reason;
1816
1846
  }
1817
1847
  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
- }
1848
+ let pending = std::mem::take(&mut state.pending_recv);
1849
+ let transport = state.transport.as_ref().map(|t| t.clone_ref(py));
1850
+ (pending, transport)
1851
+ }
1852
+
1853
+ fn apply_peer_close(
1854
+ py: Python<'_>,
1855
+ pending: VecDeque<Py<PyAny>>,
1856
+ transport: Option<Py<PyAny>>,
1857
+ ) {
1858
+ Self::fail_pending(py, pending, "Connection closed by peer");
1859
+ if let Some(transport) = transport {
1860
+ let _ = transport.bind(py).call_method0("close");
1838
1861
  }
1839
1862
  }
1840
1863
  }
@@ -1,9 +1,10 @@
1
+ use bytes::Bytes;
1
2
  use pyo3::exceptions::{PyConnectionError, PyRuntimeError, PyTimeoutError};
2
3
  use pyo3::prelude::*;
3
4
  use pyo3::types::{PyBytes, PyString};
4
- use std::io::{Read, Write};
5
+ use std::io::{self, Read, Write};
5
6
  use std::net::{TcpStream, ToSocketAddrs};
6
- use std::time::Duration;
7
+ use std::time::{Duration, Instant};
7
8
  use tungstenite::client::IntoClientRequest;
8
9
  use tungstenite::Message;
9
10
  use tungstenite::WebSocket;
@@ -52,48 +53,216 @@ pub(crate) fn build_rustls_client_config() -> PyResult<std::sync::Arc<rustls::Cl
52
53
 
53
54
  enum RecvResult {
54
55
  Text(String),
55
- Binary(Vec<u8>),
56
+ Binary(Bytes),
56
57
  }
57
58
 
59
+ mod send_pybytes_owner {
60
+ use super::*;
61
+
62
+ struct SendPyBytesOwner {
63
+ _bytes: Py<PyBytes>,
64
+ ptr: *const u8,
65
+ len: usize,
66
+ }
67
+
68
+ // SAFETY: this owner is constructed only from an exact CPython `bytes`, whose
69
+ // buffer is immutable and pointer-stable. `_bytes` is an owned reference cloned
70
+ // while the GIL is held before `py.detach`; it keeps the allocation alive until
71
+ // tungstenite drops the owner-backed `Bytes`. PyO3 permits `Py<PyBytes>` to cross
72
+ // threads and defers its decref until the GIL can be acquired.
73
+ unsafe impl Send for SendPyBytesOwner {}
74
+ unsafe impl Sync for SendPyBytesOwner {}
75
+
76
+ impl AsRef<[u8]> for SendPyBytesOwner {
77
+ fn as_ref(&self) -> &[u8] {
78
+ // SAFETY: `ptr` and `len` describe the immutable buffer owned by
79
+ // `_bytes`, which remains alive for this owner's entire lifetime.
80
+ unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
81
+ }
82
+ }
83
+
84
+ pub(super) fn new(bytes: &Bound<'_, PyBytes>) -> Bytes {
85
+ let data = bytes.as_bytes();
86
+ Bytes::from_owner(SendPyBytesOwner {
87
+ _bytes: bytes.clone().unbind(),
88
+ ptr: data.as_ptr(),
89
+ len: data.len(),
90
+ })
91
+ }
92
+ }
93
+
94
+ /// Keeps EINTR handling below rustls, whose internal I/O loop otherwise retries
95
+ /// interrupted socket reads without giving Python a chance to raise a signal.
96
+ struct SignalAwareTcpStream {
97
+ stream: TcpStream,
98
+ read_deadline: Option<Instant>,
99
+ enforce_read_deadline: bool,
100
+ }
101
+
102
+ impl SignalAwareTcpStream {
103
+ fn new(stream: TcpStream) -> Self {
104
+ Self {
105
+ stream,
106
+ read_deadline: None,
107
+ enforce_read_deadline: false,
108
+ }
109
+ }
110
+
111
+ fn apply_remaining_read_timeout(&self) -> io::Result<()> {
112
+ let remaining = self
113
+ .read_deadline
114
+ .and_then(|deadline| deadline.checked_duration_since(Instant::now()))
115
+ .filter(|remaining| !remaining.is_zero())
116
+ .ok_or_else(|| io::Error::new(io::ErrorKind::TimedOut, "read deadline elapsed"))?;
117
+ self.stream.set_read_timeout(Some(remaining))
118
+ }
119
+ }
120
+
121
+ impl Read for SignalAwareTcpStream {
122
+ fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
123
+ loop {
124
+ if self.enforce_read_deadline {
125
+ self.apply_remaining_read_timeout()?;
126
+ }
127
+ match self.stream.read(buf) {
128
+ Err(error) if error.kind() == io::ErrorKind::Interrupted => {
129
+ check_python_signals()?;
130
+ self.enforce_read_deadline = true;
131
+ }
132
+ result => return result,
133
+ }
134
+ }
135
+ }
136
+ }
137
+
138
+ impl Write for SignalAwareTcpStream {
139
+ fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
140
+ retry_interrupted(|| self.stream.write(buf))
141
+ }
142
+
143
+ fn flush(&mut self) -> io::Result<()> {
144
+ retry_interrupted(|| self.stream.flush())
145
+ }
146
+ }
147
+
148
+ type TlsStream = rustls::StreamOwned<rustls::ClientConnection, SignalAwareTcpStream>;
149
+
58
150
  /// Type-erased stream for WebSocket (avoids generic type in pyclass)
59
151
  enum WsStream {
60
- Plain(TcpStream),
61
- Tls(Box<rustls::StreamOwned<rustls::ClientConnection, TcpStream>>),
152
+ Plain(SignalAwareTcpStream),
153
+ Tls(Box<TlsStream>),
62
154
  }
63
155
 
64
156
  impl Read for WsStream {
65
- fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
157
+ fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
66
158
  match self {
67
- WsStream::Plain(s) => s.read(buf),
68
- WsStream::Tls(s) => s.read(buf),
159
+ Self::Plain(s) => s.read(buf),
160
+ Self::Tls(s) => s.read(buf),
69
161
  }
70
162
  }
71
163
  }
72
164
 
73
165
  impl Write for WsStream {
74
- fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
166
+ fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
75
167
  match self {
76
- WsStream::Plain(s) => s.write(buf),
77
- WsStream::Tls(s) => s.write(buf),
168
+ Self::Plain(s) => s.write(buf),
169
+ Self::Tls(s) => s.write(buf),
78
170
  }
79
171
  }
80
- fn flush(&mut self) -> std::io::Result<()> {
172
+
173
+ fn flush(&mut self) -> io::Result<()> {
81
174
  match self {
82
- WsStream::Plain(s) => s.flush(),
83
- WsStream::Tls(s) => s.flush(),
175
+ Self::Plain(s) => s.flush(),
176
+ Self::Tls(s) => s.flush(),
84
177
  }
85
178
  }
86
179
  }
87
180
 
88
181
  impl WsStream {
182
+ fn plain(stream: TcpStream) -> Self {
183
+ Self::Plain(SignalAwareTcpStream::new(stream))
184
+ }
185
+
186
+ fn tls(connection: rustls::ClientConnection, stream: TcpStream) -> Self {
187
+ Self::Tls(Box::new(TlsStream::new(
188
+ connection,
189
+ SignalAwareTcpStream::new(stream),
190
+ )))
191
+ }
192
+
193
+ fn begin_read_deadline(&mut self, timeout: Duration) {
194
+ let socket = self.socket_mut();
195
+ socket.read_deadline = Some(Instant::now() + timeout);
196
+ socket.enforce_read_deadline = false;
197
+ }
198
+
199
+ fn clear_read_deadline(&mut self, timeout: Duration) {
200
+ let socket = self.socket_mut();
201
+ if socket.enforce_read_deadline {
202
+ let _ = socket.stream.set_read_timeout(Some(timeout));
203
+ }
204
+ socket.read_deadline = None;
205
+ socket.enforce_read_deadline = false;
206
+ }
207
+
89
208
  fn tcp_ref(&self) -> &TcpStream {
90
209
  match self {
91
- WsStream::Plain(s) => s,
92
- WsStream::Tls(s) => s.get_ref(),
210
+ Self::Plain(s) => &s.stream,
211
+ Self::Tls(s) => &s.get_ref().stream,
212
+ }
213
+ }
214
+
215
+ fn socket_mut(&mut self) -> &mut SignalAwareTcpStream {
216
+ match self {
217
+ Self::Plain(s) => s,
218
+ Self::Tls(s) => s.get_mut(),
219
+ }
220
+ }
221
+ }
222
+
223
+ fn check_python_signals() -> io::Result<()> {
224
+ // PEP 475 retries EINTR only after giving Python a chance to raise a
225
+ // pending signal exception such as KeyboardInterrupt.
226
+ Python::attach(|py| py.check_signals()).map_err(io::Error::from)
227
+ }
228
+
229
+ fn retry_interrupted<T>(mut operation: impl FnMut() -> io::Result<T>) -> io::Result<T> {
230
+ loop {
231
+ match operation() {
232
+ Err(error) if error.kind() == io::ErrorKind::Interrupted => check_python_signals()?,
233
+ result => return result,
93
234
  }
94
235
  }
95
236
  }
96
237
 
238
+ fn is_python_error(error: &io::Error) -> bool {
239
+ // PyO3 preserves a PyErr wrapped in io::Error, allowing the API boundary
240
+ // to recover the original Python exception instead of replacing its type.
241
+ error.get_ref().is_some_and(|inner| inner.is::<PyErr>())
242
+ }
243
+
244
+ fn map_websocket_error(context: &str, error: tungstenite::Error) -> PyErr {
245
+ match error {
246
+ tungstenite::Error::Io(error) if is_python_error(&error) => error.into(),
247
+ error => PyRuntimeError::new_err(format!("{context} failed: {error}")),
248
+ }
249
+ }
250
+
251
+ fn map_receive_error(error: tungstenite::Error, receive_timeout: f64) -> PyErr {
252
+ match error {
253
+ tungstenite::Error::Io(error) if is_python_error(&error) => error.into(),
254
+ tungstenite::Error::Io(error)
255
+ if matches!(
256
+ error.kind(),
257
+ io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock
258
+ ) =>
259
+ {
260
+ PyTimeoutError::new_err(format!("Receive timed out ({receive_timeout} seconds)"))
261
+ }
262
+ error => PyRuntimeError::new_err(format!("Receive failed: {error}")),
263
+ }
264
+ }
265
+
97
266
  /// Sync client connection (pure sync, no async runtime overhead)
98
267
  #[pyclass(name = "ClientConnection", module = "websocket_rs.sync.client")]
99
268
  pub struct SyncClientConnection {
@@ -192,14 +361,18 @@ impl SyncClientConnection {
192
361
  })?;
193
362
  let conn = rustls::ClientConnection::new(config, server_name)
194
363
  .map_err(|e| PyConnectionError::new_err(format!("TLS init failed: {}", e)))?;
195
- let tls = rustls::StreamOwned::new(conn, tcp);
196
- WsStream::Tls(Box::new(tls))
364
+ WsStream::tls(conn, tcp)
197
365
  } else {
198
- WsStream::Plain(tcp)
366
+ WsStream::plain(tcp)
199
367
  };
200
368
 
201
- let (ws, _) = tungstenite::client(request, ws_stream).map_err(|e| {
202
- PyConnectionError::new_err(format!("WebSocket handshake failed: {}", e))
369
+ let (ws, _) = tungstenite::client(request, ws_stream).map_err(|error| match error {
370
+ tungstenite::HandshakeError::Failure(tungstenite::Error::Io(error))
371
+ if is_python_error(&error) =>
372
+ {
373
+ error.into()
374
+ }
375
+ error => PyConnectionError::new_err(format!("WebSocket handshake failed: {error}")),
203
376
  })?;
204
377
 
205
378
  // Reset timeouts: read = receive_timeout, write = unlimited
@@ -224,6 +397,8 @@ impl SyncClientConnection {
224
397
  fn send<'py>(&mut self, py: Python<'py>, message: &Bound<'py, PyAny>) -> PyResult<()> {
225
398
  let msg = if let Ok(s) = message.cast::<PyString>() {
226
399
  Message::Text(s.to_str()?.into())
400
+ } else if let Ok(bytes) = message.cast_exact::<PyBytes>() {
401
+ Message::Binary(send_pybytes_owner::new(bytes))
227
402
  } else if let Ok(bytes) = message.extract::<Vec<u8>>() {
228
403
  Message::Binary(bytes.into())
229
404
  } else {
@@ -237,52 +412,54 @@ impl SyncClientConnection {
237
412
  .ok_or_else(|| PyRuntimeError::new_err("WebSocket is not connected"))?;
238
413
 
239
414
  ws.send(msg)
240
- .map_err(|e| PyRuntimeError::new_err(format!("Send failed: {}", e)))
415
+ .map_err(|error| map_websocket_error("Send", error))
241
416
  })
242
417
  }
243
418
 
244
419
  fn recv(&mut self, py: Python<'_>) -> PyResult<Py<PyAny>> {
420
+ let receive_timeout = self.receive_timeout;
245
421
  let result = py.detach(|| {
246
422
  let ws = self
247
423
  .ws
248
424
  .as_mut()
249
425
  .ok_or_else(|| PyRuntimeError::new_err("WebSocket is not connected"))?;
250
426
 
251
- loop {
252
- let msg = ws.read().map_err(|e| match &e {
253
- tungstenite::Error::Io(io_err)
254
- if matches!(
255
- io_err.kind(),
256
- std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock
257
- ) =>
258
- {
259
- PyTimeoutError::new_err(format!(
260
- "Receive timed out ({} seconds)",
261
- self.receive_timeout
262
- ))
263
- }
264
- _ => PyRuntimeError::new_err(format!("Receive failed: {}", e)),
265
- })?;
427
+ ws.get_mut()
428
+ .begin_read_deadline(Duration::from_secs_f64(receive_timeout));
429
+ let mut close_details = None;
430
+ let result = loop {
431
+ let msg = match ws.read() {
432
+ Ok(msg) => msg,
433
+ Err(error) => break Err(map_receive_error(error, receive_timeout)),
434
+ };
266
435
 
267
436
  match msg {
268
- Message::Text(text) => return Ok(RecvResult::Text(text.to_string())),
269
- Message::Binary(data) => return Ok(RecvResult::Binary(data.to_vec())),
437
+ Message::Text(text) => break Ok(RecvResult::Text(text.to_string())),
438
+ Message::Binary(data) => break Ok(RecvResult::Binary(data)),
270
439
  Message::Ping(_) | Message::Pong(_) => continue,
271
440
  Message::Close(frame) => {
272
441
  if let Some(f) = frame {
273
- self.close_code = Some(f.code.into());
274
- self.close_reason = Some(f.reason.to_string());
442
+ close_details = Some((f.code.into(), f.reason.to_string()));
275
443
  }
276
- return Err(PyRuntimeError::new_err("Connection closed by server"));
444
+ break Err(PyRuntimeError::new_err("Connection closed by server"));
445
+ }
446
+ _ => {
447
+ break Err(PyRuntimeError::new_err("Received unsupported message type"));
277
448
  }
278
- _ => return Err(PyRuntimeError::new_err("Received unsupported message type")),
279
449
  }
450
+ };
451
+ ws.get_mut()
452
+ .clear_read_deadline(Duration::from_secs_f64(receive_timeout));
453
+ if let Some((code, reason)) = close_details {
454
+ self.close_code = Some(code);
455
+ self.close_reason = Some(reason);
280
456
  }
457
+ result
281
458
  })?;
282
459
 
283
460
  match result {
284
461
  RecvResult::Text(s) => Ok(PyString::new(py, &s).into_any().unbind()),
285
- RecvResult::Binary(b) => Ok(PyBytes::new(py, &b).into_any().unbind()),
462
+ RecvResult::Binary(b) => Ok(PyBytes::new(py, b.as_ref()).into_any().unbind()),
286
463
  }
287
464
  }
288
465
 
@@ -291,18 +468,30 @@ impl SyncClientConnection {
291
468
  py.detach(|| {
292
469
  if let Some(mut ws) = self.ws.take() {
293
470
  // Set a timeout for the close handshake
294
- let tcp_ref = ws.get_ref().tcp_ref();
295
- let _ = tcp_ref.set_read_timeout(Some(Duration::from_secs_f64(close_timeout)));
296
- if let Err(e) = ws.close(None) {
297
- // Read remaining frames until close confirmation or timeout
298
- if !matches!(e, tungstenite::Error::ConnectionClosed) {
471
+ let close_duration = Duration::from_secs_f64(close_timeout);
472
+ let _ = ws
473
+ .get_ref()
474
+ .tcp_ref()
475
+ .set_read_timeout(Some(close_duration));
476
+ ws.get_mut().begin_read_deadline(close_duration);
477
+ match ws.close(None) {
478
+ Err(tungstenite::Error::Io(error)) if is_python_error(&error) => {
479
+ return Err(error.into());
480
+ }
481
+ Err(error) if !matches!(error, tungstenite::Error::ConnectionClosed) => {
482
+ // Read remaining frames until close confirmation or timeout
299
483
  loop {
300
484
  match ws.read() {
301
- Ok(Message::Close(_)) | Err(_) => break,
485
+ Ok(Message::Close(_)) => break,
486
+ Err(tungstenite::Error::Io(error)) if is_python_error(&error) => {
487
+ return Err(error.into());
488
+ }
489
+ Err(_) => break,
302
490
  _ => continue,
303
491
  }
304
492
  }
305
493
  }
494
+ _ => {}
306
495
  }
307
496
  }
308
497
  Ok(())
@@ -317,7 +506,7 @@ impl SyncClientConnection {
317
506
  .as_mut()
318
507
  .ok_or_else(|| PyRuntimeError::new_err("WebSocket is not connected"))?;
319
508
  ws.send(Message::Ping(data.into()))
320
- .map_err(|e| PyRuntimeError::new_err(format!("Ping failed: {}", e)))
509
+ .map_err(|error| map_websocket_error("Ping", error))
321
510
  })
322
511
  }
323
512
 
@@ -329,7 +518,7 @@ impl SyncClientConnection {
329
518
  .as_mut()
330
519
  .ok_or_else(|| PyRuntimeError::new_err("WebSocket is not connected"))?;
331
520
  ws.send(Message::Pong(data.into()))
332
- .map_err(|e| PyRuntimeError::new_err(format!("Pong failed: {}", e)))
521
+ .map_err(|error| map_websocket_error("Pong", error))
333
522
  })
334
523
  }
335
524
 
@@ -7,6 +7,7 @@ import asyncio
7
7
  import sys
8
8
  import threading
9
9
  import time
10
+ from array import array
10
11
 
11
12
  import pytest
12
13
  import websockets
@@ -21,6 +22,10 @@ sync_connect = websocket_rs.sync.client.connect
21
22
  async_connect = websocket_rs.async_client.connect
22
23
 
23
24
 
25
+ class BytesSubclass(bytes):
26
+ pass
27
+
28
+
24
29
  async def start_echo_server():
25
30
  async def echo(websocket):
26
31
  async for message in websocket:
@@ -109,6 +114,71 @@ def test_rust_sync_api():
109
114
  print("\n所有同步 API 測試通過!\n")
110
115
 
111
116
 
117
+ def test_sync_recv_binary_move_path_preserves_content():
118
+ payload = bytes(range(256)) * 4096
119
+
120
+ with sync_connect("ws://localhost:8765") as ws:
121
+ ws.send(payload)
122
+ received = ws.recv()
123
+
124
+ assert isinstance(received, bytes)
125
+ assert received == payload
126
+
127
+
128
+ @pytest.mark.parametrize(
129
+ ("payload", "mutate_after_send"),
130
+ [
131
+ pytest.param(bytearray(b"bytearray payload"), True, id="bytearray"),
132
+ pytest.param(memoryview(b"memoryview payload"), False, id="memoryview"),
133
+ pytest.param(array("B", b"buffer object payload"), True, id="buffer-object"),
134
+ pytest.param(BytesSubclass(b"bytes subclass payload"), False, id="bytes-subclass"),
135
+ ],
136
+ )
137
+ def test_sync_send_non_exact_bytes_copy_path_round_trips(payload, mutate_after_send):
138
+ expected = bytes(payload)
139
+
140
+ with sync_connect("ws://localhost:8765") as ws:
141
+ ws.send(payload)
142
+ if mutate_after_send:
143
+ # Tripwire for future deferred-write or borrow-path expansion, not
144
+ # a probe of the current synchronous send behavior.
145
+ for index in range(len(payload)):
146
+ payload[index] ^= 0xFF
147
+ assert bytes(payload) != expected
148
+ received = ws.recv()
149
+
150
+ assert received == expected
151
+
152
+
153
+ def test_sync_send_exact_bytes_owner_survives_allocator_pressure():
154
+ stop = threading.Event()
155
+ churn_cycles = 0
156
+
157
+ def churn_allocator():
158
+ nonlocal churn_cycles
159
+ while not stop.is_set():
160
+ blocks = [bytes(bytearray(64 * 1024)) for _ in range(8)]
161
+ churn_cycles += 1
162
+ del blocks
163
+
164
+ churn_thread = threading.Thread(target=churn_allocator, daemon=True)
165
+ churn_thread.start()
166
+ try:
167
+ with sync_connect("ws://localhost:8765") as ws:
168
+ for marker in range(16):
169
+ expected = bytes([marker]) * (256 * 1024)
170
+ payload = bytes(bytearray(expected))
171
+ assert type(payload) is bytes
172
+ ws.send(payload)
173
+ del payload
174
+ assert ws.recv() == expected
175
+ finally:
176
+ stop.set()
177
+ churn_thread.join(timeout=2)
178
+
179
+ assert churn_cycles > 0
180
+
181
+
112
182
  async def test_python_async_api():
113
183
  """測試 Python 原生 async API"""
114
184
  print("測試 Python websockets 異步 API...")
@@ -8,6 +8,8 @@ Covers issues found in code review:
8
8
  """
9
9
 
10
10
  import asyncio
11
+ import os
12
+ import signal
11
13
  import socket
12
14
  import sys
13
15
  import threading
@@ -25,6 +27,10 @@ sys.stdout.reconfigure(encoding="utf-8")
25
27
  async def start_echo_server(port=8766):
26
28
  async def echo(ws):
27
29
  async for msg in ws:
30
+ if msg == b"delayed EINTR reply":
31
+ await asyncio.sleep(0.25)
32
+ elif msg == b"delayed EINTR timeout":
33
+ await asyncio.sleep(1)
28
34
  await ws.send(msg)
29
35
 
30
36
  return await websockets.serve(echo, "localhost", port, ping_interval=None)
@@ -137,6 +143,96 @@ def test_sync_recv_timeout():
137
143
  print(f"✓ sync recv timeout works ({elapsed:.1f}s)")
138
144
 
139
145
 
146
+ @pytest.mark.skipif(not hasattr(signal, "SIGUSR1"), reason="SIGUSR1 is unavailable")
147
+ def test_sync_recv_retries_eintr_after_python_signal():
148
+ previous_handler = signal.signal(signal.SIGUSR1, lambda *_: None)
149
+ stop = threading.Event()
150
+ signals_sent = 0
151
+
152
+ def send_signals():
153
+ nonlocal signals_sent
154
+ while not stop.wait(0.01):
155
+ os.kill(os.getpid(), signal.SIGUSR1)
156
+ signals_sent += 1
157
+
158
+ try:
159
+ with websocket_rs.sync.client.ClientConnection(
160
+ "ws://localhost:8766", receive_timeout=1.0
161
+ ) as ws:
162
+ ws.send(b"delayed EINTR reply")
163
+ signal_thread = threading.Thread(target=send_signals, daemon=True)
164
+ signal_thread.start()
165
+ try:
166
+ received = ws.recv()
167
+ finally:
168
+ stop.set()
169
+ signal_thread.join(timeout=1)
170
+ finally:
171
+ signal.signal(signal.SIGUSR1, previous_handler)
172
+
173
+ assert signals_sent > 0
174
+ assert received == b"delayed EINTR reply"
175
+
176
+
177
+ @pytest.mark.skipif(not hasattr(signal, "SIGUSR1"), reason="SIGUSR1 is unavailable")
178
+ def test_sync_recv_eintr_retries_preserve_timeout_deadline():
179
+ previous_handler = signal.signal(signal.SIGUSR1, lambda *_: None)
180
+ stop = threading.Event()
181
+ signals_sent = 0
182
+
183
+ def send_signals():
184
+ nonlocal signals_sent
185
+ while not stop.wait(0.01):
186
+ os.kill(os.getpid(), signal.SIGUSR1)
187
+ signals_sent += 1
188
+
189
+ try:
190
+ with websocket_rs.sync.client.ClientConnection(
191
+ "ws://localhost:8766", receive_timeout=0.2
192
+ ) as ws:
193
+ ws.send(b"delayed EINTR timeout")
194
+ signal_thread = threading.Thread(target=send_signals, daemon=True)
195
+ signal_thread.start()
196
+ started = time.perf_counter()
197
+ try:
198
+ with pytest.raises(TimeoutError):
199
+ ws.recv()
200
+ finally:
201
+ elapsed = time.perf_counter() - started
202
+ stop.set()
203
+ signal_thread.join(timeout=1)
204
+ finally:
205
+ signal.signal(signal.SIGUSR1, previous_handler)
206
+
207
+ assert signals_sent > 0
208
+ assert 0.1 < elapsed < 0.6
209
+
210
+
211
+ @pytest.mark.skipif(
212
+ os.name != "posix" or not hasattr(signal, "SIGINT"),
213
+ reason="POSIX SIGINT is unavailable",
214
+ )
215
+ def test_sync_recv_eintr_propagates_keyboard_interrupt():
216
+ previous_handler = signal.signal(signal.SIGINT, signal.default_int_handler)
217
+
218
+ def interrupt_recv():
219
+ time.sleep(0.05)
220
+ os.kill(os.getpid(), signal.SIGINT)
221
+
222
+ try:
223
+ with websocket_rs.sync.client.ClientConnection(
224
+ "ws://localhost:8766", receive_timeout=1.0
225
+ ) as ws:
226
+ ws.send(b"delayed EINTR reply")
227
+ signal_thread = threading.Thread(target=interrupt_recv, daemon=True)
228
+ signal_thread.start()
229
+ with pytest.raises(KeyboardInterrupt):
230
+ ws.recv()
231
+ signal_thread.join(timeout=1)
232
+ finally:
233
+ signal.signal(signal.SIGINT, previous_handler)
234
+
235
+
140
236
  async def test_async_send_after_close():
141
237
  """Async: sending after close should raise RuntimeError."""
142
238
  ws = await websocket_rs.async_client.connect("ws://localhost:8766")
@@ -209,27 +305,5 @@ async def test_async_connect_function_receive_timeout_kwarg_expires_promptly():
209
305
  await ws.close()
210
306
 
211
307
 
212
- async def main():
213
- server = await start_echo_server(8766)
214
-
215
- try:
216
- print("=== Timeout & Error Tests ===\n")
217
-
218
- # Sync tests
219
- test_sync_connect_timeout()
220
- test_sync_connect_forwards_params()
221
- await asyncio.to_thread(test_sync_send_after_close)
222
- await asyncio.to_thread(test_sync_recv_timeout)
223
-
224
- # Async tests
225
- await test_async_send_after_close()
226
- await test_async_recv_timeout()
227
- await test_async_connect_timeout()
228
-
229
- print("\n✅ All timeout & error tests passed!")
230
- finally:
231
- server.close()
232
-
233
-
234
308
  if __name__ == "__main__":
235
- asyncio.run(main())
309
+ raise SystemExit(pytest.main([__file__]))
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes