Skip to content

Commit d9e7f2c

Browse files
committed
Fire close event for server WebSocket close, with allowHalfOpen opt-out.
1 parent b7f4467 commit d9e7f2c

File tree

16 files changed

+242
-38
lines changed

16 files changed

+242
-38
lines changed

src/workerd/api/tests/BUILD.bazel

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,12 @@ wd_test(
514514
data = ["url-test.js"],
515515
)
516516

517+
wd_test(
518+
src = "websocket-allow-half-open-test.wd-test",
519+
args = ["--experimental"],
520+
data = ["websocket-allow-half-open-test.js"],
521+
)
522+
517523
wd_test(
518524
src = "websocket-constructor-test.wd-test",
519525
args = ["--experimental"],

src/workerd/api/tests/http-test.wd-test

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const unitTests :Workerd.Config = (
1111
( name = "SERVICE", service = "http-test" ),
1212
( name = "CACHE_ENABLED", json = "false" ),
1313
],
14-
compatibilityFlags = ["nodejs_compat", "service_binding_extra_handlers", "cache_option_disabled", "url_standard", "workers_api_getters_setters_on_prototype", "fetch_legacy_url"],
14+
compatibilityFlags = ["nodejs_compat", "service_binding_extra_handlers", "cache_option_disabled", "url_standard", "workers_api_getters_setters_on_prototype", "fetch_legacy_url", "no_web_socket_half_open_by_default"],
1515
)
1616
),
1717
( name = "http-test-cache-option-enabled",
@@ -23,7 +23,7 @@ const unitTests :Workerd.Config = (
2323
( name = "SERVICE", service = "http-test-cache-option-enabled" ),
2424
( name = "CACHE_ENABLED", json = "true" ),
2525
],
26-
compatibilityFlags = ["nodejs_compat", "service_binding_extra_handlers", "cache_option_enabled", "url_standard", "workers_api_getters_setters_on_prototype", "fetch_legacy_url"],
26+
compatibilityFlags = ["nodejs_compat", "service_binding_extra_handlers", "cache_option_enabled", "url_standard", "workers_api_getters_setters_on_prototype", "fetch_legacy_url", "no_web_socket_half_open_by_default"],
2727
))
2828
],
2929
);

