Skip to content

Commit 33eed3c

Browse files
committed
fix(net-websockets): ClientWebSocket gains .NET's outer gate (#2357)
Every operation raised WebSocketException(InvalidState) when the instance was disposed or had never connected. .NET raises ObjectDisposedException and InvalidOperationException there. THE TICKET'S FRAMING WAS TOO SIMPLE AND THE REFERENCE CORRECTED IT. It said .NET 'never' raises WebSocketException at these doors. .NET has TWO layers: the outer gate ConnectedWebSocket (ClientWebSocket.cs:163-177) raises ObjectDisposed / InvalidOperation, while the inner per-operation check (WebSocketStateHelper.cs:21-41) raises exactly WebSocketException(InvalidState). This port's WebSocket::ThrowOnInvalidState IS that inner layer -- a faithful counterpart of .NET's own protected static -- and was already correct. Rewriting it would have replaced a correct exception with a wrong one. The outer layer did not exist here at all. NO NEW DATA MEMBER WAS NEEDED, so sizeof stays 424 and this is not an SA-3 change: Abort() calls Dispose() in .NET, so Aborted is Disposed there, and socket_ is assigned only on a successful connect and cleared only by Dispose(). CloseAsync deliberately does not clear it, matching .NET. #2096's pin is inverted as that ticket anticipated -- it raised WebSocketException on both sides of the Dispose race precisely because matching .NET on one side only would be worse, and named #2357 as the ticket that would move the family. CloseAsync after Dispose used to succeed as a no-op and now faults, which is .NET's behaviour. Four mutations, all caught. Gate 17,282 run, 0 failed. Downstream: zero WebSockets sites in either consumer.
1 parent 9cf7a33 commit 33eed3c

6 files changed

