WebSocketStream: Use one WebSocketError object to error everything

Error readable, writable and wss.closed with the same WebSocketError
object.

Also add wpts for this and other close error scenarios.

Bug: 41470216
Change-Id: I0c68390ba4e7bef9db6a19516ac4afc77ed290e2
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/5321157
Reviewed-by: Nidhi Jaju <nidhijaju@chromium.org>
Commit-Queue: Adam Rice <ricea@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1266302}
diff --git a/websockets/handlers/passive-close-abort_wsh.py b/websockets/handlers/passive-close-abort_wsh.py
new file mode 100644
index 0000000..ac3f67c
--- /dev/null
+++ b/websockets/handlers/passive-close-abort_wsh.py
@@ -0,0 +1,24 @@
+# Copyright (c) 2024 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""
+Wait for a Close frame from the client and then close the connection without
+sending a Close frame in return.
+"""
+
+from mod_pywebsocket.handshake import AbortedByUserException
+
+
+def web_socket_do_extra_handshake(request):
+    pass
+
+
+def web_socket_transfer_data(request):
+    while True:
+        if request.ws_stream.receive_message() is None:
+            return
+
+
+def web_socket_passive_closing_handshake(request):
+    raise AbortedByUserException('abrupt close')
diff --git a/websockets/handlers/remote-close_wsh.py b/websockets/handlers/remote-close_wsh.py
new file mode 100644
index 0000000..aadd99e
--- /dev/null
+++ b/websockets/handlers/remote-close_wsh.py
@@ -0,0 +1,44 @@
+# Copyright (c) 2024 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""
+Perform a server-initiated close according to the parameters passed in the
+query string. Supported parameters:
+
+ * code=INT: The close code to send in the close frame. If omitted the Close
+   frame will have an empty body.
+
+ * reason=TEXT: The reason to be sent in the close frame. Only sent if `code` is
+   set.
+
+ * abrupt=1: Close the connection without sending a Close frame.
+
+Example: /remote-close?code=1000&reason=Done
+
+"""
+
+import urllib
+
+from mod_pywebsocket.handshake import AbortedByUserException
+
+
+def web_socket_do_extra_handshake(request):
+    pass
+
+
+def web_socket_transfer_data(request):
+    parts = urllib.parse.urlsplit(request.uri)
+    parameters = urllib.parse.parse_qs(parts.query)
+    if 'abrupt' in parameters:
+        # Send a ping frame to make sure this isn't misinterpreted as a
+        # handshake failure.
+        request.ws_stream.send_ping('ping')
+        # Rudely close the connection.
+        raise AbortedByUserException('Abort the connection')
+    code = None
+    reason = None
+    if 'code' in parameters:
+        code = int(parameters['code'][0])
+        reason = parameters.get('reason', [''])[0]
+    request.ws_stream.close_connection(code, reason)
diff --git a/websockets/stream/tentative/close.any.js b/websockets/stream/tentative/close.any.js
index 098caf3..ad41dc6 100644
--- a/websockets/stream/tentative/close.any.js
+++ b/websockets/stream/tentative/close.any.js
@@ -1,6 +1,7 @@
 // META: script=../../constants.sub.js
 // META: script=resources/url-constants.js
 // META: global=window,worker
+// META: variant=?default
 // META: variant=?wss
 // META: variant=?wpt_flags=h2
 
@@ -108,6 +109,15 @@
                             'one second should have elapsed');
 }, 'writer close() promise should not resolve until handshake completes');
 
