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.
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/CHANGELOG.md +20 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/Cargo.lock +1 -1
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/Cargo.toml +1 -1
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/PKG-INFO +1 -1
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/README.md +4 -5
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/pyproject.toml +1 -1
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/src/native_client.rs +76 -53
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/src/sync_client.rs +241 -52
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/tests/test_compatibility.py +70 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/tests/test_timeout_and_errors.py +97 -23
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/.github/pull_request_template.md +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/.github/workflows/pr.yml +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/.github/workflows/release.yml +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/.github/workflows/test.yml +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/.gitignore +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/LICENSE +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/Makefile +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/README.pypi.md +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/README.zh-TW.md +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/docs/API.md +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/docs/BENCHMARKS.md +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/docs/BENCHMARKS.zh-TW.md +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/docs/CONTRIBUTING.md +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/docs/MIGRATION.md +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/docs/OPTIMIZATION_RESEARCH.md +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/docs/PUBLISHING.md +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/monitor_and_merge.sh +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/pytest.ini +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/scripts/build-pgo.sh +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/scripts/pre-check.sh +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/scripts/prepare-release.sh +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/scripts/tag-release.sh +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/scripts/test.sh +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/src/async_client.rs +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/src/bin/ws_echo_fastws.rs +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/src/bin/ws_echo_server.rs +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/src/bin/ws_echo_server_tls.rs +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/src/lib.rs +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/tests/bench_socks5_handshake.py +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/tests/benchmark_picows_parity.py +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/tests/benchmark_three_servers.py +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/tests/benchmark_tls_parity.py +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/tests/diagnose_server_pattern.py +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/tests/perf_pipelined_64k.py +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/tests/test_native_features.py +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/uv.lock +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/websocket_rs/__init__.py +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/websocket_rs/__init__.pyi +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/websocket_rs/async_client.pyi +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/websocket_rs/native_client.pyi +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/websocket_rs/py.typed +0 -0
- {websocket_rs-0.7.2 → websocket_rs-0.7.3}/websocket_rs/sync/__init__.pyi +0 -0
- {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
|
|
@@ -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.
|
|
58
|
+
## ✨ What's New in v0.7.3
|
|
59
59
|
|
|
60
|
-
- **
|
|
61
|
-
- **
|
|
62
|
-
- **
|
|
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
|
|
|
@@ -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
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
-
|
|
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::
|
|
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
|
-
|
|
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 { .. } =>
|
|
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
|
-
|
|
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 { .. } =>
|
|
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
|
-
|
|
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::
|
|
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
|
|
1803
|
-
|
|
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
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
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
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
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(
|
|
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(
|
|
61
|
-
Tls(Box<
|
|
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]) ->
|
|
157
|
+
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
|
66
158
|
match self {
|
|
67
|
-
|
|
68
|
-
|
|
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]) ->
|
|
166
|
+
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
|
75
167
|
match self {
|
|
76
|
-
|
|
77
|
-
|
|
168
|
+
Self::Plain(s) => s.write(buf),
|
|
169
|
+
Self::Tls(s) => s.write(buf),
|
|
78
170
|
}
|
|
79
171
|
}
|
|
80
|
-
|
|
172
|
+
|
|
173
|
+
fn flush(&mut self) -> io::Result<()> {
|
|
81
174
|
match self {
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
196
|
-
WsStream::Tls(Box::new(tls))
|
|
364
|
+
WsStream::tls(conn, tcp)
|
|
197
365
|
} else {
|
|
198
|
-
WsStream::
|
|
366
|
+
WsStream::plain(tcp)
|
|
199
367
|
};
|
|
200
368
|
|
|
201
|
-
let (ws, _) = tungstenite::client(request, ws_stream).map_err(|
|
|
202
|
-
|
|
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(|
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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) =>
|
|
269
|
-
Message::Binary(data) =>
|
|
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
|
-
|
|
274
|
-
self.close_reason = Some(f.reason.to_string());
|
|
442
|
+
close_details = Some((f.code.into(), f.reason.to_string()));
|
|
275
443
|
}
|
|
276
|
-
|
|
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,
|
|
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
|
|
295
|
-
let _ =
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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(_))
|
|
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(|
|
|
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(|
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|