Lines changed: 225 additions & 12 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — `ClientWebSocket`'s outer gate raises .NET's exceptions (ticket #2357)
5+
6+
*2026-08-18.* Every `ClientWebSocket` operation raised `WebSocketException(InvalidState)` when
7+
the instance was disposed or had never connected. .NET raises `ObjectDisposedException` and
8+
`InvalidOperationException` there.
9+
10+
Landed under SA-5 on the user's decision of the same date. **The exception type changes** at five
11+
doors. No signature, layout, vtable or `noexcept` change — and, notably, **no new data member**:
12+
`sizeof(ClientWebSocket)` is unchanged at 424, so this is not an SA-3 change.
13+
14+
---
15+
16+
## 1. The ticket's framing was too simple, and the reference corrected it
17+
18+
The ticket reported that .NET raises `ObjectDisposedException` or `InvalidOperationException` and
19+
that *"neither type is ever `WebSocketException` there"*. .NET actually has **two layers**, and
20+
only the outer one avoids `WebSocketException`:
21+
22+
| Layer | Where | Raises |
23+
|---|---|---|
24+
| **outer** | `ClientWebSocket.ConnectedWebSocket` (`ClientWebSocket.cs:163-177`) | `ObjectDisposedException` if disposed; `InvalidOperationException("The WebSocket is not connected.")` if never connected or still connecting |
25+
| **inner** | `WebSocketStateHelper.ThrowIfInvalidState` (`WebSocketStateHelper.cs:21-41`), per operation | **`WebSocketException(InvalidState)`** when the current state forbids the operation |
26+
27+
This port had only the inner layer — `WebSocket::ThrowOnInvalidState`, which is a faithful
28+
counterpart of .NET's own `protected static WebSocket.ThrowOnInvalidState` and **was already
29+
correct**. Rewriting it would have replaced a correct exception with a wrong one. The outer layer
30+
did not exist here at all, and is what this ticket adds.
31+
32+
## 2. What changed
33+
34+
| Situation | Was | Is |
35+
|---|---|---|
36+
| `SendAsync` / `ReceiveAsync` / `CloseAsync` / `CloseOutputAsync` after `Dispose()` or `Abort()` | `WebSocketException(InvalidState)` | **`ObjectDisposedException`** |
37+
| the same before `ConnectAsync` completes | `WebSocketException(InvalidState)` | **`InvalidOperationException`**, *"The WebSocket is not connected."* |
38+
| `CloseAsync` after `Dispose()` | **succeeded as a no-op** | `ObjectDisposedException` |
39+
| a state that forbids the operation on a **live** socket | `WebSocketException(InvalidState)` | **unchanged** |
40+
| the `Dispose()` race in `socketForIo()` | `WebSocketException(InvalidState)` | `ObjectDisposedException` — still matching the non-racy path, which is what #2096 required |
41+
42+
## 3. No new data member was needed
43+
44+
.NET's `InternalState` maps exactly onto state this class already holds, because `Abort()` calls
45+
`Dispose()` (`ClientWebSocket.cs:179-193`) — so *Aborted is Disposed* there — and because
46+
`socket_` is assigned only on a successful connect and cleared only by `Dispose()`:
47+
48+
| `InternalState` | condition here |
49+
|---|---|
50+
| `Created` | `!connectStarted_` |
51+
| `Connecting` | `connectStarted_ && !socket_ && state_ == Connecting` |
52+
| `Disposed` | `connectStarted_ && !socket_ && state_ != Connecting` |
53+
| `Connected` | `socket_ != nullptr` |
54+
55+
`CloseAsync` deliberately does **not** clear `socket_`, matching .NET, where a closed socket is
56+
still `InternalState.Connected` and the *inner* layer reports the state.
57+
58+
## 4. To migrate
59+
60+
```cpp
61+
try { ws.SendAsync(...).Wait(); }
62+
catch (const WebSocketException&) { /* was reached for a disposed socket */ }
63+
64+
// now:
65+
catch (const System::ObjectDisposedException&) { /* disposed */ }
66+
catch (const System::InvalidOperationException&) { /* never connected */ }
67+
catch (const WebSocketException&) { /* live socket, wrong state -- unchanged */ }
68+
```
69+
70+
If you called `CloseAsync` on a disposed instance and relied on it succeeding, guard it or drop
71+
it: `Dispose()` has already closed the connection.
72+
73+
## 5. Downstream, measured
74+
75+
Neither `cna` nor `mobile-eggbert` references `System::Net::WebSockets` — **zero sites in both**.
76+
Neither repository was modified.

modules/net-websockets/include/System/Net/WebSockets/ClientWebSocket.hpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ namespace System::Net::WebSockets {
118118
void throwIfKeepAliveFaulted() const;
119119
/** @return A strong reference to the heartbeat state, or null when there is none. */
120120
[[nodiscard]] std::shared_ptr<KeepAlive> keepAliveState() const;
121+
/// #2357: .NET's outer `ConnectedWebSocket` gate -- ObjectDisposedException when this
122+
/// instance is disposed, InvalidOperationException when it was never connected or is
123+
/// still connecting. Adds no data member; see the definition for the state mapping.
124+
void throwIfNotConnected() const;
125+
121126
void sendFrame(SharpRuntime::bytecs opcode, const SharpRuntime::bytecs* data, size_t len, bool fin);
122127
struct RawFrame {
123128
SharpRuntime::bytecs opcode = 0;

modules/net-websockets/src/System/Net/WebSockets/ClientWebSocket.cpp

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Copyright (c) Robert Vokac and contributors
33
// Portions based on .NET runtime API (MIT License, Copyright .NET Foundation and Contributors)
44
#include "System/Net/WebSockets/ClientWebSocket.hpp"
5+
#include "System/ObjectDisposedException.hpp"
56
#include <condition_variable>
67
#include <memory>
78
#include <mutex>
@@ -294,15 +295,60 @@ void ClientWebSocket::storeState(WebSocketState next) {
294295
state_ = next;
295296
}
296297

298+
// #2357: .NET's OUTER gate, `ClientWebSocket.ConnectedWebSocket` (`ClientWebSocket.cs:163-177`).
299+
//
300+
// THE TICKET'S FRAMING WAS TOO SIMPLE, AND THE REFERENCE CORRECTS IT. It reported that every door
301+
// here raises WebSocketException(InvalidState) where .NET "never" does. .NET actually has TWO
302+
// layers, and only the outer one avoids WebSocketException:
303+
//
304+
// * OUTER -- `ConnectedWebSocket`: ObjectDisposedException if the instance is disposed,
305+
// InvalidOperationException("The WebSocket is not connected.") if it was never connected or
306+
// is still connecting. This layer did not exist here at all.
307+
// * INNER -- `WebSocketStateHelper.ThrowIfInvalidState` (`WebSocketStateHelper.cs:21-41`), run
308+
// per operation by ManagedWebSocket: WebSocketException(InvalidState) when the CURRENT state
309+
// forbids the operation. That is exactly what this port's WebSocket::ThrowOnInvalidState
310+
// already did, and it is KEPT. Rewriting it would have replaced a correct exception with a
311+
// wrong one.
312+
//
313+
// NO NEW DATA MEMBER IS NEEDED, so sizeof(ClientWebSocket) is unchanged and this is not an SA-3
314+
// change. .NET's InternalState maps exactly onto state this class already holds, because
315+
// `Abort()` calls `Dispose()` (`ClientWebSocket.cs:179-193`) so Aborted IS Disposed there, and
316+
// because `socket_` is assigned only on a successful connect and cleared only by Dispose():
317+
//
318+
// Created !connectStarted_
319+
// Connecting connectStarted_ && !socket_ && state_ == Connecting
320+
// Disposed connectStarted_ && !socket_ && state_ != Connecting
321+
// Connected socket_ != nullptr (CloseAsync does NOT clear it, matching .NET, where
322+
// a closed socket is still InternalState.Connected and
323+
// the INNER layer reports the state)
324+
void ClientWebSocket::throwIfNotConnected() const {
325+
bool disposed = false;
326+
{
327+
std::lock_guard<std::mutex> lock(stateMutex_);
328+
if (socket_) return; // Connected -- the inner check decides
329+
disposed = connectStarted_ && state_ != WebSocketState::Connecting;
330+
}
331+
if (disposed) {
332+
throw System::ObjectDisposedException("System.Net.WebSockets.ClientWebSocket");
333+
}
334+
throw System::InvalidOperationException("The WebSocket is not connected.");
335+
}
336+
297337
std::shared_ptr<System::Net::Sockets::Socket> ClientWebSocket::socketForIo() const {
298338
std::shared_ptr<System::Net::Sockets::Socket> socket;
299339
{
300340
std::lock_guard<std::mutex> lock(stateMutex_);
301341
socket = socket_;
302342
}
303343
if (!socket) {
304-
throw WebSocketException(WebSocketError::InvalidState,
305-
"The WebSocket is in an invalid state for this operation.");
344+
// #2357: this is reached when Dispose()/Abort() took the socket away underneath an
345+
// operation that had already passed the outer gate -- so it is the DISPOSED case, and
346+
// throwIfNotConnected() names it as such. #2096 deliberately raised
347+
// WebSocketException(InvalidState) here to match the non-racy path; now that the non-racy
348+
// path raises ObjectDisposedException, matching it means raising that. The two sides of
349+
// the race still agree, which is what #2096 required.
350+
throwIfNotConnected();
351+
throw System::ObjectDisposedException("System.Net.WebSockets.ClientWebSocket");
306352
}
307353
return socket;
308354
}
@@ -1007,6 +1053,7 @@ ClientWebSocket::SendAsync(const std::vector<bytecs>& buffer, intcs offset, intc
10071053
AsyncOperationScope release{ops};
10081054
CancellationScope cancellation{this, cancellationToken};
10091055
try {
1056+
throwIfNotConnected(); // #2357: the OUTER gate, before the per-operation state check
10101057
WebSocket::ThrowOnInvalidState(loadState(), {WebSocketState::Open, WebSocketState::CloseReceived});
10111058
bytecs opcode;
10121059
if (sendContinuation_) {
@@ -1037,6 +1084,7 @@ ClientWebSocket::ReceiveAsync(std::vector<bytecs>& buffer, intcs offset, intcs c
10371084
AsyncOperationScope release{ops};
10381085
CancellationScope cancellation{this, cancellationToken};
10391086
try {
1087+
throwIfNotConnected(); // #2357: the OUTER gate, before the per-operation state check
10401088
WebSocket::ThrowOnInvalidState(loadState(), {WebSocketState::Open, WebSocketState::CloseSent});
10411089

10421090
if (recvLeftoverPos_ < recvLeftover_.size()) {
@@ -1166,6 +1214,7 @@ ClientWebSocket::CloseOutputAsync(WebSocketCloseStatus closeStatus, const std::o
11661214
AsyncOperationScope release{ops};
11671215
CancellationScope cancellation{this, cancellationToken};
11681216
try {
1217+
throwIfNotConnected(); // #2357: the OUTER gate, before the per-operation state check
11691218
WebSocket::ThrowOnInvalidState(loadState(), {WebSocketState::Open, WebSocketState::CloseReceived});
11701219
sendCloseFrame(closeStatus, statusDescription);
11711220
std::lock_guard<std::mutex> lock(stateMutex_);
@@ -1192,6 +1241,11 @@ ClientWebSocket::CloseAsync(WebSocketCloseStatus closeStatus, const std::optiona
11921241
AsyncOperationScope release{ops};
11931242
CancellationScope cancellation{this, cancellationToken};
11941243
try {
1244+
// #2357: CloseAsync goes through .NET's ConnectedWebSocket gate too, so a DISPOSED
1245+
// instance faults here rather than treating the call as a no-op. It has no
1246+
// per-operation ThrowOnInvalidState of its own -- a close on an already-closed but
1247+
// still-live socket really is a no-op, and stays one.
1248+
throwIfNotConnected();
11951249
if (loadState() == WebSocketState::Open) {
11961250
sendCloseFrame(closeStatus, statusDescription);
11971251
storeState(WebSocketState::CloseSent);

modules/net-websockets/tests/System/Net/WebSockets/ClientWebSocketConcurrencyTests.cpp

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@
3939
#include "System/Net/IPEndPoint.hpp"
4040
#include "System/Net/Sockets/Socket.hpp"
4141
#include "System/Net/WebSockets/ClientWebSocket.hpp"
42+
#include "System/InvalidOperationException.hpp"
43+
#include "System/ObjectDisposedException.hpp"
44+
#include "System/Net/WebSockets/WebSocket.hpp"
4245
#include "System/Net/WebSockets/WebSocketException.hpp"
4346
#include "System/Net/WebSockets/WebSocketState.hpp"
4447
#include "System/Uri.hpp"
@@ -105,6 +108,15 @@ class SilentServer {
105108
SharpRuntime::intcs port_ = 0;
106109
};
107110

111+
/// Reaches `WebSocket::ThrowOnInvalidState`, which is `protected static` here exactly as it is in
112+
/// .NET. Deriving is the language's own way in; widening the production declaration for a test's
113+
/// convenience is not.
114+
struct InnerStateCheck : System::Net::WebSockets::WebSocket {
115+
static void Check(WebSocketState state, std::initializer_list<WebSocketState> valid) {
116+
System::Net::WebSockets::WebSocket::ThrowOnInvalidState(state, valid);
117+
}
118+
};
119+
108120
std::string wsUriFor(SharpRuntime::intcs port) {
109121
return "ws://127.0.0.1:" + std::to_string(static_cast<int>(port)) + "/";
110122
}
@@ -194,12 +206,15 @@ TEST(ClientWebSocketConcurrencyTests, Fix2096_TheStatePropertyIsSafeToReadWhileA
194206
EXPECT_EQ(ws.getStateProperty(), WebSocketState::Closed);
195207
}
196208

197-
TEST(ClientWebSocketConcurrencyTests, Fix2096_AnOperationAfterDisposeThrowsRatherThanDereferencingNull) {
198-
// The non-racy half of the same door: once Dispose() has taken the socket away, every
199-
// operation must raise, and it must raise the SAME exception whichever check catches it
200-
// first. socketForIo() deliberately reuses WebSocketException(InvalidState) for that reason
201-
// -- see its doc-comment, and ticket #2357 for whether the whole family should be
202-
// ObjectDisposedException the way .NET's is.
209+
TEST(ClientWebSocketConcurrencyTests, Fix2357_AnOperationAfterDisposeRaisesObjectDisposedException) {
210+
// REWRITTEN BY #2357, WHICH INVERTED THIS PIN. #2096 deliberately raised
211+
// WebSocketException(InvalidState) on both sides of the Dispose race, because matching .NET
212+
// on one side and not the other would be worse than either -- and it recorded #2357 as the
213+
// ticket that would move the whole family at once. It has.
214+
//
215+
// .NET's outer gate (`ClientWebSocket.cs:163-177`) raises ObjectDisposedException for a
216+
// disposed instance and InvalidOperationException for one that was never connected. Neither
217+
// is WebSocketException. The two sides of the race still agree, which is what #2096 required.
203218
SilentServer server;
204219
ClientWebSocket ws;
205220
auto task = ws.ConnectAsync(System::Uri(wsUriFor(server.port())));
@@ -209,13 +224,76 @@ TEST(ClientWebSocketConcurrencyTests, Fix2096_AnOperationAfterDisposeThrowsRathe
209224

210225
std::vector<SharpRuntime::bytecs> buffer(16, 0);
211226
auto send = ws.SendAsync(buffer, 0, 4, System::Net::WebSockets::WebSocketMessageType::Binary, true);
212-
EXPECT_THROW(send.Wait(), WebSocketException);
227+
EXPECT_THROW(send.Wait(), System::ObjectDisposedException);
213228

214229
auto receive = ws.ReceiveAsync(buffer, 0, 4);
215-
EXPECT_THROW((void)receive.getResultProperty(), WebSocketException);
230+
EXPECT_THROW((void)receive.getResultProperty(), System::ObjectDisposedException);
216231

232+
// CloseAsync used to be asserted as a no-op here. It is not one after DISPOSE: .NET routes it
233+
// through the same gate, so a disposed instance faults. A close on an already-closed but
234+
// still-live socket is still a no-op -- that is a different case, and this row does not
235+
// weaken it.
217236
auto close = ws.CloseAsync(System::Net::WebSockets::WebSocketCloseStatus::NormalClosure, std::nullopt);
218-
EXPECT_NO_THROW(close.Wait()) << "a close on an already-closed socket is a no-op, not a fault";
237+
EXPECT_THROW(close.Wait(), System::ObjectDisposedException);
238+
239+
auto closeOutput = ws.CloseOutputAsync(System::Net::WebSockets::WebSocketCloseStatus::NormalClosure,
240+
std::nullopt);
241+
EXPECT_THROW(closeOutput.Wait(), System::ObjectDisposedException);
242+
}
243+
244+
TEST(ClientWebSocketConcurrencyTests, Fix2357_AnOperationBeforeConnectRaisesInvalidOperationException) {
245+
// The gate's other half, which had no counterpart in this port at all: an instance that was
246+
// NEVER connected is not disposed, and .NET says so with a different exception and a
247+
// different sentence -- "The WebSocket is not connected."
248+
ClientWebSocket ws;
249+
std::vector<SharpRuntime::bytecs> buffer(16, 0);
250+
251+
auto send = ws.SendAsync(buffer, 0, 4, System::Net::WebSockets::WebSocketMessageType::Binary, true);
252+
EXPECT_THROW(send.Wait(), System::InvalidOperationException);
253+
254+
auto receive = ws.ReceiveAsync(buffer, 0, 4);
255+
try {
256+
(void)receive.getResultProperty();
257+
ADD_FAILURE() << "expected InvalidOperationException";
258+
} catch (const System::ObjectDisposedException&) {
259+
ADD_FAILURE() << "a never-connected instance is not a DISPOSED one";
260+
} catch (const System::InvalidOperationException& e) {
261+
EXPECT_STREQ(e.what(), "The WebSocket is not connected.");
262+
}
263+
264+
auto close = ws.CloseAsync(System::Net::WebSockets::WebSocketCloseStatus::NormalClosure, std::nullopt);
265+
EXPECT_THROW(close.Wait(), System::InvalidOperationException);
266+
}
267+
268+
TEST(ClientWebSocketConcurrencyTests, Fix2357_TheInnerPerOperationStateCheckKeepsWebSocketException) {
269+
// THE TICKET'S FRAMING WAS TOO SIMPLE AND THE REFERENCE CORRECTED IT. It reported that .NET
270+
// "never" raises WebSocketException at these doors. .NET has TWO layers, and the INNER one --
271+
// WebSocketStateHelper.ThrowIfInvalidState (`WebSocketStateHelper.cs:21-41`), run per
272+
// operation by ManagedWebSocket -- raises exactly WebSocketException(InvalidState). This
273+
// port's WebSocket::ThrowOnInvalidState IS that layer and was already right; rewriting it
274+
// would have replaced a correct exception with a wrong one.
275+
//
276+
// No live socket is used, deliberately. Parking a real connection in CloseSent needs a
277+
// cooperating server, and the claim under test is about the LAYER, not about any transport.
278+
// The helper is `protected static`, exactly as .NET declares it, so this reaches it through a
279+
// local subclass rather than by widening production surface for a test's convenience.
280+
EXPECT_THROW(InnerStateCheck::Check(WebSocketState::CloseSent, {WebSocketState::Open}),
281+
WebSocketException);
282+
EXPECT_THROW(InnerStateCheck::Check(WebSocketState::None, {WebSocketState::Open}),
283+
WebSocketException);
284+
EXPECT_NO_THROW(
285+
InnerStateCheck::Check(WebSocketState::Open, {WebSocketState::Open, WebSocketState::CloseReceived}));
286+
287+
// ...and the inner layer is NOT an ObjectDisposedException door. That is the outer gate's
288+
// job, and conflating the two is exactly what this ticket had to avoid.
289+
try {
290+
InnerStateCheck::Check(WebSocketState::Closed, {WebSocketState::Open});
291+
ADD_FAILURE() << "expected a WebSocketException";
292+
} catch (const System::ObjectDisposedException&) {
293+
ADD_FAILURE() << "the inner state check must not raise ObjectDisposedException";
294+
} catch (const WebSocketException&) {
295+
SUCCEED();
296+
}
219297
}
220298

221299
TEST(ClientWebSocketConcurrencyTests, Fix2096_AllFiveAsyncMembersJoinTheLivenessBoundary) {

plan.sqlite3

4 KB
Binary file not shown.

0 commit comments

Comments
 (0)