svelte-adapter-uws 0.5.4 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/files/handler.js +119 -18
- package/index.d.ts +48 -0
- package/package.json +1 -1
- package/plugins/cursor/server.js +36 -7
- package/plugins/groups/server.js +9 -1
- package/plugins/presence/server.js +12 -4
- package/testing.js +56 -13
- package/vite.js +7 -0
package/README.md
CHANGED
|
@@ -1274,6 +1274,22 @@ export async function GET({ platform }) {
|
|
|
1274
1274
|
|
|
1275
1275
|
The returned `Map` is the live module-level instance - read-only, do not mutate. In test mode (`process.env.VITEST` set, or `NODE_ENV === 'test'`) the assert helper additionally throws so test runners surface the failure; in production it logs and counts but does not throw, so a violation inside a uWS callback frame cannot crash the worker.
|
|
1276
1276
|
|
|
1277
|
+
### `platform.closedWsAborts`
|
|
1278
|
+
|
|
1279
|
+
Per-worker count of best-effort uWS operations that aborted because the underlying WebSocket had already closed. Bumped every time `platform.subscribe`, `platform.unsubscribe`, `platform.send`, `platform.sendCoalesced`, `platform.sendTo`, or `platform.request` is called on a `ws` whose native handle has been freed - typically because the caller `await`-ed something (auth, loader, subscribe hook) and the client closed during the wait.
|
|
1280
|
+
|
|
1281
|
+
These methods are *closed-WS safe* by contract: they swallow uWS's `Invalid access of closed uWS.WebSocket` exception, return a success-shaped no-op sentinel (`null` for subscribe, `false` for unsubscribe, `2` for send, etc.), and bump this counter. Callers can fire-and-forget without a per-site try/catch.
|
|
1282
|
+
|
|
1283
|
+
```js
|
|
1284
|
+
export async function GET({ platform }) {
|
|
1285
|
+
return json({ closedWsAborts: platform.closedWsAborts });
|
|
1286
|
+
}
|
|
1287
|
+
```
|
|
1288
|
+
|
|
1289
|
+
A non-zero value is normal under client churn (tab close, network blips, mass reconnect waves). A rapidly-growing value under steady load indicates either pathological client behaviour or that the server's async setup path is too long for its connect rate. In clustered mode, sum across workers for cluster-wide visibility.
|
|
1290
|
+
|
|
1291
|
+
Monotonic, per-worker, reset only on process restart.
|
|
1292
|
+
|
|
1277
1293
|
### `platform.pressure` and `platform.onPressure(cb)`
|
|
1278
1294
|
|
|
1279
1295
|
Worker-local backpressure signal. The adapter samples once per second (configurable) and reports the most urgent active stress as a single `reason` enum, so user code can degrade with intent instead of generic panic.
|
|
@@ -3333,6 +3349,8 @@ Per-worker limitations (acceptable for most apps):
|
|
|
3333
3349
|
- `platform.connections` - returns the count for the local worker only
|
|
3334
3350
|
- `platform.subscribers(topic)` - returns the count for the local worker only
|
|
3335
3351
|
- `platform.sendTo(filter, ...)` - iterates the local worker's connections only, no cross-worker relay
|
|
3352
|
+
- `platform.closedWsAborts` - per-worker counter; sum across workers for cluster total
|
|
3353
|
+
- `platform.assertions` - per-worker counter Map
|
|
3336
3354
|
|
|
3337
3355
|
### Docker / multi-process deployments (Linux)
|
|
3338
3356
|
|
package/files/handler.js
CHANGED
|
@@ -421,7 +421,8 @@ const closeHookRegistered = WS_ENABLED && !!wsModule.close;
|
|
|
421
421
|
*/
|
|
422
422
|
function bumpIn(ws, message) {
|
|
423
423
|
if (!closeHookRegistered) return;
|
|
424
|
-
|
|
424
|
+
let stats;
|
|
425
|
+
try { stats = ws.getUserData()[WS_STATS]; } catch { return; }
|
|
425
426
|
if (!stats) return;
|
|
426
427
|
stats.messagesIn++;
|
|
427
428
|
stats.bytesIn += typeof message === 'string' ? message.length : message.byteLength;
|
|
@@ -439,7 +440,8 @@ function bumpIn(ws, message) {
|
|
|
439
440
|
*/
|
|
440
441
|
function bumpOut(ws, payload) {
|
|
441
442
|
if (!closeHookRegistered) return;
|
|
442
|
-
|
|
443
|
+
let stats;
|
|
444
|
+
try { stats = ws.getUserData()[WS_STATS]; } catch { return; }
|
|
443
445
|
if (!stats) return;
|
|
444
446
|
stats.messagesOut++;
|
|
445
447
|
stats.bytesOut += payload.length;
|
|
@@ -489,6 +491,16 @@ function maybeWarnTopicRegistry() {
|
|
|
489
491
|
|
|
490
492
|
let publishCountWindow = 0;
|
|
491
493
|
let totalSubscriptions = 0;
|
|
494
|
+
// Count of best-effort operations that aborted because the underlying
|
|
495
|
+
// uWS WebSocket had already closed. The platform contract is that
|
|
496
|
+
// ws-targeted public methods (subscribe / unsubscribe / send /
|
|
497
|
+
// sendCoalesced / request) and internal helpers that may run after an
|
|
498
|
+
// `await` never propagate uWS's "Invalid access of closed
|
|
499
|
+
// uWS.WebSocket" exception to user code - they swallow it, return a
|
|
500
|
+
// no-op sentinel, and bump this counter. Operators can read
|
|
501
|
+
// `platform.closedWsAborts` to detect when mass-connect kernel /
|
|
502
|
+
// backpressure churn is closing sockets mid-async-setup at scale.
|
|
503
|
+
let closedWsAborts = 0;
|
|
492
504
|
|
|
493
505
|
/**
|
|
494
506
|
* Per-topic publish counters for runaway-publisher detection. Single
|
|
@@ -824,7 +836,7 @@ async function runUserSubscribeGate(ws, topic) {
|
|
|
824
836
|
function sendSubscribed(ws, topic, ref) {
|
|
825
837
|
if (ref === null) return;
|
|
826
838
|
const payload = JSON.stringify({ type: 'subscribed', topic, ref });
|
|
827
|
-
ws.send(payload, false, false);
|
|
839
|
+
try { ws.send(payload, false, false); } catch { closedWsAborts++; return; }
|
|
828
840
|
bumpOut(ws, payload);
|
|
829
841
|
}
|
|
830
842
|
|
|
@@ -840,7 +852,7 @@ function sendSubscribed(ws, topic, ref) {
|
|
|
840
852
|
function sendSubscribeDenied(ws, topic, ref, reason) {
|
|
841
853
|
if (ref === null) return;
|
|
842
854
|
const payload = JSON.stringify({ type: 'subscribe-denied', topic, ref, reason });
|
|
843
|
-
ws.send(payload, false, false);
|
|
855
|
+
try { ws.send(payload, false, false); } catch { closedWsAborts++; return; }
|
|
844
856
|
bumpOut(ws, payload);
|
|
845
857
|
}
|
|
846
858
|
|
|
@@ -852,15 +864,29 @@ function sendSubscribeDenied(ws, topic, ref, reason) {
|
|
|
852
864
|
* @param {import('uWebSockets.js').WebSocket<any>} ws
|
|
853
865
|
*/
|
|
854
866
|
function flushCoalescedFor(ws) {
|
|
855
|
-
|
|
867
|
+
let userData;
|
|
868
|
+
try { userData = ws.getUserData(); }
|
|
869
|
+
catch { closedWsAborts++; return; }
|
|
856
870
|
const pending = userData[WS_COALESCED];
|
|
857
871
|
if (!pending || pending.size === 0) return;
|
|
858
872
|
assert(pending instanceof Map, 'coalesce.pending-type', null);
|
|
873
|
+
let aborted = false;
|
|
859
874
|
drainCoalesced(pending, (msg) => {
|
|
875
|
+
if (aborted) return 2;
|
|
860
876
|
assert(typeof msg.topic === 'string', 'coalesce.entry-topic-type', null);
|
|
861
877
|
assert(typeof msg.event === 'string', 'coalesce.entry-event-type', null);
|
|
862
878
|
const payload = envelopePrefix(msg.topic, msg.event) + JSON.stringify(msg.data ?? null) + '}';
|
|
863
|
-
|
|
879
|
+
let result;
|
|
880
|
+
try { result = ws.send(payload, false, false); }
|
|
881
|
+
catch {
|
|
882
|
+
// Socket closed mid-drain. There will be no further `drain`
|
|
883
|
+
// event to retry on, so dropping the rest of the buffer is
|
|
884
|
+
// the only correct outcome - returning 1 (BACKPRESSURE) lets
|
|
885
|
+
// drainCoalesced clear the current entry and stop iterating.
|
|
886
|
+
closedWsAborts++;
|
|
887
|
+
aborted = true;
|
|
888
|
+
return 1;
|
|
889
|
+
}
|
|
864
890
|
// `result` MUST propagate to drainCoalesced. 0=SUCCESS removes the
|
|
865
891
|
// entry; 1=BACKPRESSURE removes it and halts the loop; 2=DROPPED
|
|
866
892
|
// retains the entry for retry on next drain. Don't refactor away
|
|
@@ -869,6 +895,7 @@ function flushCoalescedFor(ws) {
|
|
|
869
895
|
if (result !== 2) bumpOut(ws, payload);
|
|
870
896
|
return result;
|
|
871
897
|
});
|
|
898
|
+
if (aborted) pending.clear();
|
|
872
899
|
}
|
|
873
900
|
|
|
874
901
|
/** @type {import('./index.js').Platform} */
|
|
@@ -927,7 +954,13 @@ const platform = {
|
|
|
927
954
|
send(ws, topic, event, data) {
|
|
928
955
|
const payload = envelopePrefix(topic, event) + JSON.stringify(data ?? null) + '}';
|
|
929
956
|
assert(payload.length > 0, 'envelope.send-empty', { topic, event });
|
|
930
|
-
|
|
957
|
+
// `ws.send` throws on a freed native handle (callers may reach
|
|
958
|
+
// here after an `await` that outlasted the socket). Return 2
|
|
959
|
+
// (DROPPED, the uWS sentinel) so callers can pattern-match
|
|
960
|
+
// without distinguishing closed from backpressure-dropped.
|
|
961
|
+
let result;
|
|
962
|
+
try { result = ws.send(payload, false, false); }
|
|
963
|
+
catch { closedWsAborts++; return 2; }
|
|
931
964
|
bumpOut(ws, payload);
|
|
932
965
|
return result;
|
|
933
966
|
},
|
|
@@ -954,7 +987,9 @@ const platform = {
|
|
|
954
987
|
* on the next drain.
|
|
955
988
|
*/
|
|
956
989
|
sendCoalesced(ws, { key, topic, event, data }) {
|
|
957
|
-
|
|
990
|
+
let userData;
|
|
991
|
+
try { userData = ws.getUserData(); }
|
|
992
|
+
catch { closedWsAborts++; return; }
|
|
958
993
|
let pending = userData[WS_COALESCED];
|
|
959
994
|
if (!pending) {
|
|
960
995
|
pending = new Map();
|
|
@@ -990,7 +1025,15 @@ const platform = {
|
|
|
990
1025
|
const envelope = envelopePrefix(topic, event) + JSON.stringify(data ?? null) + '}';
|
|
991
1026
|
let count = 0;
|
|
992
1027
|
for (const ws of wsConnections) {
|
|
993
|
-
|
|
1028
|
+
// uWS's close event fires synchronously and removes from
|
|
1029
|
+
// wsConnections before any user code runs, so under normal
|
|
1030
|
+
// flow every entry here is open. Defensive try/catch covers
|
|
1031
|
+
// pathological cases (e.g. user filter triggers a close via
|
|
1032
|
+
// side effect, or another worker raced through cleanup).
|
|
1033
|
+
let userData;
|
|
1034
|
+
try { userData = ws.getUserData(); }
|
|
1035
|
+
catch { closedWsAborts++; continue; }
|
|
1036
|
+
const decision = filter(userData);
|
|
994
1037
|
if (decision && typeof decision.then === 'function') {
|
|
995
1038
|
if (!sendToAsyncWarned) {
|
|
996
1039
|
sendToAsyncWarned = true;
|
|
@@ -1005,7 +1048,8 @@ const platform = {
|
|
|
1005
1048
|
continue;
|
|
1006
1049
|
}
|
|
1007
1050
|
if (decision) {
|
|
1008
|
-
ws.send(envelope, false, false);
|
|
1051
|
+
try { ws.send(envelope, false, false); }
|
|
1052
|
+
catch { closedWsAborts++; continue; }
|
|
1009
1053
|
bumpOut(ws, envelope);
|
|
1010
1054
|
count++;
|
|
1011
1055
|
}
|
|
@@ -1040,6 +1084,30 @@ const platform = {
|
|
|
1040
1084
|
return readAssertionCounts();
|
|
1041
1085
|
},
|
|
1042
1086
|
|
|
1087
|
+
/**
|
|
1088
|
+
* Per-worker count of best-effort uWS operations that aborted
|
|
1089
|
+
* because the underlying WebSocket had already closed.
|
|
1090
|
+
*
|
|
1091
|
+
* Ws-targeted platform methods (`subscribe`, `unsubscribe`, `send`,
|
|
1092
|
+
* `sendCoalesced`, `sendTo`, `request`) and the wire-level
|
|
1093
|
+
* subscribe / subscribe-batch handlers all swallow uWS's "Invalid
|
|
1094
|
+
* access of closed uWS.WebSocket" exception so callers never need
|
|
1095
|
+
* a per-site try/catch. Each swallow bumps this counter.
|
|
1096
|
+
*
|
|
1097
|
+
* A non-zero value is normal under churn (clients close mid-async-
|
|
1098
|
+
* setup all the time). A rapidly-growing value under steady load
|
|
1099
|
+
* indicates either pathological client behaviour or that the
|
|
1100
|
+
* server's async setup path is too long for its connect rate -
|
|
1101
|
+
* worth investigating but not, by itself, a bug.
|
|
1102
|
+
*
|
|
1103
|
+
* Monotonic, per-worker, reset only on process restart.
|
|
1104
|
+
*
|
|
1105
|
+
* @returns {number}
|
|
1106
|
+
*/
|
|
1107
|
+
get closedWsAborts() {
|
|
1108
|
+
return closedWsAborts;
|
|
1109
|
+
},
|
|
1110
|
+
|
|
1043
1111
|
/**
|
|
1044
1112
|
* Number of clients subscribed to a specific topic.
|
|
1045
1113
|
*/
|
|
@@ -1138,7 +1206,13 @@ const platform = {
|
|
|
1138
1206
|
// non-ASCII topic names (`__signal:Jose`, presence rooms with
|
|
1139
1207
|
// localized labels) must not be blocked at the platform layer.
|
|
1140
1208
|
if (!isValidWireTopic(topic, true)) return 'INVALID_TOPIC';
|
|
1141
|
-
|
|
1209
|
+
// `ws.getUserData()` throws on a freed native handle. RPC and
|
|
1210
|
+
// plugin code paths routinely `await` something else before
|
|
1211
|
+
// reaching here, so the WS may already be closed by the time
|
|
1212
|
+
// this runs. Treat as a silent no-op.
|
|
1213
|
+
let subs;
|
|
1214
|
+
try { subs = ws.getUserData()[WS_SUBSCRIPTIONS]; }
|
|
1215
|
+
catch { closedWsAborts++; return null; }
|
|
1142
1216
|
assert(subs instanceof Set, 'subs.shape', null);
|
|
1143
1217
|
if (subs.has(topic)) return null;
|
|
1144
1218
|
if (subs.size >= MAX_SUBSCRIPTIONS_PER_CONNECTION) return 'RATE_LIMITED';
|
|
@@ -1150,7 +1224,13 @@ const platform = {
|
|
|
1150
1224
|
// counter bump in that case.
|
|
1151
1225
|
if (subs.has(topic)) return null;
|
|
1152
1226
|
if (subs.size >= MAX_SUBSCRIPTIONS_PER_CONNECTION) return 'RATE_LIMITED';
|
|
1153
|
-
ws.subscribe(
|
|
1227
|
+
// `ws.subscribe()` throws if the socket closed during the await
|
|
1228
|
+
// above. Under mass-connect / backpressure churn this is the
|
|
1229
|
+
// dominant abort mode (10-15% of connections close mid-setup).
|
|
1230
|
+
// Swallow, count, and return success-shaped null so callers can
|
|
1231
|
+
// fire-and-forget without per-site try/catch.
|
|
1232
|
+
try { ws.subscribe(topic); }
|
|
1233
|
+
catch { closedWsAborts++; return null; }
|
|
1154
1234
|
subs.add(topic);
|
|
1155
1235
|
totalSubscriptions++;
|
|
1156
1236
|
return null;
|
|
@@ -1227,10 +1307,17 @@ const platform = {
|
|
|
1227
1307
|
* @returns {boolean} `true` if a subscription was removed
|
|
1228
1308
|
*/
|
|
1229
1309
|
unsubscribe(ws, topic) {
|
|
1230
|
-
|
|
1310
|
+
// Closed sockets get an early-out: there is no subscription
|
|
1311
|
+
// state to remove, no uWS bookkeeping to drop, no informational
|
|
1312
|
+
// hook to fire. The platform contract is "best effort; no throw
|
|
1313
|
+
// on closed WS" - mirrors subscribe / send.
|
|
1314
|
+
let subs;
|
|
1315
|
+
try { subs = ws.getUserData()[WS_SUBSCRIPTIONS]; }
|
|
1316
|
+
catch { closedWsAborts++; return false; }
|
|
1231
1317
|
assert(subs instanceof Set, 'subs.shape-unsubscribe', null);
|
|
1232
1318
|
if (!subs.has(topic)) return false;
|
|
1233
|
-
ws.unsubscribe(topic);
|
|
1319
|
+
try { ws.unsubscribe(topic); }
|
|
1320
|
+
catch { closedWsAborts++; return false; }
|
|
1234
1321
|
subs.delete(topic);
|
|
1235
1322
|
totalSubscriptions--;
|
|
1236
1323
|
assert(totalSubscriptions >= 0, 'subs.total-negative', { totalSubscriptions });
|
|
@@ -1493,7 +1580,12 @@ const platform = {
|
|
|
1493
1580
|
* so cleanup is automatic on close - no module-level leak risk.
|
|
1494
1581
|
*/
|
|
1495
1582
|
request(ws, event, data, options) {
|
|
1496
|
-
|
|
1583
|
+
let userData;
|
|
1584
|
+
try { userData = ws.getUserData(); }
|
|
1585
|
+
catch {
|
|
1586
|
+
closedWsAborts++;
|
|
1587
|
+
return Promise.reject(new Error('connection closed'));
|
|
1588
|
+
}
|
|
1497
1589
|
let pending = userData[WS_PENDING_REQUESTS];
|
|
1498
1590
|
if (!pending) {
|
|
1499
1591
|
pending = new Map();
|
|
@@ -1514,7 +1606,14 @@ const platform = {
|
|
|
1514
1606
|
}, timeoutMs);
|
|
1515
1607
|
pending.set(ref, { resolve, reject, timer });
|
|
1516
1608
|
const payload = JSON.stringify({ type: 'request', ref, event, data: data ?? null });
|
|
1517
|
-
ws.send(payload, false, false);
|
|
1609
|
+
try { ws.send(payload, false, false); }
|
|
1610
|
+
catch {
|
|
1611
|
+
closedWsAborts++;
|
|
1612
|
+
clearTimeout(timer);
|
|
1613
|
+
pending.delete(ref);
|
|
1614
|
+
reject(new Error('connection closed'));
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1518
1617
|
bumpOut(ws, payload);
|
|
1519
1618
|
});
|
|
1520
1619
|
},
|
|
@@ -3093,7 +3192,8 @@ if (WS_ENABLED) {
|
|
|
3093
3192
|
sendSubscribeDenied(ws, msg.topic, ref, 'RATE_LIMITED');
|
|
3094
3193
|
return;
|
|
3095
3194
|
}
|
|
3096
|
-
try { ws.subscribe(msg.topic); }
|
|
3195
|
+
try { ws.subscribe(msg.topic); }
|
|
3196
|
+
catch { closedWsAborts++; return; }
|
|
3097
3197
|
subs.add(msg.topic);
|
|
3098
3198
|
totalSubscriptions++;
|
|
3099
3199
|
if (wsDebug) console.log('[ws] subscribe topic=%s', msg.topic);
|
|
@@ -3171,7 +3271,8 @@ if (WS_ENABLED) {
|
|
|
3171
3271
|
sendSubscribeDenied(ws, topic, ref, 'RATE_LIMITED');
|
|
3172
3272
|
continue;
|
|
3173
3273
|
}
|
|
3174
|
-
try { ws.subscribe(topic); }
|
|
3274
|
+
try { ws.subscribe(topic); }
|
|
3275
|
+
catch { closedWsAborts++; continue; }
|
|
3175
3276
|
subs.add(topic);
|
|
3176
3277
|
totalSubscriptions++;
|
|
3177
3278
|
subscribed++;
|
package/index.d.ts
CHANGED
|
@@ -1202,6 +1202,14 @@ export interface Platform {
|
|
|
1202
1202
|
* Send a message to a single WebSocket connection.
|
|
1203
1203
|
* Wraps in the same `{ topic, event, data }` envelope as `publish()`.
|
|
1204
1204
|
*
|
|
1205
|
+
* Returns the uWS send result: `0` = SUCCESS, `1` = BACKPRESSURE
|
|
1206
|
+
* (queued, will flush on drain), `2` = DROPPED (frame discarded
|
|
1207
|
+
* because the socket has closed or its queue is over the limit).
|
|
1208
|
+
*
|
|
1209
|
+
* Closed-WS safe: if the socket has already closed, returns `2`
|
|
1210
|
+
* (DROPPED) and bumps `platform.closedWsAborts` rather than
|
|
1211
|
+
* propagating uWS's "Invalid access" exception.
|
|
1212
|
+
*
|
|
1205
1213
|
* @example
|
|
1206
1214
|
* ```js
|
|
1207
1215
|
* // In hooks.ws.js - reply to sender:
|
|
@@ -1310,6 +1318,36 @@ export interface Platform {
|
|
|
1310
1318
|
*/
|
|
1311
1319
|
readonly connections: number;
|
|
1312
1320
|
|
|
1321
|
+
/**
|
|
1322
|
+
* Per-worker count of best-effort uWS operations that aborted
|
|
1323
|
+
* because the underlying WebSocket had already closed.
|
|
1324
|
+
*
|
|
1325
|
+
* Ws-targeted platform methods (`subscribe`, `unsubscribe`, `send`,
|
|
1326
|
+
* `sendCoalesced`, `sendTo`, `request`) and the wire-level
|
|
1327
|
+
* subscribe / subscribe-batch handlers swallow uWS's "Invalid
|
|
1328
|
+
* access of closed uWS.WebSocket" exception so callers never need
|
|
1329
|
+
* a per-site try/catch when a socket closes mid-async-setup.
|
|
1330
|
+
* Each swallow bumps this counter.
|
|
1331
|
+
*
|
|
1332
|
+
* A non-zero value is normal under client churn (browser tab close,
|
|
1333
|
+
* network blips, mass-reconnect waves). A rapidly-growing value
|
|
1334
|
+
* under steady load indicates either pathological client behaviour
|
|
1335
|
+
* or that the server's async setup path is too long for its
|
|
1336
|
+
* connect rate - worth investigating but not, on its own, a bug.
|
|
1337
|
+
*
|
|
1338
|
+
* Monotonic, per-worker, reset only on process restart. In
|
|
1339
|
+
* clustered mode, sum across workers to get the cluster total.
|
|
1340
|
+
*
|
|
1341
|
+
* @example
|
|
1342
|
+
* ```js
|
|
1343
|
+
* // periodic ops log
|
|
1344
|
+
* setInterval(() => {
|
|
1345
|
+
* console.log('closed-ws aborts:', platform.closedWsAborts);
|
|
1346
|
+
* }, 60_000);
|
|
1347
|
+
* ```
|
|
1348
|
+
*/
|
|
1349
|
+
readonly closedWsAborts: number;
|
|
1350
|
+
|
|
1313
1351
|
/**
|
|
1314
1352
|
* Number of clients subscribed to a specific topic.
|
|
1315
1353
|
*
|
|
@@ -1387,6 +1425,13 @@ export interface Platform {
|
|
|
1387
1425
|
* hook may be async (the framework awaits the hook before inspecting
|
|
1388
1426
|
* its return). Callers must `await` the result.
|
|
1389
1427
|
*
|
|
1428
|
+
* Closed-WS safe: if the socket closes during the awaited hook (or
|
|
1429
|
+
* before the call, e.g. caller `await`-ed something else first) the
|
|
1430
|
+
* method silently returns `null` rather than propagating uWS's
|
|
1431
|
+
* "Invalid access of closed uWS.WebSocket" exception. Each abort
|
|
1432
|
+
* increments `platform.closedWsAborts`. Callers can fire-and-forget
|
|
1433
|
+
* without a per-site try/catch.
|
|
1434
|
+
*
|
|
1390
1435
|
* @example
|
|
1391
1436
|
* ```js
|
|
1392
1437
|
* // In an RPC handler that needs to subscribe the connection
|
|
@@ -1456,6 +1501,9 @@ export interface Platform {
|
|
|
1456
1501
|
* otherwise removes the subscription, decrements `totalSubscriptions`,
|
|
1457
1502
|
* fires `hooks.ws.unsubscribe` (informational, not a gate - mirrors
|
|
1458
1503
|
* the wire-level unsubscribe path), and returns `true`.
|
|
1504
|
+
*
|
|
1505
|
+
* Closed-WS safe: returns `false` and bumps `platform.closedWsAborts`
|
|
1506
|
+
* if the socket has already closed.
|
|
1459
1507
|
*/
|
|
1460
1508
|
unsubscribe(ws: WebSocket<unknown>, topic: string): boolean;
|
|
1461
1509
|
|
package/package.json
CHANGED
package/plugins/cursor/server.js
CHANGED
|
@@ -254,9 +254,17 @@ export function createCursor(options = {}) {
|
|
|
254
254
|
const oldest = wsState.keys().next().value;
|
|
255
255
|
if (oldest !== undefined) wsState.delete(oldest);
|
|
256
256
|
}
|
|
257
|
+
let userData = {};
|
|
258
|
+
if (typeof ws.getUserData === 'function') {
|
|
259
|
+
// Closed-WS race: caller may reach here after an `await`
|
|
260
|
+
// that outlasted the socket; getUserData throws on a
|
|
261
|
+
// freed handle. Fall back to an empty userData rather
|
|
262
|
+
// than crashing the worker.
|
|
263
|
+
try { userData = ws.getUserData(); } catch { userData = {}; }
|
|
264
|
+
}
|
|
257
265
|
state = {
|
|
258
266
|
key: String(++connCounter),
|
|
259
|
-
user: select(
|
|
267
|
+
user: select(userData),
|
|
260
268
|
topics: new Set()
|
|
261
269
|
};
|
|
262
270
|
wsState.set(ws, state);
|
|
@@ -377,9 +385,21 @@ export function createCursor(options = {}) {
|
|
|
377
385
|
* Route a broadcast through the per-topic coalesce window when
|
|
378
386
|
* `topicThrottle` is enabled, or directly publish when disabled.
|
|
379
387
|
*
|
|
380
|
-
* Leading-edge
|
|
381
|
-
*
|
|
382
|
-
*
|
|
388
|
+
* Leading-edge claims the cadence slot synchronously (lastFlush =
|
|
389
|
+
* now) but defers the actual flush by one microtask so co-arriving
|
|
390
|
+
* broadcasts in the same JS pass batch into a single bulk frame.
|
|
391
|
+
* Without the microtask defer, an event-loop pause > topicThrottleMs
|
|
392
|
+
* caused the post-pause first cursor to fire alone (single-cursor
|
|
393
|
+
* UPDATE) while every other cursor in the same burst queued to the
|
|
394
|
+
* trailing tick: under sustained pressure (30K RPCs/sec/worker) this
|
|
395
|
+
* fragmented 86% of cursor frames into single-cursor UPDATEs.
|
|
396
|
+
* Microtasks run after the current synchronous code completes but
|
|
397
|
+
* before the next I/O / setTimeout / event-loop tick, so any
|
|
398
|
+
* subsequent broadcast() in the same handler batch adds itself to
|
|
399
|
+
* `dirty` before the flush runs.
|
|
400
|
+
*
|
|
401
|
+
* Trailing-edge fires via the single tracker-wide `tickTimer` for
|
|
402
|
+
* broadcasts that land mid-window.
|
|
383
403
|
*/
|
|
384
404
|
function broadcast(topic, key, data, platform) {
|
|
385
405
|
if (topicThrottleMs <= 0) {
|
|
@@ -389,7 +409,7 @@ export function createCursor(options = {}) {
|
|
|
389
409
|
|
|
390
410
|
let state = topicFlush.get(topic);
|
|
391
411
|
if (!state) {
|
|
392
|
-
state = { dirty: new Map(), lastFlush: 0 };
|
|
412
|
+
state = { dirty: new Map(), lastFlush: 0, pendingMicroflush: false };
|
|
393
413
|
topicFlush.set(topic, state);
|
|
394
414
|
}
|
|
395
415
|
state.dirty.set(key, { data, platform });
|
|
@@ -397,9 +417,18 @@ export function createCursor(options = {}) {
|
|
|
397
417
|
const now = Date.now();
|
|
398
418
|
if (now - state.lastFlush >= topicThrottleMs) {
|
|
399
419
|
state.lastFlush = now;
|
|
400
|
-
flushDirty(topic, state.dirty);
|
|
401
|
-
state.dirty.clear();
|
|
402
420
|
dirtyTopics.delete(topic);
|
|
421
|
+
// Schedule once per cycle slot; subsequent broadcasts inside
|
|
422
|
+
// the same microtask boundary just append to `state.dirty`.
|
|
423
|
+
if (!state.pendingMicroflush) {
|
|
424
|
+
state.pendingMicroflush = true;
|
|
425
|
+
queueMicrotask(() => {
|
|
426
|
+
state.pendingMicroflush = false;
|
|
427
|
+
if (state.dirty.size === 0) return;
|
|
428
|
+
flushDirty(topic, state.dirty);
|
|
429
|
+
state.dirty.clear();
|
|
430
|
+
});
|
|
431
|
+
}
|
|
403
432
|
return;
|
|
404
433
|
}
|
|
405
434
|
|
package/plugins/groups/server.js
CHANGED
|
@@ -168,7 +168,15 @@ export function createGroup(name, options = {}) {
|
|
|
168
168
|
// Publish join BEFORE subscribing so joiner doesn't see own join
|
|
169
169
|
platform.publish(internalTopic, 'join', { role, count: members.size });
|
|
170
170
|
|
|
171
|
-
|
|
171
|
+
// Callers reach `join` after their own async auth chain; the
|
|
172
|
+
// socket may have closed in the meantime. Roll back the
|
|
173
|
+
// member entry instead of letting uWS's "Invalid access"
|
|
174
|
+
// crash the worker.
|
|
175
|
+
try { ws.subscribe(internalTopic); }
|
|
176
|
+
catch {
|
|
177
|
+
members.delete(ws);
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
172
180
|
|
|
173
181
|
// Send current member list to the joiner
|
|
174
182
|
platform.send(ws, internalTopic, 'members', membersList());
|
|
@@ -443,7 +443,13 @@ export function createPresence(options = {}) {
|
|
|
443
443
|
let connTopics = wsTopics.get(ws);
|
|
444
444
|
if (connTopics && connTopics.has(topic)) return;
|
|
445
445
|
|
|
446
|
-
|
|
446
|
+
// Callers typically reach here after an `await` in their own
|
|
447
|
+
// join flow (auth, loader, RPC handshake). If the socket
|
|
448
|
+
// closed mid-await `getUserData()` throws; presence is a
|
|
449
|
+
// best-effort layer, so silently no-op rather than crash.
|
|
450
|
+
let userData;
|
|
451
|
+
try { userData = ws.getUserData(); } catch { return; }
|
|
452
|
+
const data = select(userData);
|
|
447
453
|
if (!data || typeof data !== 'object') {
|
|
448
454
|
throw new TypeError(
|
|
449
455
|
`presence select() must return a plain object, got ${data === null ? 'null' : typeof data}`
|
|
@@ -492,8 +498,10 @@ export function createPresence(options = {}) {
|
|
|
492
498
|
bufferDiff(topic, 'join', key, data, platform);
|
|
493
499
|
}
|
|
494
500
|
|
|
495
|
-
// Subscribe this ws to the presence channel (server-side, idempotent)
|
|
496
|
-
ws
|
|
501
|
+
// Subscribe this ws to the presence channel (server-side, idempotent).
|
|
502
|
+
// `platform.send` is closed-ws-safe on the adapter side; the
|
|
503
|
+
// direct `ws.subscribe` is not - guard locally.
|
|
504
|
+
try { ws.subscribe(presenceTopic); } catch { return; }
|
|
497
505
|
|
|
498
506
|
// Send the full current snapshot to this connection. The joining
|
|
499
507
|
// user sees the complete state (including themselves) immediately;
|
|
@@ -518,7 +526,7 @@ export function createPresence(options = {}) {
|
|
|
518
526
|
capturePlatform(platform);
|
|
519
527
|
const users = topicPresence.get(topic);
|
|
520
528
|
const presenceTopic = TOPIC_PREFIX + topic;
|
|
521
|
-
ws.subscribe(presenceTopic);
|
|
529
|
+
try { ws.subscribe(presenceTopic); } catch { return; }
|
|
522
530
|
platform.send(ws, presenceTopic, 'presence_state', snapshotState(users));
|
|
523
531
|
},
|
|
524
532
|
|
package/testing.js
CHANGED
|
@@ -150,16 +150,25 @@ export async function createTestServer(options = {}) {
|
|
|
150
150
|
|
|
151
151
|
const closeHookRegisteredT = !!handler.close;
|
|
152
152
|
let sendToAsyncWarnedT = false;
|
|
153
|
+
// Mirrors prod's `closedWsAborts`. createTestServer uses real uWS,
|
|
154
|
+
// so a closed-WS race (subscribe gate awaits something, client
|
|
155
|
+
// closes during the await, post-await ws.subscribe throws) is
|
|
156
|
+
// exercisable here exactly like in production. Hardening below
|
|
157
|
+
// catches the uWS exception, bumps this counter, and returns the
|
|
158
|
+
// platform's success-shaped no-op sentinel.
|
|
159
|
+
let closedWsAbortsT = 0;
|
|
153
160
|
function bumpInT(ws, message) {
|
|
154
161
|
if (!closeHookRegisteredT) return;
|
|
155
|
-
|
|
162
|
+
let stats;
|
|
163
|
+
try { stats = ws.getUserData()[WS_STATS]; } catch { return; }
|
|
156
164
|
if (!stats) return;
|
|
157
165
|
stats.messagesIn++;
|
|
158
166
|
stats.bytesIn += typeof message === 'string' ? message.length : message.byteLength;
|
|
159
167
|
}
|
|
160
168
|
function bumpOutT(ws, payload) {
|
|
161
169
|
if (!closeHookRegisteredT) return;
|
|
162
|
-
|
|
170
|
+
let stats;
|
|
171
|
+
try { stats = ws.getUserData()[WS_STATS]; } catch { return; }
|
|
163
172
|
if (!stats) return;
|
|
164
173
|
stats.messagesOut++;
|
|
165
174
|
stats.bytesOut += payload.length;
|
|
@@ -186,12 +195,15 @@ export async function createTestServer(options = {}) {
|
|
|
186
195
|
const delay = chaos.getDelayMs();
|
|
187
196
|
if (delay > 0) {
|
|
188
197
|
setTimeout(() => {
|
|
189
|
-
try { ws.send(payload, false, false); }
|
|
198
|
+
try { ws.send(payload, false, false); }
|
|
199
|
+
catch { closedWsAbortsT++; return; }
|
|
190
200
|
bumpOutT(ws, payload);
|
|
191
201
|
}, delay);
|
|
192
202
|
return 1;
|
|
193
203
|
}
|
|
194
|
-
|
|
204
|
+
let result;
|
|
205
|
+
try { result = ws.send(payload, false, false); }
|
|
206
|
+
catch { closedWsAbortsT++; return 2; }
|
|
195
207
|
bumpOutT(ws, payload);
|
|
196
208
|
return result;
|
|
197
209
|
}
|
|
@@ -225,7 +237,10 @@ export async function createTestServer(options = {}) {
|
|
|
225
237
|
const msg = envelope(topic, event, data);
|
|
226
238
|
let count = 0;
|
|
227
239
|
for (const ws of wsConnections) {
|
|
228
|
-
|
|
240
|
+
let userData;
|
|
241
|
+
try { userData = ws.getUserData(); }
|
|
242
|
+
catch { closedWsAbortsT++; continue; }
|
|
243
|
+
const decision = filter(userData);
|
|
229
244
|
if (decision && typeof decision.then === 'function') {
|
|
230
245
|
if (!sendToAsyncWarnedT) {
|
|
231
246
|
sendToAsyncWarnedT = true;
|
|
@@ -247,6 +262,7 @@ export async function createTestServer(options = {}) {
|
|
|
247
262
|
},
|
|
248
263
|
get connections() { return wsConnections.size; },
|
|
249
264
|
get assertions() { return readAssertionCounts(); },
|
|
265
|
+
get closedWsAborts() { return closedWsAbortsT; },
|
|
250
266
|
subscribers(topic) { return app.numSubscribers(topic); },
|
|
251
267
|
// Mirror production: report a numeric cap and a constant-time
|
|
252
268
|
// bufferedAmount so test code can exercise the same backpressure-
|
|
@@ -264,7 +280,9 @@ export async function createTestServer(options = {}) {
|
|
|
264
280
|
// user hook so async hooks gate correctly.
|
|
265
281
|
// Server-side caller: trust non-ASCII topics (matches platform.subscribe in production).
|
|
266
282
|
if (!isValidWireTopic(topic, true)) return 'INVALID_TOPIC';
|
|
267
|
-
|
|
283
|
+
let subs;
|
|
284
|
+
try { subs = ws.getUserData()[WS_SUBSCRIPTIONS]; }
|
|
285
|
+
catch { closedWsAbortsT++; return null; }
|
|
268
286
|
if (!(subs instanceof Set)) return 'INVALID_TOPIC';
|
|
269
287
|
if (subs.has(topic)) return null;
|
|
270
288
|
if (subs.size >= MAX_SUBSCRIPTIONS_PER_CONNECTION) return 'RATE_LIMITED';
|
|
@@ -272,7 +290,8 @@ export async function createTestServer(options = {}) {
|
|
|
272
290
|
if (denial !== null) return denial;
|
|
273
291
|
if (subs.has(topic)) return null;
|
|
274
292
|
if (subs.size >= MAX_SUBSCRIPTIONS_PER_CONNECTION) return 'RATE_LIMITED';
|
|
275
|
-
ws.subscribe(topic);
|
|
293
|
+
try { ws.subscribe(topic); }
|
|
294
|
+
catch { closedWsAbortsT++; return null; }
|
|
276
295
|
subs.add(topic);
|
|
277
296
|
return null;
|
|
278
297
|
},
|
|
@@ -282,9 +301,12 @@ export async function createTestServer(options = {}) {
|
|
|
282
301
|
return await runUserSubscribeGateT(ws, topic);
|
|
283
302
|
},
|
|
284
303
|
unsubscribe(ws, topic) {
|
|
285
|
-
|
|
304
|
+
let subs;
|
|
305
|
+
try { subs = ws.getUserData()[WS_SUBSCRIPTIONS]; }
|
|
306
|
+
catch { closedWsAbortsT++; return false; }
|
|
286
307
|
if (!(subs instanceof Set) || !subs.has(topic)) return false;
|
|
287
|
-
ws.unsubscribe(topic);
|
|
308
|
+
try { ws.unsubscribe(topic); }
|
|
309
|
+
catch { closedWsAbortsT++; return false; }
|
|
288
310
|
subs.delete(topic);
|
|
289
311
|
handler.unsubscribe?.(ws, topic, { platform: ws.getUserData()[WS_PLATFORM] });
|
|
290
312
|
return true;
|
|
@@ -370,7 +392,12 @@ export async function createTestServer(options = {}) {
|
|
|
370
392
|
app.publish(fanoutTopic, sharedBatchEnv, false, false);
|
|
371
393
|
},
|
|
372
394
|
request(ws, event, data, options) {
|
|
373
|
-
|
|
395
|
+
let userData;
|
|
396
|
+
try { userData = ws.getUserData(); }
|
|
397
|
+
catch {
|
|
398
|
+
closedWsAbortsT++;
|
|
399
|
+
return Promise.reject(new Error('connection closed'));
|
|
400
|
+
}
|
|
374
401
|
let pending = userData[WS_PENDING_REQUESTS];
|
|
375
402
|
if (!pending) {
|
|
376
403
|
pending = new Map();
|
|
@@ -390,7 +417,21 @@ export async function createTestServer(options = {}) {
|
|
|
390
417
|
}, timeoutMs);
|
|
391
418
|
pending.set(ref, { resolve, reject, timer });
|
|
392
419
|
const payload = JSON.stringify({ type: 'request', ref, event, data: data ?? null });
|
|
393
|
-
|
|
420
|
+
// Direct ws.send so we can distinguish "closed WS"
|
|
421
|
+
// (throws -> reject now) from "backpressure DROPPED"
|
|
422
|
+
// (returns 2 -> let it time out, matches production
|
|
423
|
+
// semantics where uWS will not retry on its own).
|
|
424
|
+
// sendOutboundT exists for chaos-injection; the request
|
|
425
|
+
// flow takes the bare path and re-uses bumpOutT.
|
|
426
|
+
try { ws.send(payload, false, false); }
|
|
427
|
+
catch {
|
|
428
|
+
closedWsAbortsT++;
|
|
429
|
+
clearTimeout(timer);
|
|
430
|
+
pending.delete(ref);
|
|
431
|
+
reject(new Error('connection closed'));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
bumpOutT(ws, payload);
|
|
394
435
|
});
|
|
395
436
|
},
|
|
396
437
|
topic(name) {
|
|
@@ -623,7 +664,8 @@ export async function createTestServer(options = {}) {
|
|
|
623
664
|
sendDeniedT(ws, msg.topic, ref, 'RATE_LIMITED');
|
|
624
665
|
return;
|
|
625
666
|
}
|
|
626
|
-
ws.subscribe(msg.topic);
|
|
667
|
+
try { ws.subscribe(msg.topic); }
|
|
668
|
+
catch { closedWsAbortsT++; return; }
|
|
627
669
|
subs.add(msg.topic);
|
|
628
670
|
sendSubscribedT(ws, msg.topic, ref);
|
|
629
671
|
return;
|
|
@@ -680,7 +722,8 @@ export async function createTestServer(options = {}) {
|
|
|
680
722
|
sendDeniedT(ws, topic, ref, 'RATE_LIMITED');
|
|
681
723
|
continue;
|
|
682
724
|
}
|
|
683
|
-
ws.subscribe(topic);
|
|
725
|
+
try { ws.subscribe(topic); }
|
|
726
|
+
catch { closedWsAbortsT++; continue; }
|
|
684
727
|
udSubs.add(topic);
|
|
685
728
|
sendSubscribedT(ws, topic, ref);
|
|
686
729
|
}
|
package/vite.js
CHANGED
|
@@ -398,6 +398,13 @@ export default function uws(options = {}) {
|
|
|
398
398
|
// see the documented "no violations" state.
|
|
399
399
|
return new Map();
|
|
400
400
|
},
|
|
401
|
+
get closedWsAborts() {
|
|
402
|
+
// Dev uses Node's `ws` library, not uWS - sockets do not
|
|
403
|
+
// throw "Invalid access" when written to after close, so
|
|
404
|
+
// the closed-WS abort path doesn't exist here. Mirror the
|
|
405
|
+
// prod surface as a constant zero.
|
|
406
|
+
return 0;
|
|
407
|
+
},
|
|
401
408
|
subscribers(topic) {
|
|
402
409
|
let count = 0;
|
|
403
410
|
for (const [, topics] of subscriptions) {
|