diff --git a/README.md b/README.md index d542069..ec98f16 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ equal to the strings of the specification, dictionaries are typed dataclasses, a | VP9 and AV1 | ✅ | ❌ | ✅ | ✅ | | `pip install` on Linux, macOS and Windows | ✅ | ✅ | ✅ | ❌ system packages | -Against aiortc, it opens connections 5–7x faster, moves 4.5–9x more data through a data channel and spends less CPU on +Against aiortc, it opens connections 7–8x faster, moves 4.4–9x more data through a data channel and spends less CPU on audio. See the [benchmarks](https://wrtc.marshal.dev/benchmarks/), which also measure Pion, libdatachannel and webrtc-rs. diff --git a/benchmarks/RESULTS.md b/benchmarks/RESULTS.md index 0013b64..561f05d 100644 --- a/benchmarks/RESULTS.md +++ b/benchmarks/RESULTS.md @@ -4,13 +4,11 @@ Medians; % columns are of aiortc's. How: [README](README.md#method). | Suite | Libraries | Date | Runs | Machine | Commit | | --- | --- | --- | --- | --- | --- | -| setup | aiortc, pion, libdatachannel, webrtc-rs | 2026-10-13 | 3 | Apple M1, 8 cores, Darwin 27.0.0 (arm64), Python 3.13.13, go1.27.1 | 3ed4d51 with uncommitted changes | -| setup | wrtc | 2026-10-14 | 3 | Apple M1, 8 cores, Darwin 27.0.0 (arm64), Python 3.13.13, go1.27.1 | ab9ed88 with uncommitted changes | -| datachannel | aiortc, pion, libdatachannel, webrtc-rs | 2026-10-13 | 3 | Apple M1, 8 cores, Darwin 27.0.0 (arm64), Python 3.13.13, go1.27.1 | 3ed4d51 with uncommitted changes | -| datachannel | wrtc | 2026-10-13 | 3 | Apple M1, 8 cores, Darwin 27.0.0 (arm64), Python 3.13.13, go1.27.1 | ab9ed88 with uncommitted changes | -| memory | wrtc, aiortc, pion, libdatachannel, webrtc-rs | 2026-10-13 | 3 | Apple M1, 8 cores, Darwin 27.0.0 (arm64), Python 3.13.13, go1.27.1 | 3ed4d51 with uncommitted changes | -| media | aiortc | 2026-10-13 | 3 | Apple M1, 8 cores, Darwin 27.0.0 (arm64), Python 3.13.13, go1.27.1 | 3ed4d51 with uncommitted changes | -| media | wrtc | 2026-10-13 | 3 | Apple M1, 8 cores, Darwin 27.0.0 (arm64), Python 3.13.13, go1.27.1 | ab9ed88 with uncommitted changes | +| setup | wrtc, aiortc, pion, libdatachannel, webrtc-rs | 2026-10-17 | 3 | Apple M1, 8 cores, Darwin 27.0.0 (arm64), Python 3.13.13, go1.27.2 | 908dd26 with uncommitted changes | +| datachannel | aiortc, pion, libdatachannel, webrtc-rs | 2026-10-17 | 3 | Apple M1, 8 cores, Darwin 27.0.0 (arm64), Python 3.13.13, go1.27.2 | 908dd26 with uncommitted changes | +| datachannel | wrtc | 2026-10-17 | 3 | Apple M1, 8 cores, Darwin 27.0.0 (arm64), Python 3.13.13, go1.27.2 | 621d5be with uncommitted changes | +| memory | wrtc, aiortc, pion, libdatachannel, webrtc-rs | 2026-10-17 | 3 | Apple M1, 8 cores, Darwin 27.0.0 (arm64), Python 3.13.13, go1.27.2 | 908dd26 with uncommitted changes | +| media | wrtc, aiortc | 2026-10-17 | 3 | Apple M1, 8 cores, Darwin 27.0.0 (arm64), Python 3.13.13, go1.27.2 | 908dd26 with uncommitted changes | ## Connection setup @@ -21,51 +19,51 @@ Medians; % columns are of aiortc's. How: [README](README.md#method). | Library | Create | Offer created | Gathered | ICE connected | Connected | Data channel open | Open, % of aiortc | | --- | --- | --- | --- | --- | --- | --- | --- | -| wrtc 0.0.2 | 0.6 / 0.6 | 0.2 / 0.3 | 1.3 / 1.5 | 2.7 / 3.7 | 4.1 / 4.6 | 4.3 / 4.8 | 15% | -| aiortc 1.15.0 | 0.3 / 0.3 | 0.1 / 0.1 | 2.1 / 2.6 | 26.8 / 27.6 | 28.2 / 28.9 | 29.1 / 30.0 | 100% | -| pion v4.2.23 | 0.8 / 1.2 | 1.9 / 2.7 | 3.7 / 4.6 | 37.5 / 117.9 | 40.3 / 121.1 | 40.9 / 122.0 | 140% | -| libdatachannel 0.24.6 | 0.0 / 0.0 | 0.2 / 0.2 | 0.2 / 0.3 | 1.0 / 1.4 | 2.1 / 2.6 | 2.2 / 2.8 | 8% | -| webrtc-rs 0.21.1 | 1.2 / 1.5 | 0.0 / 0.1 | 0.3 / 0.3 | 206.0 / 280.8 | 223.3 / 422.8 | 417.9 / 821.6 | 1437% | +| wrtc 0.0.3 | 0.5 / 0.5 | 0.2 / 0.3 | 1.2 / 1.3 | 3.0 / 3.2 | 3.4 / 3.6 | 3.5 / 3.7 | 12% | +| aiortc 1.15.0 | 0.3 / 0.3 | 0.1 / 0.1 | 2.1 / 2.6 | 26.7 / 27.5 | 28.1 / 28.9 | 29.0 / 29.9 | 100% | +| pion v4.2.23 | 0.8 / 1.4 | 1.9 / 2.7 | 3.5 / 4.4 | 36.5 / 89.0 | 39.1 / 94.5 | 39.6 / 95.4 | 136% | +| libdatachannel 0.24.6 | 0.0 / 0.0 | 0.1 / 0.3 | 0.2 / 0.4 | 1.0 / 1.8 | 2.1 / 4.0 | 2.3 / 4.3 | 8% | +| webrtc-rs 0.21.1 | 1.2 / 1.4 | 0.1 / 0.1 | 0.3 / 0.4 | 206.0 / 225.8 | 223.1 / 426.5 | 420.6 / 822.3 | 1449% | ### Trickle, cold: fresh processes (10 trials) | Library | Create | Offer created | Gathered | ICE connected | Connected | Data channel open | Open, % of aiortc | | --- | --- | --- | --- | --- | --- | --- | --- | -| wrtc 0.0.2 | 1.7 / 3.9 | 0.3 / 0.3 | 1.6 / 1.9 | 4.2 / 4.7 | 4.9 / 5.3 | 5.1 / 5.7 | 15% | -| aiortc 1.15.0 | 3.5 / 17.4 | 0.1 / 0.1 | 2.3 / 2.6 | 27.5 / 27.6 | 32.8 / 38.3 | 33.9 / 39.2 | 100% | -| pion v4.2.23 | 0.7 / 3.4 | 1.2 / 3.5 | 2.2 / 6.8 | 25.3 / 37.5 | 27.3 / 39.2 | 27.7 / 39.7 | 82% | -| libdatachannel 0.24.6 | 0.8 / 1.6 | 2.6 / 4.8 | 2.7 / 4.8 | 4.2 / 7.3 | 5.9 / 10.0 | 6.1 / 10.3 | 18% | -| webrtc-rs 0.21.1 | 0.5 / 1.1 | 0.0 / 0.1 | 0.1 / 0.3 | 205.9 / 220.6 | 242.3 / 421.6 | 423.0 / 823.9 | 1244% | +| wrtc 0.0.3 | 2.3 / 4.0 | 0.3 / 0.3 | 1.3 / 1.5 | 3.7 / 4.0 | 4.3 / 4.8 | 4.3 / 4.8 | 13% | +| aiortc 1.15.0 | 3.6 / 15.8 | 0.1 / 0.1 | 2.2 / 2.6 | 27.4 / 27.5 | 32.8 / 37.8 | 33.9 / 38.7 | 100% | +| pion v4.2.23 | 0.4 / 0.8 | 0.8 / 1.6 | 1.5 / 2.8 | 18.6 / 78.7 | 20.1 / 82.7 | 20.6 / 84.2 | 60% | +| libdatachannel 0.24.6 | 1.5 / 11.5 | 4.8 / 6.2 | 4.9 / 6.2 | 7.7 / 9.2 | 10.5 / 11.7 | 10.9 / 11.9 | 32% | +| webrtc-rs 0.21.1 | 1.0 / 12.2 | 0.1 / 0.3 | 0.2 / 0.6 | 213.4 / 406.5 | 226.7 / 610.9 | 418.8 / 1012.4 | 1237% | ### Without trickle, warm (102 trials) | Library | Create | Offer created | Gathered | ICE connected | Connected | Data channel open | Open, % of aiortc | | --- | --- | --- | --- | --- | --- | --- | --- | -| wrtc 0.0.2 | 0.6 / 0.7 | 0.2 / 0.2 | 1.1 / 1.3 | 3.9 / 4.7 | 5.1 / 5.8 | 5.2 / 5.9 | 18% | -| aiortc 1.15.0 | 0.3 / 0.3 | 0.1 / 0.1 | 2.1 / 2.6 | 26.8 / 27.4 | 28.2 / 28.8 | 29.1 / 31.1 | 100% | -| pion v4.2.23 | 0.8 / 1.4 | 1.9 / 2.7 | 3.7 / 4.6 | 49.7 / 51.1 | 51.5 / 52.9 | 52.2 / 53.8 | 179% | -| libdatachannel 0.24.6 | 0.0 / 0.0 | 0.2 / 0.4 | 0.2 / 0.3 | 1.1 / 2.9 | 2.3 / 5.0 | 2.4 / 5.4 | 8% | -| webrtc-rs 0.21.1 | 0.9 / 1.3 | 0.0 / 0.1 | 0.1 / 0.2 | 5.2 / 86.0 | 15.7 / 104.6 | 211.5 / 300.4 | 724% | +| wrtc 0.0.3 | 0.5 / 0.7 | 0.2 / 0.2 | 0.8 / 1.1 | 3.0 / 3.9 | 3.5 / 4.3 | 3.6 / 4.4 | 12% | +| aiortc 1.15.0 | 0.3 / 0.3 | 0.1 / 0.1 | 2.1 / 2.5 | 26.7 / 27.6 | 28.1 / 29.0 | 29.0 / 30.0 | 100% | +| pion v4.2.23 | 0.8 / 1.4 | 1.9 / 2.8 | 3.6 / 4.4 | 46.7 / 47.9 | 48.5 / 49.9 | 49.4 / 50.7 | 170% | +| libdatachannel 0.24.6 | 0.0 / 0.0 | 0.2 / 0.3 | 0.2 / 0.2 | 1.0 / 2.0 | 2.2 / 3.5 | 2.3 / 3.7 | 8% | +| webrtc-rs 0.21.1 | 1.1 / 1.3 | 0.0 / 0.1 | 0.2 / 0.2 | 5.5 / 79.9 | 15.8 / 107.9 | 212.2 / 303.6 | 730% | ### Without trickle, cold: fresh processes (10 trials) | Library | Create | Offer created | Gathered | ICE connected | Connected | Data channel open | Open, % of aiortc | | --- | --- | --- | --- | --- | --- | --- | --- | -| wrtc 0.0.2 | 1.7 / 2.2 | 0.3 / 0.3 | 1.5 / 1.7 | 5.0 / 5.7 | 6.1 / 6.9 | 6.3 / 7.0 | 19% | -| aiortc 1.15.0 | 3.5 / 3.6 | 0.1 / 0.1 | 2.3 / 2.3 | 27.3 / 27.6 | 32.5 / 33.0 | 33.5 / 34.0 | 100% | -| pion v4.2.23 | 0.4 / 0.8 | 0.8 / 1.5 | 1.3 / 2.5 | 25.7 / 35.1 | 26.6 / 36.3 | 27.3 / 36.9 | 80% | -| libdatachannel 0.24.6 | 0.8 / 1.6 | 2.7 / 5.2 | 2.7 / 5.2 | 4.3 / 7.8 | 5.9 / 10.4 | 6.1 / 10.7 | 18% | -| webrtc-rs 0.21.1 | 0.5 / 1.2 | 0.0 / 0.1 | 0.1 / 0.2 | 10.4 / 52.7 | 16.7 / 56.7 | 60.6 / 239.8 | 129% | +| wrtc 0.0.3 | 2.3 / 2.4 | 0.3 / 0.3 | 1.1 / 1.1 | 4.0 / 4.4 | 4.4 / 4.9 | 4.6 / 5.0 | 13% | +| aiortc 1.15.0 | 3.5 / 3.7 | 0.1 / 0.1 | 2.2 / 2.3 | 27.4 / 27.5 | 32.7 / 33.0 | 33.7 / 34.0 | 100% | +| pion v4.2.23 | 0.4 / 3.9 | 0.7 / 4.6 | 1.2 / 8.2 | 20.9 / 36.6 | 22.3 / 37.9 | 22.7 / 38.6 | 67% | +| libdatachannel 0.24.6 | 0.8 / 1.5 | 2.7 / 5.4 | 2.6 / 5.3 | 4.3 / 8.0 | 5.8 / 10.6 | 6.1 / 11.0 | 18% | +| webrtc-rs 0.21.1 | 1.0 / 1.1 | 0.1 / 0.1 | 0.2 / 0.2 | 12.0 / 37.0 | 17.5 / 40.4 | 213.5 / 240.1 | 631% | ### Cost of a warm trial, trickle | Library | CPU, both (ms) | Peak RSS, offerer (MB) | Loop lag p95 (ms) | Failed trials | | --- | --- | --- | --- | --- | -| wrtc 0.0.2 | 14.5 | 55 | 0.5 | 0 | -| aiortc 1.15.0 | 11.6 | 70 | 0.7 | 0 | -| pion v4.2.23 | 96.2 | 23 | - | 0 | -| libdatachannel 0.24.6 | 4.3 | 11 | - | 0 | -| webrtc-rs 0.21.1 | 14.7 | 8 | - | 0 | +| wrtc 0.0.3 | 10.8 | 55 | 0.3 | 0 | +| aiortc 1.15.0 | 11.7 | 71 | 0.8 | 0 | +| pion v4.2.23 | 98.3 | 23 | - | 0 | +| libdatachannel 0.24.6 | 4.7 | 10 | - | 0 | +| webrtc-rs 0.21.1 | 17.4 | 8 | - | 0 | ## Data channels @@ -73,54 +71,54 @@ Medians; % columns are of aiortc's. How: [README](README.md#method). ### Throughput (Mbps) -| Flood | wrtc 0.0.2 | aiortc 1.15.0 | pion v4.2.23 | libdatachannel 0.24.6 | webrtc-rs 0.21.1 | wrtc, % of aiortc | pion, % of aiortc | libdatachannel, % of aiortc | webrtc-rs, % of aiortc | +| Flood | wrtc 0.0.3 | aiortc 1.15.0 | pion v4.2.23 | libdatachannel 0.24.6 | webrtc-rs 0.21.1 | wrtc, % of aiortc | pion, % of aiortc | libdatachannel, % of aiortc | webrtc-rs, % of aiortc | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| ordered 1 KB x1 | 812 | 91 | 490 | 448 | 1187 | 894% | 539% | 494% | 1307% | -| ordered 1 KB x10 | 745 | 93 | 990 | 1026 | 1261 | 801% | 1064% | 1103% | 1355% | -| ordered 64 KB x1 | 1084 | 197 | 532 | 622 | 705 | 551% | 271% | 317% | 358% | -| ordered 64 KB x10 | 1110 | 173 | 1135 | 1158 | 1429 | 642% | 657% | 670% | 827% | -| unordered 1 KB x1 | 759 | 89 | 492 | 441 | 818 | 853% | 553% | 495% | 920% | -| unordered 1 KB x10 | 727 | 93 | 1023 | 1035 | 721 | 780% | 1097% | 1111% | 774% | -| unordered 64 KB x1 | 1100 | 176 | 533 | 625 | 839 | 626% | 303% | 356% | 477% | -| unordered 64 KB x10 | 1105 | 245 | 1143 | 1194 | 680 | 451% | 466% | 487% | 277% | +| ordered 1 KB x1 | 1215 | 90 | 517 | 478 | 684 | 1353% | 575% | 532% | 762% | +| ordered 1 KB x10 | 1282 | 95 | 1058 | 1039 | 1260 | 1353% | 1117% | 1097% | 1330% | +| ordered 64 KB x1 | 2071 | 183 | 485 | 653 | 1618 | 1133% | 265% | 357% | 885% | +| ordered 64 KB x10 | 1865 | 241 | 1135 | 1152 | 1307 | 772% | 470% | 477% | 541% | +| unordered 1 KB x1 | 1232 | 89 | 464 | 472 | 1185 | 1379% | 519% | 528% | 1326% | +| unordered 1 KB x10 | 1385 | 95 | 1037 | 1046 | 808 | 1460% | 1093% | 1103% | 852% | +| unordered 64 KB x1 | 2035 | 192 | 544 | 650 | 459 | 1059% | 283% | 338% | 239% | +| unordered 64 KB x10 | 1915 | 241 | 1137 | 1164 | 720 | 793% | 471% | 482% | 298% | ### Message rate (messages/s) -| Flood | wrtc 0.0.2 | aiortc 1.15.0 | pion v4.2.23 | libdatachannel 0.24.6 | webrtc-rs 0.21.1 | wrtc, % of aiortc | pion, % of aiortc | libdatachannel, % of aiortc | webrtc-rs, % of aiortc | +| Flood | wrtc 0.0.3 | aiortc 1.15.0 | pion v4.2.23 | libdatachannel 0.24.6 | webrtc-rs 0.21.1 | wrtc, % of aiortc | pion, % of aiortc | libdatachannel, % of aiortc | webrtc-rs, % of aiortc | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| ordered 1 KB x1 | 99113 | 11086 | 59782 | 54721 | 144848 | 894% | 539% | 494% | 1307% | -| ordered 1 KB x10 | 90983 | 11361 | 120850 | 125260 | 153925 | 801% | 1064% | 1103% | 1355% | -| ordered 64 KB x1 | 2067 | 375 | 1015 | 1187 | 1344 | 551% | 271% | 317% | 358% | -| ordered 64 KB x10 | 2118 | 330 | 2165 | 2210 | 2725 | 642% | 657% | 670% | 827% | -| unordered 1 KB x1 | 92678 | 10864 | 60066 | 53800 | 99905 | 853% | 553% | 495% | 920% | -| unordered 1 KB x10 | 88691 | 11377 | 124819 | 126374 | 88016 | 780% | 1097% | 1111% | 774% | -| unordered 64 KB x1 | 2098 | 335 | 1017 | 1192 | 1600 | 626% | 303% | 356% | 477% | -| unordered 64 KB x10 | 2107 | 468 | 2179 | 2277 | 1298 | 451% | 466% | 487% | 277% | +| ordered 1 KB x1 | 148350 | 10964 | 63078 | 58348 | 83512 | 1353% | 575% | 532% | 762% | +| ordered 1 KB x10 | 156464 | 11562 | 129161 | 126857 | 153778 | 1353% | 1117% | 1097% | 1330% | +| ordered 64 KB x1 | 3951 | 349 | 926 | 1245 | 3087 | 1133% | 265% | 357% | 885% | +| ordered 64 KB x10 | 3557 | 461 | 2165 | 2197 | 2492 | 772% | 470% | 477% | 541% | +| unordered 1 KB x1 | 150376 | 10904 | 56647 | 57566 | 144599 | 1379% | 519% | 528% | 1326% | +| unordered 1 KB x10 | 169038 | 11578 | 126573 | 127670 | 98637 | 1460% | 1093% | 1103% | 852% | +| unordered 64 KB x1 | 3882 | 367 | 1038 | 1240 | 876 | 1059% | 283% | 338% | 239% | +| unordered 64 KB x10 | 3652 | 461 | 2169 | 2221 | 1374 | 793% | 471% | 482% | 298% | ### CPU per MB delivered, both processes (ms) - % below 100 is cheaper. -| Flood | wrtc 0.0.2 | aiortc 1.15.0 | pion v4.2.23 | libdatachannel 0.24.6 | webrtc-rs 0.21.1 | wrtc, % of aiortc | pion, % of aiortc | libdatachannel, % of aiortc | webrtc-rs, % of aiortc | +| Flood | wrtc 0.0.3 | aiortc 1.15.0 | pion v4.2.23 | libdatachannel 0.24.6 | webrtc-rs 0.21.1 | wrtc, % of aiortc | pion, % of aiortc | libdatachannel, % of aiortc | webrtc-rs, % of aiortc | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| ordered 1 KB x1 | 30 | 130 | 85 | 67 | 12 | 23% | 65% | 52% | 9% | -| ordered 1 KB x10 | 32 | 131 | 55 | 53 | 34 | 24% | 42% | 41% | 26% | -| ordered 64 KB x1 | 15 | 74 | 74 | 44 | 10 | 21% | 101% | 60% | 14% | -| ordered 64 KB x10 | 15 | 81 | 51 | 46 | 31 | 18% | 62% | 56% | 38% | -| unordered 1 KB x1 | 32 | 132 | 86 | 69 | 14 | 24% | 65% | 53% | 11% | -| unordered 1 KB x10 | 33 | 130 | 54 | 53 | 59 | 25% | 41% | 40% | 45% | -| unordered 64 KB x1 | 15 | 80 | 75 | 43 | 12 | 19% | 94% | 53% | 15% | -| unordered 64 KB x10 | 15 | 92 | 50 | 44 | 72 | 16% | 54% | 48% | 78% | +| ordered 1 KB x1 | 20 | 132 | 82 | 62 | 13 | 15% | 62% | 47% | 10% | +| ordered 1 KB x10 | 19 | 127 | 54 | 52 | 34 | 15% | 43% | 41% | 27% | +| ordered 64 KB x1 | 8 | 78 | 83 | 41 | 10 | 10% | 107% | 52% | 12% | +| ordered 64 KB x10 | 9 | 85 | 51 | 46 | 31 | 10% | 60% | 54% | 37% | +| unordered 1 KB x1 | 20 | 131 | 90 | 64 | 15 | 15% | 68% | 49% | 11% | +| unordered 1 KB x10 | 17 | 126 | 56 | 49 | 53 | 14% | 44% | 38% | 42% | +| unordered 64 KB x1 | 8 | 76 | 75 | 40 | 23 | 11% | 99% | 53% | 30% | +| unordered 64 KB x10 | 9 | 86 | 50 | 46 | 66 | 10% | 58% | 54% | 76% | ### Round trip of 1 KB messages at 100 a second | Library | RTT p50 / p95 (ms) | Lost | p50, % of aiortc | | --- | --- | --- | --- | -| wrtc 0.0.2 | 0.7 / 1.3 | 0 | 73% | -| aiortc 1.15.0 | 1.0 / 1.3 | 0 | 100% | -| pion v4.2.23 | 0.4 / 0.7 | 0 | 44% | -| libdatachannel 0.24.6 | 0.4 / 0.5 | 0 | 38% | -| webrtc-rs 0.21.1 | 0.6 / 1.0 | 0 | 57% | +| wrtc 0.0.3 | 0.6 / 0.8 | 0 | 48% | +| aiortc 1.15.0 | 1.2 / 1.7 | 0 | 100% | +| pion v4.2.23 | 0.5 / 0.9 | 0 | 46% | +| libdatachannel 0.24.6 | 0.5 / 0.7 | 0 | 39% | +| webrtc-rs 0.21.1 | 0.7 / 1.2 | 0 | 59% | ## Memory @@ -133,39 +131,39 @@ Medians; % columns are of aiortc's. How: [README](README.md#method). | Library | Baseline (MB) | Created (KB) | Data channel (KB) | Audio (KB) | Created, % of aiortc | Data channel, % of aiortc | Audio, % of aiortc | | --- | --- | --- | --- | --- | --- | --- | --- | -| wrtc 0.0.2 | 91 | 144 | 805 | 1674 | 328% | 164% | 126% | -| aiortc 1.15.0 | 126 | 44 | 492 | 1325 | 100% | 100% | 100% | -| pion v4.2.23 | 28 | 94 | 2902 | 2715 | 214% | 590% | 205% | -| libdatachannel 0.24.6 | 5 | 23 | 772 | - | 52% | 157% | - | -| webrtc-rs 0.21.1 | 6 | 238 | 542 | - | 543% | 110% | - | +| wrtc 0.0.3 | 92 | 146 | 814 | 1701 | 335% | 166% | 129% | +| aiortc 1.15.0 | 128 | 44 | 491 | 1322 | 100% | 100% | 100% | +| pion v4.2.23 | 28 | 94 | 3323 | 2795 | 216% | 677% | 212% | +| libdatachannel 0.24.6 | 5 | 22 | 771 | - | 51% | 157% | - | +| webrtc-rs 0.21.1 | 6 | 238 | 542 | - | 547% | 110% | - | ### Kept after closing every connection (MB over the baseline) | Library | Created | Data channel | Audio | | --- | --- | --- | --- | -| wrtc 0.0.2 | 37.7 | 193.9 | 369.6 | -| aiortc 1.15.0 | 14.1 | 108.2 | 228.4 | -| pion v4.2.23 | 22.3 | 592.1 | 575.5 | -| libdatachannel 0.24.6 | 14.1 | 169.6 | - | -| webrtc-rs 0.21.1 | 52.8 | 117.3 | - | +| wrtc 0.0.3 | 36.7 | 195.1 | 376.1 | +| aiortc 1.15.0 | 14.2 | 108.3 | 227.9 | +| pion v4.2.23 | 22.6 | 680.3 | 594.2 | +| libdatachannel 0.24.6 | 13.8 | 168.9 | - | +| webrtc-rs 0.21.1 | 52.9 | 117.3 | - | ### RSS of both processes by connections (MB) | Library | State | 1 | 10 | 50 | 100 | 200 | | --- | --- | --- | --- | --- | --- | --- | -| wrtc 0.0.2 | created | 100 | 101 | 107 | 114 | 128 | -| wrtc 0.0.2 | datachannel | 104 | 112 | 144 | 184 | 264 | -| wrtc 0.0.2 | audio | 106 | 123 | 189 | 272 | 440 | -| aiortc 1.15.0 | created | 132 | 132 | 134 | 136 | 140 | -| aiortc 1.15.0 | datachannel | 135 | 140 | 160 | 184 | 233 | -| aiortc 1.15.0 | audio | 138 | 151 | 206 | 276 | 402 | -| pion v4.2.23 | created | 31 | 33 | 39 | 42 | 50 | -| pion v4.2.23 | datachannel | 42 | 63 | 144 | 286 | 619 | -| pion v4.2.23 | audio | 42 | 63 | 156 | 284 | 583 | -| libdatachannel 0.24.6 | created | 14 | 15 | 16 | 17 | 19 | -| libdatachannel 0.24.6 | datachannel | 19 | 28 | 58 | 97 | 173 | -| webrtc-rs 0.21.1 | created | 10 | 14 | 24 | 36 | 59 | -| webrtc-rs 0.21.1 | datachannel | 15 | 21 | 43 | 69 | 123 | +| wrtc 0.0.3 | created | 99 | 100 | 106 | 113 | 128 | +| wrtc 0.0.3 | datachannel | 103 | 111 | 145 | 185 | 266 | +| wrtc 0.0.3 | audio | 105 | 123 | 192 | 276 | 445 | +| aiortc 1.15.0 | created | 134 | 135 | 136 | 138 | 143 | +| aiortc 1.15.0 | datachannel | 137 | 142 | 162 | 186 | 235 | +| aiortc 1.15.0 | audio | 141 | 154 | 208 | 277 | 403 | +| pion v4.2.23 | created | 30 | 33 | 38 | 43 | 50 | +| pion v4.2.23 | datachannel | 41 | 61 | 175 | 302 | 707 | +| pion v4.2.23 | audio | 42 | 63 | 150 | 291 | 597 | +| libdatachannel 0.24.6 | created | 14 | 14 | 16 | 17 | 18 | +| libdatachannel 0.24.6 | datachannel | 18 | 27 | 58 | 96 | 173 | +| webrtc-rs 0.21.1 | created | 10 | 14 | 24 | 36 | 58 | +| webrtc-rs 0.21.1 | datachannel | 15 | 21 | 43 | 70 | 123 | ### Most connections @@ -173,11 +171,11 @@ Medians; % columns are of aiortc's. How: [README](README.md#method). | Library | Last good | Stopped by | RSS at the last step (MB) | Setup p95 at the last step (ms) | | --- | --- | --- | --- | --- | -| wrtc 0.0.2 | 1450 | setup failed: wrtc: no dc_open in 15.0 s | 1131 | 455.1 | -| aiortc 1.15.0 | 1100 | setup failed: aiortc: no dc_open in 15.0 s | 674 | 74.6 | -| pion v4.2.23 | 200 | setup failed: pion: no dc_open in 15.0 s | 490 | 1685.5 | -| libdatachannel 0.24.6 | 2000 | reached 2000 connections | 1526 | 161.7 | -| webrtc-rs 0.21.1 | 2000 | reached 2000 connections | 997 | 673.3 | +| wrtc 0.0.3 | 1450 | setup failed: wrtc: no dc_open in 15.0 s | 1156 | 417.0 | +| aiortc 1.15.0 | 1100 | setup failed: aiortc: no dc_open in 15.0 s | 676 | 77.8 | +| pion v4.2.23 | 300 | setup failed: pion: no dc_open in 15.0 s | 896 | 4251.7 | +| libdatachannel 0.24.6 | 2000 | reached 2000 connections | 1546 | 221.0 | +| webrtc-rs 0.21.1 | 2000 | reached 2000 connections | 1084 | 700.6 | ## Media @@ -189,18 +187,18 @@ Medians; % columns are of aiortc's. How: [README](README.md#method). | Case | ffmpeg CPU/frame (ms) | wrtc decoder | wrtc fps | wrtc kbps | wrtc CPU/frame (ms) | aiortc decoder | aiortc fps | aiortc kbps | aiortc CPU/frame (ms) | wrtc, % of aiortc | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| VP8 720p30 2.5 Mbps | 1.1 | libvpx | 29.9 | 2392 | 5.6 | libvpx | 30.0 | 2391 | 5.9 | 94% | -| VP8 1080p30 5 Mbps | 2.3 | libvpx | 30.0 | 4795 | 8.5 | libvpx | 30.0 | 4793 | 9.2 | 92% | -| VP8 1080p60 8 Mbps | 2.1 | libvpx | 60.0 | 7654 | 6.0 | libvpx | 60.0 | 7651 | 6.0 | 101% | -| VP8 2160p30 20 Mbps | 7.8 | libvpx | 29.9 | 19177 | 12.3 | libvpx | 29.5 | 19220 | 14.5 | 85% | -| H264 720p30 2.5 Mbps | 0.7 | VideoToolbox | 30.0 | 2476 | 4.9 | FFmpeg h264 | 30.1 | 2475 | 5.0 | 98% | -| H264 1080p30 5 Mbps | 1.4 | libopus | 30.0 | 4961 | 6.4 | FFmpeg h264 | 30.0 | 4942 | 7.4 | 86% | -| H264 1080p60 8 Mbps | 1.4 | VideoToolbox | 60.0 | 7986 | 4.3 | FFmpeg h264 | 60.0 | 7979 | 5.5 | 79% | -| H264 2160p30 20 Mbps | 5.5 | VideoToolbox | 29.9 | 20060 | 11.1 | FFmpeg h264 | 0.0 | 20043 | - | - | -| H264-High 720p30 2.5 Mbps | 1.2 | VideoToolbox | 30.0 | 2480 | 4.8 | - | - | - | - | - | -| H264-High 1080p30 5 Mbps | 2.4 | VideoToolbox | 30.0 | 4928 | 6.2 | - | - | - | - | - | -| H264-High 1080p60 8 Mbps | 2.2 | VideoToolbox | 60.0 | 8041 | 4.4 | - | - | - | - | - | -| H264-High 2160p30 20 Mbps | 9.3 | VideoToolbox | 29.9 | 19888 | 11.1 | - | - | - | - | - | +| VP8 720p30 2.5 Mbps | 1.1 | libvpx | 29.9 | 2387 | 6.9 | libvpx | 30.0 | 2391 | 7.2 | 96% | +| VP8 1080p30 5 Mbps | 2.3 | libvpx | 30.0 | 4786 | 9.7 | libvpx | 30.0 | 4793 | 10.5 | 92% | +| VP8 1080p60 8 Mbps | 2.1 | libvpx | 59.9 | 7654 | 6.9 | libvpx | 60.0 | 7669 | 7.2 | 97% | +| VP8 2160p30 20 Mbps | 7.7 | libvpx | 30.0 | 19193 | 12.7 | libvpx | 29.5 | 19217 | 14.8 | 86% | +| H264 720p30 2.5 Mbps | 0.7 | VideoToolbox | 30.0 | 2472 | 4.3 | FFmpeg h264 | 30.0 | 2475 | 6.2 | 71% | +| H264 1080p30 5 Mbps | 1.3 | VideoToolbox | 30.0 | 4957 | 6.1 | FFmpeg h264 | 30.0 | 4937 | 8.4 | 72% | +| H264 1080p60 8 Mbps | 1.4 | VideoToolbox | 59.9 | 7984 | 4.6 | FFmpeg h264 | 60.0 | 7985 | 6.2 | 74% | +| H264 2160p30 20 Mbps | 5.3 | VideoToolbox | 30.0 | 20020 | 9.9 | FFmpeg h264 | 0.0 | 20041 | - | - | +| H264-High 720p30 2.5 Mbps | 1.1 | VideoToolbox | 30.0 | 2477 | 4.3 | - | - | - | - | - | +| H264-High 1080p30 5 Mbps | 2.3 | VideoToolbox | 30.0 | 4917 | 6.2 | - | - | - | - | - | +| H264-High 1080p60 8 Mbps | 2.2 | VideoToolbox | 59.9 | 8042 | 4.5 | - | - | - | - | - | +| H264-High 2160p30 20 Mbps | 9.3 | VideoToolbox | 30.0 | 19842 | 9.8 | - | - | - | - | - | ### Video send @@ -208,14 +206,14 @@ Medians; % columns are of aiortc's. How: [README](README.md#method). | Case | wrtc encoder | wrtc fps | wrtc kbps | wrtc CPU/frame (ms) | aiortc encoder | aiortc fps | aiortc kbps | aiortc CPU/frame (ms) | wrtc, % of aiortc | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| H264 720p30 2.5 Mbps | VideoToolbox | 30.0 | 2254 | 2.9 | libx264 | 30.0 | 2509 | 29.5 | 10% | -| VP8 720p30 1.5 Mbps | libvpx | 30.0 | 1328 | 12.9 | libvpx | 30.0 | 1509 | 12.0 | 107% | -| VP8 1080p30 1.5 Mbps | libvpx | 16.0 | 1087 | 26.0 | libvpx | 30.0 | 1787 | 19.1 | 136% | -| VP8 1080p60 1.5 Mbps | libvpx | 8.8 | 740 | 34.7 | libvpx | 60.0 | 3610 | 12.5 | 277% | -| VP8 2160p30 1.5 Mbps | libvpx | 3.3 | 920 | 96.9 | libvpx | 30.0 | 7482 | 34.4 | 282% | -| H264 1080p30 3 Mbps | VideoToolbox | 30.0 | 2736 | 3.6 | libx264 | 30.0 | 3017 | 35.5 | 10% | -| H264 1080p60 3 Mbps | VideoToolbox | 60.0 | 2622 | 2.9 | libx264 | 60.0 | 6003 | 27.3 | 11% | -| H264 2160p30 3 Mbps | VideoToolbox | 18.2 | 2803 | 7.9 | libx264 | 30.0 | 3001 | 63.6 | 12% | +| H264 720p30 2.5 Mbps | VideoToolbox | 30.0 | 2271 | 3.4 | libx264 | 30.0 | 2509 | 33.4 | 10% | +| VP8 720p30 1.5 Mbps | libvpx | 30.0 | 1330 | 14.8 | libvpx | 30.0 | 1509 | 13.9 | 107% | +| VP8 1080p30 1.5 Mbps | libvpx | 15.7 | 1084 | 29.5 | libvpx | 30.0 | 1787 | 21.9 | 135% | +| VP8 1080p60 1.5 Mbps | libvpx | 9.0 | 756 | 39.0 | libvpx | 60.0 | 3611 | 13.4 | 290% | +| VP8 2160p30 1.5 Mbps | libvpx | 3.2 | 894 | 102.9 | libvpx | 30.0 | 7482 | 34.5 | 298% | +| H264 1080p30 3 Mbps | VideoToolbox | 30.0 | 2713 | 4.1 | libx264 | 30.1 | 3031 | 38.2 | 11% | +| H264 1080p60 3 Mbps | VideoToolbox | 60.0 | 2609 | 3.1 | libx264 | 60.0 | 6003 | 27.5 | 11% | +| H264 2160p30 3 Mbps | VideoToolbox | 18.2 | 2757 | 7.7 | libx264 | 30.0 | 3001 | 64.4 | 12% | ### Video call @@ -223,14 +221,14 @@ Medians; % columns are of aiortc's. How: [README](README.md#method). | Case | wrtc decoder | wrtc fps | wrtc kbps | wrtc latency p50 / p95 (ms) | wrtc receiver / sender CPU/frame (ms) | aiortc decoder | aiortc fps | aiortc kbps | aiortc latency p50 / p95 (ms) | aiortc receiver / sender CPU/frame (ms) | wrtc, % of aiortc | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| VP8 720p30 2.5 Mbps | libvpx | 30.0 | 2506 | 21.2 / 23.1 | 4.9 / 10.3 | libvpx | 30.0 | 1471 | 42.7 / 45.0 | 3.9 / 10.2 | 125% | -| VP8 1080p30 5 Mbps | libvpx | 30.1 | 5002 | 21.4 / 23.1 | 5.3 / 12.5 | libvpx | 30.1 | 1789 | 43.3 / 45.3 | 4.3 / 14.0 | 125% | -| H264 720p30 2.5 Mbps | VideoToolbox | 30.0 | 2520 | 24.0 / 25.7 | 5.2 / 3.0 | FFmpeg h264 | 30.0 | 2339 | 43.4 / 45.3 | 3.1 / 26.9 | 166% | -| H264 1080p30 5 Mbps | VideoToolbox | 30.0 | 5009 | 30.0 / 32.1 | 6.3 / 3.3 | FFmpeg h264 | 30.0 | 2264 | 43.5 / 45.2 | 3.9 / 33.3 | 162% | -| VP9 720p30 2.5 Mbps | libvpx | 30.0 | 2509 | 21.1 / 23.0 | 5.4 / 16.8 | - | - | - | - | - | - | -| VP9 1080p30 5 Mbps | libvpx | 30.0 | 5013 | 21.2 / 23.3 | 8.5 / 18.7 | - | - | - | - | - | - | -| AV1 720p30 2.5 Mbps | dav1d | 30.1 | 2500 | 24.2 / 26.5 | 6.2 / 18.0 | - | - | - | - | - | - | -| AV1 1080p30 5 Mbps | dav1d | 30.0 | 4988 | 26.2 / 28.2 | 6.0 / 20.4 | - | - | - | - | - | - | +| VP8 720p30 2.5 Mbps | libvpx | 30.1 | 2500 | 22.4 / 24.9 | 5.8 / 11.4 | libvpx | 30.1 | 1466 | 43.5 / 45.4 | 4.5 / 11.5 | 130% | +| VP8 1080p30 5 Mbps | libvpx | 30.0 | 5006 | 23.1 / 25.2 | 6.0 / 14.0 | libvpx | 30.1 | 1789 | 44.4 / 46.0 | 4.8 / 15.9 | 126% | +| H264 720p30 2.5 Mbps | VideoToolbox | 30.0 | 2524 | 23.1 / 25.7 | 5.1 / 3.8 | FFmpeg h264 | 30.1 | 2387 | 44.4 / 45.7 | 3.5 / 30.7 | 145% | +| H264 1080p30 5 Mbps | VideoToolbox | 30.1 | 5008 | 28.4 / 31.9 | 6.3 / 4.2 | FFmpeg h264 | 30.0 | 2248 | 44.4 / 45.6 | 4.2 / 37.1 | 149% | +| VP9 720p30 2.5 Mbps | libvpx | 30.1 | 2502 | 21.1 / 23.2 | 6.1 / 19.1 | - | - | - | - | - | - | +| VP9 1080p30 5 Mbps | libvpx | 30.0 | 5012 | 24.3 / 26.6 | 9.9 / 21.0 | - | - | - | - | - | - | +| AV1 720p30 2.5 Mbps | dav1d | 30.0 | 2495 | 26.2 / 28.5 | 7.2 / 21.1 | - | - | - | - | - | - | +| AV1 1080p30 5 Mbps | dav1d | 30.0 | 4988 | 25.5 / 27.5 | 6.2 / 20.6 | - | - | - | - | - | - | ### Audio receive @@ -238,9 +236,9 @@ Medians; % columns are of aiortc's. How: [README](README.md#method). | Case | wrtc CPU per stream (% of a core) | wrtc delivered | wrtc loop lag p95 (ms) | aiortc CPU per stream (% of a core) | aiortc delivered | aiortc loop lag p95 (ms) | wrtc, % of aiortc | | --- | --- | --- | --- | --- | --- | --- | --- | -| 1 stream | 5.26 | 100% | 0.7 | 6.98 | 100% | 1.4 | 75% | -| 10 streams | 2.31 | 100% | 0.7 | 4.13 | 100% | 1.4 | 56% | -| 50 streams | 0.96 | 100% | 0.8 | 1.95 | 100% | 3.5 | 49% | +| 1 stream | 6.47 | 100% | 1.4 | 9.29 | 100% | 1.4 | 70% | +| 10 streams | 2.89 | 100% | 1.4 | 4.75 | 100% | 1.5 | 61% | +| 50 streams | 1.10 | 100% | 1.3 | 2.14 | 100% | 3.8 | 51% | ### Audio send @@ -248,7 +246,7 @@ Medians; % columns are of aiortc's. How: [README](README.md#method). | Case | wrtc CPU per stream (% of a core) | wrtc delivered | wrtc loop lag p95 (ms) | aiortc CPU per stream (% of a core) | aiortc delivered | aiortc loop lag p95 (ms) | wrtc, % of aiortc | | --- | --- | --- | --- | --- | --- | --- | --- | -| 1 stream | 6.58 | 101% | 1.4 | 17.52 | 100% | 1.4 | 38% | -| 10 streams | 2.59 | 101% | 0.9 | 11.45 | 100% | 1.8 | 23% | -| 50 streams | 1.38 | 101% | 1.4 | 6.22 | 100% | 6.4 | 22% | +| 1 stream | 7.49 | 101% | 1.4 | 21.55 | 100% | 1.4 | 35% | +| 10 streams | 3.24 | 101% | 1.4 | 12.78 | 100% | 1.9 | 25% | +| 50 streams | 1.52 | 101% | 2.0 | 6.19 | 100% | 5.2 | 25% | diff --git a/benchmarks/__main__.py b/benchmarks/__main__.py index c5e6b50..e582413 100644 --- a/benchmarks/__main__.py +++ b/benchmarks/__main__.py @@ -24,11 +24,12 @@ import pathlib import platform import sys +import time from typing import IO, TYPE_CHECKING from benchmarks import report from benchmarks.report import SUITES -from benchmarks.runner import BENCHMARKS, LIBRARIES, RESULTS, Driver, Options +from benchmarks.runner import BENCHMARKS, LIBRARIES, RESULTS, Driver, Options, duration from benchmarks.stats import commit, go_version, machine if TYPE_CHECKING: @@ -153,8 +154,15 @@ def _manifest(options: Options, versions: dict[str, Hello]) -> Manifest: async def _run(names: list[str], options: Options) -> Run: + today = dt.datetime.now().astimezone() + options.progress(f'started {today:%Y-%m-%d}: {", ".join(names)} of {", ".join(options.libraries)}') manifest = _manifest(options, await _versions(options.libraries, options.log)) - suites = {name: await SUITES[name].run(options) for name in names} + suites: dict[str, object] = {} + for name in names: + started = time.monotonic() + suites[name] = await SUITES[name].run(options) + options.progress(f'{name}: done in {duration(time.monotonic() - started)}') + options.progress(f'done in {duration(time.monotonic() - options.started)}') return {'manifest': manifest, 'suites': suites} @@ -166,7 +174,12 @@ def main() -> None: stamp = dt.datetime.now().astimezone().strftime('%Y-%m-%dT%H%M%S') name = f'{stamp}-{platform.system().lower()}-{platform.machine().lower()}' with (directory / f'{name}.log').open('ab') as log: - results = asyncio.run(_run(command.suites, command.options(log))) + options = command.options(log) + try: + results = asyncio.run(_run(command.suites, options)) + except KeyboardInterrupt: + options.progress(f'interrupted, no results saved; drivers logged to {log.name}') + sys.exit(130) path = directory / f'{name}.json' path.write_text(json.dumps(results, indent=1) + '\n', encoding='utf-8') diff --git a/benchmarks/charts/datachannel-cpu.png b/benchmarks/charts/datachannel-cpu.png index c647e1f..acc5e70 100644 Binary files a/benchmarks/charts/datachannel-cpu.png and b/benchmarks/charts/datachannel-cpu.png differ diff --git a/benchmarks/charts/datachannel-rtt.png b/benchmarks/charts/datachannel-rtt.png index ce18e41..7861c0b 100644 Binary files a/benchmarks/charts/datachannel-rtt.png and b/benchmarks/charts/datachannel-rtt.png differ diff --git a/benchmarks/charts/datachannel-throughput.png b/benchmarks/charts/datachannel-throughput.png index cdeadfe..f2a5bc4 100644 Binary files a/benchmarks/charts/datachannel-throughput.png and b/benchmarks/charts/datachannel-throughput.png differ diff --git a/benchmarks/charts/media-audio.png b/benchmarks/charts/media-audio.png index 9e2cf7d..0bf3e58 100644 Binary files a/benchmarks/charts/media-audio.png and b/benchmarks/charts/media-audio.png differ diff --git a/benchmarks/charts/media-video-call.png b/benchmarks/charts/media-video-call.png index 78e808a..01d6e83 100644 Binary files a/benchmarks/charts/media-video-call.png and b/benchmarks/charts/media-video-call.png differ diff --git a/benchmarks/charts/media-video-latency.png b/benchmarks/charts/media-video-latency.png index cab9de2..927881c 100644 Binary files a/benchmarks/charts/media-video-latency.png and b/benchmarks/charts/media-video-latency.png differ diff --git a/benchmarks/charts/media-video-receive.png b/benchmarks/charts/media-video-receive.png index aa3fc62..b4bedcc 100644 Binary files a/benchmarks/charts/media-video-receive.png and b/benchmarks/charts/media-video-receive.png differ diff --git a/benchmarks/charts/media-video-send.png b/benchmarks/charts/media-video-send.png index 6436f4d..810e231 100644 Binary files a/benchmarks/charts/media-video-send.png and b/benchmarks/charts/media-video-send.png differ diff --git a/benchmarks/charts/memory.png b/benchmarks/charts/memory.png index b6563bd..0f2097d 100644 Binary files a/benchmarks/charts/memory.png and b/benchmarks/charts/memory.png differ diff --git a/benchmarks/charts/setup.png b/benchmarks/charts/setup.png index 9519988..537b791 100644 Binary files a/benchmarks/charts/setup.png and b/benchmarks/charts/setup.png differ diff --git a/benchmarks/runner.py b/benchmarks/runner.py index 799fe89..aa2dad3 100644 --- a/benchmarks/runner.py +++ b/benchmarks/runner.py @@ -11,6 +11,7 @@ import asyncio import contextlib +import datetime as dt import fnmatch import itertools import os @@ -18,7 +19,8 @@ import shutil import subprocess import sys -from dataclasses import dataclass +import time +from dataclasses import dataclass, field from typing import IO, TYPE_CHECKING, TypedDict, cast import psutil @@ -189,11 +191,22 @@ class Options: repeat: int log: IO[bytes] cases: Sequence[str] = () + started: float = field(default_factory=time.monotonic) def selects(self, case: str) -> bool: """Whether a case of that name runs.""" return len(self.cases) == 0 or any(fnmatch.fnmatchcase(case, pattern) for pattern in self.cases) + def progress(self, message: str) -> None: + """Writes a line to stderr after the time of day and the time since the run started.""" + elapsed = duration(time.monotonic() - self.started) + print(f'{dt.datetime.now().astimezone():%H:%M:%S} +{elapsed} {message}', file=sys.stderr, flush=True) + + +def duration(seconds: float) -> str: + """A span of seconds as H:MM:SS.""" + return str(dt.timedelta(seconds=round(seconds))) + @dataclass class Usage: @@ -269,6 +282,7 @@ async def start(cls, library: str, log: IO[bytes]) -> Self: cwd=ROOT, env={**os.environ, 'WRTC_ALLOW_LOOPBACK': '1'}, limit=LINE_LIMIT, + start_new_session=True, ) driver = cls(library, process) diff --git a/benchmarks/suites/datachannel.py b/benchmarks/suites/datachannel.py index c2a53e3..0b85884 100644 --- a/benchmarks/suites/datachannel.py +++ b/benchmarks/suites/datachannel.py @@ -212,7 +212,7 @@ async def run(options: Options) -> dict[str, Results]: } for library, cell in _cases(options): - print(f'datachannel: {library} {_name(cell)}', file=sys.stderr, flush=True) + options.progress(f'datachannel: {library} {_name(cell)}') if cell is None: results[library]['ping'].append(await ping_run(library, timing, options.log)) else: diff --git a/benchmarks/suites/media.py b/benchmarks/suites/media.py index cad2259..f91c6bf 100644 --- a/benchmarks/suites/media.py +++ b/benchmarks/suites/media.py @@ -394,7 +394,6 @@ async def _attempt(run: Awaitable[Run]) -> Run | None: async def _record(case: Case, plan: Plan, log: IO[bytes], *, results: Results) -> None: """Runs a case into the results, None if it failed.""" - print(f'media: {case.library} {case.kind} {case.name}', file=sys.stderr, flush=True) if case.kind in AUDIO_KINDS: audio = await _attempt(_audio(case, plan, log)) results['audio'].setdefault(case.kind, {}).setdefault(case.name, []).append(audio) @@ -438,6 +437,7 @@ async def run(options: Options) -> MediaResults: shuffled = cases * options.repeat random.shuffle(shuffled) for case in shuffled: + options.progress(f'media: {case.library} {case.kind} {case.name}') await _record(case, plan, options.log, results=results[case.library]) order = list(dict.fromkeys(case.name for case in cases)) diff --git a/benchmarks/suites/memory.py b/benchmarks/suites/memory.py index 26ded46..fdd211e 100644 --- a/benchmarks/suites/memory.py +++ b/benchmarks/suites/memory.py @@ -272,7 +272,7 @@ async def run(options: Options) -> dict[str, Results]: library: {'states': {state: [] for state in STATES}, 'growth': []} for library in options.libraries } for library, state in _cases(options): - print(f'memory: {library} {_name(state)}', file=sys.stderr, flush=True) + options.progress(f'memory: {library} {_name(state)}') if state is None: results[library]['growth'].append(await growth_run(library, sizes, options.log)) else: diff --git a/benchmarks/suites/setup.py b/benchmarks/suites/setup.py index 1a64f99..7323dcf 100644 --- a/benchmarks/suites/setup.py +++ b/benchmarks/suites/setup.py @@ -176,7 +176,7 @@ async def run(options: Options) -> dict[Mode, dict[str, Results]]: } for case in _cases(options): - print(f'setup: {case.library} {case.name}', file=sys.stderr, flush=True) + options.progress(f'setup: {case.library} {case.name}') result, trickle = results[case.mode][case.library], case.mode == 'trickle' if case.cold: result['cold'].append(await cold_trial(case.library, options.log, trickle=trickle)) diff --git a/docs/source/benchmarks.md b/docs/source/benchmarks.md index 6571304..4dd70f8 100644 --- a/docs/source/benchmarks.md +++ b/docs/source/benchmarks.md @@ -22,9 +22,9 @@ processes, and the charts show the medians of each library's newest run. Against aiortc, Python WebRTC: -- opens a connection **5–7x faster**; -- moves **4.5–9x more data** through a data channel, with **4–6x less CPU** per megabyte; -- spends **less CPU on audio** at every stream count, up to 4.5x less when sending; +- opens a connection **7–8x faster**; +- moves **4.4–9x more data** through a data channel, with **4–6x less CPU** per megabyte; +- spends **less CPU on audio** at every stream count, up to 4x less when sending; - encodes H.264 in hardware on macOS, at **a tenth of the CPU** per frame; - delivers call video with **about half the latency**; - holds **more connections** in one process (1450 against 1100). @@ -37,7 +37,7 @@ read worse because of that, and the sections below explain each one. ![Connection setup](https://raw.githubusercontent.com/MarshalX/python-webrtc/main/benchmarks/charts/setup.png) -The time from creating a connection to an open data channel: about 5 ms, against 29 ms for aiortc. Cold runs start +The time from creating a connection to an open data channel: about 3.5 ms, against 29 ms for aiortc. Cold runs start fresh processes. The benchmark keeps the defaults: DTLS 1.3 is on, while SNAP and SPED, the other parts of [WARP](guides/field-trials.md#warp), are off and would save more round trips. @@ -72,7 +72,7 @@ This chart favors aiortc, for two reasons that come from the engine rather than Memory stays reserved after connections close, because the allocator keeps it for reuse: opening and closing 100 connections over and over doesn't grow the process past the first round. -The process itself starts smaller, 91 MB against 126 MB, and holds 1450 connections where aiortc stops at 1100. +The process itself starts smaller, 92 MB against 128 MB, and holds 1450 connections where aiortc stops at 1100. ## Media @@ -81,7 +81,7 @@ The process itself starts smaller, 91 MB against 126 MB, and holds 1450 connecti ![Video receive](https://raw.githubusercontent.com/MarshalX/python-webrtc/main/benchmarks/charts/media-video-receive.png) CPU for each frame received, decoded and read in Python. VP8 decodes with the same library in both, so the bars stay -close. H.264 at 1080p is cheaper, decoded by VideoToolbox on macOS. +close. H.264 is cheaper at every resolution, decoded by VideoToolbox on macOS. ### Video send @@ -102,13 +102,13 @@ encoder on macOS and costs a tenth of the CPU per frame. ![Video call](https://raw.githubusercontent.com/MarshalX/python-webrtc/main/benchmarks/charts/media-video-call.png) Two peers of the same library call each other, and each library's congestion control picks the bitrate. Python WebRTC -reaches the bitrate the video asks for, 2.5 or 5 Mbps, while aiortc stays near 1.5–2.3 Mbps. The panel under the bars +reaches the bitrate the video asks for, 2.5 or 5 Mbps, while aiortc stays near 1.5–2.4 Mbps. The panel under the bars shows it: Python WebRTC receives up to three times the bits for every frame, so a better picture, and decoding more bits costs more CPU per frame. ![Video call latency](https://raw.githubusercontent.com/MarshalX/python-webrtc/main/benchmarks/charts/media-video-latency.png) -From writing a frame on one side to reading it on the other: 21–30 ms against aiortc's 43 ms, at the higher +From writing a frame on one side to reading it on the other: 22–28 ms against aiortc's 44 ms, at the higher bitrate. ### Audio diff --git a/docs/source/guides/aiortc.md b/docs/source/guides/aiortc.md index 67e158c..f133753 100644 --- a/docs/source/guides/aiortc.md +++ b/docs/source/guides/aiortc.md @@ -75,22 +75,12 @@ Behaves differently: ## Performance -Code moved to the layer runs several times faster than on aiortc. aiortc's own benchmark driver, run once on aiortc -1.15 and once on the layer, in the same session: Apple M1, medians of 3 runs. - -| Case | Layer against aiortc | -|------|----------------------| -| Connection setup, to an open data channel | **5.4x faster**: 5.8 ms against 31.4 ms | -| Data channel throughput | **3.9–7.6x more** data a second | -| Audio send, CPU per stream | **3–4x less** | -| Connections in one process | 1100 against 850 | -| Memory per connection | **more**: 3.6x bare, 2x with a data channel, 1.45x with audio | - -Memory per connection is where aiortc is lighter: the engine keeps a full browser's state for each connection. See -[Benchmarks](../benchmarks.md#memory). Video send isn't compared, since the two encode different resolutions. - -Against the same code written for the native API, the layer costs 0–8% more. The difference is one copy per received -video frame into a PyAV frame, and about 24 MB at startup from importing PyAV. +Python WebRTC opens connections 7–8x faster than aiortc, moves 4.4–9x more data through a data channel and spends +less CPU on audio. See the [benchmarks](../benchmarks.md). + +The layer gives up some of that for compatibility: it costs up to 8% more than the same code on the native API. Each +received video frame is copied into a PyAV frame, and importing PyAV adds about 24 MB at startup. The native API has +neither cost, see [Moving to the native API](#moving-to-the-native-api). ## Moving to the native API diff --git a/docs/source/guides/field-trials.md b/docs/source/guides/field-trials.md index b38a0e8..994db21 100644 --- a/docs/source/guides/field-trials.md +++ b/docs/source/guides/field-trials.md @@ -1,7 +1,7 @@ # Field trials The native WebRTC engine ships some features turned off, behind field trials. A trial has a name, like -`WebRTC-Sctp-Snap`, and a group, usually `Enabled`. Browsers set them with a command-line flag. In python-webrtc, +`WebRTC-Sctp-Snap`, and a group, usually `Enabled`. Browsers set them with a command-line flag. In Python WebRTC, `webrtc.field_trials` sets them: a mapping of names to groups that works like {data}`os.environ`. ```python @@ -32,6 +32,32 @@ The engine reads the trials once, when it starts: on the first {class}`~webrtc.R Trial names change between releases of the engine, which is libwebrtc M152 in this release. An unknown name is accepted and does nothing, as it does in the browsers, so check the spelling. +## Socket receive buffer + +Python WebRTC gives every UDP socket a receive buffer of at least 8 MB, so a burst of data channel messages isn't +dropped while the engine is busy. Setting `WebRTC-SetSocketReceiveBuffer` to any group turns this off: `Enabled-` +sets your size, and `Disabled` leaves it to the engine and the system. + +Linux caps the buffer at `net.core.rmem_max`, often about 200 KB. With that cap, a flood of data channel messages +overflows the buffer, and the channel can stall for seconds while SCTP recovers the lost packets. To lift the cap: + +```bash +sudo sysctl -w net.core.rmem_max=8388608 +``` + +Add `net.core.rmem_max = 8388608` to `/etc/sysctl.conf` to keep it after a reboot. + +## Connected sockets + +On macOS and Linux, when one remote address both sends to a UDP socket and receives from it, like the peer of a busy +data channel, Python WebRTC gives that peer a second socket on the same local address, connected to it. The kernel +then skips its route and socket lookups on every datagram, which makes data channels faster. Packets on the wire don't +change, so the peer can't tell, and every other address keeps using the first socket. + +Set `PythonWebRTC-ConnectedSockets` to `Disabled` to keep one socket per address, as the engine does on its own: to +rule this out while debugging a connection, or on a machine shared with other users, as the second socket shares the +address while it binds. + ## WARP [WARP](https://datatracker.ietf.org/doc/draft-uberti-tsvwg-warp/) connects faster by saving round trips. It's made of four parts, and each of them works on its own: a peer that @@ -55,5 +81,5 @@ The server has to know the `id` of the channel, so the signalling sends it along [OpenAI's WARP guide](https://developers.openai.com/api/docs/guides/realtime-webrtc-warp) describes. The [GPT-Live example](../examples/openai_live.md) connects this way. -With both trials, a data channel between two python-webrtc peers opens 50% sooner, and a GPT-Live session starts 62% +With both trials, a data channel between two Python WebRTC peers opens 50% sooner, and a GPT-Live session starts 62% sooner once the API has answered. diff --git a/docs/source/index.md b/docs/source/index.md index d8a40d2..b952a97 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -109,7 +109,7 @@ Prebuilt wheels for Linux, macOS and Windows, including free-threaded Python. No | VP9 and AV1 | ✅ | ❌ | ✅ | ✅ | | `pip install` on Linux, macOS and Windows | ✅ | ✅ | ✅ | ❌ system packages | -Against aiortc, it opens connections 5–7x faster, moves 4.5–9x more data through a data channel and spends less CPU on +Against aiortc, it opens connections 7–8x faster, moves 4.4–9x more data through a data channel and spends less CPU on audio. See the [benchmarks](benchmarks.md), which also measure Pion, libdatachannel and webrtc-rs. ## Where to start diff --git a/pyproject.toml b/pyproject.toml index 87ab867..b2a3166 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build" [project] name = "wrtc" # "webrtc" isn't allowed on PyPI -version = "0.0.2" +version = "0.0.3" description = "Chrome's WebRTC engine (libwebrtc M152), with the browser API, in Python" readme = "README.md" license = "BSD-3-Clause" diff --git a/python-webrtc/cpp/NATIVE.md b/python-webrtc/cpp/NATIVE.md index 047646a..dae87b8 100644 --- a/python-webrtc/cpp/NATIVE.md +++ b/python-webrtc/cpp/NATIVE.md @@ -125,7 +125,8 @@ Python garbage. - Every change of binding or activity ends in an update of the root on the loop that holds it. A change made from Python goes through `_changing()` (a spent `once` handler too), so roots checks on other loops wait for it. An - unbind updates roots after unbinding, and `LoopState.update` re-reads the binding after storing a root + unbind updates roots after unbinding, on the loop of the mailbox the unbind itself found (`Unbind()` returns its + id), as a loop read before it may be stale. `LoopState.update` re-reads the binding after storing a root (`_recheck`): whichever runs last sees the other. - An object is kept for the events still owed to it, not for the state it shows (an announced data channel reads open before its open event). diff --git a/python-webrtc/cpp/src/codecs/videotoolbox.mm b/python-webrtc/cpp/src/codecs/videotoolbox.mm index 7258c07..f46d213 100644 --- a/python-webrtc/cpp/src/codecs/videotoolbox.mm +++ b/python-webrtc/cpp/src/codecs/videotoolbox.mm @@ -48,6 +48,7 @@ constexpr unsigned kLumaRange = 219; constexpr unsigned kChromaRange = 224; constexpr unsigned kFullRange = 255; + constexpr unsigned kByteBits = 8; // libwebrtc's own H.264 QP thresholds, which its quality scaling applies over an encoder's by default constexpr int kLowQpThreshold = 24; @@ -462,25 +463,54 @@ bool Convert(webrtc::VideoFrame &frame) { } const int width = static_cast(CVPixelBufferGetWidth(pixelBuffer)); const int height = static_cast(CVPixelBufferGetHeight(pixelBuffer)); + const uint8_t *luma = PlaneData(pixelBuffer, 0); + const uint8_t *chroma = PlaneData(pixelBuffer, 1); + if (luma == nullptr || chroma == nullptr) { + CVPixelBufferUnlockBaseAddress(pixelBuffer, kCVPixelBufferLock_ReadOnly); + return false; + } auto i420 = _pool.CreateI420(width, height); - libyuv::NV12ToI420(PlaneData(pixelBuffer, 0), PlaneStride(pixelBuffer, 0), PlaneData(pixelBuffer, 1), - PlaneStride(pixelBuffer, 1), i420->MutableDataY(), i420->StrideY(), i420->MutableDataU(), - i420->StrideU(), i420->MutableDataV(), i420->StrideV(), width, height); + ToVideoRange(luma, PlaneStride(pixelBuffer, 0), chroma, PlaneStride(pixelBuffer, 1), *i420); CVPixelBufferUnlockBaseAddress(pixelBuffer, kCVPixelBufferLock_ReadOnly); - Compress(i420->MutableDataY(), i420->StrideY(), width, height, kLumaRange); - Compress(i420->MutableDataU(), i420->StrideU(), i420->ChromaWidth(), i420->ChromaHeight(), kChromaRange); - Compress(i420->MutableDataV(), i420->StrideV(), i420->ChromaWidth(), i420->ChromaHeight(), kChromaRange); frame.set_video_frame_buffer(i420); return true; } - // 0-255 onto 16-(16 + range); arithmetic rather than a table, so it vectorizes - static void Compress(uint8_t *data, int stride, int width, int height, unsigned range) { - for (int row = 0; row < height; ++row) { - uint8_t *line = data + (static_cast(row) * stride); - for (int column = 0; column < width; ++column) { - line[column] = - static_cast(kRangeOffset + (((line[column] * range) + (kFullRange / 2)) / kFullRange)); + // 0-255 onto 16-(16 + range), rounded; arithmetic rather than a table, so it vectorizes + static uint8_t Compressed(unsigned value, unsigned range) { + // v / 255 as (v + 1 + (v >> 8)) >> 8, exact below 65280: 16-bit lanes, twice as many as 32-bit ones + const auto scaled = static_cast((value * range) + (kFullRange / 2)); + return static_cast(kRangeOffset + + static_cast((scaled + 1 + (scaled >> kByteBits)) >> kByteBits)); + } + + // NV12 into I420 and onto video range in one pass over each row + static void ToVideoRange(const uint8_t *y, int strideY, const uint8_t *uv, int strideUV, webrtc::I420Buffer &i420) { + // the buffer's getters are virtual: read once, so the loops vectorize + const ptrdiff_t width = i420.width(); + const ptrdiff_t height = i420.height(); + const ptrdiff_t chromaWidth = i420.ChromaWidth(); + const ptrdiff_t chromaHeight = i420.ChromaHeight(); + uint8_t *toY = i420.MutableDataY(); + uint8_t *toU = i420.MutableDataU(); + uint8_t *toV = i420.MutableDataV(); + const ptrdiff_t strideToY = i420.StrideY(); + const ptrdiff_t strideToU = i420.StrideU(); + const ptrdiff_t strideToV = i420.StrideV(); + for (ptrdiff_t row = 0; row < height; ++row) { + const uint8_t *from = y + (row * strideY); + uint8_t *line = toY + (row * strideToY); + for (ptrdiff_t column = 0; column < width; ++column) { + line[column] = Compressed(from[column], kLumaRange); + } + } + for (ptrdiff_t row = 0; row < chromaHeight; ++row) { + const uint8_t *from = uv + (row * strideUV); + uint8_t *u = toU + (row * strideToU); + uint8_t *v = toV + (row * strideToV); + for (ptrdiff_t column = 0; column < chromaWidth; ++column) { + u[column] = Compressed(from[2 * column], kChromaRange); + v[column] = Compressed(from[(2 * column) + 1], kChromaRange); } } } diff --git a/python-webrtc/cpp/src/interfaces/peer_connection_factory.cpp b/python-webrtc/cpp/src/interfaces/peer_connection_factory.cpp index 554a4f9..4fc6734 100644 --- a/python-webrtc/cpp/src/interfaces/peer_connection_factory.cpp +++ b/python-webrtc/cpp/src/interfaces/peer_connection_factory.cpp @@ -22,11 +22,15 @@ #include #include #include +#include +#include #include #include +#include #include #include +#include #ifndef _WIN32 #include @@ -49,6 +53,45 @@ namespace python_webrtc { } return thread; } + + // a leaked session keeps libsrtp up: its self-tests (~1 ms) run again after its last session, so once, on a thread + class LibsrtpKeeper { + public: + LibsrtpKeeper() = default; + ~LibsrtpKeeper() { Join(); } + LibsrtpKeeper(const LibsrtpKeeper &) = delete; + LibsrtpKeeper &operator=(const LibsrtpKeeper &) = delete; + LibsrtpKeeper(LibsrtpKeeper &&) = delete; + LibsrtpKeeper &operator=(LibsrtpKeeper &&) = delete; + + void Start() { + if (!_started) { + _started = true; + _thread = std::thread([]() { + constexpr size_t kKeyAndSalt = 30; + const webrtc::ZeroOnFreeBuffer key(kKeyAndSalt); + (void)(new webrtc::SrtpSession())->SetSend(webrtc::kSrtpAes128CmSha1_80, key, {}); + }); + } + } + + // before a fork, so the child doesn't inherit libsrtp's lock held + void Join() { + if (_thread.joinable()) { + _thread.join(); + } + } + + private: + bool _started = false; + std::thread _thread; + }; + + // under PeerConnectionFactory's mutex + LibsrtpKeeper &Libsrtp() { + static LibsrtpKeeper keeper; + return keeper; + } } // namespace // webrtc::CreatePeerConnectionFactory without the RTC event log (holds every packet) or audio processing (no capture) @@ -70,11 +113,8 @@ namespace python_webrtc { std::unique_ptr PeerConnectionFactory::CreatePortAllocator(const webrtc::PeerConnectionInterface::RTCConfiguration &configuration) { - std::unique_ptr allocator; - BlockingCallOn(_workerThread, [&]() { - allocator = python_webrtc::CreatePortAllocator(webrtc::CreateEnvironment(_fieldTrials.CreateCopy()), - _networkManager, _packetSocketFactory); - }); + auto allocator = python_webrtc::CreatePortAllocator(webrtc::CreateEnvironment(_fieldTrials.CreateCopy()), + _networkManager, _packetSocketFactory); // as libwebrtc's factory does allocator->SetPortRange(configuration.port_allocator_config.min_port, configuration.port_allocator_config.max_port); allocator->set_flags(configuration.port_allocator_config.flags); @@ -83,25 +123,32 @@ namespace python_webrtc { PeerConnectionFactory::PeerConnectionFactory() : _fieldTrials(_trials), _networkIgnoreMask(_loopbackAllowed ? 0 : webrtc::kDefaultNetworkIgnoreMask) { - _workerThread = Started(std::make_unique(std::make_unique()), "wrtc-network"); + // the user's WebRTC-SetSocketReceiveBuffer, even Disabled, replaces the floor + const int receiveBuffer = + _fieldTrials.Contains("WebRTC-SetSocketReceiveBuffer") ? 0 : NetworkSocketServer::kReceiveBuffer; + const bool siblings = !_fieldTrials.IsDisabled(NetworkSocketServer::kSiblingsTrial); + _workerThread = + Started(std::make_unique(std::make_unique(receiveBuffer, siblings)), + "wrtc-network"); _signalingThread = Started(webrtc::Thread::Create(), "wrtc-signaling"); // sanitizer builds check that these threads never enter Python _workerThread->PostTask(&TagNativeThread); _signalingThread->PostTask(&TagNativeThread); - BlockingCallOn(_workerThread, [this]() { - _audioDeviceModule = webrtc::make_ref_counted(); - auto *sockets = _workerThread->socketserver(); - _networkManager = - std::make_unique(webrtc::CreateEnvironment(_fieldTrials.CreateCopy()), sockets); - _packetSocketFactory = std::make_shared(sockets); - }); + _audioDeviceModule = webrtc::make_ref_counted(); + auto *sockets = _workerThread->socketserver(); + _networkManager = + std::make_unique(webrtc::CreateEnvironment(_fieldTrials.CreateCopy()), sockets); + _packetSocketFactory = std::make_shared(sockets); + // kept updating, so a connection after an idle gap doesn't enumerate the interfaces again + _workerThread->PostTask([networks = _networkManager.get()]() { networks->StartUpdating(); }); _factory = webrtc::CreateModularPeerConnectionFactory(Dependencies()); if (!_factory) { BlockingCallOn(_workerThread, [this]() { _audioDeviceModule = nullptr; _packetSocketFactory = nullptr; + _networkManager->StopUpdating(); _networkManager = nullptr; }); throw RTCException(webrtc::RTCErrorType::INTERNAL_ERROR, "Failed to create the peer connection factory"); @@ -118,6 +165,7 @@ namespace python_webrtc { BlockingCallOn(_workerThread, [this]() { this->_audioDeviceModule = nullptr; _packetSocketFactory = nullptr; + _networkManager->StopUpdating(); _networkManager = nullptr; }); @@ -138,6 +186,7 @@ namespace python_webrtc { } #endif InitializeSSL(); + Libsrtp().Start(); _started = true; return NativeObject::Create(); } @@ -206,6 +255,7 @@ namespace python_webrtc { pthread_atfork( []() { _mutex.lock(); + Libsrtp().Join(); Dispatcher::LockForFork(); }, []() { diff --git a/python-webrtc/cpp/src/interfaces/rtc_data_channel.cpp b/python-webrtc/cpp/src/interfaces/rtc_data_channel.cpp index ce1b0e0..8ab490d 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_data_channel.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_data_channel.cpp @@ -60,17 +60,6 @@ namespace python_webrtc { } void RTCDataChannel::Init(pybind11::module &m) { - pybind11::class_(m, "DataChannelMessage") - .def_property_readonly("data", [](const DataChannelMessage &message) -> pybind11::object { - const auto *data = message.data.cdata(); - if (message.binary) { - return pybind11::bytes(data, message.data.size()); - } - // the upper layers verify UTF-8, as libwebrtc doesn't - return pybind11::reinterpret_steal( - PyUnicode_DecodeUTF8(data, static_cast(message.data.size()), "replace")); - }); - DefineBinding(pybind11::class_>(m, "RTCDataChannel")) .def_property_readonly("_id", &RTCDataChannel::Id) .def_property_readonly("label", nogil_fn(&RTCDataChannel::GetLabel)) @@ -157,7 +146,16 @@ namespace python_webrtc { } void RTCDataChannel::OnMessage(const webrtc::DataBuffer &buffer) { - Emit("message", DataChannelMessage{.data = buffer.data, .binary = buffer.binary}); + // the buffer is refcounted, so it's shared with the loop thread without a copy + EmitConverted("message", [data = buffer.data, binary = buffer.binary]() -> pybind11::object { + const auto *bytes = data.cdata(); + if (binary) { + return pybind11::bytes(bytes, data.size()); + } + // the upper layers verify UTF-8, as libwebrtc doesn't + return pybind11::reinterpret_steal( + PyUnicode_DecodeUTF8(bytes, static_cast(data.size()), "replace")); + }); } void RTCDataChannel::OnBufferedAmountChange(uint64_t sentDataSize) { @@ -328,8 +326,30 @@ namespace python_webrtc { } void RTCDataChannel::SendNow(webrtc::DataBuffer buffer) { - // posted in call order; libwebrtc reports a failed send's bytes through OnBufferedAmountChange too - _channel->SendAsync(std::move(buffer), nullptr); + bool post = false; + { + const std::scoped_lock lock(_outgoingMutex); + post = _outgoing.empty(); + _outgoing.push_back(std::move(buffer)); + } + if (post) { + _factory->workerThread()->PostTask([weak = weak_from_this()]() { + if (auto self = weak.lock()) { + self->SendOutgoing(); + } + }); + } + } + + void RTCDataChannel::SendOutgoing() { + std::vector outgoing; + { + const std::scoped_lock lock(_outgoingMutex); + outgoing.swap(_outgoing); + } + for (const auto &buffer : outgoing) { + _channel->Send(buffer); + } } void RTCDataChannel::SetStateLocked(DataState state) { diff --git a/python-webrtc/cpp/src/interfaces/rtc_data_channel.h b/python-webrtc/cpp/src/interfaces/rtc_data_channel.h index da35199..c723a27 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_data_channel.h +++ b/python-webrtc/cpp/src/interfaces/rtc_data_channel.h @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -32,13 +33,6 @@ namespace python_webrtc { - // A received message, converted to str or bytes by Python - struct DataChannelMessage { - // refcounted and only read, so it's shared across threads without a copy - webrtc::CopyOnWriteBuffer data; - bool binary; - }; - class RTCDataChannel : public webrtc::DataChannelObserver, public NativeObject, public Emitter { @@ -125,6 +119,7 @@ namespace python_webrtc { private: void SendNow(webrtc::DataBuffer buffer); + void SendOutgoing(); void SetStateLocked(DataState state); @@ -141,6 +136,9 @@ namespace python_webrtc { DataState _lastState = DataState::kConnecting; // sent before libwebrtc opens an announced channel; under _closeMutex std::deque _held; + // sent in one network-thread task: a task per message makes the network thread wait on its queue's lock + std::mutex _outgoingMutex; + std::vector _outgoing; Surfaced _surfacedState; // closing locally sets the state to closing right away, without a closing event; guards the state changes diff --git a/python-webrtc/cpp/src/interfaces/rtc_ice_transport.cpp b/python-webrtc/cpp/src/interfaces/rtc_ice_transport.cpp index a93d3fa..225f1eb 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_ice_transport.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_ice_transport.cpp @@ -41,7 +41,7 @@ namespace python_webrtc { flags |= webrtc::PORTALLOCATOR_ENABLE_IPV6; } allocator->set_flags(flags); - // like our connections: no waits between gathering phases + // no waits between gathering phases allocator->set_step_delay(0); allocator->SetNetworkIgnoreMask(factory.networkIgnoreMask()); return allocator; diff --git a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/descriptions.cpp b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/descriptions.cpp index 250668e..237a478 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/descriptions.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/descriptions.cpp @@ -286,6 +286,8 @@ namespace python_webrtc { if (!self || self->IsClosed()) { completion->Fail(RTCCallbackException(closedError(remote ? "setRemoteDescription" : "setLocalDescription"))); } else if (error.ok()) { + const auto current = self->connection(); + self->_remoteDescribed = current && current->remote_description() != nullptr; if (remote) { // a new remote description may reuse the address of the one the end of candidates was for const std::scoped_lock lock(self->_remoteEndOfCandidatesMutex); @@ -568,21 +570,37 @@ namespace python_webrtc { DescriptionView view; std::vector live; bool shown = false; + bool read = false; + uint64_t generation = 0; + const bool bound = IsBound(); + const auto mailbox = MailboxId(); { // the descriptions as the last event that changed them left them, until the next one const std::scoped_lock lock(_descriptionsMutex); - if (IsBound() && _shown && _shownGeneration == _descriptionsGeneration) { + generation = _descriptionsGeneration; + auto &cached = _liveDescriptions[static_cast(kind)]; + if (bound && _shown && _shownGeneration == generation) { view = _shown->kinds[static_cast(kind)]; live = _shown->live; shown = true; + } else if (bound && cached && cached->generation == generation && cached->mailbox == mailbox) { + view = cached->view; + live = cached->live; + read = true; } } - if (!shown) { + if (!shown && !read) { // the description is owned by the peer connection and must be read on the signaling thread BlockingCallOn(_factory->signalingThread(), [&]() { view = ReadDescription(pc, kind); live = liveDescriptions(pc); }); + if (bound) { + // kept until an event changes the descriptions again + const std::scoped_lock lock(_descriptionsMutex); + _liveDescriptions[static_cast(kind)] = + LiveDescription{.mailbox = mailbox, .generation = generation, .view = view, .live = live}; + } } const std::scoped_lock lock(_descriptionsMutex); diff --git a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/ice.cpp b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/ice.cpp index d83e894..d451e77 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/ice.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/ice.cpp @@ -22,6 +22,10 @@ namespace python_webrtc { return pc ? pc->can_trickle_ice_candidates() : std::nullopt; } + bool RTCPeerConnection::HasRemoteDescription() const { + return _remoteDescribed; + } + template void RTCPeerConnection::EmitGathering(const char *name, const Args &...args) { _heldGathering.Emit([this, name, args...]() { Emit(name, args...); }); @@ -348,25 +352,58 @@ namespace python_webrtc { std::optional sdpMLineIndex, const std::optional &usernameFragment) { Completion completion(std::move(mailbox), token); + if (!connection()) { + completion.Fail(RTCCallbackException(closedError("addIceCandidate"))); + return; + } + if (!_remoteDescribed) { + std::set mids; + if (auto error = candidateSections(nullptr, sdpMid, sdpMLineIndex, usernameFragment, mids)) { + completion.Fail(*error); + } + return; + } + + std::unique_ptr iceCandidate; + std::optional error; + if (!candidate.empty()) { + webrtc::SdpParseError parseError; + iceCandidate = + webrtc::IceCandidate::Create(sdpMid.value_or(""), sdpMLineIndex.value_or(0), candidate, &parseError); + if (!iceCandidate) { + error = RTCCallbackException(webrtc::RTCErrorType::UNSUPPORTED_OPERATION, + "Failed to parse the ICE candidate: " + parseError.description); + } + } + + _factory->signalingThread()->PostTask([weak = weak_from_this(), completion = std::move(completion), + iceCandidate = std::move(iceCandidate), error = std::move(error), sdpMid, + sdpMLineIndex, usernameFragment]() mutable { + if (auto self = weak.lock()) { + self->ApplyIceCandidate(std::move(completion), std::move(iceCandidate), error, sdpMid, sdpMLineIndex, + usernameFragment); + } + }); + } + + void RTCPeerConnection::ApplyIceCandidate(Completion completion, std::unique_ptr candidate, + const std::optional &error, + const std::optional &sdpMid, std::optional sdpMLineIndex, + const std::optional &usernameFragment) { auto pc = connection(); if (!pc) { completion.Fail(RTCCallbackException(closedError("addIceCandidate"))); return; } - std::optional error; std::set mids; - const webrtc::SessionDescriptionInterface *remote = nullptr; - BlockingCallOn(_factory->signalingThread(), [&]() { - remote = pc->remote_description(); - error = candidateSections(remote, sdpMid, sdpMLineIndex, usernameFragment, mids); - }); - if (error) { - completion.Fail(*error); + const auto *remote = pc->remote_description(); + if (auto sectionError = candidateSections(remote, sdpMid, sdpMLineIndex, usernameFragment, mids)) { + completion.Fail(*sectionError); return; } - if (candidate.empty()) { + if (!candidate && !error) { // the end of candidates, which libwebrtc doesn't take: the remote description shows it { const std::scoped_lock lock(_remoteEndOfCandidatesMutex); @@ -380,22 +417,17 @@ namespace python_webrtc { completion.Succeed(); return; } - - webrtc::SdpParseError parseError; - auto iceCandidate = - webrtc::IceCandidate::Create(sdpMid.value_or(""), sdpMLineIndex.value_or(0), candidate, &parseError); - if (!iceCandidate) { - completion.Fail(RTCCallbackException(webrtc::RTCErrorType::UNSUPPORTED_OPERATION, - "Failed to parse the ICE candidate: " + parseError.description)); + if (error) { + completion.Fail(*error); return; } - if (isBlockedCandidate(iceCandidate->candidate())) { + if (isBlockedCandidate(candidate->candidate())) { completion.Succeed(); return; } - auto added = std::make_shared(*iceCandidate); + auto added = std::make_shared(*candidate); // libwebrtc copies the callback auto shared = std::make_shared(std::move(completion)); auto complete = [weak = weak_from_this(), shared, added](webrtc::RTCError error) { @@ -410,7 +442,7 @@ namespace python_webrtc { shared->Fail(RTCCallbackException(std::move(error))); } }; - pc->AddIceCandidate(std::move(iceCandidate), complete); + pc->AddIceCandidate(std::move(candidate), complete); } } // namespace python_webrtc diff --git a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtc_peer_connection.cpp b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtc_peer_connection.cpp index e86df62..80a04c9 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtc_peer_connection.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtc_peer_connection.cpp @@ -37,16 +37,12 @@ namespace python_webrtc { webrtc::PeerConnectionDependencies dependencies(this); dependencies.allocator = _factory->CreatePortAllocator(configuration); - auto *allocator = dependencies.allocator.get(); auto result = _factory->factory()->CreatePeerConnectionOrError(configuration, std::move(dependencies)); if (!result.ok()) { throw RTCException(result.error()); } - // skips libwebrtc's 50 ms waits between gathering phases with nothing to allocate - BlockingCallOn(_factory->workerThread(), [allocator]() { allocator->set_step_delay(0); }); - _jinglePeerConnection = result.MoveValue(); // new until its first event, unless read unbound: libwebrtc's own state runs ahead of the events _surfacedIceGatheringState.Changed(true, IceGatheringState::kIceGatheringNew); @@ -69,6 +65,7 @@ namespace python_webrtc { DefineBinding( pybind11::class_>(m, "RTCPeerConnection")) .def_property_readonly("_id", &RTCPeerConnection::Id) + .def_property_readonly("_hasRemoteDescription", &RTCPeerConnection::HasRemoteDescription) .def(pybind11::init(nogil_factory(+[](const std::optional &configuration) { return RTCPeerConnection::Create(configuration); })), @@ -726,7 +723,7 @@ namespace python_webrtc { return pc && pc->ShouldFireNegotiationNeededEvent(eventId); } - void RTCPeerConnection::Close() { + std::optional RTCPeerConnection::Close() { const auto gathering = GetIceGatheringState(); webrtc::scoped_refptr pc; { @@ -746,7 +743,7 @@ namespace python_webrtc { }); Parking::At("connection.close"); // a closed connection, its channels and transports fire nothing more; remote tracks fire ended - Unbind(); + const auto unbound = Unbind(); { const TrackedLock lock(_wrappersMutex); for (auto &channel : _channels) { @@ -770,6 +767,7 @@ namespace python_webrtc { // wrappers still referenced from Python keep their (closed) state ReleaseWrappers(); + return unbound; } void RTCPeerConnection::SurfaceSignalingState(SignalingState state) { diff --git a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtc_peer_connection.h b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtc_peer_connection.h index 5abfbd0..97d31a9 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtc_peer_connection.h +++ b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtc_peer_connection.h @@ -128,7 +128,8 @@ namespace python_webrtc { void SetConfiguration(const ConfigurationInit &init); - void Close(); + // the id of the mailbox the connection was bound to, whose loop unroots it + std::optional Close(); PeerConnectionState GetConnectionState(); @@ -152,6 +153,8 @@ namespace python_webrtc { std::optional GetCanTrickleIceCandidates(); + [[nodiscard]] bool HasRemoteDescription() const; + // Python asks it when delivering a negotiationneeded event bool ShouldFireNegotiationNeededEvent(uint32_t eventId); @@ -353,6 +356,10 @@ namespace python_webrtc { void ApplyRemoteDescription(Completion completion, webrtc::SdpType type, ParsedDescription description, const std::optional &error); + void ApplyIceCandidate(Completion completion, std::unique_ptr candidate, + const std::optional &error, const std::optional &sdpMid, + std::optional sdpMLineIndex, const std::optional &usernameFragment); + // What completes setLocalDescription (kLocal) or setRemoteDescription (kRemote); created right before the // operation starts, as it holds its events std::function @@ -469,6 +476,13 @@ namespace python_webrtc { // bumped by the events that change descriptions (see RefreshDescriptions) uint64_t _descriptionsGeneration = 0; std::array _descriptionsCachedGeneration{}; + struct LiveDescription { + std::optional mailbox; + uint64_t generation = 0; + DescriptionView view; + std::vector live; + }; + std::array, kDescriptionKinds> _liveDescriptions; std::map _snapshots; uint64_t _lastSnapshot = 0; std::optional _shown{std::in_place}; @@ -478,7 +492,7 @@ namespace python_webrtc { // [[MaxMessageSize]] of the current SCTP transport as the last applied description set it, NaN without one std::atomic _maxMessageSize{std::numeric_limits::quiet_NaN()}; - // media sections of the remote description the remote peer ended the candidates of + std::atomic _remoteDescribed{false}; std::mutex _remoteEndOfCandidatesMutex; std::set _remoteEndOfCandidates; const webrtc::SessionDescriptionInterface *_remoteEndOfCandidatesDescription = nullptr; diff --git a/python-webrtc/cpp/src/media/frame_buffer_pool.h b/python-webrtc/cpp/src/media/frame_buffer_pool.h index 00750cf..68092a5 100644 --- a/python-webrtc/cpp/src/media/frame_buffer_pool.h +++ b/python-webrtc/cpp/src/media/frame_buffer_pool.h @@ -14,6 +14,8 @@ #include #include +#include +#include #include #include #include @@ -25,6 +27,8 @@ namespace python_webrtc { public: static constexpr size_t kDefaultSize = 8; + explicit FrameBufferPool(size_t size = kDefaultSize) : _pool(/*zero_initialize=*/false, size) {} + void Resize(std::optional size) { const std::scoped_lock lock(_mutex); _pool.Resize(size && *size > 0 ? static_cast(*size) : kDefaultSize); @@ -40,6 +44,26 @@ namespace python_webrtc { return webrtc::I420Buffer::Create(width, height); } + webrtc::scoped_refptr CreateI422(int width, int height) { + { + const std::scoped_lock lock(_mutex); + if (auto buffer = _pool.CreateI422Buffer(width, height)) { + return buffer; + } + } + return webrtc::I422Buffer::Create(width, height); + } + + webrtc::scoped_refptr CreateI444(int width, int height) { + { + const std::scoped_lock lock(_mutex); + if (auto buffer = _pool.CreateI444Buffer(width, height)) { + return buffer; + } + } + return webrtc::I444Buffer::Create(width, height); + } + webrtc::scoped_refptr CreateNV12(int width, int height) { { const std::scoped_lock lock(_mutex); @@ -70,7 +94,7 @@ namespace python_webrtc { private: std::mutex _mutex; - webrtc::VideoFrameBufferPool _pool{/*zero_initialize=*/false, kDefaultSize}; + webrtc::VideoFrameBufferPool _pool; }; } // namespace python_webrtc diff --git a/python-webrtc/cpp/src/media/video_frame_buffer.cpp b/python-webrtc/cpp/src/media/video_frame_buffer.cpp index 8ac1ca8..710356c 100644 --- a/python-webrtc/cpp/src/media/video_frame_buffer.cpp +++ b/python-webrtc/cpp/src/media/video_frame_buffer.cpp @@ -10,11 +10,11 @@ #include #include #include +#include #include #include #include -#include #include #include #include @@ -24,8 +24,10 @@ #include +#include "frame_buffer_pool.h" #include "utils/buffer.h" #include "utils/gil.h" +#include "utils/libwebrtc_thread.h" namespace python_webrtc { @@ -123,43 +125,6 @@ namespace python_webrtc { } } - // NV12 planes kept alive by what they point into, wrapped without a copy - class WrappedNV12Buffer : public webrtc::NV12BufferInterface { - public: - WrappedNV12Buffer(int width, int height, const uint8_t *y, int strideY, const uint8_t *uv, int strideUV, - std::shared_ptr> owned) - : _width(width), _height(height), _y(y), _uv(uv), _strideY(strideY), _strideUV(strideUV), - _owned(std::move(owned)) {} - - [[nodiscard]] int width() const override { return _width; } - - [[nodiscard]] int height() const override { return _height; } - - [[nodiscard]] const uint8_t *DataY() const override { return _y; } - - [[nodiscard]] const uint8_t *DataUV() const override { return _uv; } - - [[nodiscard]] int StrideY() const override { return _strideY; } - - [[nodiscard]] int StrideUV() const override { return _strideUV; } - - webrtc::scoped_refptr ToI420() override { - auto i420 = webrtc::I420Buffer::Create(_width, _height); - libyuv::NV12ToI420(_y, _strideY, _uv, _strideUV, i420->MutableDataY(), i420->StrideY(), i420->MutableDataU(), - i420->StrideU(), i420->MutableDataV(), i420->StrideV(), _width, _height); - return i420; - } - - private: - int _width; - int _height; - const uint8_t *_y; - const uint8_t *_uv; - int _strideY; - int _strideUV; - std::shared_ptr> _owned; - }; - // opaque alpha in the 4th byte of each pixel void FillOpaque(uint8_t *dst, size_t stride, int width, int height) { for (int row = 0; row < height; ++row) { @@ -170,6 +135,61 @@ namespace python_webrtc { } } + void CopyRows(const uint8_t *source, size_t sourceStride, uint8_t *target, size_t targetStride, size_t rowBytes, + size_t rows) { + if (sourceStride == rowBytes && targetStride == rowBytes) { + std::memcpy(target, source, rowBytes * rows); + return; + } + for (size_t row = 0; row < rows; ++row) { + std::memcpy(target + (row * targetStride), source + (row * sourceStride), rowBytes); + } + } + + // a frame made from data is mostly sent and dropped before the next, and an idle 2160p one keeps 12 MB + struct DataFramePool : FrameBufferPool { + DataFramePool() : FrameBufferPool(3) {} + }; + + struct PooledBuffer { + webrtc::scoped_refptr buffer; + std::array data{}; + std::array stride{}; + }; + + template + PooledBuffer Planar(webrtc::scoped_refptr buffer) { + PooledBuffer result{.data = {buffer->MutableDataY(), buffer->MutableDataU(), buffer->MutableDataV(), nullptr}, + .stride = {buffer->StrideY(), buffer->StrideU(), buffer->StrideV(), 0}}; + result.buffer = std::move(buffer); + return result; + } + + std::optional Pooled(const PixelFormat &format, int width, int height) { + if (format.bits != kEightBit || format.alpha) { + return std::nullopt; + } + static ForkLocal pools; + auto &pool = pools.Get(); + switch (format.layout) { + case Layout::I420: + return Planar(pool.CreateI420(width, height)); + case Layout::I422: + return Planar(pool.CreateI422(width, height)); + case Layout::I444: + return Planar(pool.CreateI444(width, height)); + case Layout::NV12: { + auto buffer = pool.CreateNV12(width, height); + PooledBuffer result{.data = {buffer->MutableDataY(), buffer->MutableDataUV(), nullptr, nullptr}, + .stride = {buffer->StrideY(), buffer->StrideUV(), 0, 0}}; + result.buffer = std::move(buffer); + return result; + } + default: + return std::nullopt; + } + } + } // namespace void VideoFrameBuffer::Init(pybind11::module &m) { @@ -302,22 +322,26 @@ namespace python_webrtc { } total += static_cast(result->_stride[i]) * rows[i]; } - result->_owned = std::make_shared>(total); - { const gil_release release; + if (auto pooled = Pooled(format, width, height)) { + for (size_t i = 0; i < planes.size(); ++i) { + auto [offset, stride] = layout[i]; + CopyRows(source + offset, stride, pooled->data[i], pooled->stride[i], result->_stride[i], rows[i]); + result->_data[i] = pooled->data[i]; + } + result->_stride = pooled->stride; + result->_webrtc = std::move(pooled->buffer); + return result; + } + result->_owned = std::make_shared>(total); for (size_t i = 0; i < planes.size(); ++i) { auto [offset, stride] = layout[i]; - const size_t rowBytes = result->_stride[i]; - for (size_t row = 0; row < rows[i]; ++row) { - std::memcpy(result->_owned->data() + offsets[i] + (row * rowBytes), source + offset + (row * stride), - rowBytes); - } + CopyRows(source + offset, stride, result->_owned->data() + offsets[i], result->_stride[i], result->_stride[i], + rows[i]); + result->_data[i] = result->_owned->data() + offsets[i]; } } - for (size_t i = 0; i < planes.size(); ++i) { - result->_data[i] = result->_owned->data() + offsets[i]; - } return result; } @@ -483,10 +507,6 @@ namespace python_webrtc { } // the planes live as long as the libwebrtc buffer wrapping them auto keep = [owned = _owned, webrtc = _webrtc]() {}; - if (format.layout == Layout::NV12) { - return webrtc::make_ref_counted(_width, _height, _data[0], _stride[0], _data[1], _stride[1], - _owned); - } if (format.layout == Layout::RGB) { auto buffer = webrtc::I420Buffer::Create(_width, _height); auto convert = format.rgbOrder ? libyuv::ABGRToI420 : libyuv::ARGBToI420; diff --git a/python-webrtc/cpp/src/testing.cpp b/python-webrtc/cpp/src/testing.cpp index d151074..c6d693c 100644 --- a/python-webrtc/cpp/src/testing.cpp +++ b/python-webrtc/cpp/src/testing.cpp @@ -15,6 +15,7 @@ #include "utils/libwebrtc_thread.h" #include "utils/mailbox.h" #include "utils/native_object.h" +#include "utils/network_socket_server.h" #include "utils/parking.h" #include "utils/registry.h" #include "utils/task_queue_factory.h" @@ -35,6 +36,7 @@ #include #include #include +#include #include #include @@ -355,6 +357,23 @@ namespace python_webrtc { BlockingCallOn(factory->signalingThread(), []() {}); }, nogil(), "Returns once the signaling, worker and signaling threads ran what they queued."); + testing.def("sibling_sockets", &NetworkSocketServer::SiblingsMade, + "How many connected sibling sockets the network threads of the process made."); + testing.def( + "udp_receive_buffer", + []() { + const auto factory = PeerConnectionFactory::GetOrCreateDefault(); + auto *thread = factory->workerThread(); + return BlockingCallOn(thread, [thread]() { + const std::unique_ptr socket(thread->socketserver()->CreateSocket(AF_INET, SOCK_DGRAM)); + int size = -1; + if (socket) { + (void)socket->GetOption(webrtc::Socket::OPT_RCVBUF, &size); + } + return size; + }); + }, + nogil(), "The receive buffer of a new UDP socket of the network thread, in bytes as the kernel reports it."); testing.def( "sframe_skip_counters", [](SFrameTransform &transform, uint64_t next) { transform.Context().SkipCounters(next); }, diff --git a/python-webrtc/cpp/src/utils/field_trials.h b/python-webrtc/cpp/src/utils/field_trials.h index e3e9d6c..d40317b 100644 --- a/python-webrtc/cpp/src/utils/field_trials.h +++ b/python-webrtc/cpp/src/utils/field_trials.h @@ -42,6 +42,8 @@ namespace python_webrtc { return it == _trials.end() ? std::string() : it->second; } + [[nodiscard]] bool Contains(std::string_view name) const { return _trials.contains(std::string(name)); } + [[nodiscard]] std::unique_ptr CreateCopy() const override { return std::make_unique(*this); } diff --git a/python-webrtc/cpp/src/utils/mailbox.cpp b/python-webrtc/cpp/src/utils/mailbox.cpp index 7144966..79638eb 100644 --- a/python-webrtc/cpp/src/utils/mailbox.cpp +++ b/python-webrtc/cpp/src/utils/mailbox.cpp @@ -134,7 +134,8 @@ namespace python_webrtc { if (_closed) { _dropped++; } else { - wake = _records.empty(); + wake = !_awake; + _awake = true; _records.push_back(std::move(record)); const WakeEnds *ends = wake && direct ? WakeLocked() : nullptr; if (ends != nullptr) { @@ -199,6 +200,8 @@ namespace python_webrtc { { const std::scoped_lock lock(_mutex); if (_records.empty()) { + // the loop drains until a take finds nothing, so the next post wakes it + _awake = false; return pybind11::none(); } record = std::move(_records.front()); @@ -281,12 +284,13 @@ namespace python_webrtc { (void)Bind(parent.mailbox()); } - void Binding::Unbind() { + std::optional Binding::Unbind() { std::shared_ptr unbound; { const std::scoped_lock lock(_mutex); unbound.swap(_mailbox); } + return unbound ? std::optional(unbound->Id()) : std::nullopt; } bool Binding::IsBound() const { diff --git a/python-webrtc/cpp/src/utils/mailbox.h b/python-webrtc/cpp/src/utils/mailbox.h index 739415c..d4381a1 100644 --- a/python-webrtc/cpp/src/utils/mailbox.h +++ b/python-webrtc/cpp/src/utils/mailbox.h @@ -81,6 +81,7 @@ namespace python_webrtc { const uint64_t _id; mutable std::mutex _mutex; std::deque _records; + bool _awake = false; bool _closed = false; uint64_t _dropped = 0; std::optional _wake; @@ -94,7 +95,8 @@ namespace python_webrtc { void Inherit(const Binding &parent); - void Unbind(); + // the id of the mailbox it was bound to, if any + std::optional Unbind(); [[nodiscard]] bool IsBound() const; @@ -133,12 +135,7 @@ namespace python_webrtc { // an rvalue argument is moved into the record, and from it into Python template void Emit(const char *name, Args &&...args) { - CheckNativeThreadDetached("Emitter::Emit"); - if (!IsBound()) { - return; - } - // nothing keeps it for the record while its constructor runs - auto self = static_cast(this)->weak_from_this().lock(); + auto self = Source(); if (!self) { return; } @@ -152,7 +149,29 @@ namespace python_webrtc { }}); } + template + void EmitConverted(const char *name, Convert convert) { + auto self = Source(); + if (!self) { + return; + } + Post(Record{.source = self, + .target = self->Id(), + .name = name, + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks): the analyzer loses a moved shared_ptr + .args = [self, convert = std::move(convert)]() { return pybind11::make_tuple(self, convert()); }}); + } + private: + std::shared_ptr Source() { + CheckNativeThreadDetached("Emitter::Emit"); + if (!IsBound()) { + return nullptr; + } + // nothing keeps it for the record while its constructor runs + return static_cast(this)->weak_from_this().lock(); + } + friend T; Emitter() = default; diff --git a/python-webrtc/cpp/src/utils/network_socket_server.cpp b/python-webrtc/cpp/src/utils/network_socket_server.cpp index 0954edc..4d78ec1 100644 --- a/python-webrtc/cpp/src/utils/network_socket_server.cpp +++ b/python-webrtc/cpp/src/utils/network_socket_server.cpp @@ -7,16 +7,26 @@ #include "network_socket_server.h" +#include +#include #include +#include +#include +#include + +#ifndef _WIN32 +#include + +#include +#include +#endif #ifdef __APPLE__ #include -#include #include #include -#include #include #endif @@ -24,10 +34,19 @@ namespace python_webrtc { namespace { - // Forwards to a socket, repeating its read event while the reads its handler makes succeed +#ifndef _WIN32 + void ShareAddress(int descriptor, bool share) { + const int value = share ? 1 : 0; + (void)setsockopt(descriptor, SOL_SOCKET, SO_REUSEADDR, &value, sizeof value); + (void)setsockopt(descriptor, SOL_SOCKET, SO_REUSEPORT, &value, sizeof value); + } +#endif + + // Forwards to a socket, repeating its read event while the reads its handler makes succeed; see MakeSibling class DrainingSocket : public webrtc::Socket { public: - DrainingSocket(webrtc::Socket *socket, webrtc::Buffer &scratch) : _socket(socket), _scratch(scratch) { + DrainingSocket(NetworkSocketServer &server, webrtc::Socket *socket, webrtc::Buffer &scratch, int receiveBuffer) + : _server(server), _socket(socket), _scratch(scratch), _receiveBuffer(receiveBuffer) { // the analyzer loses the callbacks in libwebrtc's receiver list and reports them leaked #ifndef __clang_analyzer__ _socket->SubscribeReadEvent(this, [this](webrtc::Socket *) { Drain(); }); @@ -37,29 +56,88 @@ namespace python_webrtc { #endif } - ~DrainingSocket() override { *_alive = false; } + ~DrainingSocket() override { + *_alive = false; + DropSibling(); + } DrainingSocket(const DrainingSocket &) = delete; DrainingSocket &operator=(const DrainingSocket &) = delete; DrainingSocket(DrainingSocket &&) = delete; DrainingSocket &operator=(DrainingSocket &&) = delete; - [[nodiscard]] webrtc::SocketAddress GetLocalAddress() const override { return _socket->GetLocalAddress(); } + // libwebrtc's UDP socket reads it for every packet it sends: kept from the bind, not asked of the kernel + [[nodiscard]] webrtc::SocketAddress GetLocalAddress() const override { + return _local ? *_local : _socket->GetLocalAddress(); + } [[nodiscard]] webrtc::SocketAddress GetRemoteAddress() const override { return _socket->GetRemoteAddress(); } - int Bind(const webrtc::SocketAddress &address) override { return _socket->Bind(address); } - int Connect(const webrtc::SocketAddress &address) override { return _socket->Connect(address); } + int Bind(const webrtc::SocketAddress &address) override { + _local.reset(); + const int result = _socket->Bind(address); + if (result == 0) { + if (auto local = _socket->GetLocalAddress(); !local.IsNil()) { + _local = std::move(local); + } + } + return result; + } + int Connect(const webrtc::SocketAddress &address) override { + _local.reset(); + return _socket->Connect(address); + } int Send(const void *data, size_t size) override { return _socket->Send(data, size); } int SendTo(const void *data, size_t size, const webrtc::SocketAddress &address) override { + if (_sibling && address == _siblingRemote) { + const int sent = _sibling->Send(data, size); + if (sent >= 0) { + return sent; + } + const int error = _sibling->GetError(); +#ifndef _WIN32 + // an unconnected socket never learns of the destination's ICMP port unreachable: neither does this one + if (error == ECONNREFUSED) { + return static_cast(size); + } +#endif + _socket->SetError(error); + return sent; + } + if (address == _lastDestination) { + _sends = std::min(_sends + 1, kSiblingSends); + // both ways, or one connection's datagrams arrive on two queues out of order, which SCTP takes for loss + if (_sends == kSiblingSends && _receives == kSiblingSends && _lastSource == address && !_sibling && + _server.SiblingsAllowed()) { + MakeSibling(address); + } + } else { + _lastDestination = address; + _sends = 1; + } return _socket->SendTo(data, size, address); } int Recv(void *data, size_t size, int64_t *timestamp) override { - return Read(_socket->Recv(data, size, timestamp)); + return Read(Reading()->Recv(data, size, timestamp)); } int RecvFrom(void *data, size_t size, webrtc::SocketAddress *address, int64_t *timestamp) override { - return Read(_socket->RecvFrom(data, size, address, timestamp)); + return Read(Reading()->RecvFrom(data, size, address, timestamp)); } int RecvFrom(ReceiveBuffer &buffer) override { ReceiveBuffer scratch(_scratch); - const int received = Read(_socket->RecvFrom(scratch)); + webrtc::Socket *reading = Reading(); + int received = Read(reading->RecvFrom(scratch)); + if (received >= 0 && reading == _socket.get()) { + // an empty datagram counts too, so that a flood of them can't hold the backlog + _backlog -= std::max(received, 1); + if (scratch.source_address == _lastSource) { + _receives = std::min(_receives + 1, kSiblingSends); + } else { + _lastSource = scratch.source_address; + _receives = 1; + } + } + // drained: libwebrtc's UDP socket reads its local address to log a failed read, but not an empty one + if (received < 0 && (webrtc::IsBlockingError(reading->GetError()) || reading != _socket.get())) { + received = 0; + } buffer.payload.SetData(_scratch.data(), received > 0 ? static_cast(received) : 0); buffer.source_address = scratch.source_address; buffer.arrival_time = scratch.arrival_time; @@ -68,39 +146,155 @@ namespace python_webrtc { } int Listen(int backlog) override { return _socket->Listen(backlog); } webrtc::Socket *Accept(webrtc::SocketAddress *address) override { return _socket->Accept(address); } - int Close() override { return _socket->Close(); } + int Close() override { + _local.reset(); + DropSibling(); + return _socket->Close(); + } [[nodiscard]] int GetError() const override { return _socket->GetError(); } void SetError(int error) override { _socket->SetError(error); } [[nodiscard]] ConnState GetState() const override { return _socket->GetState(); } int GetOption(Option option, int *value) override { return _socket->GetOption(option, value); } - int SetOption(Option option, int value) override { return _socket->SetOption(option, value); } + int SetOption(Option option, int value) override { + std::erase_if(_options, [&](const auto &set) { return set.first == option; }); + _options.emplace_back(option, value); + if (_sibling) { + (void)SetOptionOn(*_sibling, option, value); + } + return SetOptionOn(*_socket, option, value); + } private: - // bounded, so that one busy socket doesn't hold up the thread's other sockets and tasks static constexpr int kMaxReadsPerEvent = 64; + static constexpr int kSiblingSends = 32; + + int SetOptionOn(webrtc::Socket &socket, Option option, int value) const { + if (option == OPT_RCVBUF && value < _receiveBuffer && socket.SetOption(option, _receiveBuffer) == 0) { + return 0; + } + return socket.SetOption(option, value); + } + + [[nodiscard]] webrtc::Socket *Reading() const { return _reading != nullptr ? _reading : _socket.get(); } + + void MakeSibling(const webrtc::SocketAddress &remote) { +#ifndef _WIN32 + if (!_local) { + return; + } + std::unique_ptr sibling(_server.CreateUnwrappedSocket(_local->family(), SOCK_DGRAM)); + if (!sibling) { + return; + } + const int descriptor = Descriptor(*_socket); + const int siblingDescriptor = Descriptor(*sibling); + ShareAddress(descriptor, true); + ShareAddress(siblingDescriptor, true); + const bool connected = sibling->Bind(*_local) == 0 && sibling->Connect(remote) == 0; + ShareAddress(siblingDescriptor, false); + ShareAddress(descriptor, false); + if (!connected) { + return; + } + if (_receiveBuffer > 0) { + (void)sibling->SetOption(OPT_RCVBUF, _receiveBuffer); + } + for (const auto &[option, value] : _options) { + (void)SetOptionOn(*sibling, option, value); + } +#ifndef __clang_analyzer__ + sibling->SubscribeReadEvent(this, [this](webrtc::Socket *) { DrainSibling(); }); + sibling->SubscribeWriteEvent(this, [this](webrtc::Socket *) { NotifyWriteEvent(this); }); +#endif + _sibling = std::move(sibling); + _siblingRemote = remote; + NetworkSocketServer::CountSibling(); + int queued = 0; + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-vararg): FIONREAD is only an ioctl + _backlog = ioctl(descriptor, FIONREAD, &queued) == 0 ? queued : 0; +#else + (void)remote; +#endif + } + + void DropSibling() { + if (!_sibling) { + return; + } + if (_reading == _sibling.get()) { + _reading = nullptr; + } + _sibling->UnsubscribeReadEvent(this); + _sibling->UnsubscribeWriteEvent(this); + _sibling.reset(); + _siblingRemote.Clear(); + } + + void DrainSibling() { + const auto alive = _alive; + while (_backlog > 0) { + const Drained drained = Drain(); + if (!*alive || !_sibling) { + return; + } + if (drained == Drained::kEmpty) { + _backlog = 0; + } + } + _reading = _sibling.get(); + Drain(); + if (*alive) { + _reading = nullptr; + } + } + +#ifndef _WIN32 + static int Descriptor(webrtc::Socket &socket) { + return static_cast(socket).GetDescriptor(); + } +#endif int Read(int result) { _read = result >= 0; return result; } - void Drain() { + enum class Drained : uint8_t { kEmpty, kMore, kDestroyed }; + + Drained Drain() { // a handler may destroy the socket const auto alive = _alive; for (int i = 0; i < kMaxReadsPerEvent; ++i) { _read = false; NotifyReadEvent(this); - if (!*alive || !_read) { - return; + if (!*alive) { + return Drained::kDestroyed; + } + if (!_read) { + return Drained::kEmpty; } } + return Drained::kMore; } + NetworkSocketServer &_server; std::unique_ptr _socket; // the server's, shared by its sockets webrtc::Buffer &_scratch; + const int _receiveBuffer; std::shared_ptr _alive = std::make_shared(true); bool _read = false; + std::optional _local; + // what libwebrtc set, for a sibling made later + std::vector> _options; + std::unique_ptr _sibling; + webrtc::SocketAddress _siblingRemote; + webrtc::SocketAddress _lastDestination; + int _sends = 0; + webrtc::Socket *_reading = nullptr; + int _backlog = 0; + webrtc::SocketAddress _lastSource; + int _receives = 0; }; #ifdef __APPLE__ @@ -182,7 +376,8 @@ namespace python_webrtc { } // namespace #ifdef __APPLE__ - NetworkSocketServer::NetworkSocketServer() : _kqueue(kqueue()) { + NetworkSocketServer::NetworkSocketServer(int receiveBuffer, bool siblings) + : _receiveBuffer(receiveBuffer), _siblings(siblings), _kqueue(kqueue()) { RTC_CHECK(_kqueue >= 0) << "kqueue failed: " << errno; struct kevent wake{}; EV_SET(&wake, 0, EVFILT_USER, EV_ADD | EV_CLEAR, 0, 0, nullptr); @@ -202,6 +397,15 @@ namespace python_webrtc { return Created(dispatcher, type); } + webrtc::Socket *NetworkSocketServer::CreateUnwrappedSocket(int family, int type) { + auto *dispatcher = new KqueueDispatcher(*this); + if (!dispatcher->Create(family, type)) { + delete dispatcher; + return nullptr; + } + return dispatcher; + } + webrtc::Socket *NetworkSocketServer::WrapSocket(SOCKET descriptor) { auto *dispatcher = new KqueueDispatcher(descriptor, *this); if (!dispatcher->Initialize()) { @@ -312,20 +516,28 @@ namespace python_webrtc { PhysicalSocketServer::WakeUp(); } #else - NetworkSocketServer::NetworkSocketServer() = default; + NetworkSocketServer::NetworkSocketServer(int receiveBuffer, bool siblings) + : _receiveBuffer(receiveBuffer), _siblings(siblings) {} NetworkSocketServer::~NetworkSocketServer() = default; webrtc::Socket *NetworkSocketServer::CreateSocket(int family, int type) { return Created(webrtc::PhysicalSocketServer::CreateSocket(family, type), type); } + + webrtc::Socket *NetworkSocketServer::CreateUnwrappedSocket(int family, int type) { + return webrtc::PhysicalSocketServer::CreateSocket(family, type); + } #endif webrtc::Socket *NetworkSocketServer::Created(webrtc::Socket *socket, int type) { if (socket == nullptr || type != SOCK_DGRAM) { return socket; } - return new DrainingSocket(socket, _scratch); + if (_receiveBuffer > 0) { + (void)socket->SetOption(webrtc::Socket::OPT_RCVBUF, _receiveBuffer); + } + return new DrainingSocket(*this, socket, _scratch, _receiveBuffer); } } // namespace python_webrtc diff --git a/python-webrtc/cpp/src/utils/network_socket_server.h b/python-webrtc/cpp/src/utils/network_socket_server.h index 30051a0..1d07c8c 100644 --- a/python-webrtc/cpp/src/utils/network_socket_server.h +++ b/python-webrtc/cpp/src/utils/network_socket_server.h @@ -11,6 +11,9 @@ #include #include +#include +#include + #ifdef __APPLE__ #include #include @@ -23,7 +26,11 @@ namespace python_webrtc { // The network thread's sockets: UDP drains every datagram into one shared buffer; Apple waits with kqueue, not poll class NetworkSocketServer : public webrtc::PhysicalSocketServer { public: - NetworkSocketServer(); + // the kernel may cap it (Linux at net.core.rmem_max) + static constexpr int kReceiveBuffer = 8 << 20; + static constexpr const char *kSiblingsTrial = "PythonWebRTC-ConnectedSockets"; + + NetworkSocketServer(int receiveBuffer, bool siblings); ~NetworkSocketServer() override; NetworkSocketServer(const NetworkSocketServer &) = delete; NetworkSocketServer &operator=(const NetworkSocketServer &) = delete; @@ -31,6 +38,10 @@ namespace python_webrtc { NetworkSocketServer &operator=(NetworkSocketServer &&) = delete; webrtc::Socket *CreateSocket(int family, int type) override; + webrtc::Socket *CreateUnwrappedSocket(int family, int type); + [[nodiscard]] bool SiblingsAllowed() const { return _siblings; } + static int64_t SiblingsMade() { return _siblingsMade.load(); } + static void CountSibling() { ++_siblingsMade; } #ifdef __APPLE__ webrtc::Socket *WrapSocket(SOCKET descriptor) override; @@ -46,6 +57,9 @@ namespace python_webrtc { webrtc::Socket *Created(webrtc::Socket *socket, int type); webrtc::Buffer _scratch; + const int _receiveBuffer; + const bool _siblings; + static inline std::atomic _siblingsMade{0}; #ifdef __APPLE__ struct Watched { diff --git a/python-webrtc/cpp/src/utils/port_allocator.cpp b/python-webrtc/cpp/src/utils/port_allocator.cpp index 08c3316..5e177ec 100644 --- a/python-webrtc/cpp/src/utils/port_allocator.cpp +++ b/python-webrtc/cpp/src/utils/port_allocator.cpp @@ -103,6 +103,15 @@ namespace python_webrtc { // active TCP candidates only, or every TCP port connects to every remote one (interfaces²) set_allow_tcp_listen(false); } + + webrtc::PortAllocatorSession *CreateSessionInternal(absl::string_view contentName, int component, + absl::string_view iceUfrag, + absl::string_view icePwd) override { + // skips libwebrtc's 50 ms waits between gathering phases with nothing to allocate + // the connection sets them right before it starts its candidate pool + set_step_delay(0); + return BasicPortAllocator::CreateSessionInternal(contentName, component, iceUfrag, icePwd); + } }; } // namespace diff --git a/python-webrtc/python/webrtc/interfaces/rtc_data_channel.py b/python-webrtc/python/webrtc/interfaces/rtc_data_channel.py index ae8a044..246b6da 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_data_channel.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_data_channel.py @@ -256,16 +256,16 @@ def _changes_activity(self, name: str) -> bool: @override def _create_event(self, name: str, *args: object) -> webrtc.Event | None: - if name in {'open', 'message'} and self.ready_state != RTCDataChannelState.open: + native = self._native_obj + if name in {'open', 'message'} and native.readyState != RTCDataChannelState.open: # discarded: closed before open, or a message once closing return None if name == 'message': - (message,) = cast('tuple[wrtc.DataChannelMessage]', args) - data: str | bytes | Blob = message.data + (data,) = args # binary_type as of delivery, per the specification - if isinstance(data, bytes) and self._native_obj.binaryType == BinaryType.blob: + if isinstance(data, bytes) and native.binaryType == BinaryType.blob: data = Blob([data]) - return MessageEvent(name, data) + return MessageEvent(name, cast('str | bytes | Blob', data)) if name == 'error': (error,) = cast('tuple[wrtc.RTCCallbackException]', args) return RTCErrorEvent(name, RTCErrorEventInit(_event_error(error))) diff --git a/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py b/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py index c3761f4..02efa68 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py @@ -10,7 +10,6 @@ from __future__ import annotations import asyncio -import contextlib import re import threading from typing import TYPE_CHECKING, Callable, ClassVar, Literal, TypeVar, Union, cast, overload @@ -44,13 +43,11 @@ from webrtc.models.rtp_parameters import RTCRtpCodec from webrtc.utils import lifetime from webrtc.utils.events import AnyHandler, EventTarget, HandlerDecorator -from webrtc.utils.loops import call_native, call_native_batched -from webrtc.utils.operations import OperationsChain, later +from webrtc.utils.loops import LoopState, call_native, call_native_batched +from webrtc.utils.operations import Operation, OperationsChain, later from webrtc.utils.strings import usv_string if TYPE_CHECKING: - from collections.abc import AsyncGenerator - from typing_extensions import Self from webrtc.enums import RTCDtlsTransportState, RTCIceConnectionState, RTCIceTransportState, RTCPeerConnectionState @@ -288,8 +285,7 @@ def _init_native(self, native_obj: wrtc.RTCPeerConnection | None) -> None: self._chain = None self._deferred_negotiation_id = None - @contextlib.asynccontextmanager - async def _operation(self) -> AsyncGenerator[None, None]: + def _operation(self) -> Operation: """Chains an operation, like setting a description, after the running ones (see :obj:`OperationsChain`).""" if self._chain is None: with _chains_lock: @@ -297,8 +293,7 @@ async def _operation(self) -> AsyncGenerator[None, None]: self._chain = OperationsChain(self._chain_emptied, self._is_closed, self._changing) # bound before it's pending: only its loop roots it _ = self._attach() - async with self._chain.operation(): - yield + return self._chain.operation() def _is_closed(self) -> bool: return self.signaling_state == RTCSignalingState.closed @@ -722,6 +717,9 @@ async def add_ice_candidate( async with self._operation(): self._check_state('add an ICE candidate') + if not self._native_obj._hasRemoteDescription: + msg = 'A candidate can only be added once there is a remote description' + raise webrtc.InvalidStateError(msg) await later() await call_native(self._native_obj.addIceCandidate, candidate_str, sdp_mid, sdp_m_line_index, ufrag) @@ -866,7 +864,10 @@ def close(self) -> None: No events follow except remote tracks' ``ended``. See :mdn:`RTCPeerConnection/close`. """ with self._changing(): - self._native_obj.close() + unbound = self._native_obj.close() + state = LoopState.find(unbound) + if state is not None: + state.update(self) async def __aenter__(self) -> Self: return self diff --git a/python-webrtc/python/webrtc/utils/events.py b/python-webrtc/python/webrtc/utils/events.py index 3cc3544..870e759 100644 --- a/python-webrtc/python/webrtc/utils/events.py +++ b/python-webrtc/python/webrtc/utils/events.py @@ -50,23 +50,24 @@ def __call__(self, handler: Callable[[_E_contra], _R], /) -> Callable[[_E_contra ... -def call_handler(loop: asyncio.AbstractEventLoop, handler: Callable[[_E], object], event: _E, *, message: str) -> None: - """Calls a handler; exceptions, async ones too, go to the loop's exception handler.""" +def _report(loop: asyncio.AbstractEventLoop, exception: BaseException, *, message: str, event: object) -> None: + loop.call_exception_handler({'message': message, 'exception': exception, 'event': event}) - def report(exception: BaseException) -> None: - loop.call_exception_handler({'message': message, 'exception': exception, 'event': event}) - - def done(task: asyncio.Future[object]) -> None: - tasks.discard(task) - if not task.cancelled() and (exception := task.exception()) is not None: - report(exception) +def call_handler(loop: asyncio.AbstractEventLoop, handler: Callable[[_E], object], event: _E, *, message: str) -> None: + """Calls a handler; exceptions, async ones too, go to the loop's exception handler.""" try: result = handler(event) except Exception as e: - report(e) + _report(loop, e, message=message, event=event) return - if inspect.isawaitable(result): + if result is not None and inspect.isawaitable(result): + + def done(task: asyncio.Future[object]) -> None: + tasks.discard(task) + if not task.cancelled() and (exception := task.exception()) is not None: + _report(loop, exception, message=message, event=event) + task = asyncio.ensure_future(result, loop=loop) # strong ref until done, or the task could be collected tasks = LoopState.of(loop).tasks @@ -91,13 +92,25 @@ class _Registration(NamedTuple): once: bool +class _Plan(NamedTuple): + """How an event is delivered on a loop, as of the last change of the handlers.""" + + loop: asyncio.AbstractEventLoop + registrations: tuple[_Registration, ...] + elsewhere: bool + + class _Handlers: """An object's handlers by event name, each with its registering loop.""" - __slots__ = ('_lock', '_registrations') + __slots__ = ('_lock', '_plans', '_registrations', 'removals') def __init__(self) -> None: self._registrations: dict[str, list[_Registration]] = {} + # by event name, for the last delivering loop; cleared on every change + self._plans: dict[str, _Plan] = {} + # counts removals, so a delivery knows its handlers are all still registered + self.removals = 0 # re-entrant: gc may release a loop under it, which removes handlers self._lock = threading.RLock() @@ -109,6 +122,7 @@ def add(self, name: str, handler: Handler, loop: asyncio.AbstractEventLoop, *, o registrations.append(_Registration(handler, loop, once)) # re-stored: a loop released under the lock may have dropped the emptied list self._registrations[name] = registrations + self._plans.clear() def remove(self, name: str | None, handler: AnyHandler | None) -> None: """Removes a handler of an event, every handler of it, or every handler of every event.""" @@ -129,7 +143,9 @@ def _keep(self, name: str, keep: Callable[[_Registration], bool]) -> None: return for registration in list(registrations): if not keep(registration): + self.removals += 1 _discard(registrations, registration) + self._plans.clear() if len(registrations) == 0: _ = self._registrations.pop(name, None) @@ -137,10 +153,20 @@ def has(self, name: str, registration: _Registration) -> bool: with self._lock: return any(r is registration for r in self._registrations.get(name, ())) - def for_loop(self, name: str, loop: asyncio.AbstractEventLoop) -> list[_Registration]: - """An event's handlers registered from a loop, in order.""" + def plan(self, name: str, loop: asyncio.AbstractEventLoop) -> _Plan: + """How an event is delivered on a loop; cached until the handlers change.""" + plan = self._plans.get(name) + if plan is not None and plan.loop is loop: + return plan with self._lock: - return [r for r in self._registrations.get(name, ()) if r.loop is loop] + removals = self.removals + registrations = self._registrations.get(name, ()) + mine = tuple(r for r in registrations if r.loop is loop) + plan = _Plan(loop, mine, len(mine) < len(registrations)) + # a later change clears it; not kept if gc removed some meanwhile, or with no handler here to pin the loop + if len(mine) > 0 and self.removals == removals: + self._plans[name] = plan + return plan def all_loops(self) -> list[asyncio.AbstractEventLoop]: """The loops with a handler of any event, in the order of registration.""" @@ -169,7 +195,13 @@ def _discard(registrations: list[_Registration], registration: _Registration) -> return -def _handler_names(registrations: list[_Registration]) -> str: +@functools.cache +def _handler_error(name: str) -> str: + """The message of an exception in a handler of an event; event names are few.""" + return f'Exception in {name!r} event handler' + + +def _handler_names(registrations: tuple[_Registration, ...]) -> str: return ', '.join(getattr(r.handler, '__qualname__', repr(r.handler)) for r in registrations) @@ -179,7 +211,12 @@ def _roots_of(loop: asyncio.AbstractEventLoop | None) -> LoopState | None: def _check_handled( - target: EventTarget[Never], name: str, registrations: list[_Registration], *, roots: LoopState, generation: int + target: EventTarget[Never], + name: str, + registrations: tuple[_Registration, ...], + *, + roots: LoopState, + generation: int, ) -> None: """Asserts an object about to run handlers is rooted or active.""" if not target._rootable: @@ -409,17 +446,18 @@ def _deliver(self, state: LoopState, name: str, args: tuple[object, ...], *, pri handlers = self._handlers if handlers is None: return - loop = state.loop - registrations = handlers.for_loop(name, loop) + plan = handlers.plan(name, state.loop) # read before _open(), so the check can detect a racing change generation = state.generation - event = self._create_event(name, *args) if len(registrations) > 0 and self._open() else None + event = self._create_event(name, *args) if len(plan.registrations) > 0 and self._open() else None if event is not None: if primary and checking_roots(): - _check_handled(self, name, registrations, roots=state, generation=generation) - self._call(loop, name, event, registrations=registrations) - if primary: - self._forward(handlers, name, args, delivering=loop) + _check_handled(self, name, plan.registrations, roots=state, generation=generation) + self._call(plan.loop, name, event, registrations=plan.registrations) + # the handlers may have changed the others + plan = handlers.plan(name, plan.loop) + if primary and plan.elsewhere: + self._forward(handlers, name, args, delivering=plan.loop) def _forward( self, handlers: _Handlers, name: str, args: tuple[object, ...], *, delivering: asyncio.AbstractEventLoop @@ -431,17 +469,24 @@ def _forward( elsewhere.post(functools.partial(self._deliver, elsewhere, name, args, primary=False)) def _call( - self, loop: asyncio.AbstractEventLoop, name: str, event: webrtc.Event, *, registrations: list[_Registration] + self, + loop: asyncio.AbstractEventLoop, + name: str, + event: webrtc.Event, + *, + registrations: tuple[_Registration, ...], ) -> None: event.target = self handlers = self._handlers + removals = handlers.removals if handlers is not None else -1 for registration in registrations: - if handlers is None or not handlers.has(name, registration): + # one removed by an earlier handler isn't called + if handlers is None or (handlers.removals != removals and not handlers.has(name, registration)): continue if registration.once: with self._changing(): handlers.remove(name, registration.handler) - call_handler(loop, registration.handler, event, message=f'Exception in {name!r} event handler') + call_handler(loop, registration.handler, event, message=_handler_error(name)) def _on_event(self, name: str, *args: object) -> None: """Called for every event on the bound loop, before its handlers. diff --git a/python-webrtc/python/webrtc/utils/loops.py b/python-webrtc/python/webrtc/utils/loops.py index 9f13b86..f158c9d 100644 --- a/python-webrtc/python/webrtc/utils/loops.py +++ b/python-webrtc/python/webrtc/utils/loops.py @@ -28,16 +28,19 @@ if TYPE_CHECKING: from collections.abc import Generator - from typing_extensions import Concatenate, Never, ParamSpec + from typing_extensions import Concatenate, Never, ParamSpec, TypeAlias + from webrtc.base import WebRTCObject from webrtc.utils.events import EventTarget _P = ParamSpec('_P') -_T = TypeVar('_T') + #: (event name, token, failed, args with the native object first) + _EventRecord: TypeAlias = tuple[str, int, bool, tuple[object, ...]] + #: an event record, or a marker or completion record: (None, token, failed, args or None) + _Record: TypeAlias = _EventRecord | tuple[None, int, bool, tuple[object, ...] | None] -#: (event name or None, token, failed, args or None) -_Record = tuple['str | None', int, bool, 'tuple[object, ...] | None'] +_T = TypeVar('_T') class _Item(NamedTuple): @@ -163,7 +166,10 @@ def __init__(self, loop: asyncio.AbstractEventLoop) -> None: self._released = False # the last item resumed code that runs before the next one self._resumed = False - self._held: _Item | None = None + # an event record is held as is + self._held: _Item | _EventRecord | None = None + # the last event's native object and wrapper, while a batch runs + self._recent: tuple[object, WebRTCObject[object]] | None = None # the mailbox's wake fd while the loop watches it self._wake_fd: int | None = None # _watch ran: the fd is tried once @@ -178,6 +184,12 @@ def loop(self) -> asyncio.AbstractEventLoop: """:obj:`asyncio.AbstractEventLoop`: The loop.""" return self._live_loop + @property + def drain_scheduled(self) -> bool: + """:obj:`bool`: Whether a drain of the mailbox is scheduled on the loop.""" + with self._lock: + return self._scheduled + @property def _live_loop(self) -> asyncio.AbstractEventLoop: """The loop, alive when called from its callbacks.""" @@ -379,29 +391,33 @@ def _run_batch(self) -> bool: """Runs items until one must wait; returns whether any are left.""" if self._take() is None: return False - for _ in range(self.MAX_BATCH): - resumes = self._run() - item = self._take() - if item is None: - return False - if self._yields(resumes=resumes, after_ready=item.after_ready): - return True - return True + # asyncio's private ready queue (CPython's loop; absent on others), looked up once per batch + ready: object = getattr(self._loop_ref(), '_ready', None) + try: + for _ in range(self.MAX_BATCH): + resumes = self._run() + held = self._take() + if held is None: + return False + after_ready = isinstance(held, _Item) and held.after_ready + if resumes or after_ready or self._busy(ready): + return True + return True + finally: + self._recent = None - def _take(self) -> _Item | None: - """Holds and returns the next live item.""" + def _take(self) -> _Item | _EventRecord | None: + """Holds and returns the next live item, or event record.""" while self._held is None: record: _Record | None = self.mailbox.take() if record is None: return None - self._held = self._item(record) + self._held = record if record[0] is not None else self._item(record) return self._held def _item(self, record: _Record) -> _Item | None: - name, token, failed, args = record - if name is not None: - run = functools.partial(self._dispatch_event, name, args if args is not None else ()) - return _Item(run, resumes=False, after_ready=False) + """The item of a marker or a completion record.""" + _, token, failed, args = record with self._lock: marker = self._markers.pop(token, None) if marker is not None: @@ -412,12 +428,22 @@ def _item(self, record: _Record) -> _Item | None: run = functools.partial(self._settle_call, call, args, failed=failed) return _Item(run, resumes=True, after_ready=True) - def _dispatch_event(self, name: str, args: tuple[object, ...]) -> None: + def _dispatch_event(self, record: _EventRecord) -> None: """Delivers an event record, its native object first, to that object's wrapper, recreating a dead one.""" + name, _, _, args = record native, args = args[0], args[1:] - wrapper = lifetime.find(cast('lifetime.Native', native)) + recent = self._recent + if recent is not None and recent[0] is native: + wrapper: WebRTCObject[object] | None = recent[1] + else: + wrapper = lifetime.find(cast('lifetime.Native', native)) created = wrapper is None - target = (wrapper if wrapper is not None else lifetime.wrapper_of(cast('lifetime.Native', native)))._target() + if wrapper is None: + wrapper = lifetime.wrapper_of(cast('lifetime.Native', native)) + else: + # kept for the batch, as a channel's messages come in a row; a fresh wrapper isn't + self._recent = (native, wrapper) + target = wrapper._target() if target is None: return target._deliver(self, name, args) @@ -444,16 +470,18 @@ def _settle_call(call: _Call, args: tuple[object, ...] | None, *, failed: bool) def _run(self) -> bool: """Runs the held item; returns whether it resumes code.""" - item, self._held = self._held, None - if item is None: + held, self._held = self._held, None + if held is None: + return False + self.batches.clear() + if not isinstance(held, _Item): + self._dispatch_event(held) return False - if item.resumes: + if held.resumes: self._resumed = True _ = self._live_loop.call_soon(self._settle) - - self.batches.clear() - _ = item.run() - return item.resumes + _ = held.run() + return held.resumes def _settle(self, deferred: int = 0) -> None: # resumed code may span several iterations: wait until nothing else is ready @@ -472,8 +500,10 @@ def _others_ready(self) -> bool: # CPython internals: other loops (e.g. uvloop) never report any loop = self._loop_ref() ready: object = getattr(loop, '_ready', None) + if not isinstance(ready, collections.deque) or len(ready) == 0: + return False # copy() holds the deque's lock; iterating races call_soon_threadsafe on free-threaded builds - handles = cast('collections.deque[object]', ready).copy() if isinstance(ready, collections.deque) else () + handles = cast('collections.deque[object]', ready).copy() for handle in handles: callback = getattr(handle, '_callback', None) owner = callback.state if isinstance(callback, _Callback) else getattr(callback, '__self__', None) @@ -482,12 +512,12 @@ def _others_ready(self) -> bool: return True return False - def _yields(self, *, resumes: bool, after_ready: bool) -> bool: - """Whether the next item waits for the loop instead of running now.""" + def _busy(self, ready: object) -> bool: + """Whether the next item waits for the loop's other ready callbacks, given ``loop._ready``.""" # without loop._ready, run one item per iteration - if resumes or after_ready: + if ready is None: return True - return not hasattr(self._loop_ref(), '_ready') or self._others_ready() + return (not isinstance(ready, collections.deque) or len(ready) > 0) and self._others_ready() def update(self, target: EventTarget[Never]) -> None: """Roots or unroots an object and its rooted children from their ``_activity()``.""" @@ -646,6 +676,7 @@ def _detach(self) -> tuple[dict[int, _Call], list[Callable[[], object]], list[Ev self._soon.clear() self._markers.clear() self._held = None + self._recent = None self.tasks.clear() self.active.clear() targets = list(self.targets) diff --git a/python-webrtc/python/webrtc/utils/operations.py b/python-webrtc/python/webrtc/utils/operations.py index b3599e9..814e5da 100644 --- a/python-webrtc/python/webrtc/utils/operations.py +++ b/python-webrtc/python/webrtc/utils/operations.py @@ -26,7 +26,7 @@ from webrtc.utils.loops import LoopState if TYPE_CHECKING: - from collections.abc import AsyncGenerator + from types import TracebackType class OperationsChain: @@ -60,32 +60,9 @@ def busy(self) -> bool: """:obj:`bool`: Whether an operation is running.""" return self._last is not None and not self._last.done() - @contextlib.asynccontextmanager - async def operation(self) -> AsyncGenerator[None, None]: + def operation(self) -> Operation: """Chains an operation after the ones that are running.""" - if self._closed(): - # it fails its checks right away - yield - return - done: concurrent.futures.Future[None] = concurrent.futures.Future() - with self._changing(), self._lock: - previous = self._last - self._last = done - try: - if previous is not None and not previous.done(): - await self._after(previous, done) - try: - yield - except Exception: - self._cancel_if_closed() - raise - self._cancel_if_closed() - finally: - if previous is not None and not previous.done(): - # cancelled while waiting: the next operations still wait for the previous one - previous.add_done_callback(lambda _: self._end(done)) - else: - self._end(done) + return Operation(self) def _cancel_if_closed(self) -> None: if self._closed(): @@ -122,6 +99,60 @@ def _end(self, done: concurrent.futures.Future[None]) -> None: self._on_empty() +class Operation: + """An operation of the chain, used with ``async with``. + + Not an async generator: a loop's ``shutdown_asyncgens()`` would close one under a suspended operation, whose end + then fails as an unraisable error that keeps its frames and their connections. + """ + + __slots__ = ('_chain', '_done', '_previous') + + def __init__(self, chain: OperationsChain) -> None: + self._chain = chain + # None while closed: the operation fails its checks right away + self._done: concurrent.futures.Future[None] | None = None + self._previous: concurrent.futures.Future[None] | None = None + + async def __aenter__(self) -> None: + chain = self._chain + if chain._closed(): + return + done: concurrent.futures.Future[None] = concurrent.futures.Future() + with chain._changing(), chain._lock: + previous = chain._last + chain._last = done + self._done, self._previous = done, previous + if previous is not None and not previous.done(): + try: + await chain._after(previous, done) + except BaseException: + self._end() + raise + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc: BaseException | None, traceback: TracebackType | None + ) -> None: + if self._done is None: + return + try: + # a cancellation or an exit isn't replaced + if exc_type is None or issubclass(exc_type, Exception): + self._chain._cancel_if_closed() + finally: + self._end() + + def _end(self) -> None: + chain, done, previous = self._chain, self._done, self._previous + if done is None: + return + if previous is not None and not previous.done(): + # cancelled while waiting: the next operations still wait for the previous one + previous.add_done_callback(lambda _: chain._end(done)) + else: + chain._end(done) + + async def later() -> None: """Waits for a later task, where the operations of a connection take effect. diff --git a/stubs/wrtc/__init__.pyi b/stubs/wrtc/__init__.pyi index b078fcf..74f4bf1 100644 --- a/stubs/wrtc/__init__.pyi +++ b/stubs/wrtc/__init__.pyi @@ -5,7 +5,7 @@ import typing_extensions import webrtc.enums import webrtc.exceptions from . import _testing as _testing -__all__: list[str] = ['ConfigurationInit', 'DataChannelMessage', 'IceCandidateInit', 'IceServerInit', 'Mailbox', 'MediaStream', 'MediaStreamTrack', 'MediaStreamTrackProcessor', 'PeerConnectionFactory', 'PythonWebRTCException', 'PythonWebRTCExceptionBase', 'RTCCallbackException', 'RTCCertificate', 'RTCDTMFSender', 'RTCDataChannel', 'RTCDtlsTransport', 'RTCEncodedFrame', 'RTCIceTransport', 'RTCPeerConnection', 'RTCRtpReceiver', 'RTCRtpScriptTransform', 'RTCRtpSender', 'RTCRtpTransceiver', 'RTCSctpTransport', 'RTCSessionDescription', 'RTCSessionDescriptionInit', 'RtcpParameters', 'RtpCapabilities', 'RtpCodec', 'RtpCodecCapability', 'RtpCodecParameters', 'RtpEncodingParameters', 'RtpExtension', 'RtpHeaderExtensionCapability', 'RtpParameters', 'RtpTransceiverInit', 'SFrameTransform', 'TrackGenerator', 'VideoFrameBuffer', 'copyAudioSamples', 'disableOpenH264', 'getUserMedia', 'loadOpenH264', 'openH264Enabled'] +__all__: list[str] = ['ConfigurationInit', 'IceCandidateInit', 'IceServerInit', 'Mailbox', 'MediaStream', 'MediaStreamTrack', 'MediaStreamTrackProcessor', 'PeerConnectionFactory', 'PythonWebRTCException', 'PythonWebRTCExceptionBase', 'RTCCallbackException', 'RTCCertificate', 'RTCDTMFSender', 'RTCDataChannel', 'RTCDtlsTransport', 'RTCEncodedFrame', 'RTCIceTransport', 'RTCPeerConnection', 'RTCRtpReceiver', 'RTCRtpScriptTransform', 'RTCRtpSender', 'RTCRtpTransceiver', 'RTCSctpTransport', 'RTCSessionDescription', 'RTCSessionDescriptionInit', 'RtcpParameters', 'RtpCapabilities', 'RtpCodec', 'RtpCodecCapability', 'RtpCodecParameters', 'RtpEncodingParameters', 'RtpExtension', 'RtpHeaderExtensionCapability', 'RtpParameters', 'RtpTransceiverInit', 'SFrameTransform', 'TrackGenerator', 'VideoFrameBuffer', 'copyAudioSamples', 'disableOpenH264', 'getUserMedia', 'loadOpenH264', 'openH264Enabled'] class RTCCallbackException: def toPython(self) -> webrtc.exceptions.RTCException: ... @@ -19,7 +19,7 @@ class Mailbox: @property def id(self) -> int: ... - def take(self) -> tuple[str | None, int, bool, tuple[object, ...] | None] | None: + def take(self) -> tuple[str, int, bool, tuple[object, ...]] | tuple[None, int, bool, tuple[object, ...] | None] | None: ... def enableWake(self) -> int | None: ... @@ -631,10 +631,6 @@ class RTCRtpTransceiver: @property def stopping(self) -> bool: ... -class DataChannelMessage: - @property - def data(self) -> str | bytes: - ... class RTCDataChannel(Binding): @property def _id(self) -> int: @@ -689,6 +685,9 @@ class RTCDataChannel(Binding): ... class RTCPeerConnection(Binding): @property + def _hasRemoteDescription(self) -> bool: + ... + @property def _id(self) -> int: ... @staticmethod @@ -726,7 +725,7 @@ class RTCPeerConnection(Binding): @typing.overload def addTransceiver(self, track: MediaStreamTrack, init: RtpTransceiverInit | None) -> RTCRtpTransceiver: ... - def close(self) -> None: + def close(self) -> int | None: ... def createAnswer(self, mailbox: Mailbox, token: typing.SupportsInt | typing.SupportsIndex) -> None: ... diff --git a/stubs/wrtc/_testing.pyi b/stubs/wrtc/_testing.pyi index 636449e..1194d34 100644 --- a/stubs/wrtc/_testing.pyi +++ b/stubs/wrtc/_testing.pyi @@ -92,3 +92,7 @@ def registered(identity: Identity) -> Registered: ... def release(name: str) -> None: ... +def sibling_sockets() -> int: + ... +def udp_receive_buffer() -> int: + ... diff --git a/tests/helpers.py b/tests/helpers.py index 3773a22..50a41a1 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -216,6 +216,19 @@ async def next_task() -> None: await timer +async def asleep() -> None: + """Delivers what is queued until no drain is scheduled, so that under :func:`undelivered` later records wait. + + A drain woken before the Dispatcher parked takes the records posted after, as a mailbox stays awake until a take + finds it empty. + """ + state = loops.LoopState.of(asyncio.get_running_loop()) + await delivered() + # a drain that ended with records left schedules the next one itself + while state.drain_scheduled: + await delivered() + + @contextlib.contextmanager def undelivered() -> Generator[None, None, None]: """Keeps what libwebrtc's threads post in the mailboxes, by parking the Dispatcher's wake. diff --git a/tests/test_events.py b/tests/test_events.py index cbeb0f2..21719c2 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -22,6 +22,7 @@ import webrtc import wrtc from tests.helpers import ( + asleep, called, collect, connect, @@ -284,6 +285,7 @@ def on_change(_event: webrtc.Event) -> None: async def _set_undelivered(pc: webrtc.RTCPeerConnection) -> asyncio.Future[None]: """Sets the local description, which libwebrtc applies while its events and completion wait in the mailbox.""" + await asleep() wrtc._testing.park('description.completed') try: setting = asyncio.ensure_future(pc.set_local_description()) diff --git a/tests/test_field_trials.py b/tests/test_field_trials.py index 5eeb405..8ba4483 100644 --- a/tests/test_field_trials.py +++ b/tests/test_field_trials.py @@ -10,9 +10,11 @@ from __future__ import annotations import asyncio +import contextlib import multiprocessing import multiprocessing.context import os +import socket import subprocess import sys from typing import TYPE_CHECKING, cast @@ -297,3 +299,71 @@ def test_sped_carries_the_handshake_in_stun() -> None: assert without != set() assert _handshakes_with(SPED) < without + + +@isolated +def _receive_buffer_with(trials: dict[str, str]) -> int: + webrtc.field_trials.update(trials) + return wrtc._testing.udp_receive_buffer() + + +def _kernel_receive_buffer(requested: int | None = None) -> int: + """What the kernel gives a new UDP socket, asking for the size or not.""" + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as udp: + if requested is not None: + # refused above the system's limit, as the library's request is + with contextlib.suppress(OSError): + udp.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, requested) + return udp.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF) + + +def test_udp_sockets_receive_into_8_mb_by_default() -> None: + assert _receive_buffer_with({}) == _kernel_receive_buffer(8 << 20) + + +@pytest.mark.parametrize('group', ['Disabled', 'Enabled-1024']) +def test_receive_buffer_trial_replaces_the_default(group: str) -> None: + """The engine applies the trial itself, to a connection's sockets.""" + assert _receive_buffer_with({'WebRTC-SetSocketReceiveBuffer': group}) == _kernel_receive_buffer() + + +async def _flood(count: int) -> None: + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + channel = caller.create_data_channel('flood') + announced = wait_for_event(callee, 'datachannel') + await connect(caller, callee) + event = await announced + assert isinstance(event, webrtc.RTCDataChannelEvent) + remote = event.channel + await wait_until(lambda: channel.ready_state == webrtc.RTCDataChannelState.open, 'the channel to open') + received = 0 + all_received = asyncio.Event() + + def on_message(_event: webrtc.MessageEvent) -> None: + nonlocal received + received += 1 + if received == count: + all_received.set() + + remote.on('message', on_message) + for _ in range(count): + channel.send(bytes(1024)) + await asyncio.wait_for(all_received.wait(), seconds(10)) + caller.close() + callee.close() + + +@isolated +def _siblings_after_a_flood(trials: dict[str, str]) -> int: + webrtc.field_trials.update(trials) + asyncio.run(_flood(1000)) + return wrtc._testing.sibling_sockets() + + +@pytest.mark.skipif(sys.platform == 'win32', reason='Windows keeps one socket per address') +def test_a_flooded_connection_gets_connected_sibling_sockets() -> None: + assert _siblings_after_a_flood({}) > 0 + + +def test_connected_sockets_trial_turns_siblings_off() -> None: + assert _siblings_after_a_flood({'PythonWebRTC-ConnectedSockets': 'Disabled'}) == 0 diff --git a/tests/test_lifetime.py b/tests/test_lifetime.py index a01025d..92fd489 100644 --- a/tests/test_lifetime.py +++ b/tests/test_lifetime.py @@ -1238,3 +1238,24 @@ async def test_wrappers_take_no_attributes() -> None: obj.close() elif isinstance(obj, webrtc.MediaStreamTrack): obj.stop() + + +def test_close_unroots_a_connection_bound_while_it_closes(monkeypatch: pytest.MonkeyPatch) -> None: + """A loop that binds a connection after close() read its loop doesn't keep it once closed.""" + pc = webrtc.RTCPeerConnection() + + async def scenario() -> None: + loop_read = events.states_elsewhere + + def bind_meanwhile() -> list[loops.LoopState]: + monkeypatch.setattr(events, 'states_elsewhere', loop_read) + # as another thread's first operation would, right after close() read the (absent) loop + pc.on('negotiationneeded', lambda _: None) + return loop_read() + + monkeypatch.setattr(events, 'states_elsewhere', bind_meanwhile) + pc.close() + await delivered() + assert pc not in loops.LoopState.of(asyncio.get_running_loop()).active + + asyncio.run(scenario()) diff --git a/tests/test_network.py b/tests/test_network.py index aa65999..6941376 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -131,3 +131,26 @@ async def test_gathering_runs_its_phases_without_waiting_between_them( # libwebrtc's default waits 50 ms between its three phases assert time.perf_counter() - started < seconds(0.1) + + +@pytest.mark.asyncio +async def test_a_candidate_pool_runs_its_phases_without_waiting_between_them( + create_pc: CreatePC, pytestconfig: pytest.Config +) -> None: + """The pool, gathered as the connection is created, doesn't wait before its first phase either.""" + if pytestconfig.getoption('--gc-on-emit'): + pytest.skip('a wall-clock limit, and --gc-on-emit collects garbage on every event') + pc = create_pc(webrtc.RTCConfiguration(ice_candidate_pool_size=1)) + pc.create_data_channel('gather') + started = time.perf_counter() + # not polled: a poll's interval would hide the wait + complete = wait_for_event( + pc, + 'icegatheringstatechange', + predicate=lambda _: pc.ice_gathering_state == webrtc.RTCIceGatheringState.complete, + ) + await pc.set_local_description() + await complete + + # libwebrtc's default waits 50 ms before the pool's first phase + assert time.perf_counter() - started < seconds(0.04) diff --git a/tests/test_network_stress.py b/tests/test_network_stress.py index 6aad3ce..2231a20 100644 --- a/tests/test_network_stress.py +++ b/tests/test_network_stress.py @@ -20,6 +20,7 @@ import pytest import webrtc +import wrtc from tests.helpers import connect, exchange_offer_answer, wait_for_event, wait_for_ice_gathering_complete, wait_until from tests.timing import seconds @@ -125,3 +126,65 @@ async def test_refused_tcp_connects_neither_spin_nor_leak(create_pc: CreatePC) - assert time.process_time() - started < seconds(0.5) assert _descriptors() <= descriptors + + +async def _selected_pair(pc: webrtc.RTCPeerConnection) -> webrtc.RTCIceCandidatePairStats: + report = await pc.get_stats() + selected = next( + s.selected_candidate_pair_id + for s in report.values() + if isinstance(s, webrtc.RTCTransportStats) and s.selected_candidate_pair_id is not None + ) + pair = report[selected] + assert isinstance(pair, webrtc.RTCIceCandidatePairStats) + return pair + + +def _skip_unless_a_flood_fits(pytestconfig: pytest.Config) -> None: + if pytestconfig.getoption('--gc-on-emit'): + pytest.skip('a flood of events, and --gc-on-emit collects garbage on every event') + # the library's floor: below it the kernel overflows here and SCTP stalls on recovery + if wrtc._testing.udp_receive_buffer() < 8 << 20: + pytest.skip('the system caps the UDP receive buffer below 8 MB (on Linux, net.core.rmem_max)') + + +@pytest.mark.asyncio +async def test_a_flood_between_connections_of_one_thread_loses_no_datagram( + caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection, pytestconfig: pytest.Config +) -> None: + """The network thread reads what it sends to its own sockets before their kernel buffers overflow.""" + _skip_unless_a_flood_fits(pytestconfig) + channel = caller.create_data_channel('flood') + announced = wait_for_event(callee, 'datachannel') + await connect(caller, callee) + event = await announced + assert isinstance(event, webrtc.RTCDataChannelEvent) + remote = event.channel + await wait_until(lambda: channel.ready_state == webrtc.RTCDataChannelState.open, 'the channel to open') + + count = 20000 + received = 0 + all_received = asyncio.Event() + + def on_message(_event: webrtc.MessageEvent) -> None: + nonlocal received + received += 1 + if received == count: + all_received.set() + + remote.on('message', on_message) + channel.buffered_amount_low_threshold = 256 << 10 + payload = b'x' * 1024 + for _ in range(count): + # as a sender keeping under a high watermark does + if channel.buffered_amount > 1 << 20: + await wait_for_event(channel, 'bufferedamountlow') + channel.send(payload) + await all_received.wait() + + sent = (await _selected_pair(caller)).packets_sent + delivered = (await _selected_pair(callee)).packets_received + # SCTP retransmits what the kernel dropped, so only these counts show the loss + assert sent is not None + assert delivered is not None + assert sent - delivered <= 16, f'{sent - delivered} of {sent} datagrams dropped' diff --git a/tests/test_operations.py b/tests/test_operations.py index bb903a1..eb6cf08 100644 --- a/tests/test_operations.py +++ b/tests/test_operations.py @@ -119,6 +119,21 @@ async def hold() -> None: await asyncio.wait_for(held, seconds(5)) +@pytest.mark.asyncio +async def test_add_ice_candidate_without_a_remote_description_rejects_at_once() -> None: + """With the chain empty, it rejects in its first step, before the loop runs anything else.""" + pc = webrtc.RTCPeerConnection() + try: + added = asyncio.ensure_future(pc.add_ice_candidate()) + await asyncio.sleep(0) + assert added.done() + with pytest.raises(webrtc.InvalidStateError, match='remote description'): + added.result() + assert pc.remote_description is None + finally: + pc.close() + + def test_operations_stranded_by_a_closed_loop_end_quietly() -> None: """Operations waiting in the chain when their loop closes end without errors once collected.""" chain = OperationsChain(lambda: None, lambda: False) @@ -131,6 +146,8 @@ async def operation() -> None: operations = [operation() for _ in range(3)] tasks = [loop.create_task(coroutine) for coroutine in operations] loop.run_until_complete(asyncio.sleep(0)) + # as asyncio.run and pytest-asyncio close a loop: its async generators first + loop.run_until_complete(loop.shutdown_asyncgens()) loop.close() for coroutine in operations: # as collecting it does diff --git a/tests/test_robustness_threads.py b/tests/test_robustness_threads.py index d081d55..85b6f59 100644 --- a/tests/test_robustness_threads.py +++ b/tests/test_robustness_threads.py @@ -21,7 +21,7 @@ import webrtc import wrtc -from tests.helpers import connect, exchange_offer_answer, settled_alive, wait_for_event, wait_until +from tests.helpers import connect, delivered, exchange_offer_answer, settled_alive, wait_for_event, wait_until from tests.isolation import isolated from tests.timing import seconds from webrtc.utils.loops import LoopState @@ -352,6 +352,8 @@ async def _start_while_the_previous_operation_releases() -> list[str]: assert offer is not None pending = LoopState.of(asyncio.get_running_loop()).pending + # the offer's completion is still on the signaling thread, before the point the park would stop it at + await delivered() wrtc._testing.park('description.completed') try: # resolved, while its signaling task is parked before it releases what it held diff --git a/uv.lock b/uv.lock index ab593c4..d39b0c4 100644 --- a/uv.lock +++ b/uv.lock @@ -4522,7 +4522,7 @@ wheels = [ [[package]] name = "wrtc" -version = "0.0.2" +version = "0.0.3" source = { editable = "." } dependencies = [ { name = "typing-extensions" },