src/workerd/api/tests/tail-worker-test.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/workerd/api/tests/tail-worker-test.wd-test

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const unitTests :Workerd.Config = (
1212
( name = "SERVICE", service = "http-test" ),
1313
( name = "CACHE_ENABLED", json = "false" ),
1414
],
15-
compatibilityFlags = ["nodejs_compat", "service_binding_extra_handlers", "cache_option_disabled", "queues_json_messages", "url_standard", "workers_api_getters_setters_on_prototype", "fetch_legacy_url"],
15+
compatibilityFlags = ["nodejs_compat", "service_binding_extra_handlers", "cache_option_disabled", "queues_json_messages", "url_standard", "workers_api_getters_setters_on_prototype", "fetch_legacy_url", "no_web_socket_half_open_by_default"],
1616
streamingTails = ["log", "log-invalid"],
1717
),
1818
),
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright (c) 2017-2022 Cloudflare, Inc.
2+
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3+
// https://opensource.org/licenses/Apache-2.0
4+
5+
import { strictEqual } from 'node:assert';
6+
7+
// Test that when allowHalfOpen is false (default with compat flag), a server-initiated
8+
// close sets readyState to CLOSED.
9+
export const autoCloseReplyWhenNotHalfOpen = {
10+
async test() {
11+
const pair = new WebSocketPair();
12+
const [client, server] = Object.values(pair);
13+
14+
// accept() without options — allowHalfOpen defaults to false with the compat flag.
15+
client.accept();
16+
server.accept();
17+
18+
const closePromise = new Promise((resolve) => {
19+
client.addEventListener('close', (event) => {
20+
// When allowHalfOpen is false, the runtime auto-sends a close reply,
21+
// so both closedIncoming and closedOutgoing are true — readyState should be CLOSED.
22+
resolve({
23+
readyState: client.readyState,
24+
code: event.code,
25+
wasClean: event.wasClean,
26+
});
27+
});
28+
});
29+
30+
// Server initiates close.
31+
server.close(1000, 'server closing');
32+
33+
const result = await closePromise;
34+
strictEqual(result.readyState, WebSocket.CLOSED);
35+
strictEqual(result.code, 1000);
36+
strictEqual(result.wasClean, true);
37+
},
38+
};
39+
40+
// Test that when allowHalfOpen is true via accept(), a server-initiated close sets
41+
// readyState to CLOSING.
42+
export const halfOpenCloseKeepsClosingState = {
43+
async test() {
44+
const pair = new WebSocketPair();
45+
const [client, server] = Object.values(pair);
46+
47+
// Opt into half-open mode via accept() options.
48+
client.accept({ allowHalfOpen: true });
49+
server.accept();
50+
51+
const closePromise = new Promise((resolve) => {
52+
client.addEventListener('close', (event) => {
53+
// When allowHalfOpen is true, no auto-reply is sent, so only closedIncoming
54+
// is true — readyState should be CLOSING.
55+
resolve({
56+
readyState: client.readyState,
57+
code: event.code,
58+
wasClean: event.wasClean,
59+
});
60+
});
61+
});
62+
63+
// Server initiates close.
64+
server.close(1000, 'server closing');
65+
66+
const result = await closePromise;
67+
strictEqual(result.readyState, WebSocket.CLOSING);
68+
strictEqual(result.code, 1000);
69+
strictEqual(result.wasClean, true);
70+
71+
// The client must manually close to complete the handshake.
72+
client.close(1000, 'client reply');
73+
},
74+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using Workerd = import "/workerd/workerd.capnp";
2+
3+
const unitTests :Workerd.Config = (
4+
services = [
5+
( name = "websocket-allow-half-open-test",
6+
worker = (
7+
modules = [
8+
(name = "worker", esModule = embed "websocket-allow-half-open-test.js")
9+
],
10+
compatibilityFlags = ["nodejs_compat", "no_web_socket_half_open_by_default"]
11+
)
12+
),
13+
],
14+
);

src/workerd/api/web-socket.c++

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ WebSocket::WebSocket(
5757
binaryType_(FeatureFlags::get(js).getWebsocketBinaryTypeDefault() ? BinaryType::BLOB
5858
: BinaryType::ARRAYBUFFER),
5959
serializedAttachment(kj::mv(package.serializedAttachment)),
60+
allowHalfOpen(package.allowHalfOpen),
6061
farNative(initNative(ioContext,
6162
ws,
6263
kj::mv(KJ_REQUIRE_NONNULL(package.maybeTags)),
@@ -76,6 +77,7 @@ WebSocket::WebSocket(jsg::Lock& js, kj::Own<kj::WebSocket> native)
7677
url(kj::none),
7778
binaryType_(FeatureFlags::get(js).getWebsocketBinaryTypeDefault() ? BinaryType::BLOB
7879
: BinaryType::ARRAYBUFFER),
80+
allowHalfOpen(!FeatureFlags::get(js).getWebSocketCloseReadyStateClosed()),
7981
farNative(nullptr),
8082
outgoingMessages(IoContext::current().addObject(kj::heap<OutgoingMessagesMap>())) {
8183
auto nativeObj = kj::heap<Native>();
@@ -88,6 +90,7 @@ WebSocket::WebSocket(jsg::Lock& js, kj::String url)
8890
url(kj::mv(url)),
8991
binaryType_(FeatureFlags::get(js).getWebsocketBinaryTypeDefault() ? BinaryType::BLOB
9092
: BinaryType::ARRAYBUFFER),
93+
allowHalfOpen(!FeatureFlags::get(js).getWebSocketCloseReadyStateClosed()),
9194
farNative(nullptr),
9295
outgoingMessages(IoContext::current().addObject(kj::heap<OutgoingMessagesMap>())) {
9396
auto nativeObj = kj::heap<Native>();
@@ -398,7 +401,7 @@ kj::Promise<DeferredProxy<void>> WebSocket::couple(
398401
co_return co_await promise;
399402
}
400403

401-
void WebSocket::accept(jsg::Lock& js) {
404+
void WebSocket::accept(jsg::Lock& js, jsg::Optional<AcceptOptions> options) {
402405
auto& native = *farNative;
403406
JSG_REQUIRE(!native.state.is<AwaitingConnection>(), TypeError,
404407
"Websockets obtained from the 'new WebSocket()' constructor cannot call accept");
@@ -414,6 +417,12 @@ void WebSocket::accept(jsg::Lock& js) {
414417
return;
415418
}
416419

420+
KJ_IF_SOME(opts, options) {
421+
KJ_IF_SOME(value, opts.allowHalfOpen) {
422+
allowHalfOpen = AllowHalfOpen(value);
423+
}
424+
}
425+
417426
internalAccept(js, IoContext::current().getCriticalSection());
418427
}
419428

@@ -605,11 +614,8 @@ void WebSocket::send(jsg::Lock& js, kj::OneOf<kj::Array<byte>, kj::String> messa
605614
KJ_UNREACHABLE;
606615
}();
607616

608-
auto pendingAutoResponses =
609-
autoResponseStatus.pendingAutoResponseDeque.size() - autoResponseStatus.queuedAutoResponses;
610-
autoResponseStatus.queuedAutoResponses = autoResponseStatus.pendingAutoResponseDeque.size();
611617
outgoingMessages->insert(
612-
GatedMessage{kj::mv(maybeOutputLock), kj::mv(msg), pendingAutoResponses});
618+
GatedMessage{kj::mv(maybeOutputLock), kj::mv(msg), getPendingAutoResponseCount()});
613619

614620
ensurePumping(js);
615621
}
@@ -680,22 +686,13 @@ void WebSocket::close(
680686

681687
assertNoError(js);
682688

683-
// pendingAutoResponses stores the number of queuedAutoResponses that will be pumped before sending
684-
// the current GatedMessage, guaranteeing order.
685-
// queuedAutoResponses stores the total number of auto-response messages that are already in accounted
686-
// for in previous GatedMessages. This is useful to easily calculate the number of pendingAutoResponses
687-
// for each new GateMessage.
688-
auto pendingAutoResponses =
689-
autoResponseStatus.pendingAutoResponseDeque.size() - autoResponseStatus.queuedAutoResponses;
690-
autoResponseStatus.queuedAutoResponses = autoResponseStatus.pendingAutoResponseDeque.size();
691-
692689
outgoingMessages->insert(GatedMessage{IoContext::current().waitForOutputLocksIfNecessary(),
693690
kj::WebSocket::Close{
694691
// Code 1005 actually translates to sending a close message with no body on the wire.
695692
static_cast<uint16_t>(code.orDefault(1005)),
696693
kj::mv(reason).orDefault(jsg::USVString(kj::str())),
697694
},
698-
pendingAutoResponses});
695+
getPendingAutoResponseCount()});
699696

700697
native.closedOutgoing = true;
701698
closedOutgoingForHib = true;
@@ -990,6 +987,13 @@ kj::Promise<void> WebSocket::pump(IoContext& context,
990987
completed = true;
991988
}
992989

990+
size_t WebSocket::getPendingAutoResponseCount() {
991+
auto count =
992+
autoResponseStatus.pendingAutoResponseDeque.size() - autoResponseStatus.queuedAutoResponses;
993+
autoResponseStatus.queuedAutoResponses = autoResponseStatus.pendingAutoResponseDeque.size();
994+
return count;
995+
}
996+
993997
void WebSocket::tryReleaseNative(jsg::Lock& js) {
994998
// If the native WebSocket is no longer needed (the connection closed) and there are no more
995999
// messages to send, we can discard the underlying connection.
@@ -1057,6 +1061,23 @@ kj::Promise<kj::Maybe<kj::Exception>> WebSocket::readLoop(
10571061
}
10581062
KJ_CASE_ONEOF(close, kj::WebSocket::Close) {
10591063
native.closedIncoming = true;
1064+
if (!allowHalfOpen.toBool() && !native.closedOutgoing && !native.outgoingAborted &&
1065+
!native.state.is<Released>()) {
1066+
// When allowHalfOpen is false (the spec-compliant default with the
1067+
// no_web_socket_half_open_by_default compat flag), automatically send a reciprocal
1068+
// Close frame through the outgoing message pump so that readyState is CLOSED (3)
1069+
// when the close event fires. Skip if a close frame was already sent (e.g. the
1070+
// application called close() before the server sent its Close), or if the outgoing
1071+
// side is otherwise unusable.
1072+
outgoingMessages->insert(
1073+
GatedMessage{IoContext::current().waitForOutputLocksIfNecessary(),
1074+
kj::WebSocket::Close{close.code, kj::str(close.reason)},
1075+
getPendingAutoResponseCount()});
1076+
1077+
native.closedOutgoing = true;
1078+
closedOutgoingForHib = true;
1079+
ensurePumping(js);
1080+
}
10601081
dispatchEventImpl(js, js.alloc<CloseEvent>(close.code, kj::mv(close.reason), true));
10611082
// Native WebSocket no longer needed; release.
10621083
tryReleaseNative(js);
@@ -1202,6 +1223,7 @@ WebSocket::HibernationPackage WebSocket::buildPackageForHibernation() {
12021223
.serializedAttachment = kj::mv(serializedAttachment),
12031224
.maybeTags = kj::none,
12041225
.closedOutgoingConnection = closedOutgoingForHib,
1226+
.allowHalfOpen = allowHalfOpen,
12051227
};
12061228
}
12071229

src/workerd/api/web-socket.h

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
#include <workerd/io/observer.h>
1212
#include <workerd/jsg/jsg.h>
1313
#include <workerd/util/checked-queue.h>
14+
#include <workerd/util/strong-bool.h>
1415
#include <workerd/util/weak-refs.h>
1516

1617
#include <kj/compat/http.h>
@@ -88,6 +89,8 @@ class CloseEvent: public Event {
8889
bool clean;
8990
};
9091

92+
WD_STRONG_BOOL(AllowHalfOpen);
93+
9194
// The forward declaration is necessary so we can make some
9295
// WebSocket methods accessible to WebSocketPair via friend declaration.
9396
class WebSocket;
@@ -207,6 +210,9 @@ class WebSocket: public EventTarget {
207210

208211
// True forever once the JS WebSocket calls `close()`.
209212
bool closedOutgoingConnection = false;
213+
214+
// Whether the WebSocket allows half-open close state.
215+
AllowHalfOpen allowHalfOpen = AllowHalfOpen::YES;
210216
};
211217

212218
~WebSocket() noexcept(false) {
@@ -287,13 +293,19 @@ class WebSocket: public EventTarget {
287293
// ---------------------------------------------------------------------------
288294
// JS API.
289295

296+
struct AcceptOptions {
297+
jsg::Optional<bool> allowHalfOpen;
298+
299+
JSG_STRUCT(allowHalfOpen);
300+
};
301+
290302
// Creates a new outbound WebSocket.
291303
static jsg::Ref<WebSocket> constructor(jsg::Lock& js,
292304
kj::String url,
293305
jsg::Optional<kj::OneOf<kj::Array<kj::String>, kj::String>> protocols);
294306

295307
// Begin delivering events locally.
296-
void accept(jsg::Lock& js);
308+
void accept(jsg::Lock& js, jsg::Optional<AcceptOptions> options);
297309

298310
// Same as accept(), but websockets that are created with `new WebSocket()` in JS cannot call
299311
// accept(). Instead, we only permit the C++ constructor to call this "internal" version of accept()
@@ -387,9 +399,20 @@ class WebSocket: public EventTarget {
387399
open: Event;
388400
error: ErrorEvent;
389401
});
402+
JSG_TS_DEFINE(interface WebSocketAcceptOptions {
403+
/**
404+
* When set to `true`, receiving a server-initiated WebSocket Close frame will not
405+
* automatically send a reciprocal Close frame, leaving the connection in a half-open
406+
* state. Defaults to `false` when the `no_web_socket_half_open_by_default`
407+
* compatibility flag is enabled.
408+
*/
409+
allowHalfOpen?: boolean;
410+
});
390411
JSG_TS_OVERRIDE(extends EventTarget<WebSocketEventMap> {
391412
get binaryType(): "blob" | "arraybuffer";
392413
set binaryType(value: "blob" | "arraybuffer");
414+
accept(options?: WebSocketAcceptOptions): void;
415+
constructor(url: string, protocols?: string[] | string);
393416
});
394417
}
395418

@@ -421,6 +444,14 @@ class WebSocket: public EventTarget {
421444
// `close()`, thereby preventing calls to `send()` even after we wake from hibernation.
422445
bool closedOutgoingForHib = false;
423446

447+
// When YES, a server-initiated close does NOT automatically send a reciprocal close frame,
448+
// leaving readyState as CLOSING (2) when the close event fires. The application is then
449+
// responsible for calling close() explicitly. When NO (spec-compliant default with the
450+
// no_web_socket_half_open_by_default compat flag), a close reply is sent automatically and
451+
// readyState is CLOSED (3) when the close event fires.
452+
// Default is YES (legacy behavior); overridden from the compat flag at construction time.
453+
AllowHalfOpen allowHalfOpen = AllowHalfOpen::YES;
454+
424455
// Maximum allowed size for WebSocket messages
425456
inline static const size_t SUGGESTED_MAX_MESSAGE_SIZE = 1u << 20;
426457

@@ -615,6 +646,11 @@ class WebSocket: public EventTarget {
615646

616647
void ensurePumping(jsg::Lock& js);
617648

649+
// Returns the number of pending auto-responses that should be sent before the next outgoing
650+
// message, and advances the queuedAutoResponses counter. Called each time a GatedMessage is
651+
// inserted into outgoingMessages to guarantee auto-response ordering.
652+
size_t getPendingAutoResponseCount();
653+
618654
// Write messages from `outgoingMessages` into `ws`.
619655
//
620656
// These are not necessarily called under isolate lock, but they are called on the given
@@ -639,8 +675,8 @@ class WebSocket: public EventTarget {
639675
};
640676

641677
#define EW_WEBSOCKET_ISOLATE_TYPES \
642-
api::CloseEvent, api::CloseEvent::Initializer, api::WebSocket, api::WebSocketPair, \
643-
api::WebSocketPair::PairIterator, \
678+
api::CloseEvent, api::CloseEvent::Initializer, api::WebSocket, api::WebSocket::AcceptOptions, \
679+
api::WebSocketPair, api::WebSocketPair::PairIterator, \
644680
api::WebSocketPair::PairIterator:: \
645681
Next // The list of websocket.h types that are added to worker.c++'s JSG_DECLARE_ISOLATE_TYPE
646682

src/workerd/io/compatibility-date.capnp

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1429,7 +1429,7 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef {
14291429
websocketBinaryTypeDefault @166 :Bool
14301430
$compatEnableFlag("websocket_standard_binary_type")
14311431
$compatDisableFlag("no_websocket_standard_binary_type")
1432-
$compatEnableDate("2026-03-17");
1432+
$compatEnableDate("2026-03-31");
14331433
# Per the WHATWG WebSocket spec, the binaryType attribute defaults to "blob"
14341434
# and binary messages are delivered as Blob objects. Previously, workerd did
14351435
# not expose the binaryType property at all and always delivered binary
@@ -1472,4 +1472,14 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef {
14721472
# - Getter .length is explicitly 0 and setter .length is 1.
14731473
# - Getter .name is "get <name>" and setter .name is "set <name>" per the
14741474
# Web IDL spec, instead of empty strings.
1475+
1476+
webSocketCloseReadyStateClosed @170 :Bool
1477+
$compatEnableFlag("no_web_socket_half_open_by_default")
1478+
$compatDisableFlag("web_socket_half_open_by_default")
1479+
$compatEnableDate("2026-03-17");
1480+
# When enabled, a reciprocal Close frame is automatically sent through the
1481+
# outgoing message pump when a server-initiated close is received, and the
1482+
# WebSocket readyState is CLOSED (3) when the close event fires. Previously,
1483+
# no close reply was sent and readyState was CLOSING (2). The WebSocket spec
1484+
# requires readyState to be CLOSED when the close event is dispatched.
14751485
}

0 commit comments

Comments
 (0)