Squashed 'third_party/allwpilib/' changes from 83f1860047..f1a82828fe

f1a82828fe [wpiutil] Add DataLog and DataLogManager Stop() (#5860)
2a04e12c6f [apriltag] AprilTagFieldLayout: Add accessors for origin and field dimensions (#5869)
33e0089afb Cleanup usages of std::function<void(void)> (#5864)
d06fa633d5 [build] Fix protobuf generation when building with make (#5867)
049732afb8 [cscore] Make camera connection logging clearer (#5866)
87f7c19f90 [wpimath] Make InterpolatingDoubleTreeMap constructor public (#5865)
6b53ef47cf [wpimath] Don't recreate TrapezoidProfile in ProfiledPIDController calculate() (#5863)
8a3a268ae6 [commands] Add finallyDo with zero-arg lambda (#5862)
1c35d42cd0 [wpilib] Pop diagnostic for deprecated function use (#5859)
ddc8db6c26 [wpimath] Add feedforward constant constructor to ElevatorSim (#5823)
c6aff2c431 [upstream_utils] Update to LLVM 17.0.4 (#5855)
a9c5b18a39 [build] Update OpenCV to 2024-4.8.0-2 (#5854)
9540b6922d [hal] Add CAN IDs for AndyMark and Vivid Hosting (#5852)
83a7d33c47 [glass] Improve display of protobuf/struct type strings (#5850)
a4a8ad9c75 [commands] Make Java SelectCommand generic (#5849)
9eecf2a456 [build] Add CMake option to build Java sources jars (#5768)
9536a311cb [wpilib] Add support for the PS5 DualSense controller (#5257)
8d5e6737fc [wpilibc] SolenoidSim: Add virtual destructor (#5848)
07e13d60a2 [ntcore] Fix write_impl (#5847)
1713386869 [wpiutil] ProtobufMessageDatabase: Fix out-of-order Add() rebuild (#5845)
35472f5fc9 [ntcore] Fix a use-after-free in client close (#5844)
ed168b522c [ntcore] Disable buf pool when asan is enabled (#5843)
3e7ba2cc6f [wpinet] WebSocket: Fix write behavior (#5841)
80c47da237 [sim] Disable the robot program when DS disconnects (#5818)
abe1cec90c [wpilib] Update Usage Reporting ResourceType from NI Libraries (#5842)
cdf981abba [glass] Fix position of data type in NT view (#5840)
04dcd80adb [build] Publish unit tests for examples (#5838)
49920234ac [build] Fix checkstyle rules to allow Windows paths (#5839)
366b715942 [wpilib] Fix SendableChooser test (#5835)
3ba501f947 [commands] Java: Fix CommandXboxController.leftTrigger() parameter order (#5831)
ec569a58ef [wpimath] Make KalmanTypeFilter interface public (#5830)
b91317fd36 [wpiutil] DataLog.addSchema(): Don't add into a set view (#5829)
2ab4fcbc24 [wpiutil] ProtobufMessageDatabase: Clear messages first (#5827)
98c14f1692 [wpimath] Add EKF/UKF u-y-R correct overload (#5832)
60bcdeded9 [ci] Disable java in sanitizer builds (#5833)
c87f8fd538 [commands] Add DeferredCommand (#5566)
ad80eb3a0b [ci] Update actions for comment-command (#5824)
c7d6ad5a0b [ntcore] WebSocketConnection: Use weak capture (#5822)
8a8e220792 [simgui] Add 'Invalid' option for AllianceStation (#5820)
cfc6a47f76 [sim] DS plugin: Fix off-by-one error when setting alliance station (#5819)
8efa586ace [ntcore] Don't check type string on publishing an entry (#5816)
23ea188e60 [glass] Add protobuf decode error log message (#5812)
928e87b4f4 [build] Add combined test meta-task (#5813)
63ef585d4b [wpiutil] Fix compilation of MathExtras.h on Windows with /sdl (#5809)
b03a7668f9 [build] Windows CMake/vcpkg fixes (#5807)
3f08bcde54 [hal] Fix HAL AllianceStation on rio (#5811)
196d963dc4 [ntcore] Fix off-by-one error in stream write (#5810)
f4cbcbc984 Fix typos (NFC) (#5804)
ec0f7fefb0 [myrobot] Update the myRobot JRE (#5805)
3d618bdbfd [wpiutil] Fix Java struct array unpacking (#5801)
1fa7445667 [ntcore] Check for valid client in incoming text and binary (#5799)
269b9647da [ci] Update JDK for combine step (#5794)
bee32f080e [docs] Add wpiunits to JavaDocs (#5793)
25dad5a531 [wpinet] TCPConnector_parallel: Don't use thread_local (#5791)
4a93581f1a [build] cmake: use default library type for libglassnt, libglass, wpigui, and imgui (#5797)
abb2857e03 [wpilib] Counter: Fix default distance per pulse, add distance and rate to C++ (#5796)
b14a61e1c0 [readme] Add link to QuickBuffers release page (#5795)
cf54d9ccb7 [wpiutil, ntcore] Add structured data support (#5391)
ecb7cfa9ef [wpimath] Add Exponential motion profile (#5720)
7c6fe56cf2 [ntcore] Fix crash on disconnect (#5788)
85147bf69e [wpinet] WebSocketSerializer: Fix UB (#5787)
244163acad [wpinet] uv::Stream::TryWrite(): Return 0 on EAGAIN (#5784)
820728503d [hal] Remove extra semicolon in RoboRioData (#5786)
45f307d87e [upstream_utils] Upgrade to LLVM 17.0.3 (#5785)
4ce4d63efc [wpilibj] Fix RobotBase.isSimulation() (#5783)
579007ceb3 [commands] Add requirements parameter to Commands.idle() (#5774)
3f3a169149 [wpilib] Make physics sim setState() functions public (#5779)
7501e4ac88 [wpilib] Close sim device in ADIS IMUs (#5776)
99630d2e78 [wpimath] Upgrade to EJML 0.43.1 (#5778)
02cbbc997d [wpimath] Make Vector-Vector binary operators return Vector (#5772)
ed93889e17 [examples] Fix typo in TimesliceRobot example name (#5773)
da70e4c262 [docs] Add jinja2 to CMake prerequisites (#5771)
e814595ea7 [wpimath] Add ChassisSpeeds.fromRobotRelativeSpeeds() (#5744)
f98c943445 [wpimath] LinearSystemId: Add DCMotorSystem overload (#5770)
b3eb64b0f7 [wpiutil] ct_string: Use inline namespace for literals (#5767)
7d9ba256c2 Revert "[build] Add CMake option to build Java source jars (#5756)" (#5766)
1f6492e3d8 [sysid] Update JSON library usage (#5765)
638f04f626 [wpiutil] Add protobuf to thirdparty sources (#5746)
210255bfff [wpiutil] Update json to 3.11.2 (#5680)
896772c750 [wpimath] Add DCMotor functions for Kraken X60 and Neo Vortex (#5759)
fd427f6c82 [wpimath] Fix hardcoded module count in SwerveDriveKinematics.resetHeading() (#5762)
c0b4c6cce6 [wpimath] Add overloads for Transform2d and Transform3d (#5757)
9a0aafd8ab [examples] Make swerve examples multiply desired module speeds by cosine of heading error (#5758)
1c724884ca [build] Add CMake option to build Java source jars (#5756)
5b0db6b93e [ci] Forward CI as well (#5755)
f8cbbbac12 [ci] Take 2 on passing GITHUB_REF (#5754)
b9944be09c [ci] Pass GITHUB_REF to docker container (#5753)
de5e4eda6c [build] Update apriltag, libssh, googletest for 2024 (#5752)
227e660e20 [upstream_utils] Upgrade to LLVM 17.0.2 (#5750)
36f94c9f21 [commands,romi,xrp] Add frcYear to vendordep (#5747)
741d166457 [glass] NT view: enhance array support (#5732)
1d23513945 [ntcore] Fix string array value comparison (#5745)
ff1849052e [commands] Make command scheduling order consistent (#5470)
58e8474368 [build] Disable armsimulation unit test (#5739)
fb07b0da49 [examples] Add XRP C++ Examples and Templates (#5743)
81893ad73d Run wpiformat with clang-format 17 (#5740)
faa1e665ba [wpimath] Add ElevatorFeedforward.calculate(currentV, nextV) overload (#5715)
a789632052 [build] Update to native utils 2024.3.1 (#5738)
8f60ab5182 [build] Update OpenCV to 2024-4.8.0-1 (#5737)
33243f982b [wpimath] Expand Quaternion class with additional operators (#5600)
420f2f7c80 [ntcore] Add RTT-only subprotocol (#5731)
2b63e35ded [ntcore] Fix moving outgoing queue to new period (#5735)
be939cb636 [ntcore] Fix notification of SetDefaultEntryValue (#5733)
69a54de202 [build] Update enterprise plugin (#5730)
fef03a3ff5 [commands] Clean up C++ includes after Requirements was added (#5719)
8b7c6852cf [ntcore] Networking improvements (#5659)
1d19e09ca9 [wpiutil] Set WPI_{UN}IGNORE_DEPRECATED to empty when all else fails (#5728)
58141d6eb5 [wpilib] Make BooleanEvent more consistent (#5436)
6576d9b474 [wpilib] SendableChooser: implement Sendable instead of NTSendable (#5718)
a4030c670f [build] Update to gradle 8.4, enable win arm builds (#5727)
0960f11eba [wpinet] Revert removal of uv_clock_gettime() (#5723)
cb1bd0a3be [wpiutil] Get more precise system time on Windows (#5722)
4831277ffe [wpigui] Fix loading a maximized window on second monitor (#5721)
3eb372c25a [wpiutil] SendableBuilder: Add PublishConst methods (#5158)
1fec8596a4 [ci] Fix -dirty version (#5716)
f7e47d03f3 [build] Remove unnecessary CMake config installs (#5714)
a331ed2374 [sysid] Add SysId (#5672)
8d2cbfce16 [wpiutil] DataLog: Stop logging if insufficient free space (#5699)
48facb9cef [ntcoreffi] Add DataLogManager (#5702)
aecbcb08fc [ntcore] Correctly start DataLog for existing publishers (#5703)
5e295dfbda [wpiutil] DataLog: Limit total buffer allocation (#5700)
c7c7e05d9d [ci] Unbreak combiner (#5698)
c92bad52cb [wpilib] DataLogManager: Use system time valid function (#5697)
d404af5f24 [wpilib] RobotController: Add isSystemTimeValid() (#5696)
e56f1a3632 [ci] Run combine but skip all steps (#5695)
8f5bcad244 [ci] Use sccache for cmake builds (#5692)
703dedc4a6 [ci] Upgrade get-cmake action to fix node12 deprecation warning (#5694)
c69a0d7504 [ci] Don't run example unit test that segfaults (#5693)
66358d103e Add menu items for online docs to GUI tools (#5689)
4be8384a76 [ci] Disable combine on PR builds (#5691)
90288f06a6 [ci] Fix Gradle disk space issues (#5688)
9e9583412e [wpigui] Make wpi::gui::OpenURL() fork the process first (#5687)
d4fcd80b7b [ci] Gradle: Use container only for build step (#5684)
7b70e66772 [outlineviewer] Fix thirdparty library include sorting (#5683)
5f651df5d5 [build] Clean up Gradle configs (#5685)
65b26738d5 Add CMakeSettings.json to gitignore (#5682)
d0305951ad Fix GitHub inline warnings (#5681)
e8d4a20331 [build][cmake] Fix windows tests and re-enable CI tests (#5674)
2b58bbde0b [xrp] Add Reflectance sensor and rangefinder classes (#5673)
dd5612fbee [json] Add forward definition header (#5676)
eab44534c3 [wpimath] Remove unused SmallString include (#5677)
5ab54ff760 Replace wpi::raw_istream with wpi::MemoryBuffer (#5675)
1b6ec5a95d [wpiutil] Upgrade to LLVM 17.0.1 (#5482)
07a0d22fe6 [build] Build examples in CMake CI (#5667)
97021f074a [build] Upgrade imgui and implot (#5668)
87ce1e3761 [build] Fix wpilibNewCommands CMake install (#5671)
6ef94de9b5 [wpimath] Add tests for ArmFeedforward and ElevatorFeedforward (#5663)
c395b29fb4 [wpinet] Add WebSocket::TrySendFrames() (#5607)
c4643ba047 [romi/xrp] Fix version typo in vendordep json (#5664)
51dcb8b55a [examples] Make Romi/XRP Examples use appropriate vendordeps (#5665)
daf7702007 [build] Test each example in a new environment (#5662)
e67df8c180 [wpilib] Const-qualify EncoderSim getters (#5660)
7be290147c [wpiutil] Refactor SpanMatcher and TestPrinters from ntcore (#5658)
9fe258427a [commands] Add proxy factory to Commands (#5603)
633c5a8a22 [commands] Add C++ Requirements struct (#5504)
b265a68eea [commands] Add interruptor parameter to onCommandInterrupt callbacks (#5461)
e93c233d60 [ntcore] Compute Value memory size when creating value (#5657)
5383589f99 [wpinet] uv::Request: Return shared_ptr from Release() (#5656)
40b552be4a [wpinet] uv::Stream: Return error from TryWrite() (#5655)
202a75fe08 [wpinet] RequestImpl: Avoid infinite loop in shared_from_this() (#5654)
8896515eb7 [wpinet] uv::Buffer: Add bytes() accessor (#5653)
ae59a2fba2 [wpinet] uv::Error: Change default error to 0 (#5652)
3b51ecc35b [wpiutil] SpanExtras: Add take_back and take_front (#5651)
17f1062885 Replace std::snprintf() with wpi::format_to_n_c_str() (#5645)
bb39900353 [romi/xrp] Add Romi and XRP Vendordeps (#5644)
cb99517838 [build] cmake: Use default install location on windows for dlls (#5580)
25b0622d4c [build] Add Windows CMake CI (#5516)
34e7849605 Add warning to development builds instructions (NFC) (#5646)
e9e611c9d8 [cameraserver] Remove CameraServer.SetSize() (#5650)
94f58cc536 [wpilib] Remove Compressor.Enabled() (#5649)
4da5aee88a [wpimath] Remove SlewRateLimiter 2 argument constructor (#5648)
2e3ddf5502 Update versions in development builds instructions to 2024 (#5647)
19a8850fb1 [examples] Add TimesliceRobot templates (#3683)
9047682202 [sim] Add XRP-specific plugin (#5631)
575348b81c [wpilib] Use IsSimulation() consistently (#3534)
12e2043b77 [wpilib] Clean up Notifier (#5630)
4bac4dd0f4 [wpimath] Move PIDController from frc2 to frc namespace (#5640)
494cfd78c1 [wpiutil] Fix deprecation warning in LLVM for C++23 (#5642)
43a727e868 [apriltag] Make loadAprilTagFieldLayout throw an unchecked exception instead (#5629)
ad4b017321 [ci] Use Ninja for faster builds (#5626)
4f2114d6f5 Fix warnings from GCC 13 release build (#5637)
e7e927fe26 [build] Also compress debug info for CMake RelWithDebInfo build type (#5638)
205a40c895 [build] Specify zlib for debug info compression (#5636)
707444f000 [apriltag] Suppress -Wtype-limits warning in asserts from GCC 13 (#5635)
3b79cb6ed3 [commands] Revert SubsystemBase deprecation/removal (#5634)
bc7f23a632 [build] Compress Linux debug info (#5633)
57b2d6f254 [build] Update to image 2024 v1.0 (#5625)
339ef1ea39 [wpilib] DataLogManager: Warn user if logging to RoboRIO 1 internal storage (#5617)
7a9a901a73 [build] Fix cmake config files (#5624)
298f8a6e33 [wpilib] Add Mechanism2d tests and make Java impl match C++ (#5527)
d7ef817bae [apriltag] Update apriltag library (#5619)
c3fb31fd0e [docs] Switch to Java 17 api docs (#5613)
bd64f81cf9 [build] Run Google tests in release mode in CI (#5615)
66e6bd81ea [wpimath] Cleanup wpimath/algorithms.md (NFC) (#5621)
4fa56fd884 [build] Add missing find_dependency call (#5623)
f63d958995 [build] Update to native utils 2024.2.0 (#5601)
a9ab08f48b [wpimath] Rename ChassisSpeeds.fromDiscreteSpeeds() to discretize() (#5616)
8e05983a4a [wpimath] Add math docs to plant inversion feedforward internals (NFC) (#5618)
3a33ce918b [ntcore] Add missing StringMap include (#5620)
a6157f184d [wpiutil] timestamp: Add ShutdownNowRio (#5610)
e9f612f581 [build] Guard policy setting for CMake versions below 3.24 (#5612)
1a6df6fec6 [wpimath] Fix DARE Q decomposition (#5611)
9b3f7fb548 [build] Exclude IntelliJ folders from spotless XML (#5602)
814f18c7f5 [wpimath] Fix computation of C for DARE (A, C) detectability check (#5609)
ac23f92451 [hal] Add GetTeamNumber (#5596)
a750bee54d [wpimath] Use std::norm() in IsStabilizable() (#5599)
8e2465f8a0 [wpimath] Add arithmetic functions to wheel speeds classes (#5465)
10d4f5b5df [wpimath] Clean up notation in DARE precondition docs (#5595)
b2dd59450b [hal] Fix unfinished/incorrect GetCPUTemp functions (#5598)
99f66b1e24 [wpimath] Replace frc/EigenCore.h typedefs with Eigen's where possible (#5597)
383289bc4b [build] Make custom CMake macros use lowercase (#5594)
45e7720ec1 [build] Add error message when downloading files in CMake (#5593)
4e0d785356 [wpimath] ChassisSpeeds: document that values aren't relative to the robot (NFC) (#5551)
3c04580a57 [commands] ProxyCommand: Use inner command name in unique_ptr constructor (#5570)
cf19102c4a [commands] SelectCommand: Fix leakage and multiple composition bug (#5571)
171375f440 [ntcoreffi] Link to NI libraries (#5589)
89add5d05b Disable flaky tests (#5591)
a8d4b162ab [ntcore] Remove RPC manual tests (#5590)
39a73b5b58 [commands] C++: Add CommandPtr supplier constructor to ProxyCommand (#5572)
36d514eae7 [commands] Refactor C++ ScheduleCommand to use SmallSet (#5568)
52297ffe29 [commands] Add idle command (#5555)
67043a8eeb [wpimath] Add angular jerk unit (#5582)
51b0fb1492 [wpimath] Fix incorrect header inclusion in angular_acceleration.h (#5587)
b7657a8e28 [wpimath] Split WPIMathJNI into logical chunks (#5552)
ea17f90f87 [build] Fix tool builds with multiple arm platforms installed (#5586)
f1d7b05723 [wpimath] Clean up unit formatter (#5584)
d7264ff597 Replace wpi::errs() usage with fmtlib (#5560)
ab3bf39e0e [wpiutil] Upgrade to fmt 10.1.1 (#5585)
165ebe4c79 Upgrade to fmt 10.1.0 (#5326)
8e2a7fd306 Include thirdparty libraries with angle brackets (#5578)
e322ab8e46 [wpimath] Fix docs for DARE ABQRN stabilizability check (NFC) (#5579)
360fb835f4 [upstream_utils] Handle edge case in filename matches (#5576)
9d86624c00 [build] Fix CMake configure warnings (#5577)
969979d6c7 [wpiutil] Update to foonathan memory 0.7-3 (#5573)
0d2d989e84 [wpimath] Update to gcem 1.17.0 (#5575)
cf86af7166 [wpiutil] Update to mpack 1.1.1 (#5574)
a0c029a35b [commands] Fix dangling SelectCommand documentation (NFC) (#5567)
349141b91b [upstream_utils] Document adding a patch (NFC) (#5432)
7889b35b67 [wpimath] Add RamseteController comparison to LTV controller docs (NFC) (#5559)
b3ef536677 [build] Ignore nt/sim json files in spotless (#5565)
ed895815b5 [build] Compile Java with UTF-8 encoding (#5564)
2e4ad35e36 [wpiutil] jni_util: Add JSpan and CriticalJSpan (#5554)
8f3d6a1d4b [wpimath] Remove discretizeAQTaylor() (#5562)
7c20fa1b18 [wpimath] Refactor DARE tests to reduce RAM usage at compile time (#5557)
89e738262c [ntcore] Limit buffer pool size to 64KB per connection (#5485)
96f7fa662e Upgrade Maven dependencies (#5553)
7a2d336d52 [wpinet] Leak multicast handles during windows shutdown (#5550)
f9e2757d8f [wpimath] Use JDoubleArrayRef in all JNI functions (#5546)
0cf6e37dc1 [wpimath] Make LTV controller constructors use faster DARE solver (#5543)
6953a303b3 [build] Fix the windows build with fmt (#5544)
7a37e3a496 [wpimath] Correct Rotation3d::RotateBy doc comment (NFC) (#5541)
186b409e16 [wpimath] Remove internal Eigen header include (#5539)
03764dfe93 [wpimath] Add static matrix support to DARE solver (#5536)
394cfeadbd [wpimath] Use SDA algorithm instead of SSCA for DARE solver (#5526)
a4b7fde767 [wpilib] Add mechanism specific SetState overloads to physics sims (#5534)
8121566258 [wpimath] Fix CoordinateSystem.convert() Transform3d overload (#5532)
b542e01a0b [glass] Fix array crash when clearing existing workspace (#5535)
e2e1b763b2 [wpigui] Fix PFD file dialogs not closing after window closing (#5530)
86d7bbc4e4 [examples] Add Java Examples and Templates for the XRP (#5529)
e8b5d44752 [wpimath] Make Java Quaternion use doubles instead of Vector (#5525)
38c198fa64 [myRobot] Add apriltags to myRobot build (#5528)
00450c3548 [wpimath] Upgrade to EJML 0.42 (#5531)
faf3cecd83 [wpimath] Don't copy Matrix and underlying storage in VecBuilder (#5524)
6b896a38dc [build] Don't enforce WITH_FLAT_INSTALL with MSVC (part 2) (#5517)
c01814b80e [wpiutil] Add C API for DataLog (#5509)
b5bd0771eb [wpimath] Document extrinsic vs intrinsic rotations (NFC) (#5508)
84ed8aec05 [build] Don't enforce WITH_FLAT_INSTALL with MSVC (#5515)
999f677d8c [ntcoreffi] Add WPI_Impl_SetupNowRio to exported symbols (#5510)
338f37d302 Fix header sorting of libssh (#5507)
75cbd9d6d0 [glass] Add background color selector to glass plots (#5506)
e2c190487b [examples] Add flywheel bang-bang controller example (#4071)
c52dad609e [wpinet] WebSocket: Send pong in response to ping (#5498)
e2d17a24a6 [hal] Expose power rail disable and cpu temp functionality (#5477)
3ad5d2e42d [hal,wpiutil] Use HMB for FPGA Timestamps (#5499)
b46a872494 [ntcore] Remove pImpl from implementation (#5480)
d8c59ccc71 [wpimath] Add tests for MathUtil clamp() and interpolate() (#5501)
0552c8621d [glass,ov] Improve Glass and OutlineViewer title bar message (#5502)
90e37a129f [wpiutil,wpimath] Add generic InterpolatingTreeMap (#5372)
d83a6edc20 [wpilib] Update GetMatchTime docs and units (#5232)
6db2c42966 [wpimath] Trajectory: Throw on empty lists of States (#5497)
21439b606c [wpimath] Disallow LTV controller max velocities above 15 m/s (#5495)
7496e0d208 [ntcore] Value: More efficiently store arrays (#5484)
0c93aded8a [wpimath] Change kinematics.ToTwist2d(end - start) to kinematics.ToTwist2d(start, end) (#5493)
815a8403e5 [wpimath] Give infeasible trajectory constraints a better exception message (#5492)
35a8b129d9 [wpimath] Add RotateBy() function to pose classes (#5491)
26d6e68c8f [upstream_utils] Add GCEM to CI (#5483)
6aa469ae45 [wpilib] Document how to create LinearSystem object for physics sim classes (NFC) (#5488)
a01b6467d3 [wpimath] Link to docs on LQR and KF tolerances (#5486)
d814f1d123 [wpimath] Fix copy-paste error from Pose2d docs (NFC) (#5490)
98f074b072 [wpimath] Add folder prefix to geometry includes (#5489)
e9858c10e9 [glass] Add tooltips for NT settings (#5476)
12dda24f06 [examples] Fix C robot template not correctly looping (#5474)
fc75d31755 [apriltag] Update apriltaglib (#5475)
a95994fff6 [wpiutil] timestamp: Call FPGA functions directly (#5235)
2ba8fbb6f4 [wpimath] Improve documentation for SwerveModulePosition::operator- (#5468)
b8cdf97621 [build] Prepare for Windows arm64 builds (#5390)
552f4b76b5 [wpimath] Add FOC-enabled Falcon constants to the DCMotor class (#5469)
1938251436 [examples] Add Feedforward to ElevatorProfiledPid (#5300)
873c2a6c10 [examples] Update ElevatorTrapezoidProfile example (#5466)
99b88be4f3 [wpilib] Reduce usage of NTSendable (#5434)
d125711023 [hal] Fix Java REVPH faults bitfield (take 2) (#5464)
c3fab7f1f2 [ntcore] Don't update timestamp when value is unchanged (#5356)
5ec7f18bdc [wpilib] EventLoop docs: Remove BooleanEvent references (NFC) (#5463)
c065ae1fcf [wpiunits] Add subproject for a Java typesafe unit system (#5371)
44acca7c00 [wpiutil] Add ClassPreloader (#5365)
88b11832ec [hal] Fix Java REVPH faults bitfield (#5148)
fb57d82e52 [ntcore] Enhance Java raw value support
3a6e40a44b [wpiutil] Enhance DataLog Java raw value support
8dae5af271 [wpiutil] Add compile-time string utilities (ct_string) (#5462)
fc56f8049a [wpilib] DriverStation: Change alliance station to use optional (#5229)
ef155438bd [build] Consume libuv via cmake config instead of via pkg-config (#5438)
86e91e6724 [wpimath] Refactor TrapezoidProfile API (#5457)
72a4543493 [wpilib] DutyCycleEncoderSim: Expand API (#5443)
657338715d [wpimath] Add ChassisSpeeds method to fix drifting during compound swerve drive maneuvers (#5425)
1af224c21b Add missing <functional> includes (#5459)
0b91ca6d5a [wpilib] SendableChooser: Add onChange listener (#5458)
6f7cdd460e [wpimath] Pose3d: Switch to JNI for exp and log (#5444)
c69e34c80c [wpimath] ChassisSpeeds: Add arithmetic functions (#5293)
335e7dd89d [wpilib] Simulation: Add ctor parameter to set starting state of mechanism sims (#5288)
14f30752ab [wpilib] Deprecate Accelerometer and Gyro interfaces (#5445)
70b60e3a74 [commands] Trigger: Fix method names in requireNonNullParam (#5454)
593767c8c7 [wpimath] Improve Euler angle calculations in gimbal lock (#5437)
daf022d3da [build] Make devImplementation inherit from implementation (#5450)
9b8d90b852 [examples] Convert the unitless joystick inputs to actual physical units (#5451)
1f6428ab63 [ntcore] Fix undefined comparison behavior when array is empty (#5448)
17eb9161cd Update code owners for removal of old commands (#5447)
3c4b58ae1e [wpinet] Upgrade to libuv 1.46.0 (#5446)
aaea85ff16 [commands] Merge CommandBase into Command and SubsystemBase into Subsystem (#5392)
7ac932996a [ci] Use PAT for workflow dispatch (#5442)
efe1987e8b [ci] Trigger pages repo workflow (#5441)
828bc5276f [wpiutil] Upgrade to LLVM 16.0.6 (#5435)
701df9eb87 [ci] Change documentation publish to single-commit (#5440)
e5452e3f69 [wpiutil] Add WPICleaner and an example how to use it (#4850)
7a099cb02a [commands] Remove deprecated classes and functions (#5409)
b250a03944 [wpilib] Add function to wait for DS Connection (#5230)
a6463ed761 [wpiutil] Fix unused variable warning in release build (#5430)
f031513470 [ntcore] NetworkTable::GetSubTables(): Remove duplicates (#5076)
f8e74e2f7c [hal] Unify PWM simulation Speed, Position, and Raw (#5277)
fd5699b240 Remove references to Drake (#5427)
e2d385d80a [build] cmake: Respect USE_SYSTEM_FMTLIB (#5429)
d37f990ce3 [hal] Fix HAL Relay/Main doc module (NFC) (#5422)
a7a8b874ac [docs] Expand HAL_ENUM in doxygen docs (#5421)
3a61deedde [wpimath] Rotation2d: Only use gcem::hypot when constexpr evaluated (#5419)
96145de7db [examples] Fix formatting (NFC) (#5420)
fffe6a7b9a [examples] Improve Pneumatics example coverage in Solenoid and RapidReactCmdBot examples (#4998)
6b5817836d [wpimath] Add tolerance for some tests (#5416)
3233883f3e [cscore] Fix warnings on macos arm (#5415)
c4fc21838f [commands] Add ConditionalCommand getInterruptionBehavior (#5161)
89fc51f0d4 Add tests for SendableChooser and Command Sendable functionality (#5179)
663bf25aaf [docs] Generate docs for symbols in __cplusplus (#5412)
fe32127ea8 [command] Clean up Command doc comments (NFC) (#5321)
c1a01569b4 [wpilib][hal] PWM Raw using microseconds (#5283)
1fca519fb4 [wpiutil] Remove remnants of ghc fs and tcb_span libraries (#5411)
90602cc135 [github] Update issue template to collect more project info (#5090)
34412ac57e [build] Exclude files in bin from Spotless (#5410)
61aa60f0e3 [wpilib] Add robot callback that is called when the DS is initially connected (#5231)
ebae341a91 [commands] Add test for subsystem registration and periodic (#5408)
5d3a133f9f Remove spaces in NOLINT comments (#5407)
3a0e484691 [wpimath] Fix clang-tidy warnings (#5403)
eb3810c765 [wpiutil] Fix clang-tidy warnings (#5406)
c4dc697192 [hal] WS Simulation: Add message filtering capability (#5395)
0eccc3f247 [ntcore] Fix clang-tidy warnings (#5405)
f4dda4bac0 [hal] Add javadocs for JNI (NFC) (#5298)
1c20c69793 [cscore] Fix clang-tidy warnings (#5404)
1501607e48 [commands] Fix clang-tidy warnings (#5402)
991f4b0f62 [wpimath] PIDController: Add IZone (#5315)
f5b0d1484b [wpimath] Add isNear method to MathUtil (#5353)
2ce248f66c [hal] Fix clang-tidy warnings (#5401)
5fc4aee2d2 [wpimath] SwerveDriveKinematics: Rename currentChassisSpeed to desiredChassisSpeed (#5393)
50b90ceb54 [wpimath] SwerveDriveKinematics: Add reset method (#5398)
316cd2a453 [commands] Notify DriverStationSim in CommandTestBaseWithParam (#5400)
d4ea5fa902 [cscore] VideoMode: Add equals override (Java) (#5397)
d6bd72d738 [wpimath] ProfiledPIDController: Add getConstraints (#5399)
25ad5017a9 [wpimath] Refactor kinematics, odometry, and pose estimator (#5355)
5c2addda0f [doc] Add missing pneumatics docs (NFC) (#5389)
c3e04a6ea2 Fix loading tests on macos 12 (#5388)
d5ed9fb859 [wpimath] Create separate archive with just units headers (#5383)
901ab693d4 [wpimath] Use UtilityClassTest for more utility classes (#5384)
9d53231b01 [wpilib] DataLogManager: Add warning for low storage space (#5364)
d466933963 [wpiutil] Group doxygen into MPack module (#5380)
652d1c44e3 [wpiutil] Upgrade to macOS 12 to remove concept shims (#5379)
6414be0e5d [wpimath] Group units doxygen modules (#5382)
7ab5800487 [wpiutil] Fix docs typo in SmallVector (#5381)
59905ea721 Replace WPI_DEPRECATED() macro with [[deprecated]] attribute (#5373)
753cb49a5e [ntcore] Fix doxygen module in generated C types (NFC) (#5374)
1c00a52b67 [hal] Expose CAN timestamp base clock (#5357)
91cbcea841 Replace SFINAE with concepts (#5361)
d57d1a4598 [wpimath] Remove unnecessary template argument from unit formatter (#5367)
5acc5e22aa [wpimath] Only compute eigenvalues with EigenSolvers (#5369)
d3c9316a97 extend shuffleboard test timeout (#5377)
1ea868081a [ci] Fix /format command (#5376)
5fac18ff4a Update formatting to clang-format 16 (#5370)
a94a998002 [wpimath] Generalize Eigen formatter (#5360)
125f6ea101 [wpimath] Make SwerveDriveKinematics::ToChassisSpeeds() take const-ref argument (#5363)
51066a5a8a [wpimath] Move unit formatters into units library (#5358)
282c032b60 [wpilibc] Add unit-aware Joystick.GetDirection() (#5319)
073d19cb69 [build] Fix CMake warning (#5359)
01490fc77b [wpiutil] DataLog: Add documentation for append methods (NFC) (#5348)
c9b612c986 [wpilibcExamples] Make C++ state-space elevator KF and LQR match Java (#5346)
eed1e6e3cb [wpimath] Replace DiscretizeAQTaylor() with DiscretizeAQ() (#5344)
c976f40364 [readme] Document how to run examples in simulation (#5340)
4d28bdc19e [ci] Update Github Pages deploy action parameters (#5343)
e0f851871f [ci] Fix github pages deploy version (#5342)
063c8cbedc Run wpiformat (NFC) (#5341)
96e41c0447 [ci] Update deploy and sshagent actions (#5338)
fd294bdd71 [build] Fix compilation with GCC 13 (#5322)
d223e4040b [dlt] Add delete without download functionality (#5329)
abc19bcb43 [upstream_utils] Zero out commit hashes and show 40 digits in index hashes (#5336)
e909f2e687 [build] Update gradle cache repo name (#5334)
52bd5b972d [wpimath] Rewrite DARE solver (#5328)
3876a2523a [wpimath] Remove unused MatrixImpl() function (#5330)
c82fcb1975 [wpiutil] Add reflection based cleanup helper (#4919)
15ba95df7e [wpiutil] Use std::filesystem (#4941)
77c2124fc5 [wpimath] Remove Eigen's custom STL types (#4945)
27fb47ab10 [glass] Field2D: Embed standard field images (#5159)
102e4f2566 [wpilib] Remove deprecated and broken SPI methods (#5249)
463a90f1df [wpilib, hal] Add function to read the RSL state (#5312)
7a90475eec [wpilib] Update RobotBase documentation (NFC) (#5320)
218cfea16b [wpilib] DutyCycleEncoder: Fix reset behavior (#5287)
91392823ff [build] Update to gradle 8.1 (#5303)
258b7cc48b [wpilibj] Filesystem.getDeployDirectory(): Strip JNI path from user.dir (#5317)
26cc43bee1 [wpilib] Add documentation to SPI mode enum (NFC) (#5324)
ac4da9b1cb [hal] Add HAL docs for Addressable LED (NFC) (#5304)
21d4244cf7 [wpimath] Fix DCMotor docs (NFC) (#5309)
1dff81bea7 [hal] Miscellaneous HAL doc fixes (NFC) (#5306)
7ce75574bf [wpimath] Upgrade to Drake v1.15.0 (#5310)
576bd646ae [hal] Add CANManufacturer for Redux Robotics (#5305)
ee3b4621e5 [commands] Add onlyWhile and onlyIf (#5291)
40ca094686 [commands] Fix RepeatCommand calling end() twice (#5261)
9cbeb841f5 [rtns] Match imaging tool capitalization (#5265)
a63d06ff77 [examples] Add constants to java gearsbot example (#5248)
b6c43322a3 [wpilibc] XboxController: Add return tag to docs (NFC) (#5246)
5162d0001c [hal] Fix and document addressable LED timings (#5272)
90fabe9651 [wpilibj] Use method references in drive class initSendable() (#5251)
24828afd11 [wpimath] Fix desaturateWheelSpeeds to account for negative speeds (#5269)
e099948a77 [wpimath] Clean up rank notation in docs (NFC) (#5274)
fd2d8cb9c1 [hal] Use std::log2() for base-2 logarithm (#5278)
ba8c64bcff [wpimath] Fix misspelled Javadoc parameters in pose estimators (NFC) (#5292)
f53c6813d5 [wpimath] Patch Eigen warnings (#5290)
663703d370 [gitattributes] Mark json files as lf text files (#5256)
aa34aacf6e [wpilib] Shuffleboard: Keep duplicates on SelectTab() (#5198)
63512bbbb8 [wpimath] Fix potential divide-by-zero in RKDP (#5242)
9227b2166e [wpilibj] DriverStation: Fix joystick data logs (#5240)
fbf92e9190 [wpinet] ParallelTcpConnector: don't connect to duplicate addresses (#5169)
2108a61362 [ntcore] NT4 client: close timed-out connections (#5175)
0a66479693 [ntcore] Optimize scan of outgoing messages (#5227)
b510c17ef6 [hal] Fix RobotController.getComments() mishandling quotes inside the comments string (#5197)
e7a7eb2e93 [commands] WaitCommand: Remove subclass doc note (NFC) (#5200)
a465f2d8f0 [examples] Shuffleboard: Correct parameter order (#5204)
a3364422fa LICENSE.md: Bump year to 2023 (#5195)
df3242a40a [wpimath] Fix NaN in C++ MakeCostMatrix() that takes an array (#5194)
00abb8c1e0 [commands] RamseteCommand: default-initialize m_prevSpeeds (#5188)
c886273fd7 [wpilibj] DutyCycleEncoder.setDistancePerRotation(): fix simulation (#5147)
53b5fd2ace [ntcore] Use int64 for datalog type string (#5186)
56b758320f [wpilib] DataLogManager: increase time for datetime to be valid (#5185)
08f298e4cd [wpimath] Fix Pose3d log returning Twist3d NaN for theta between 1E-8 and 1E-7 (#5168)
6d0c5b19db [commands] CommandScheduler.isComposed: Remove incorrect throws clause (NFC) (#5183)
0d22cf5ff7 [wpilib] Fix enableLiveWindowInTest crashing in disabled (#5173)
32ec5b3f75 [wpilib] Add isTestEnabled and minor docs cleanup (#5172)
e5c4c6b1a7 [wpimath] Fix invalid iterator access in TimeInterpolatableBuffer (#5138)
099d048d9e [wpimath] Fix Pose3d log returning Twist3d NaN for theta between 1E-9 and 1E-8 (#5143)
4af84a1c12 Fix Typos (NFC) (#5137)
ce3686b80d [wpimath] Check LTV controller max velocity precondition (#5142)
4b0eecaee0 [commands] Subsystem: Add default command removal method (#5064)
edf4ded412 [wpilib] PH: Revert to 5V rail being fixed 5V (#5122)
4c46b6aff9 [wpilibc] Fix DataLogManager crash on exit in sim (#5125)
490ca4a68a [wpilibc] Fix XboxController::GetBackButton doc (NFC) (#5131)
cbb5b0b802 [hal] Simulation: Fix REV PH solenoids 8+ (#5132)
bb7053d9ee [hal] Fix HAL_GetRuntimeType being slow on the roboRIO (#5130)
9efed9a533 Update .clang-format to c++20 (#5121)
dbbfe1aed2 [wpilib] Use PH voltage to calc Analog pressure switch threshold (#5115)
de65a135c3 [wpilib] DutyCycleEncoderSim: Add channel number constructor (#5118)
3e9788cdff [docs] Strip path from generated NT docs (#5119)
ecb072724d [ntcore] Client::Disconnect(): actually close connection (#5113)
0d462a4561 [glass] NT view: Change string/string array to quoted (#5111)
ba37986561 [ntcore] NetworkClient::Disconnect: Add null check (#5112)
25ab9cda92 [glass,ov] Provide menu item to create topic from root (#5110)
2f6251d4a6 [glass] Set default value when publishing new topic (#5109)
e9a7bed988 [wpimath] Add timestamp getter to MathShared (#5091)
9cc14bbb43 [ntcore] Add stress test to dev executable (#5107)
8068369542 [wpinet] uv: Stop creating handles when closing loop (#5102)
805c837a42 [ntcore] Fix use-after-free in server (#5101)
fd18577ba0 [commands] Improve documentation of addRequirements (NFC) (#5103)
74dea9f05e [wpimath] Fix exception for empty pose buffer in pose estimators (#5106)
9eef79d638 [wpilib] PneumaticHub: Document range of enableCompressorAnalog (NFC) (#5099)
843574a810 [ntcore] Use wpi::Now instead of loop time for transmit time
226ef35212 [wpinet] WebSocket: Reduce server send frame overhead
b30664d630 [ntcore] Reduce initial connection overhead
804e5ce236 [examples] MecanumDrive: Fix axis comment in C++ example (NFC) (#5096)
49af88f2bb [examples] ArmSimulation: Fix flaky test (#5093)
d56314f866 [wpiutil] Disable mock time on the Rio (#5092)
43975ac7cc [examples] ArmSimulation, ElevatorSimulation: Extract mechanism to class (#5052)
5483464158 [examples, templates] Improve descriptions (NFC) (#5051)
785e7dd85c [wpilibc] SendableChooser: static_assert copy- and default-constructibility (#5078)
e57ded8c39 [ntcore] Improve disconnect error reporting (#5085)
01f0394419 [wpinet] Revert WebSocket: When Close() is called, call closed immediately (#5084)
59be120982 [wpimath] Fix Pose3d exp()/log() and add rotation vector constructor to Rotation3d (#5072)
37f065032f [wpilib] Refactor TimedRobot tests (#5068)
22a170bee7 [wpilib] Add Notifier test (#5070)
2f310a748c [wpimath] Fix DCMotor.getSpeed() (#5061)
b43ec87f57 [wpilib] ElevatorSim: Fix WouldHitLimit methods (#5057)
19267bef0c [ntcore] Output warning on property set on unpublished topic (#5059)
84cbd48d84 [ntcore] Handle excludeSelf on SetDefault (#5058)
1f35750865 [cameraserver] Add GetInstance() to all functions (#5054)
8230fc631d [wpilib] Revert throw on nonexistent SimDevice name in SimDeviceSim (#5053)
b879a6f8c6 [wpinet] WebSocket: When Close() is called, call closed immediately (#5047)
49459d3e45 [ntcore] Change wire timeout to fixed 1 second (#5048)
4079eabe9b [wpimath] Discard stale pose estimates (#5045)
fe5d226a19 [glass] Fix option for debug-level NT logging (#5049)
b7535252c2 [ntcore] Don't leak buffers in rare WS shutdown case (#5046)
b61ac6db33 [ntcore] Add client disconnect function (#5022)
7b828ce84f [wpimath] Add nearest to Pose2d and Translation2d (#4882)
08a536291b [examples] Improvements to Elevator Simulation Example (#4937)
193a10d020 [wpigui] Limit frame rate to 120 fps by default (#5030)
7867bbde0e [wpilib] Clarify DS functions provided by FMS (NFC) (#5043)
fa7c01b598 [glass] Add option for debug-level NT logging (#5007)
2b81610248 [wpiutil] Add msgpack to datalog Python example (#5032)
a4a369b8da CONTRIBUTING.md: Add unicodeit CLI to math docs guidelines (#5031)
d991f6e435 [wpilib] Throw on nonexistent SimDevice name in SimDeviceSim constructor (#5041)
a27a047ae8 [hal] Check for null in getSimDeviceName JNI (#5038)
2f96cae31a [examples] Hatchbots: Add telemetry (#5011)
83ef8f9658 [simulation] GUI: Fix buffer overflow in joystick axes copy (#5036)
4054893669 [commands] Fix C++ Select() factory (#5024)
f75acd11ce [commands] Use Timer.restart() (#5023)
8bf67b1b33 [wpimath] PIDController::Calculate(double, double): update setpoint flag (#5021)
49bb1358d8 [wpiutil] MemoryBuffer: Fix GetMemoryBufferForStream (#5017)
9c4c07c0f9 [wpiutil] Remove NDEBUG check for debug-level logging (#5018)
1a47cc2e86 [ntcore] Use full handle when subscribing (#5013)
7cd30cffbc Ignore networktables.json (#5006)
92aecab2ef [commands] Command controllers are not subclasses (NFC) (#5000)
8785bba080 [ntcore] Special-case default timestamps (#5003)
9e5b7b8040 [ntcore] Handle topicsonly followed by value subscribe (#4991)
917906530a [wpilib] Add Timer::Restart() (#4963)
00aa66e4fd [wpimath] Remove extraneous assignments from DiscretizeAB() (#4967)
893320544a [examples] C++ RamseteCommand: Fix units (#4954)
b95d0e060d [wpilib] XboxController: Fix docs discrepancy (NFC) (#4993)
008232b43c [ntcore] Write empty persistent file if none found (#4996)
522be348f4 [examples] Rewrite tags (NFC) (#4961)
d48a83dee2 [wpimath] Update Wikipedia links for quaternion to Euler angle conversion (NFC) (#4995)
504fa22143 [wpimath] Workaround intellisense Eigen issue (#4992)
b2b25bf09f [commands] Fix docs inconsistency for toggleOnFalse(Command) (NFC) (#4978)
ce3dc4eb3b [hal] Properly use control word that is in sync with DS data (#4989)
1ea48caa7d [wpilib] Fix C++ ADXRS450 and Java SPI gyro defs (#4988)
fb101925a7 [build] Include wpimathjni in commands binaries (#4981)
657951f6dd [starter] Add a process starter for use by the installer for launching tools (#4931)
a60ca9d71c [examples] Update AprilTag field load API usage (#4975)
f8a45f1558 [wpimath] Remove print statements from tests (#4977)
ecba8b99a8 [examples] Fix swapped arguments in MecanumControllerCommand example (#4976)
e95e88fdf9 [examples] Add comment to drivedistanceoffboard example (#4877)
371d15dec3 [examples] Add Computer Vision Pose Estimation and Latency Compensation Example (#4901)
cb9b8938af [sim] Enable docking in the GUI (#4960)
3b084ecbe0 [apriltag] AprilTagFieldLayout: Improve API shape for loading builtin JSONs (#4949)
27ba096ea1 [wpilib] Fix MOI calculation error in SingleJointedArmSim (#4968)
42c997a3c4 [wpimath] Fix Pose3d exponential and clean up Pose3d logarithm (#4970)
5f1a025f27 [wpilibj] Fix typo in MecanumDrive docs (NFC) (#4969)
0ebf79b54c [wpimath] Fix typo in Pose3d::Exp() docs (NFC) (#4966)
a8c465f3fb [wpimath] HolonomicDriveController: Add getters for the controllers (#4948)
a7b1ab683d [wpilibc] Add unit test for fast deconstruction of GenericHID (#4953)
bd6479dc29 [build] Add Spotless for JSON (#4956)
5cb0340a8c [hal, wpilib] Load joystick values upon code initialization (#4950)
ab0e8c37a7 [readme] Update build requirements (NFC) (#4947)
b74ac1c645 [build] Add apriltag to C++ cmake example builds (#4944)
cf1a411acf [examples] Add example programs for AprilTags detection (#4932)
1e05b21ab5 [wpimath] Fix PID atSetpoint to not return true prematurely (#4906)
e5a6197633 [wpimath] Fix SwerveDriveKinematics not initializing a new array each time (#4942)
039edcc23f [ntcore] Queue current value on subscriber creation (#4938)
f7f19207e0 [wpimath] Allow multiple vision measurements from same timestamp (#4917)
befd12911c [commands] Delete UB-causing rvalue variants of CommandPtr methods (#4923)
34519de60a [commands] Fix spacing in command composition exception (#4924)
dc4355c031 [hal] Add handle constructor and name getters for sim devices (#4925)
53d8d33bca [hal, wpilibj] Add missing distance per pulse functions to EncoderSim (#4928)
530ae40614 [apriltag] Explain what April tag poses represent (NFC) (#4930)
79f565191e [examples] DigitalCommunication, I2CCommunication: Add tests (#4865)
2cd9be413f [wpilib, examples] Cleanup PotentiometerPID, Ultrasonic, UltrasonicPID examples (#4893)
babb0c1fcf [apriltag] Add 2023 field layout JSON (#4912)
330ba45f9c [wpimath] Fix swerve kinematics util classes equals function (#4907)
51272ef6b3 [fieldImages] Add 2023 field (#4915)
0d105ab771 [commands] Deduplicate command test utils (#4897)
cf4235ea36 [wpiutil] Guard MSVC pragma in SymbolExports.h (#4911)
2d4b7b9147 [build] Update opencv version in opencv.gradle (#4909)
aec6f3d506 [ntcore] Fix client flush behavior (#4903)
bfe346c76a [build] Fix cmake java resources (#4898)

Change-Id: Ia1dd90fe42c6cd5df281b8a5b710e136f54355f4
git-subtree-dir: third_party/allwpilib
git-subtree-split: f1a82828fed8950f9a3f1586c44327027627a0c8
Signed-off-by: James Kuszmaul <jabukuszmaul+collab@gmail.com>
diff --git a/wpinet/src/main/native/cpp/DsClient.cpp b/wpinet/src/main/native/cpp/DsClient.cpp
index 86f8f00..97509cb 100644
--- a/wpinet/src/main/native/cpp/DsClient.cpp
+++ b/wpinet/src/main/native/cpp/DsClient.cpp
@@ -21,6 +21,9 @@
     : m_logger{logger},
       m_tcp{uv::Tcp::Create(loop)},
       m_timer{uv::Timer::Create(loop)} {
+  if (!m_tcp || !m_timer) {
+    return;
+  }
   m_tcp->end.connect([this] {
     WPI_DEBUG4(m_logger, "DS connection closed");
     clearIp();
diff --git a/wpinet/src/main/native/cpp/EventLoopRunner.cpp b/wpinet/src/main/native/cpp/EventLoopRunner.cpp
index 7c7e79c..6c143ac 100644
--- a/wpinet/src/main/native/cpp/EventLoopRunner.cpp
+++ b/wpinet/src/main/native/cpp/EventLoopRunner.cpp
@@ -59,6 +59,7 @@
       h.SetLoopClosing(true);
       h.Close();
     });
+    loop.SetClosing();
   });
   m_owner.Join();
 }
diff --git a/wpinet/src/main/native/cpp/MulticastHandleManager.cpp b/wpinet/src/main/native/cpp/MulticastHandleManager.cpp
index d249a1c..ab44da8 100644
--- a/wpinet/src/main/native/cpp/MulticastHandleManager.cpp
+++ b/wpinet/src/main/native/cpp/MulticastHandleManager.cpp
@@ -10,3 +10,17 @@
   static MulticastHandleManager manager;
   return manager;
 }
+
+#ifdef _WIN32
+MulticastHandleManager::~MulticastHandleManager() {
+  // Multicast handles cannot be safely destructed on windows during shutdown.
+  // Just leak all handles.
+  for (auto&& i : resolvers) {
+    i.second.release();
+  }
+
+  for (auto&& i : announcers) {
+    i.second.release();
+  }
+}
+#endif
diff --git a/wpinet/src/main/native/cpp/MulticastHandleManager.h b/wpinet/src/main/native/cpp/MulticastHandleManager.h
index 8c070f7..9925e84 100644
--- a/wpinet/src/main/native/cpp/MulticastHandleManager.h
+++ b/wpinet/src/main/native/cpp/MulticastHandleManager.h
@@ -20,6 +20,9 @@
       resolvers;
   wpi::DenseMap<size_t, std::unique_ptr<wpi::MulticastServiceAnnouncer>>
       announcers;
+#ifdef _WIN32
+  ~MulticastHandleManager();
+#endif
 };
 
 MulticastHandleManager& GetMulticastManager();
diff --git a/wpinet/src/main/native/cpp/ParallelTcpConnector.cpp b/wpinet/src/main/native/cpp/ParallelTcpConnector.cpp
index 317f0a2..5fb1dd5 100644
--- a/wpinet/src/main/native/cpp/ParallelTcpConnector.cpp
+++ b/wpinet/src/main/native/cpp/ParallelTcpConnector.cpp
@@ -4,6 +4,8 @@
 
 #include "wpinet/ParallelTcpConnector.h"
 
+#include <cstring>
+
 #include <fmt/format.h>
 #include <wpi/Logger.h>
 
@@ -24,6 +26,9 @@
       m_reconnectRate{reconnectRate},
       m_connected{std::move(connected)},
       m_reconnectTimer{uv::Timer::Create(loop)} {
+  if (!m_reconnectTimer) {
+    return;
+  }
   m_reconnectTimer->timeout.connect([this] {
     if (!IsConnected()) {
       WPI_DEBUG1(m_logger, "timed out, reconnecting");
@@ -62,6 +67,29 @@
   }
 }
 
+static bool AddressEquals(const sockaddr& a, const sockaddr& b) {
+  if (a.sa_family != b.sa_family) {
+    return false;
+  }
+  if (a.sa_family == AF_INET) {
+    return reinterpret_cast<const sockaddr_in&>(a).sin_addr.s_addr ==
+           reinterpret_cast<const sockaddr_in&>(b).sin_addr.s_addr;
+  }
+  if (a.sa_family == AF_INET6) {
+    return std::memcmp(&(reinterpret_cast<const sockaddr_in6&>(a).sin6_addr),
+                       &(reinterpret_cast<const sockaddr_in6&>(b).sin6_addr),
+                       sizeof(in6_addr)) == 0;
+  }
+  return false;
+}
+
+static inline sockaddr_storage CopyAddress(const sockaddr& addr,
+                                           socklen_t len) {
+  sockaddr_storage storage;
+  std::memcpy(&storage, &addr, len);
+  return storage;
+}
+
 void ParallelTcpConnector::Connect() {
   if (IsConnected()) {
     return;
@@ -85,8 +113,25 @@
 
           // kick off parallel connection attempts
           for (auto ai = &addrinfo; ai; ai = ai->ai_next) {
+            // check for duplicates
+            bool duplicate = false;
+            for (auto&& attempt : m_attempts) {
+              if (AddressEquals(*ai->ai_addr, reinterpret_cast<const sockaddr&>(
+                                                  attempt.first))) {
+                duplicate = true;
+                break;
+              }
+            }
+            if (duplicate) {
+              continue;
+            }
+
             auto tcp = uv::Tcp::Create(m_loop);
-            m_attempts.emplace_back(tcp);
+            if (!tcp) {
+              continue;
+            }
+            m_attempts.emplace_back(CopyAddress(*ai->ai_addr, ai->ai_addrlen),
+                                    tcp);
 
             auto connreq = std::make_shared<uv::TcpConnectReq>();
             connreq->connected.connect(
@@ -164,8 +209,8 @@
   }
   m_resolvers.clear();
 
-  for (auto&& tcpWeak : m_attempts) {
-    if (auto tcp = tcpWeak.lock()) {
+  for (auto&& attempt : m_attempts) {
+    if (auto tcp = attempt.second.lock()) {
       if (tcp.get() != except) {
         WPI_DEBUG4(m_logger, "canceling connection attempt ({})",
                    static_cast<void*>(tcp.get()));
diff --git a/wpinet/src/main/native/cpp/PortForwarder.cpp b/wpinet/src/main/native/cpp/PortForwarder.cpp
index 257b620..67cd806 100644
--- a/wpinet/src/main/native/cpp/PortForwarder.cpp
+++ b/wpinet/src/main/native/cpp/PortForwarder.cpp
@@ -49,6 +49,9 @@
                         unsigned int remotePort) {
   m_impl->runner.ExecSync([&](uv::Loop& loop) {
     auto server = uv::Tcp::Create(loop);
+    if (!server) {
+      return;
+    }
 
     // bind to local port
     server->Bind("", port);
@@ -71,6 +74,10 @@
       client->SetData(connected);
 
       auto remote = uv::Tcp::Create(loop);
+      if (!remote) {
+        client->Close();
+        return;
+      }
       remote->error.connect(
           [remotePtr = remote.get(),
            clientWeak = std::weak_ptr<uv::Tcp>(client)](uv::Error err) {
diff --git a/wpinet/src/main/native/cpp/WebSocket.cpp b/wpinet/src/main/native/cpp/WebSocket.cpp
index ba57925..43b901e 100644
--- a/wpinet/src/main/native/cpp/WebSocket.cpp
+++ b/wpinet/src/main/native/cpp/WebSocket.cpp
@@ -5,6 +5,9 @@
 #include "wpinet/WebSocket.h"
 
 #include <random>
+#include <span>
+#include <string>
+#include <string_view>
 
 #include <fmt/format.h>
 #include <wpi/Base64.h>
@@ -14,34 +17,91 @@
 #include <wpi/raw_ostream.h>
 #include <wpi/sha1.h>
 
+#include "WebSocketDebug.h"
+#include "WebSocketSerializer.h"
 #include "wpinet/HttpParser.h"
 #include "wpinet/raw_uv_ostream.h"
 #include "wpinet/uv/Stream.h"
 
 using namespace wpi;
 
-namespace {
-class WebSocketWriteReq : public uv::WriteReq {
+#ifdef WPINET_WEBSOCKET_VERBOSE_DEBUG
+static std::string DebugBinary(std::span<const uint8_t> val) {
+#ifdef WPINET_WEBSOCKET_VERBOSE_DEBUG_CONTENT
+  std::string str;
+  wpi::raw_string_ostream stros{str};
+  for (auto ch : val) {
+    stros << fmt::format("{:02x},", static_cast<unsigned int>(ch) & 0xff);
+  }
+  return str;
+#else
+  return "";
+#endif
+}
+
+static inline std::string_view DebugText(std::string_view val) {
+#ifdef WPINET_WEBSOCKET_VERBOSE_DEBUG_CONTENT
+  return val;
+#else
+  return "";
+#endif
+}
+#endif  // WPINET_WEBSOCKET_VERBOSE_DEBUG
+
+class WebSocket::WriteReq : public uv::WriteReq,
+                            public detail::WebSocketWriteReqBase {
  public:
-  explicit WebSocketWriteReq(
+  explicit WriteReq(
+      std::weak_ptr<WebSocket> ws,
       std::function<void(std::span<uv::Buffer>, uv::Error)> callback)
-      : m_callback{std::move(callback)} {
-    finish.connect([this](uv::Error err) {
-      for (auto&& buf : m_internalBufs) {
-        buf.Deallocate();
-      }
-      m_callback(m_userBufs, err);
-    });
+      : m_ws{std::move(ws)}, m_callback{std::move(callback)} {
+    finish.connect([this](uv::Error err) { Send(err); });
   }
 
+  void Send(uv::Error err) {
+    auto ws = m_ws.lock();
+    if (!ws || err) {
+      WS_DEBUG("no WS or error, calling callback\n");
+      m_frames.ReleaseBufs();
+      m_callback(m_userBufs, err);
+      return;
+    }
+
+    // Continue() is designed so this is *only* called on frame boundaries
+    if (m_controlCont) {
+      // We have a control frame; switch to it.  We will come back here via
+      // the control frame's m_cont when it's done.
+      WS_DEBUG("Continuing with a control write\n");
+      auto controlCont = std::move(m_controlCont);
+      m_controlCont.reset();
+      return controlCont->Send({});
+    }
+    int result = Continue(ws->m_stream, shared_from_this());
+    WS_DEBUG("Continue() -> {}\n", result);
+    if (result <= 0) {
+      m_frames.ReleaseBufs();
+      m_callback(m_userBufs, uv::Error{result});
+      if (result == 0 && m_cont) {
+        WS_DEBUG("Continuing with another write\n");
+        ws->m_curWriteReq = m_cont;
+        return m_cont->Send({});
+      } else {
+        ws->m_writeInProgress = false;
+        ws->m_curWriteReq.reset();
+        ws->m_lastWriteReq.reset();
+      }
+    }
+  }
+
+  std::weak_ptr<WebSocket> m_ws;
   std::function<void(std::span<uv::Buffer>, uv::Error)> m_callback;
-  SmallVector<uv::Buffer, 4> m_internalBufs;
-  SmallVector<uv::Buffer, 4> m_userBufs;
+  std::shared_ptr<WriteReq> m_cont;
+  std::shared_ptr<WriteReq> m_controlCont;
 };
-}  // namespace
 
 static constexpr uint8_t kFlagMasking = 0x80;
 static constexpr uint8_t kLenMask = 0x7f;
+static constexpr size_t kWriteAllocSize = 4096;
 
 class WebSocket::ClientHandshakeData {
  public:
@@ -154,7 +214,7 @@
 
   // Build client request
   SmallVector<uv::Buffer, 4> bufs;
-  raw_uv_ostream os{bufs, 4096};
+  raw_uv_ostream os{bufs, kWriteAllocSize};
 
   os << "GET " << uri << " HTTP/1.1\r\n";
   os << "Host: " << host << "\r\n";
@@ -258,11 +318,12 @@
 
   // Start handshake timer if a timeout was specified
   if (options.handshakeTimeout != (uv::Timer::Time::max)()) {
-    auto timer = uv::Timer::Create(m_stream.GetLoopRef());
-    timer->timeout.connect(
-        [this]() { Terminate(1006, "connection timed out"); });
-    timer->Start(options.handshakeTimeout);
-    m_clientHandshake->timer = timer;
+    if (auto timer = uv::Timer::Create(m_stream.GetLoopRef())) {
+      timer->timeout.connect(
+          [this]() { Terminate(1006, "connection timed out"); });
+      timer->Start(options.handshakeTimeout);
+      m_clientHandshake->timer = timer;
+    }
   }
 }
 
@@ -272,7 +333,7 @@
 
   // Build server response
   SmallVector<uv::Buffer, 4> bufs;
-  raw_uv_ostream os{bufs, 4096};
+  raw_uv_ostream os{bufs, kWriteAllocSize};
 
   // Handle unsupported version
   if (version != "13") {
@@ -320,13 +381,13 @@
 void WebSocket::SendClose(uint16_t code, std::string_view reason) {
   SmallVector<uv::Buffer, 4> bufs;
   if (code != 1005) {
-    raw_uv_ostream os{bufs, 4096};
+    raw_uv_ostream os{bufs, kWriteAllocSize};
     const uint8_t codeMsb[] = {static_cast<uint8_t>((code >> 8) & 0xff),
                                static_cast<uint8_t>(code & 0xff)};
     os << std::span{codeMsb};
     os << reason;
   }
-  Send(kFlagFin | kOpClose, bufs, [](auto bufs, uv::Error) {
+  SendControl(kFlagFin | kOpClose, bufs, [](auto bufs, uv::Error) {
     for (auto&& buf : bufs) {
       buf.Deallocate();
     }
@@ -345,6 +406,17 @@
   m_stream.Shutdown([this] { m_stream.Close(); });
 }
 
+static inline void Unmask(std::span<uint8_t> data,
+                          std::span<const uint8_t, 4> key) {
+  int n = 0;
+  for (uint8_t& ch : data) {
+    ch ^= key[n++];
+    if (n >= 4) {
+      n = 0;
+    }
+  }
+}
+
 void WebSocket::HandleIncoming(uv::Buffer& buf, size_t size) {
   // ignore incoming data if we're failed or closed
   if (m_state == FAILED || m_state == CLOSED) {
@@ -445,32 +517,37 @@
         }
 
         // limit maximum size
-        if ((m_payload.size() + m_frameSize) > m_maxMessageSize) {
+        bool control = (m_header[0] & kFlagControl) != 0;
+        if (((control ? m_controlPayload.size() : m_payload.size()) +
+             m_frameSize) > m_maxMessageSize) {
           return Fail(1009, "message too large");
         }
       }
     }
 
     if (m_frameSize != UINT64_MAX) {
-      size_t need = m_frameStart + m_frameSize - m_payload.size();
+      bool control = (m_header[0] & kFlagControl) != 0;
+      size_t need;
+      if (control) {
+        need = m_frameSize - m_controlPayload.size();
+      } else {
+        need = m_frameStart + m_frameSize - m_payload.size();
+      }
       size_t toCopy = (std::min)(need, data.size());
-      m_payload.append(data.data(), data.data() + toCopy);
+      if (control) {
+        m_controlPayload.append(data.data(), data.data() + toCopy);
+      } else {
+        m_payload.append(data.data(), data.data() + toCopy);
+      }
       data.remove_prefix(toCopy);
       need -= toCopy;
       if (need == 0) {
         // We have a complete frame
         // If the message had masking, unmask it
         if ((m_header[1] & kFlagMasking) != 0) {
-          uint8_t key[4] = {
-              m_header[m_headerSize - 4], m_header[m_headerSize - 3],
-              m_header[m_headerSize - 2], m_header[m_headerSize - 1]};
-          int n = 0;
-          for (uint8_t& ch : std::span{m_payload}.subspan(m_frameStart)) {
-            ch ^= key[n++];
-            if (n >= 4) {
-              n = 0;
-            }
-          }
+          Unmask(control ? std::span{m_controlPayload}
+                         : std::span{m_payload}.subspan(m_frameStart),
+                 std::span<const uint8_t, 4>{&m_header[m_headerSize - 4], 4});
         }
 
         // Handle message
@@ -478,17 +555,23 @@
         uint8_t opcode = m_header[0] & kOpMask;
         switch (opcode) {
           case kOpCont:
+            WS_DEBUG("WS Fragment {} [{}]\n", m_payload.size(),
+                     DebugBinary(m_payload));
             switch (m_fragmentOpcode) {
               case kOpText:
                 if (!m_combineFragments || fin) {
-                  text(std::string_view{reinterpret_cast<char*>(
-                                            m_payload.data()),
-                                        m_payload.size()},
-                       fin);
+                  std::string_view content{
+                      reinterpret_cast<char*>(m_payload.data()),
+                      m_payload.size()};
+                  WS_DEBUG("WS RecvText(Defrag) {} ({})\n", m_payload.size(),
+                           DebugText(content));
+                  text(content, fin);
                 }
                 break;
               case kOpBinary:
                 if (!m_combineFragments || fin) {
+                  WS_DEBUG("WS RecvBinary(Defrag) {} ({})\n", m_payload.size(),
+                           DebugBinary(m_payload));
                   binary(m_payload, fin);
                 }
                 break;
@@ -500,42 +583,38 @@
               m_fragmentOpcode = 0;
             }
             break;
-          case kOpText:
+          case kOpText: {
+            std::string_view content{reinterpret_cast<char*>(m_payload.data()),
+                                     m_payload.size()};
             if (m_fragmentOpcode != 0) {
+              WS_DEBUG("WS RecvText {} ({}) -> INCOMPLETE FRAGMENT\n",
+                       m_payload.size(), DebugText(content));
               return Fail(1002, "incomplete fragment");
             }
             if (!m_combineFragments || fin) {
-#ifdef WPINET_WEBSOCKET_VERBOSE_DEBUG
-              fmt::print(
-                  "WS RecvText({})\n",
-                  std::string_view{reinterpret_cast<char*>(m_payload.data()),
-                                   m_payload.size()});
-#endif
-              text(std::string_view{reinterpret_cast<char*>(m_payload.data()),
-                                    m_payload.size()},
-                   fin);
+              WS_DEBUG("WS RecvText {} ({})\n", m_payload.size(),
+                       DebugText(content));
+              text(content, fin);
             }
             if (!fin) {
+              WS_DEBUG("WS RecvText {} StartFrag\n", m_payload.size());
               m_fragmentOpcode = opcode;
             }
             break;
+          }
           case kOpBinary:
             if (m_fragmentOpcode != 0) {
+              WS_DEBUG("WS RecvBinary {} ({}) -> INCOMPLETE FRAGMENT\n",
+                       m_payload.size(), DebugBinary(m_payload));
               return Fail(1002, "incomplete fragment");
             }
             if (!m_combineFragments || fin) {
-#ifdef WPINET_WEBSOCKET_VERBOSE_DEBUG
-              SmallString<128> str;
-              raw_svector_ostream stros{str};
-              for (auto ch : m_payload) {
-                stros << fmt::format("{:02x},",
-                                     static_cast<unsigned int>(ch) & 0xff);
-              }
-              fmt::print("WS RecvBinary({})\n", str.str());
-#endif
+              WS_DEBUG("WS RecvBinary {} ({})\n", m_payload.size(),
+                       DebugBinary(m_payload));
               binary(m_payload, fin);
             }
             if (!fin) {
+              WS_DEBUG("WS RecvBinary {} StartFrag\n", m_payload.size());
               m_fragmentOpcode = opcode;
             }
             break;
@@ -545,14 +624,15 @@
             if (!fin) {
               code = 1002;
               reason = "cannot fragment control frames";
-            } else if (m_payload.size() < 2) {
+            } else if (m_controlPayload.size() < 2) {
               code = 1005;
             } else {
-              code = (static_cast<uint16_t>(m_payload[0]) << 8) |
-                     static_cast<uint16_t>(m_payload[1]);
-              reason = drop_front(
-                  {reinterpret_cast<char*>(m_payload.data()), m_payload.size()},
-                  2);
+              code = (static_cast<uint16_t>(m_controlPayload[0]) << 8) |
+                     static_cast<uint16_t>(m_controlPayload[1]);
+              reason =
+                  drop_front({reinterpret_cast<char*>(m_controlPayload.data()),
+                              m_controlPayload.size()},
+                             2);
             }
             // Echo the close if we didn't previously send it
             if (m_state != CLOSING) {
@@ -569,13 +649,30 @@
             if (!fin) {
               return Fail(1002, "cannot fragment control frames");
             }
-            ping(m_payload);
+            // If the connection is open, send a Pong in response
+            if (m_state == OPEN) {
+              SmallVector<uv::Buffer, 4> bufs;
+              {
+                raw_uv_ostream os{bufs, kWriteAllocSize};
+                os << m_controlPayload;
+              }
+              SendPong(bufs, [](auto bufs, uv::Error) {
+                for (auto&& buf : bufs) {
+                  buf.Deallocate();
+                }
+              });
+            }
+            WS_DEBUG("WS RecvPing() {} ({})\n", m_controlPayload.size(),
+                     DebugBinary(m_controlPayload));
+            ping(m_controlPayload);
             break;
           case kOpPong:
             if (!fin) {
               return Fail(1002, "cannot fragment control frames");
             }
-            pong(m_payload);
+            WS_DEBUG("WS RecvPong() {} ({})\n", m_controlPayload.size(),
+                     DebugBinary(m_controlPayload));
+            pong(m_controlPayload);
             break;
           default:
             return Fail(1002, "invalid message opcode");
@@ -585,7 +682,11 @@
         m_header.clear();
         m_headerSize = 0;
         if (!m_combineFragments || fin) {
-          m_payload.clear();
+          if (control) {
+            m_controlPayload.clear();
+          } else {
+            m_payload.clear();
+          }
         }
         m_frameStart = m_payload.size();
         m_frameSize = UINT64_MAX;
@@ -594,114 +695,157 @@
   }
 }
 
-static void WriteFrame(WebSocketWriteReq& req,
-                       SmallVectorImpl<uv::Buffer>& bufs, bool server,
-                       uint8_t opcode, std::span<const uv::Buffer> data) {
-  SmallVector<uv::Buffer, 4> internalBufs;
-  raw_uv_ostream os{internalBufs, 4096};
-
+static void VerboseDebug(const WebSocket::Frame& frame) {
 #ifdef WPINET_WEBSOCKET_VERBOSE_DEBUG
-  if ((opcode & 0x7f) == 0x01) {
+  if ((frame.opcode & 0x7f) == 0x01) {
     SmallString<128> str;
-    for (auto&& d : data) {
+#ifdef WPINET_WEBSOCKET_VERBOSE_DEBUG_CONTENT
+    for (auto&& d : frame.data) {
       str.append(std::string_view(d.base, d.len));
     }
+#endif
     fmt::print("WS SendText({})\n", str.str());
-  } else if ((opcode & 0x7f) == 0x02) {
+  } else if ((frame.opcode & 0x7f) == 0x02) {
     SmallString<128> str;
+#ifdef WPINET_WEBSOCKET_VERBOSE_DEBUG_CONTENT
     raw_svector_ostream stros{str};
-    for (auto&& d : data) {
+    for (auto&& d : frame.data) {
       for (auto ch : d.data()) {
         stros << fmt::format("{:02x},", static_cast<unsigned int>(ch) & 0xff);
       }
     }
-    fmt::print("WS SendBinary({})\n", str.str());
-  }
 #endif
-
-  // opcode (includes FIN bit)
-  os << static_cast<unsigned char>(opcode);
-
-  // payload length
-  uint64_t size = 0;
-  for (auto&& buf : data) {
-    size += buf.len;
-  }
-  if (size < 126) {
-    os << static_cast<unsigned char>((server ? 0x00 : kFlagMasking) | size);
-  } else if (size <= 0xffff) {
-    os << static_cast<unsigned char>((server ? 0x00 : kFlagMasking) | 126);
-    const uint8_t sizeMsb[] = {static_cast<uint8_t>((size >> 8) & 0xff),
-                               static_cast<uint8_t>(size & 0xff)};
-    os << std::span{sizeMsb};
+    fmt::print("WS SendBinary({})\n", str.str());
   } else {
-    os << static_cast<unsigned char>((server ? 0x00 : kFlagMasking) | 127);
-    const uint8_t sizeMsb[] = {static_cast<uint8_t>((size >> 56) & 0xff),
-                               static_cast<uint8_t>((size >> 48) & 0xff),
-                               static_cast<uint8_t>((size >> 40) & 0xff),
-                               static_cast<uint8_t>((size >> 32) & 0xff),
-                               static_cast<uint8_t>((size >> 24) & 0xff),
-                               static_cast<uint8_t>((size >> 16) & 0xff),
-                               static_cast<uint8_t>((size >> 8) & 0xff),
-                               static_cast<uint8_t>(size & 0xff)};
-    os << std::span{sizeMsb};
-  }
-
-  // clients need to mask the input data
-  if (!server) {
-    // generate masking key
-    static std::random_device rd;
-    static std::default_random_engine gen{rd()};
-    std::uniform_int_distribution<unsigned int> dist(0, 255);
-    uint8_t key[4];
-    for (uint8_t& v : key) {
-      v = dist(gen);
-    }
-    os << std::span<const uint8_t>{key, 4};
-    // copy and mask data
-    int n = 0;
-    for (auto&& buf : data) {
-      for (auto&& ch : buf.data()) {
-        os << static_cast<unsigned char>(static_cast<uint8_t>(ch) ^ key[n++]);
-        if (n >= 4) {
-          n = 0;
-        }
+    SmallString<128> str;
+#ifdef WPINET_WEBSOCKET_VERBOSE_DEBUG_CONTENT
+    raw_svector_ostream stros{str};
+    for (auto&& d : frame.data) {
+      for (auto ch : d.data()) {
+        stros << fmt::format("{:02x},", static_cast<unsigned int>(ch) & 0xff);
       }
     }
-    bufs.append(internalBufs.begin(), internalBufs.end());
-    // don't send the user bufs as we copied their data
-  } else {
-    bufs.append(internalBufs.begin(), internalBufs.end());
-    // servers can just send the buffers directly without masking
-    bufs.append(data.begin(), data.end());
+#endif
+    fmt::print("WS SendOp({}, {})\n", frame.opcode, str.str());
   }
-  req.m_internalBufs.append(internalBufs.begin(), internalBufs.end());
-  req.m_userBufs.append(data.begin(), data.end());
+#endif
 }
 
 void WebSocket::SendFrames(
     std::span<const Frame> frames,
     std::function<void(std::span<uv::Buffer>, uv::Error)> callback) {
   // If we're not open, emit an error and don't send the data
+  WS_DEBUG("SendFrames({})\n", frames.size());
   if (m_state != OPEN) {
-    int err;
-    if (m_state == CONNECTING) {
-      err = UV_EAGAIN;
-    } else {
-      err = UV_ESHUTDOWN;
-    }
-    SmallVector<uv::Buffer, 4> bufs;
-    for (auto&& frame : frames) {
-      bufs.append(frame.data.begin(), frame.data.end());
-    }
-    callback(bufs, uv::Error{err});
+    SendError(frames, callback);
     return;
   }
 
-  auto req = std::make_shared<WebSocketWriteReq>(std::move(callback));
+  // Build request
+  auto req = std::make_shared<WriteReq>(weak_from_this(), std::move(callback));
+  int numBytes = 0;
+  for (auto&& frame : frames) {
+    VerboseDebug(frame);
+    numBytes += req->m_frames.AddFrame(frame, m_server);
+    req->m_continueFrameOffs.emplace_back(numBytes);
+    req->m_userBufs.append(frame.data.begin(), frame.data.end());
+  }
+
+  if (m_writeInProgress) {
+    if (auto lastReq = m_lastWriteReq.lock()) {
+      // if write currently in progress, process as a continuation of that
+      m_lastWriteReq = req;
+      // make sure we're really at the end
+      while (lastReq->m_cont) {
+        lastReq = lastReq->m_cont;
+      }
+      lastReq->m_cont = std::move(req);
+      return;
+    }
+  }
+
+  m_writeInProgress = true;
+  m_curWriteReq = req;
+  m_lastWriteReq = req;
+  req->Send({});
+}
+
+std::span<const WebSocket::Frame> WebSocket::TrySendFrames(
+    std::span<const Frame> frames,
+    std::function<void(std::span<uv::Buffer>, uv::Error)> callback) {
+  // If we're not open, emit an error and don't send the data
+  if (m_state != WebSocket::OPEN) {
+    SendError(frames, callback);
+    return {};
+  }
+
+  // If something else is still in flight, don't send anything
+  if (m_writeInProgress) {
+    return frames;
+  }
+
+  return detail::TrySendFrames(
+      m_server, m_stream, frames,
+      [this](std::function<void(std::span<uv::Buffer>, uv::Error)>&& cb) {
+        auto req = std::make_shared<WriteReq>(weak_from_this(), std::move(cb));
+        m_writeInProgress = true;
+        m_curWriteReq = req;
+        m_lastWriteReq = req;
+        return req;
+      },
+      std::move(callback));
+}
+
+void WebSocket::SendControl(
+    uint8_t opcode, std::span<const uv::Buffer> data,
+    std::function<void(std::span<uv::Buffer>, uv::Error)> callback) {
+  Frame frame{opcode, data};
+  // If we're not open, emit an error and don't send the data
+  if (m_state != WebSocket::OPEN) {
+    SendError({{frame}}, callback);
+    return;
+  }
+
+  // If nothing else is in flight, just use SendFrames()
+  std::shared_ptr<WriteReq> curReq = m_curWriteReq.lock();
+  if (!m_writeInProgress || !curReq) {
+    return SendFrames({{frame}}, std::move(callback));
+  }
+
+  // There's a write request in flight, but since this is a control frame, we
+  // want to send it as soon as we can, without waiting for all frames in that
+  // request (or any continuations) to be sent.
+  auto req = std::make_shared<WriteReq>(weak_from_this(), std::move(callback));
+  VerboseDebug(frame);
+  size_t numBytes = req->m_frames.AddFrame(frame, m_server);
+  req->m_userBufs.append(frame.data.begin(), frame.data.end());
+  req->m_continueFrameOffs.emplace_back(numBytes);
+  req->m_cont = curReq;
+  // There may be multiple control packets in flight; maintain in-order
+  // transmission. Linear search here is O(n^2), but should be pretty rare.
+  if (!curReq->m_controlCont) {
+    curReq->m_controlCont = std::move(req);
+  } else {
+    curReq = curReq->m_controlCont;
+    while (curReq->m_cont != req->m_cont) {
+      curReq = curReq->m_cont;
+    }
+    curReq->m_cont = std::move(req);
+  }
+}
+
+void WebSocket::SendError(
+    std::span<const Frame> frames,
+    const std::function<void(std::span<uv::Buffer>, uv::Error)>& callback) {
+  int err;
+  if (m_state == WebSocket::CONNECTING) {
+    err = UV_EAGAIN;
+  } else {
+    err = UV_ESHUTDOWN;
+  }
   SmallVector<uv::Buffer, 4> bufs;
   for (auto&& frame : frames) {
-    WriteFrame(*req, bufs, m_server, frame.opcode, frame.data);
+    bufs.append(frame.data.begin(), frame.data.end());
   }
-  m_stream.Write(bufs, req);
+  callback(bufs, uv::Error{err});
 }
diff --git a/wpinet/src/main/native/cpp/WebSocketDebug.h b/wpinet/src/main/native/cpp/WebSocketDebug.h
new file mode 100644
index 0000000..5653b5f
--- /dev/null
+++ b/wpinet/src/main/native/cpp/WebSocketDebug.h
@@ -0,0 +1,21 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#pragma once
+
+#include <fmt/format.h>
+
+// #define WPINET_WEBSOCKET_VERBOSE_DEBUG
+// #define WPINET_WEBSOCKET_VERBOSE_DEBUG_CONTENT
+
+#ifdef __clang__
+#pragma clang diagnostic ignored "-Wgnu-zero-variadic-macro-arguments"
+#endif
+
+#ifdef WPINET_WEBSOCKET_VERBOSE_DEBUG
+#define WS_DEBUG(format, ...) \
+  ::fmt::print(FMT_STRING(format) __VA_OPT__(, ) __VA_ARGS__)
+#else
+#define WS_DEBUG(fmt, ...)
+#endif
diff --git a/wpinet/src/main/native/cpp/WebSocketSerializer.cpp b/wpinet/src/main/native/cpp/WebSocketSerializer.cpp
new file mode 100644
index 0000000..c5d9548
--- /dev/null
+++ b/wpinet/src/main/native/cpp/WebSocketSerializer.cpp
@@ -0,0 +1,108 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#include "WebSocketSerializer.h"
+
+#include <random>
+
+using namespace wpi::detail;
+
+static constexpr uint8_t kFlagMasking = 0x80;
+static constexpr size_t kWriteAllocSize = 4096;
+
+static std::span<uint8_t> BuildHeader(std::span<uint8_t, 10> header,
+                                      bool server,
+                                      const wpi::WebSocket::Frame& frame) {
+  uint8_t* pHeader = header.data();
+
+  // opcode (includes FIN bit)
+  *pHeader++ = frame.opcode;
+
+  // payload length
+  uint64_t size = 0;
+  for (auto&& buf : frame.data) {
+    size += buf.len;
+  }
+  if (size < 126) {
+    *pHeader++ = (server ? 0x00 : kFlagMasking) | size;
+  } else if (size <= 0xffff) {
+    *pHeader++ = (server ? 0x00 : kFlagMasking) | 126;
+    *pHeader++ = (size >> 8) & 0xff;
+    *pHeader++ = size & 0xff;
+  } else {
+    *pHeader++ = (server ? 0x00 : kFlagMasking) | 127;
+    *pHeader++ = (size >> 56) & 0xff;
+    *pHeader++ = (size >> 48) & 0xff;
+    *pHeader++ = (size >> 40) & 0xff;
+    *pHeader++ = (size >> 32) & 0xff;
+    *pHeader++ = (size >> 24) & 0xff;
+    *pHeader++ = (size >> 16) & 0xff;
+    *pHeader++ = (size >> 8) & 0xff;
+    *pHeader++ = size & 0xff;
+  }
+  return header.subspan(0, pHeader - header.data());
+}
+
+size_t SerializedFrames::AddClientFrame(const WebSocket::Frame& frame) {
+  uint8_t headerBuf[10];
+  auto header = BuildHeader(headerBuf, false, frame);
+
+  // allocate a buffer per frame
+  size_t size = header.size() + 4;
+  for (auto&& buf : frame.data) {
+    size += buf.len;
+  }
+  m_allocBufs.emplace_back(uv::Buffer::Allocate(size));
+  m_bufs.emplace_back(m_allocBufs.back());
+
+  char* internalBuf = m_allocBufs.back().data().data();
+  std::memcpy(internalBuf, header.data(), header.size());
+  internalBuf += header.size();
+
+  // generate masking key
+  static std::random_device rd;
+  static std::default_random_engine gen{rd()};
+  std::uniform_int_distribution<unsigned int> dist(0, 255);
+  uint8_t key[4];
+  for (uint8_t& v : key) {
+    v = dist(gen);
+  }
+  std::memcpy(internalBuf, key, 4);
+  internalBuf += 4;
+
+  // copy and mask data
+  int n = 0;
+  for (auto&& buf : frame.data) {
+    for (auto&& ch : buf.data()) {
+      *internalBuf++ = static_cast<uint8_t>(ch) ^ key[n++];
+      if (n >= 4) {
+        n = 0;
+      }
+    }
+  }
+  return size;
+}
+
+size_t SerializedFrames::AddServerFrame(const WebSocket::Frame& frame) {
+  uint8_t headerBuf[10];
+  auto header = BuildHeader(headerBuf, true, frame);
+
+  // manage allocBufs to efficiently store header
+  if (m_allocBufs.empty() ||
+      (m_allocBufPos + header.size()) > kWriteAllocSize) {
+    m_allocBufs.emplace_back(uv::Buffer::Allocate(kWriteAllocSize));
+    m_allocBufPos = 0;
+  }
+  char* internalBuf = m_allocBufs.back().data().data() + m_allocBufPos;
+  std::memcpy(internalBuf, header.data(), header.size());
+  m_bufs.emplace_back(internalBuf, header.size());
+  m_allocBufPos += header.size();
+  // servers can just send the buffers directly without masking
+  m_bufs.append(frame.data.begin(), frame.data.end());
+  size_t sent = header.size();
+  for (auto&& buf : frame.data) {
+    sent += buf.len;
+  }
+  return sent;
+}
diff --git a/wpinet/src/main/native/cpp/WebSocketSerializer.h b/wpinet/src/main/native/cpp/WebSocketSerializer.h
new file mode 100644
index 0000000..264b8f5
--- /dev/null
+++ b/wpinet/src/main/native/cpp/WebSocketSerializer.h
@@ -0,0 +1,304 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#pragma once
+
+#include <functional>
+#include <memory>
+#include <utility>
+
+#include <wpi/SmallVector.h>
+#include <wpi/SpanExtras.h>
+
+#include "WebSocketDebug.h"
+#include "wpinet/WebSocket.h"
+#include "wpinet/uv/Buffer.h"
+
+namespace wpi::detail {
+
+class SerializedFrames {
+ public:
+  SerializedFrames() = default;
+  SerializedFrames(const SerializedFrames&) = delete;
+  SerializedFrames& operator=(const SerializedFrames&) = delete;
+  ~SerializedFrames() { ReleaseBufs(); }
+
+  size_t AddFrame(const WebSocket::Frame& frame, bool server) {
+    if (server) {
+      return AddServerFrame(frame);
+    } else {
+      return AddClientFrame(frame);
+    }
+  }
+
+  size_t AddClientFrame(const WebSocket::Frame& frame);
+  size_t AddServerFrame(const WebSocket::Frame& frame);
+
+  void ReleaseBufs() {
+    for (auto&& buf : m_allocBufs) {
+      buf.Deallocate();
+    }
+    m_allocBufs.clear();
+  }
+
+  SmallVector<uv::Buffer, 4> m_allocBufs;
+  SmallVector<uv::Buffer, 4> m_bufs;
+  size_t m_allocBufPos = 0;
+};
+
+class WebSocketWriteReqBase {
+ public:
+  template <typename Stream, typename Req>
+  int Continue(Stream& stream, std::shared_ptr<Req> req);
+
+  SmallVector<uv::Buffer, 4> m_userBufs;
+  SerializedFrames m_frames;
+  SmallVector<int, 0> m_continueFrameOffs;
+  size_t m_continueBufPos = 0;
+  size_t m_continueFramePos = 0;
+};
+
+template <typename Stream, typename Req>
+int WebSocketWriteReqBase::Continue(Stream& stream, std::shared_ptr<Req> req) {
+  if (m_continueBufPos >= m_frames.m_bufs.size()) {
+    return 0;  // nothing more to send
+  }
+
+  // try writing everything remaining
+  std::span bufs = std::span{m_frames.m_bufs}.subspan(m_continueBufPos);
+  int numBytes = 0;
+  for (auto&& buf : bufs) {
+    numBytes += buf.len;
+  }
+
+  int sentBytes = stream.TryWrite(bufs);
+  WS_DEBUG("TryWrite({}) -> {} (expected {})\n", bufs.size(), sentBytes,
+           numBytes);
+  if (sentBytes < 0) {
+    return sentBytes;  // error
+  }
+
+  if (sentBytes == numBytes) {
+    m_continueBufPos = m_frames.m_bufs.size();
+    return 0;  // nothing more to send
+  }
+
+  // we didn't send everything; deal with the leftovers
+
+  // figure out what the last (partially) frame sent actually was
+  auto offIt = m_continueFrameOffs.begin() + m_continueFramePos;
+  auto offEnd = m_continueFrameOffs.end();
+  while (offIt != offEnd && *offIt < sentBytes) {
+    ++offIt;
+  }
+  assert(offIt != offEnd);
+
+  // build a list of buffers to send as a normal write:
+  SmallVector<uv::Buffer, 4> writeBufs;
+  auto bufIt = bufs.begin();
+  auto bufEnd = bufs.end();
+
+  // start with the remaining portion of the last buffer actually sent
+  int pos = 0;
+  while (bufIt != bufEnd && pos < sentBytes) {
+    pos += (bufIt++)->len;
+  }
+  if (bufIt != bufs.begin() && pos != sentBytes) {
+    writeBufs.emplace_back(
+        wpi::take_back((bufIt - 1)->bytes(), pos - sentBytes));
+  }
+
+  // continue through the last buffer of the last partial frame
+  while (bufIt != bufEnd && offIt != offEnd && pos < *offIt) {
+    pos += bufIt->len;
+    writeBufs.emplace_back(*bufIt++);
+  }
+  if (offIt != offEnd) {
+    ++offIt;
+  }
+
+  // if writeBufs is still empty, write all of the next frame
+  if (writeBufs.empty()) {
+    while (bufIt != bufEnd && offIt != offEnd && pos < *offIt) {
+      pos += bufIt->len;
+      writeBufs.emplace_back(*bufIt++);
+    }
+    if (offIt != offEnd) {
+      ++offIt;
+    }
+  }
+
+  m_continueFramePos = offIt - m_continueFrameOffs.begin();
+  m_continueBufPos += bufIt - bufs.begin();
+
+  if (writeBufs.empty()) {
+    WS_DEBUG("Write Done\n");
+    return 0;
+  }
+  WS_DEBUG("Write({})\n", writeBufs.size());
+  stream.Write(writeBufs, req);
+  return 1;
+}
+
+template <typename MakeReq, typename Stream>
+std::span<const WebSocket::Frame> TrySendFrames(
+    bool server, Stream& stream, std::span<const WebSocket::Frame> frames,
+    MakeReq&& makeReq,
+    std::function<void(std::span<uv::Buffer>, uv::Error)> callback) {
+  WS_DEBUG("TrySendFrames({})\n", frames.size());
+  auto frameIt = frames.begin();
+  auto frameEnd = frames.end();
+  while (frameIt != frameEnd) {
+    auto frameStart = frameIt;
+
+    // build buffers to send
+    SerializedFrames sendFrames;
+    SmallVector<int, 32> frameOffs;
+    int numBytes = 0;
+    while (frameIt != frameEnd) {
+      frameOffs.emplace_back(numBytes);
+      numBytes += sendFrames.AddFrame(*frameIt++, server);
+      if ((server && (numBytes >= 65536 || frameOffs.size() > 32)) ||
+          (!server && numBytes >= 8192)) {
+        // don't waste too much memory or effort on header generation or masking
+        break;
+      }
+    }
+
+    // try to send
+    int sentBytes = stream.TryWrite(sendFrames.m_bufs);
+    WS_DEBUG("TryWrite({}) -> {} (expected {})\n", sendFrames.m_bufs.size(),
+             sentBytes, numBytes);
+
+    if (sentBytes == 0) {
+      // we haven't started a frame yet; clean up any bufs that have actually
+      // sent, and return unsent frames
+      SmallVector<uv::Buffer, 4> bufs;
+      for (auto it = frames.begin(); it != frameStart; ++it) {
+        bufs.append(it->data.begin(), it->data.end());
+      }
+      callback(bufs, {});
+#ifdef __clang__
+      // work around clang bug
+      return {frames.data() + (frameStart - frames.begin()),
+              frames.data() + (frameEnd - frames.begin())};
+#else
+      return {frameStart, frameEnd};
+#endif
+    } else if (sentBytes < 0) {
+      // error
+      SmallVector<uv::Buffer, 4> bufs;
+      for (auto&& frame : frames) {
+        bufs.append(frame.data.begin(), frame.data.end());
+      }
+      callback(bufs, uv::Error{sentBytes});
+      return frames;
+    } else if (sentBytes != numBytes) {
+      // we didn't send everything; deal with the leftovers
+
+      // figure out what the last (partially) frame sent actually was
+      auto offIt = frameOffs.begin();
+      auto offEnd = frameOffs.end();
+      bool isFin = true;
+      while (offIt != offEnd && *offIt < sentBytes) {
+        ++offIt;
+        isFin = (frameStart->opcode & WebSocket::kFlagFin) != 0;
+        ++frameStart;
+      }
+
+      if (offIt != offEnd && *offIt == sentBytes && isFin) {
+        // we finished at a normal FIN frame boundary; no need for a Write()
+        SmallVector<uv::Buffer, 4> bufs;
+        for (auto it = frames.begin(); it != frameStart; ++it) {
+          bufs.append(it->data.begin(), it->data.end());
+        }
+        callback(bufs, {});
+#ifdef __clang__
+        // work around clang bug
+        return {frames.data() + (frameStart - frames.begin()),
+                frames.data() + (frameEnd - frames.begin())};
+#else
+        return {frameStart, frameEnd};
+#endif
+      }
+
+      // build a list of buffers to send as a normal write:
+      SmallVector<uv::Buffer, 4> writeBufs;
+      auto bufIt = sendFrames.m_bufs.begin();
+      auto bufEnd = sendFrames.m_bufs.end();
+
+      // start with the remaining portion of the last buffer actually sent
+      int pos = 0;
+      while (bufIt != bufEnd && pos < sentBytes) {
+        pos += (bufIt++)->len;
+      }
+      if (bufIt != sendFrames.m_bufs.begin() && pos != sentBytes) {
+        writeBufs.emplace_back(
+            wpi::take_back((bufIt - 1)->bytes(), pos - sentBytes));
+      }
+
+      // continue through the last buffer of the last partial frame
+      while (bufIt != bufEnd && offIt != offEnd && pos < *offIt) {
+        pos += bufIt->len;
+        writeBufs.emplace_back(*bufIt++);
+      }
+      if (offIt != offEnd) {
+        ++offIt;
+      }
+
+      // move allocated buffers into request
+      auto req = makeReq(std::move(callback));
+      req->m_frames.m_allocBufs = std::move(sendFrames.m_allocBufs);
+      req->m_frames.m_allocBufPos = sendFrames.m_allocBufPos;
+
+      // if partial frame was non-FIN, put any additional non-FIN frames into
+      // continuation (so the caller isn't responsible for doing this)
+      size_t continuePos = 0;
+      while (frameStart != frameEnd && !isFin) {
+        if (offIt != offEnd) {
+          // we already generated the wire buffers for this frame, use them
+          while (pos < *offIt && bufIt != bufEnd) {
+            pos += bufIt->len;
+            continuePos += bufIt->len;
+            req->m_frames.m_bufs.emplace_back(*bufIt++);
+          }
+          ++offIt;
+        } else {
+          // WS_DEBUG("generating frame for continuation {} {}\n",
+          //          frameStart->opcode, frameStart->data.size());
+          // need to generate and add this frame
+          continuePos += req->m_frames.AddFrame(*frameStart, server);
+        }
+        req->m_continueFrameOffs.emplace_back(continuePos);
+        isFin = (frameStart->opcode & WebSocket::kFlagFin) != 0;
+        ++frameStart;
+      }
+
+      // only the non-returned user buffers are added to the request
+      for (auto it = frames.begin(); it != frameStart; ++it) {
+        req->m_userBufs.append(it->data.begin(), it->data.end());
+      }
+
+      WS_DEBUG("Write({})\n", writeBufs.size());
+      stream.Write(writeBufs, req);
+#ifdef __clang__
+      // work around clang bug
+      return {frames.data() + (frameStart - frames.begin()),
+              frames.data() + (frameEnd - frames.begin())};
+#else
+      return {frameStart, frameEnd};
+#endif
+    }
+  }
+
+  // nothing left to send
+  SmallVector<uv::Buffer, 4> bufs;
+  for (auto&& frame : frames) {
+    bufs.append(frame.data.begin(), frame.data.end());
+  }
+  callback(bufs, {});
+  return {};
+}
+
+}  // namespace wpi::detail
diff --git a/wpinet/src/main/native/cpp/http_parser.cpp b/wpinet/src/main/native/cpp/http_parser.cpp
index 2bec4a7..66fff6a 100644
--- a/wpinet/src/main/native/cpp/http_parser.cpp
+++ b/wpinet/src/main/native/cpp/http_parser.cpp
@@ -1859,7 +1859,7 @@
             && parser->content_length != ULLONG_MAX);
 
         /* The difference between advancing content_length and p is because
-         * the latter will automaticaly advance on the next loop iteration.
+         * the latter will automatically advance on the next loop iteration.
          * Further, if content_length ends up at 0, we want to see the last
          * byte again for our message complete callback.
          */
@@ -2347,7 +2347,7 @@
       case s_dead:
         return 1;
 
-      /* Skip delimeters */
+      /* Skip delimiters */
       case s_req_schema_slash:
       case s_req_schema_slash_slash:
       case s_req_server_start:
diff --git a/wpinet/src/main/native/cpp/uv/Async.cpp b/wpinet/src/main/native/cpp/uv/Async.cpp
index f84bb9b..58ef5f3 100644
--- a/wpinet/src/main/native/cpp/uv/Async.cpp
+++ b/wpinet/src/main/native/cpp/uv/Async.cpp
@@ -17,6 +17,9 @@
 }
 
 std::shared_ptr<Async<>> Async<>::Create(const std::shared_ptr<Loop>& loop) {
+  if (loop->IsClosing()) {
+    return nullptr;
+  }
   auto h = std::make_shared<Async>(loop, private_init{});
   int err = uv_async_init(loop->GetRaw(), h->GetRaw(), [](uv_async_t* handle) {
     Async& h = *static_cast<Async*>(handle->data);
diff --git a/wpinet/src/main/native/cpp/uv/Check.cpp b/wpinet/src/main/native/cpp/uv/Check.cpp
index 13c2229..75ff47c 100644
--- a/wpinet/src/main/native/cpp/uv/Check.cpp
+++ b/wpinet/src/main/native/cpp/uv/Check.cpp
@@ -9,6 +9,9 @@
 namespace wpi::uv {
 
 std::shared_ptr<Check> Check::Create(Loop& loop) {
+  if (loop.IsClosing()) {
+    return nullptr;
+  }
   auto h = std::make_shared<Check>(private_init{});
   int err = uv_check_init(loop.GetRaw(), h->GetRaw());
   if (err < 0) {
@@ -20,6 +23,9 @@
 }
 
 void Check::Start() {
+  if (IsLoopClosing()) {
+    return;
+  }
   Invoke(&uv_check_start, GetRaw(), [](uv_check_t* handle) {
     Check& h = *static_cast<Check*>(handle->data);
     h.check();
diff --git a/wpinet/src/main/native/cpp/uv/FsEvent.cpp b/wpinet/src/main/native/cpp/uv/FsEvent.cpp
index 044390e..d77bf37 100644
--- a/wpinet/src/main/native/cpp/uv/FsEvent.cpp
+++ b/wpinet/src/main/native/cpp/uv/FsEvent.cpp
@@ -13,6 +13,9 @@
 namespace wpi::uv {
 
 std::shared_ptr<FsEvent> FsEvent::Create(Loop& loop) {
+  if (loop.IsClosing()) {
+    return nullptr;
+  }
   auto h = std::make_shared<FsEvent>(private_init{});
   int err = uv_fs_event_init(loop.GetRaw(), h->GetRaw());
   if (err < 0) {
diff --git a/wpinet/src/main/native/cpp/uv/GetAddrInfo.cpp b/wpinet/src/main/native/cpp/uv/GetAddrInfo.cpp
index 14721f2..c3ec000 100644
--- a/wpinet/src/main/native/cpp/uv/GetAddrInfo.cpp
+++ b/wpinet/src/main/native/cpp/uv/GetAddrInfo.cpp
@@ -18,6 +18,9 @@
 void GetAddrInfo(Loop& loop, const std::shared_ptr<GetAddrInfoReq>& req,
                  std::string_view node, std::string_view service,
                  const addrinfo* hints) {
+  if (loop.IsClosing()) {
+    return;
+  }
   SmallString<128> nodeStr{node};
   SmallString<128> serviceStr{service};
   int err = uv_getaddrinfo(
diff --git a/wpinet/src/main/native/cpp/uv/GetNameInfo.cpp b/wpinet/src/main/native/cpp/uv/GetNameInfo.cpp
index a6ad36d..9720cc3 100644
--- a/wpinet/src/main/native/cpp/uv/GetNameInfo.cpp
+++ b/wpinet/src/main/native/cpp/uv/GetNameInfo.cpp
@@ -15,6 +15,9 @@
 
 void GetNameInfo(Loop& loop, const std::shared_ptr<GetNameInfoReq>& req,
                  const sockaddr& addr, int flags) {
+  if (loop.IsClosing()) {
+    return;
+  }
   int err = uv_getnameinfo(
       loop.GetRaw(), req->GetRaw(),
       [](uv_getnameinfo_t* req, int status, const char* hostname,
diff --git a/wpinet/src/main/native/cpp/uv/Idle.cpp b/wpinet/src/main/native/cpp/uv/Idle.cpp
index 452bc7e..7b94b3f 100644
--- a/wpinet/src/main/native/cpp/uv/Idle.cpp
+++ b/wpinet/src/main/native/cpp/uv/Idle.cpp
@@ -9,6 +9,9 @@
 namespace wpi::uv {
 
 std::shared_ptr<Idle> Idle::Create(Loop& loop) {
+  if (loop.IsClosing()) {
+    return nullptr;
+  }
   auto h = std::make_shared<Idle>(private_init{});
   int err = uv_idle_init(loop.GetRaw(), h->GetRaw());
   if (err < 0) {
@@ -20,6 +23,9 @@
 }
 
 void Idle::Start() {
+  if (IsLoopClosing()) {
+    return;
+  }
   Invoke(&uv_idle_start, GetRaw(), [](uv_idle_t* handle) {
     Idle& h = *static_cast<Idle*>(handle->data);
     h.idle();
diff --git a/wpinet/src/main/native/cpp/uv/NetworkStream.cpp b/wpinet/src/main/native/cpp/uv/NetworkStream.cpp
index 3538596..12750b2 100644
--- a/wpinet/src/main/native/cpp/uv/NetworkStream.cpp
+++ b/wpinet/src/main/native/cpp/uv/NetworkStream.cpp
@@ -11,6 +11,9 @@
 }
 
 void NetworkStream::Listen(int backlog) {
+  if (IsLoopClosing()) {
+    return;
+  }
   Invoke(&uv_listen, GetRawStream(), backlog,
          [](uv_stream_t* handle, int status) {
            auto& h = *static_cast<NetworkStream*>(handle->data);
diff --git a/wpinet/src/main/native/cpp/uv/Pipe.cpp b/wpinet/src/main/native/cpp/uv/Pipe.cpp
index 9548874..7993604 100644
--- a/wpinet/src/main/native/cpp/uv/Pipe.cpp
+++ b/wpinet/src/main/native/cpp/uv/Pipe.cpp
@@ -11,6 +11,9 @@
 namespace wpi::uv {
 
 std::shared_ptr<Pipe> Pipe::Create(Loop& loop, bool ipc) {
+  if (loop.IsClosing()) {
+    return nullptr;
+  }
   auto h = std::make_shared<Pipe>(private_init{});
   int err = uv_pipe_init(loop.GetRaw(), h->GetRaw(), ipc ? 1 : 0);
   if (err < 0) {
@@ -22,7 +25,7 @@
 }
 
 void Pipe::Reuse(std::function<void()> callback, bool ipc) {
-  if (IsClosing()) {
+  if (IsLoopClosing() || IsClosing()) {
     return;
   }
   if (!m_reuseData) {
@@ -69,6 +72,9 @@
 
 void Pipe::Connect(std::string_view name,
                    const std::shared_ptr<PipeConnectReq>& req) {
+  if (IsLoopClosing()) {
+    return;
+  }
   SmallString<128> nameBuf{name};
   uv_pipe_connect(req->GetRaw(), GetRaw(), nameBuf.c_str(),
                   [](uv_connect_t* req, int status) {
diff --git a/wpinet/src/main/native/cpp/uv/Poll.cpp b/wpinet/src/main/native/cpp/uv/Poll.cpp
index 3713453..7d35615 100644
--- a/wpinet/src/main/native/cpp/uv/Poll.cpp
+++ b/wpinet/src/main/native/cpp/uv/Poll.cpp
@@ -9,6 +9,9 @@
 namespace wpi::uv {
 
 std::shared_ptr<Poll> Poll::Create(Loop& loop, int fd) {
+  if (loop.IsClosing()) {
+    return nullptr;
+  }
   auto h = std::make_shared<Poll>(private_init{});
   int err = uv_poll_init(loop.GetRaw(), h->GetRaw(), fd);
   if (err < 0) {
@@ -20,6 +23,9 @@
 }
 
 std::shared_ptr<Poll> Poll::CreateSocket(Loop& loop, uv_os_sock_t sock) {
+  if (loop.IsClosing()) {
+    return nullptr;
+  }
   auto h = std::make_shared<Poll>(private_init{});
   int err = uv_poll_init_socket(loop.GetRaw(), h->GetRaw(), sock);
   if (err < 0) {
@@ -31,7 +37,7 @@
 }
 
 void Poll::Reuse(int fd, std::function<void()> callback) {
-  if (IsClosing()) {
+  if (IsLoopClosing() || IsClosing()) {
     return;
   }
   if (!m_reuseData) {
@@ -56,7 +62,7 @@
 }
 
 void Poll::ReuseSocket(uv_os_sock_t sock, std::function<void()> callback) {
-  if (IsClosing()) {
+  if (IsLoopClosing() || IsClosing()) {
     return;
   }
   if (!m_reuseData) {
@@ -81,6 +87,9 @@
 }
 
 void Poll::Start(int events) {
+  if (IsLoopClosing()) {
+    return;
+  }
   Invoke(&uv_poll_start, GetRaw(), events,
          [](uv_poll_t* handle, int status, int events) {
            Poll& h = *static_cast<Poll*>(handle->data);
diff --git a/wpinet/src/main/native/cpp/uv/Prepare.cpp b/wpinet/src/main/native/cpp/uv/Prepare.cpp
index e4ca160..aa1a89d 100644
--- a/wpinet/src/main/native/cpp/uv/Prepare.cpp
+++ b/wpinet/src/main/native/cpp/uv/Prepare.cpp
@@ -9,6 +9,9 @@
 namespace wpi::uv {
 
 std::shared_ptr<Prepare> Prepare::Create(Loop& loop) {
+  if (loop.IsClosing()) {
+    return nullptr;
+  }
   auto h = std::make_shared<Prepare>(private_init{});
   int err = uv_prepare_init(loop.GetRaw(), h->GetRaw());
   if (err < 0) {
@@ -20,6 +23,9 @@
 }
 
 void Prepare::Start() {
+  if (IsLoopClosing()) {
+    return;
+  }
   Invoke(&uv_prepare_start, GetRaw(), [](uv_prepare_t* handle) {
     Prepare& h = *static_cast<Prepare*>(handle->data);
     h.prepare();
diff --git a/wpinet/src/main/native/cpp/uv/Process.cpp b/wpinet/src/main/native/cpp/uv/Process.cpp
index 3c10db6..c872ff9 100644
--- a/wpinet/src/main/native/cpp/uv/Process.cpp
+++ b/wpinet/src/main/native/cpp/uv/Process.cpp
@@ -13,6 +13,10 @@
 
 std::shared_ptr<Process> Process::SpawnArray(Loop& loop, std::string_view file,
                                              std::span<const Option> options) {
+  if (loop.IsClosing()) {
+    return nullptr;
+  }
+
   // convert Option array to libuv structure
   uv_process_options_t coptions;
 
diff --git a/wpinet/src/main/native/cpp/uv/Signal.cpp b/wpinet/src/main/native/cpp/uv/Signal.cpp
index 10dd7b4..8f998e2 100644
--- a/wpinet/src/main/native/cpp/uv/Signal.cpp
+++ b/wpinet/src/main/native/cpp/uv/Signal.cpp
@@ -9,6 +9,9 @@
 namespace wpi::uv {
 
 std::shared_ptr<Signal> Signal::Create(Loop& loop) {
+  if (loop.IsClosing()) {
+    return nullptr;
+  }
   auto h = std::make_shared<Signal>(private_init{});
   int err = uv_signal_init(loop.GetRaw(), h->GetRaw());
   if (err < 0) {
@@ -20,6 +23,9 @@
 }
 
 void Signal::Start(int signum) {
+  if (IsLoopClosing()) {
+    return;
+  }
   Invoke(
       &uv_signal_start, GetRaw(),
       [](uv_signal_t* handle, int signum) {
diff --git a/wpinet/src/main/native/cpp/uv/Stream.cpp b/wpinet/src/main/native/cpp/uv/Stream.cpp
index e7f6031..e054003 100644
--- a/wpinet/src/main/native/cpp/uv/Stream.cpp
+++ b/wpinet/src/main/native/cpp/uv/Stream.cpp
@@ -35,6 +35,9 @@
 }
 
 void Stream::Shutdown(const std::shared_ptr<ShutdownReq>& req) {
+  if (IsLoopClosing()) {
+    return;
+  }
   if (Invoke(&uv_shutdown, req->GetRaw(), GetRawStream(),
              [](uv_shutdown_t* req, int status) {
                auto& h = *static_cast<ShutdownReq*>(req->data);
@@ -50,6 +53,9 @@
 }
 
 void Stream::Shutdown(std::function<void()> callback) {
+  if (IsLoopClosing()) {
+    return;
+  }
   auto req = std::make_shared<ShutdownReq>();
   if (callback) {
     req->complete.connect(std::move(callback));
@@ -58,6 +64,9 @@
 }
 
 void Stream::StartRead() {
+  if (IsLoopClosing()) {
+    return;
+  }
   Invoke(&uv_read_start, GetRawStream(), &Handle::AllocBuf,
          [](uv_stream_t* stream, ssize_t nread, const uv_buf_t* buf) {
            auto& h = *static_cast<Stream*>(stream->data);
@@ -79,14 +88,17 @@
 
 void Stream::Write(std::span<const Buffer> bufs,
                    const std::shared_ptr<WriteReq>& req) {
+  if (IsLoopClosing()) {
+    return;
+  }
   if (Invoke(&uv_write, req->GetRaw(), GetRawStream(), bufs.data(), bufs.size(),
              [](uv_write_t* r, int status) {
                auto& h = *static_cast<WriteReq*>(r->data);
                if (status < 0) {
                  h.ReportError(status);
                }
+               auto ptr = h.Release();  // one-shot, but finish() may Keep()
                h.finish(Error(status));
-               h.Release();  // this is always a one-shot
              })) {
     req->Keep();
   }
@@ -98,20 +110,32 @@
 }
 
 int Stream::TryWrite(std::span<const Buffer> bufs) {
+  if (IsLoopClosing()) {
+    return UV_ECANCELED;
+  }
   int val = uv_try_write(GetRawStream(), bufs.data(), bufs.size());
+  if (val == UV_EAGAIN) {
+    return 0;
+  }
   if (val < 0) {
     this->ReportError(val);
-    return 0;
+    return val;
   }
   return val;
 }
 
 int Stream::TryWrite2(std::span<const Buffer> bufs, Stream& send) {
+  if (IsLoopClosing()) {
+    return UV_ECANCELED;
+  }
   int val = uv_try_write2(GetRawStream(), bufs.data(), bufs.size(),
                           send.GetRawStream());
+  if (val == UV_EAGAIN) {
+    return 0;
+  }
   if (val < 0) {
     this->ReportError(val);
-    return 0;
+    return val;
   }
   return val;
 }
diff --git a/wpinet/src/main/native/cpp/uv/Tcp.cpp b/wpinet/src/main/native/cpp/uv/Tcp.cpp
index ae01683..b163a0e 100644
--- a/wpinet/src/main/native/cpp/uv/Tcp.cpp
+++ b/wpinet/src/main/native/cpp/uv/Tcp.cpp
@@ -11,6 +11,9 @@
 namespace wpi::uv {
 
 std::shared_ptr<Tcp> Tcp::Create(Loop& loop, unsigned int flags) {
+  if (loop.IsClosing()) {
+    return nullptr;
+  }
   auto h = std::make_shared<Tcp>(private_init{});
   int err = uv_tcp_init_ex(loop.GetRaw(), h->GetRaw(), flags);
   if (err < 0) {
@@ -22,7 +25,7 @@
 }
 
 void Tcp::Reuse(std::function<void()> callback, unsigned int flags) {
-  if (IsClosing()) {
+  if (IsLoopClosing() || IsClosing()) {
     return;
   }
   if (!m_reuseData) {
@@ -103,6 +106,9 @@
 
 void Tcp::Connect(const sockaddr& addr,
                   const std::shared_ptr<TcpConnectReq>& req) {
+  if (IsLoopClosing()) {
+    return;
+  }
   if (Invoke(&uv_tcp_connect, req->GetRaw(), GetRaw(), &addr,
              [](uv_connect_t* req, int status) {
                auto& h = *static_cast<TcpConnectReq*>(req->data);
@@ -118,6 +124,9 @@
 }
 
 void Tcp::Connect(const sockaddr& addr, std::function<void()> callback) {
+  if (IsLoopClosing()) {
+    return;
+  }
   auto req = std::make_shared<TcpConnectReq>();
   req->connected.connect(std::move(callback));
   Connect(addr, req);
diff --git a/wpinet/src/main/native/cpp/uv/Timer.cpp b/wpinet/src/main/native/cpp/uv/Timer.cpp
index 9d52173..e9b33fc 100644
--- a/wpinet/src/main/native/cpp/uv/Timer.cpp
+++ b/wpinet/src/main/native/cpp/uv/Timer.cpp
@@ -9,6 +9,9 @@
 namespace wpi::uv {
 
 std::shared_ptr<Timer> Timer::Create(Loop& loop) {
+  if (loop.IsClosing()) {
+    return nullptr;
+  }
   auto h = std::make_shared<Timer>(private_init{});
   int err = uv_timer_init(loop.GetRaw(), h->GetRaw());
   if (err < 0) {
@@ -32,6 +35,9 @@
 }
 
 void Timer::Start(Time timeout, Time repeat) {
+  if (IsLoopClosing()) {
+    return;
+  }
   Invoke(
       &uv_timer_start, GetRaw(),
       [](uv_timer_t* handle) {
diff --git a/wpinet/src/main/native/cpp/uv/Tty.cpp b/wpinet/src/main/native/cpp/uv/Tty.cpp
index 6043a93..5e5756c 100644
--- a/wpinet/src/main/native/cpp/uv/Tty.cpp
+++ b/wpinet/src/main/native/cpp/uv/Tty.cpp
@@ -9,6 +9,9 @@
 namespace wpi::uv {
 
 std::shared_ptr<Tty> Tty::Create(Loop& loop, uv_file fd, bool readable) {
+  if (loop.IsClosing()) {
+    return nullptr;
+  }
   auto h = std::make_shared<Tty>(private_init{});
   int err = uv_tty_init(loop.GetRaw(), h->GetRaw(), fd, readable ? 1 : 0);
   if (err < 0) {
diff --git a/wpinet/src/main/native/cpp/uv/Udp.cpp b/wpinet/src/main/native/cpp/uv/Udp.cpp
index 689d5a7..1922c57 100644
--- a/wpinet/src/main/native/cpp/uv/Udp.cpp
+++ b/wpinet/src/main/native/cpp/uv/Udp.cpp
@@ -38,6 +38,9 @@
 }
 
 std::shared_ptr<Udp> Udp::Create(Loop& loop, unsigned int flags) {
+  if (loop.IsClosing()) {
+    return nullptr;
+  }
   auto h = std::make_shared<Udp>(private_init{});
   int err = uv_udp_init_ex(loop.GetRaw(), h->GetRaw(), flags);
   if (err < 0) {
@@ -135,6 +138,9 @@
 
 void Udp::Send(const sockaddr& addr, std::span<const Buffer> bufs,
                const std::shared_ptr<UdpSendReq>& req) {
+  if (IsLoopClosing()) {
+    return;
+  }
   if (Invoke(&uv_udp_send, req->GetRaw(), GetRaw(), bufs.data(), bufs.size(),
              &addr, [](uv_udp_send_t* r, int status) {
                auto& h = *static_cast<UdpSendReq*>(r->data);
@@ -150,12 +156,18 @@
 
 void Udp::Send(const sockaddr& addr, std::span<const Buffer> bufs,
                std::function<void(std::span<Buffer>, Error)> callback) {
+  if (IsLoopClosing()) {
+    return;
+  }
   Send(addr, bufs,
        std::make_shared<CallbackUdpSendReq>(bufs, std::move(callback)));
 }
 
 void Udp::Send(std::span<const Buffer> bufs,
                const std::shared_ptr<UdpSendReq>& req) {
+  if (IsLoopClosing()) {
+    return;
+  }
   if (Invoke(&uv_udp_send, req->GetRaw(), GetRaw(), bufs.data(), bufs.size(),
              nullptr, [](uv_udp_send_t* r, int status) {
                auto& h = *static_cast<UdpSendReq*>(r->data);
@@ -171,10 +183,16 @@
 
 void Udp::Send(std::span<const Buffer> bufs,
                std::function<void(std::span<Buffer>, Error)> callback) {
+  if (IsLoopClosing()) {
+    return;
+  }
   Send(bufs, std::make_shared<CallbackUdpSendReq>(bufs, std::move(callback)));
 }
 
 void Udp::StartRecv() {
+  if (IsLoopClosing()) {
+    return;
+  }
   Invoke(&uv_udp_recv_start, GetRaw(), &AllocBuf,
          [](uv_udp_t* handle, ssize_t nread, const uv_buf_t* buf,
             const sockaddr* addr, unsigned flags) {
diff --git a/wpinet/src/main/native/cpp/uv/Work.cpp b/wpinet/src/main/native/cpp/uv/Work.cpp
index 818a93b..d94619f 100644
--- a/wpinet/src/main/native/cpp/uv/Work.cpp
+++ b/wpinet/src/main/native/cpp/uv/Work.cpp
@@ -13,6 +13,9 @@
 }
 
 void QueueWork(Loop& loop, const std::shared_ptr<WorkReq>& req) {
+  if (loop.IsClosing()) {
+    return;
+  }
   int err = uv_queue_work(
       loop.GetRaw(), req->GetRaw(),
       [](uv_work_t* req) {
@@ -37,6 +40,9 @@
 
 void QueueWork(Loop& loop, std::function<void()> work,
                std::function<void()> afterWork) {
+  if (loop.IsClosing()) {
+    return;
+  }
   auto req = std::make_shared<WorkReq>();
   if (work) {
     req->work.connect(std::move(work));