+promise_test(async t => {
+  const wss = new WebSocketStream(`${BASEURL}/passive-close-abort`);
+  await wss.opened;
+  wss.close({closeCode: 4000, reason: 'because'});
+  const error = await wss.closed.then(t.unreached_func('closed should reject'), e => e);
+  assert_equals(error.constructor, WebSocketError, 'error should be WebSocketError');
+  assert_equals(error.closeCode, 1006, 'close code should be Abnormal Closure');
+}, 'incomplete closing handshake should be considered unclean close');
+
 const abortOrCancel = [
   {
     method: 'abort',
diff --git a/websockets/stream/tentative/remote-close.any.js b/websockets/stream/tentative/remote-close.any.js
new file mode 100644
index 0000000..b7fd321
--- /dev/null
+++ b/websockets/stream/tentative/remote-close.any.js
@@ -0,0 +1,74 @@
+// META: script=../../constants.sub.js
+// META: script=resources/url-constants.js
+// META: global=window,worker
+// META: variant=?default
+// META: variant=?wss
+// META: variant=?wpt_flags=h2
+
+'use strict';
+
+promise_test(async t => {
+  const wss = new WebSocketStream(`${BASEURL}/remote-close?code=1000`);
+  const { readable, writable } = await wss.opened;
+  const { closeCode, reason } = await wss.closed;
+  assert_equals(closeCode, 1000, 'code should be 1000');
+  assert_equals(reason, '', 'reason should be empty');
+  const { value, done } = await readable.getReader().read();
+  assert_true(done, 'readable should be closed');
+  await promise_rejects_dom(t, 'InvalidStateError', writable.getWriter().ready,
+                            'writable should be errored');
+}, 'clean close should be clean');
+
+promise_test(async () => {
+  const wss = new WebSocketStream(`${BASEURL}/remote-close`);
+  const { closeCode, reason } = await wss.closed;
+  assert_equals(closeCode, 1005, 'code should be No Status Rcvd');
+  assert_equals(reason, '', 'reason should be empty');
+}, 'close frame with no body should result in status code 1005');
+
+promise_test(async () => {
+  const wss = new WebSocketStream(`${BASEURL}/remote-close?code=4000&reason=robot`);
+  const { closeCode, reason } = await wss.closed;
+  assert_equals(closeCode, 4000, 'code should be 4000');
+  assert_equals(reason, 'robot', 'reason should be set');
+}, 'reason should be passed through');
+
+promise_test(async () => {
+  const wss = new WebSocketStream(`${BASEURL}/remote-close?code=4000&` +
+                                  'reason=%E3%83%AD%E3%83%9C%E3%83%83%E3%83%88');
+  const { reason } = await wss.closed;
+  assert_equals(reason, 'ロボット', 'reason should be set');
+}, 'UTF-8 reason should work');
+
+promise_test(async t => {
+  const wss = new WebSocketStream(`${BASEURL}/remote-close?code=4567`);
+  const { writable } = await wss.opened;
+  const veryLargeMessage = new Uint8Array(20 * 1024 * 1024);  // 20MB.
+  const writePromise = writable.getWriter().write(veryLargeMessage);
+  const closedError = await wss.closed.then(t.unreached_func('closed should reject'), e => e);
+  assert_equals(closedError.constructor, WebSocketError, 'error should be WebSocketError');
+  assert_equals(closedError.closeCode, 4567, 'closeCode should be set');
+  promise_rejects_js(t, WebSocketError, writePromise, 'write() should reject');
+}, 'close with unwritten data should not be considered clean');
+
+promise_test(async t => {
+  const wss = new WebSocketStream(`${BASEURL}/remote-close?code=4222&reason=remote`);
+  await wss.opened;
+  wss.close({closeCode: 4111, reason: 'local'});
+  const { closeCode, reason } = await wss.closed;
+  assert_equals(closeCode, 4222, 'remote code should be used');
+  assert_equals(reason, 'remote', 'remote reason should be used');
+}, 'remote code and reason should be used');
+
+promise_test(async t => {
+  const wss = new WebSocketStream(`${BASEURL}/remote-close?abrupt=1`);
+  const { readable, writable } = await wss.opened;
+  const closedError = await wss.closed.then(t.unreached_func('closed should reject'), e => e);
+  assert_equals(closedError.constructor, WebSocketError, 'error should be a WebSocketError');
+  assert_equals(closedError.name, 'WebSocketError', 'error name should be WebSocketError');
+  assert_equals(closedError.closeCode, 1006, 'code should be Abnormal Closure');
+  await promise_rejects_exactly(t, closedError, readable.getReader().read(),
+                                'readable should be errored with the same object');
+  await promise_rejects_exactly(t, closedError, writable.getWriter().ready,
+                                'writable should be errored with the same object');
+}, 'abrupt close should give an error');