From b962e074639af7de5fc6283d8bf6e0dd54648de9 Mon Sep 17 00:00:00 2001 From: Pavel Rappo Date: Mon, 9 May 2016 23:33:09 +0100 Subject: [PATCH] 8087113: Websocket API and implementation Reviewed-by: chegar --- .../classes/java/net/http/CharsetToolkit.java | 159 -- .../classes/java/net/http/RawChannel.java | 15 +- .../share/classes/java/net/http/WS.java | 390 +++++ .../classes/java/net/http/WSBuilder.java | 175 +++ .../java/net/http/WSCharsetToolkit.java | 126 ++ .../classes/java/net/http/WSDisposable.java | 30 + .../java/net/http/WSDisposableText.java | 67 + .../share/classes/java/net/http/WSFrame.java | 486 +++++++ .../java/net/http/WSFrameConsumer.java | 289 ++++ .../java/net/http/WSMessageConsumer.java | 42 + .../java/net/http/WSMessageSender.java | 189 +++ .../java/net/http/WSOpeningHandshake.java | 268 ++++ .../java/net/http/WSOutgoingMessage.java | 164 +++ .../java/net/http/WSProtocolException.java | 68 + .../classes/java/net/http/WSReceiver.java | 275 ++++ .../share/classes/java/net/http/WSShared.java | 202 +++ .../classes/java/net/http/WSSharedPool.java | 148 ++ .../java/net/http/WSSignalHandler.java | 137 ++ .../classes/java/net/http/WSTransmitter.java | 176 +++ .../share/classes/java/net/http/WSUtils.java | 75 + .../share/classes/java/net/http/WSWriter.java | 134 ++ .../classes/java/net/http/WebSocket.java | 1288 +++++++++++++++++ .../net/http/WebSocketHandshakeException.java | 66 + .../classes/java/net/http/package-info.java | 1 + .../net/httpclient/BasicWebSocketAPITest.java | 332 +++++ .../java/net/httpclient/HandshakePhase.java | 265 ++++ 26 files changed, 5402 insertions(+), 165 deletions(-) delete mode 100644 jdk/src/java.httpclient/share/classes/java/net/http/CharsetToolkit.java create mode 100644 jdk/src/java.httpclient/share/classes/java/net/http/WS.java create mode 100644 jdk/src/java.httpclient/share/classes/java/net/http/WSBuilder.java create mode 100644 jdk/src/java.httpclient/share/classes/java/net/http/WSCharsetToolkit.java create mode 100644 jdk/src/java.httpclient/share/classes/java/net/http/WSDisposable.java create mode 100644 jdk/src/java.httpclient/share/classes/java/net/http/WSDisposableText.java create mode 100644 jdk/src/java.httpclient/share/classes/java/net/http/WSFrame.java create mode 100644 jdk/src/java.httpclient/share/classes/java/net/http/WSFrameConsumer.java create mode 100644 jdk/src/java.httpclient/share/classes/java/net/http/WSMessageConsumer.java create mode 100644 jdk/src/java.httpclient/share/classes/java/net/http/WSMessageSender.java create mode 100644 jdk/src/java.httpclient/share/classes/java/net/http/WSOpeningHandshake.java create mode 100644 jdk/src/java.httpclient/share/classes/java/net/http/WSOutgoingMessage.java create mode 100644 jdk/src/java.httpclient/share/classes/java/net/http/WSProtocolException.java create mode 100644 jdk/src/java.httpclient/share/classes/java/net/http/WSReceiver.java create mode 100644 jdk/src/java.httpclient/share/classes/java/net/http/WSShared.java create mode 100644 jdk/src/java.httpclient/share/classes/java/net/http/WSSharedPool.java create mode 100644 jdk/src/java.httpclient/share/classes/java/net/http/WSSignalHandler.java create mode 100644 jdk/src/java.httpclient/share/classes/java/net/http/WSTransmitter.java create mode 100644 jdk/src/java.httpclient/share/classes/java/net/http/WSUtils.java create mode 100644 jdk/src/java.httpclient/share/classes/java/net/http/WSWriter.java create mode 100644 jdk/src/java.httpclient/share/classes/java/net/http/WebSocket.java create mode 100644 jdk/src/java.httpclient/share/classes/java/net/http/WebSocketHandshakeException.java create mode 100644 jdk/test/java/net/httpclient/BasicWebSocketAPITest.java create mode 100644 jdk/test/java/net/httpclient/HandshakePhase.java diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/CharsetToolkit.java b/jdk/src/java.httpclient/share/classes/java/net/http/CharsetToolkit.java deleted file mode 100644 index 1264fda0a7b..00000000000 --- a/jdk/src/java.httpclient/share/classes/java/net/http/CharsetToolkit.java +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Copyright (c) 2016, Oracle and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Oracle designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA - * or visit www.oracle.com if you need additional information or have any - */ -package java.net.http; - -import java.nio.ByteBuffer; -import java.nio.CharBuffer; -import java.nio.charset.CharacterCodingException; -import java.nio.charset.CharsetDecoder; -import java.nio.charset.CharsetEncoder; -import java.nio.charset.CoderResult; - -import static java.nio.charset.StandardCharsets.UTF_8; - -// The purpose of this class is to separate charset-related tasks from the main -// WebSocket logic, simplifying where possible. -// -// * Coders hide the differences between coding and flushing stages on the -// API level -// * Verifier abstracts the way the verification is performed -// (spoiler: it's a decoding into a throw-away buffer) -// -// Coding methods throw exceptions instead of returning coding result denoting -// errors, since any kind of handling and recovery is not expected. -final class CharsetToolkit { - - private CharsetToolkit() { } - - static final class Verifier { - - private final CharsetDecoder decoder = UTF_8.newDecoder(); - // A buffer used to check validity of UTF-8 byte stream by decoding it. - // The contents of this buffer are never used. - // The size is arbitrary, though it should probably be chosen from the - // performance perspective since it affects the total number of calls to - // decoder.decode() and amount of work in each of these calls - private final CharBuffer blackHole = CharBuffer.allocate(1024); - - void verify(ByteBuffer in, boolean endOfInput) - throws CharacterCodingException { - while (true) { - // Since decoder.flush() cannot produce an error, it's not - // helpful for verification. Therefore this step is skipped. - CoderResult r = decoder.decode(in, blackHole, endOfInput); - if (r.isOverflow()) { - blackHole.clear(); - } else if (r.isUnderflow()) { - break; - } else if (r.isError()) { - r.throwException(); - } else { - // Should not happen - throw new InternalError(); - } - } - } - - Verifier reset() { - decoder.reset(); - return this; - } - } - - static final class Encoder { - - private final CharsetEncoder encoder = UTF_8.newEncoder(); - private boolean coding = true; - - CoderResult encode(CharBuffer in, ByteBuffer out, boolean endOfInput) - throws CharacterCodingException { - - if (coding) { - CoderResult r = encoder.encode(in, out, endOfInput); - if (r.isOverflow()) { - return r; - } else if (r.isUnderflow()) { - if (endOfInput) { - coding = false; - } else { - return r; - } - } else if (r.isError()) { - r.throwException(); - } else { - // Should not happen - throw new InternalError(); - } - } - assert !coding; - return encoder.flush(out); - } - - Encoder reset() { - coding = true; - encoder.reset(); - return this; - } - } - - static CharBuffer decode(ByteBuffer in) throws CharacterCodingException { - return UTF_8.newDecoder().decode(in); - } - - static final class Decoder { - - private final CharsetDecoder decoder = UTF_8.newDecoder(); - private boolean coding = true; // Either coding or flushing - - CoderResult decode(ByteBuffer in, CharBuffer out, boolean endOfInput) - throws CharacterCodingException { - - if (coding) { - CoderResult r = decoder.decode(in, out, endOfInput); - if (r.isOverflow()) { - return r; - } else if (r.isUnderflow()) { - if (endOfInput) { - coding = false; - } else { - return r; - } - } else if (r.isError()) { - r.throwException(); - } else { - // Should not happen - throw new InternalError(); - } - } - assert !coding; - return decoder.flush(out); - } - - Decoder reset() { - coding = true; - decoder.reset(); - return this; - } - } -} diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/RawChannel.java b/jdk/src/java.httpclient/share/classes/java/net/http/RawChannel.java index 536eda47afd..e4dc3b00b8d 100644 --- a/jdk/src/java.httpclient/share/classes/java/net/http/RawChannel.java +++ b/jdk/src/java.httpclient/share/classes/java/net/http/RawChannel.java @@ -39,18 +39,22 @@ final class RawChannel implements ByteChannel, GatheringByteChannel { private final HttpClientImpl client; private final HttpConnection connection; - private volatile boolean closed; private interface RawEvent { - /** must return the selector interest op flags OR'd. */ + /** + * must return the selector interest op flags OR'd. + */ int interestOps(); - /** called when event occurs. */ + /** + * called when event occurs. + */ void handle(); } - interface NonBlockingEvent extends RawEvent { } + interface NonBlockingEvent extends RawEvent { + } RawChannel(HttpClientImpl client, HttpConnection connection) { this.client = client; @@ -127,12 +131,11 @@ final class RawChannel implements ByteChannel, GatheringByteChannel { @Override public boolean isOpen() { - return !closed; + return connection.isOpen(); } @Override public void close() throws IOException { - closed = true; connection.close(); } diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/WS.java b/jdk/src/java.httpclient/share/classes/java/net/http/WS.java new file mode 100644 index 00000000000..d970b5ecd2f --- /dev/null +++ b/jdk/src/java.httpclient/share/classes/java/net/http/WS.java @@ -0,0 +1,390 @@ +/* + * Copyright (c) 2015, 2016, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package java.net.http; + +import java.io.IOException; +import java.net.ProtocolException; +import java.net.http.WSOpeningHandshake.Result; +import java.nio.ByteBuffer; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Executor; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import static java.lang.System.Logger.Level.ERROR; +import static java.lang.System.Logger.Level.WARNING; +import static java.net.http.WSUtils.logger; +import static java.util.Objects.requireNonNull; + +/* + * A WebSocket client. + * + * Consists of two independent parts; a transmitter responsible for sending + * messages, and a receiver which notifies the listener of incoming messages. + */ +final class WS implements WebSocket { + + private final String subprotocol; + private final RawChannel channel; + private final WSTransmitter transmitter; + private final WSReceiver receiver; + private final Listener listener; + private final Object stateLock = new Object(); + private volatile State state = State.CONNECTED; + private final CompletableFuture whenClosed = new CompletableFuture<>(); + + static CompletableFuture newInstanceAsync(WSBuilder b) { + CompletableFuture result = new WSOpeningHandshake(b).performAsync(); + Listener listener = b.getListener(); + Executor executor = b.getClient().executorService(); + return result.thenApply(r -> { + WS ws = new WS(listener, r.subprotocol, r.channel, executor); + ws.start(); + return ws; + }); + } + + private WS(Listener listener, String subprotocol, RawChannel channel, + Executor executor) { + this.listener = wrapListener(listener); + this.channel = channel; + this.subprotocol = subprotocol; + Consumer errorHandler = error -> { + if (error == null) { + throw new InternalError(); + } + // If the channel is closed, we need to update the state, to denote + // there's no point in trying to continue using WebSocket + if (!channel.isOpen()) { + synchronized (stateLock) { + tryChangeState(State.ERROR); + } + } + }; + transmitter = new WSTransmitter(executor, channel, errorHandler); + receiver = new WSReceiver(this.listener, this, executor, channel); + } + + private void start() { + receiver.start(); + } + + @Override + public CompletableFuture sendText(ByteBuffer message, boolean isLast) { + throw new UnsupportedOperationException("Not implemented"); + } + + @Override + public CompletableFuture sendText(CharSequence message, boolean isLast) { + requireNonNull(message, "message"); + synchronized (stateLock) { + checkState(); + return transmitter.sendText(message, isLast); + } + } + + @Override + public CompletableFuture sendText(Stream message) { + requireNonNull(message, "message"); + synchronized (stateLock) { + checkState(); + return transmitter.sendText(message); + } + } + + @Override + public CompletableFuture sendBinary(ByteBuffer message, boolean isLast) { + requireNonNull(message, "message"); + synchronized (stateLock) { + checkState(); + return transmitter.sendBinary(message, isLast); + } + } + + @Override + public CompletableFuture sendPing(ByteBuffer message) { + requireNonNull(message, "message"); + synchronized (stateLock) { + checkState(); + return transmitter.sendPing(message); + } + } + + @Override + public CompletableFuture sendPong(ByteBuffer message) { + requireNonNull(message, "message"); + synchronized (stateLock) { + checkState(); + return transmitter.sendPong(message); + } + } + + @Override + public CompletableFuture sendClose(CloseCode code, CharSequence reason) { + requireNonNull(code, "code"); + requireNonNull(reason, "reason"); + synchronized (stateLock) { + return doSendClose(() -> transmitter.sendClose(code, reason)); + } + } + + @Override + public CompletableFuture sendClose() { + synchronized (stateLock) { + return doSendClose(() -> transmitter.sendClose()); + } + } + + private CompletableFuture doSendClose(Supplier> s) { + checkState(); + boolean closeChannel = false; + synchronized (stateLock) { + if (state == State.CLOSED_REMOTELY) { + closeChannel = tryChangeState(State.CLOSED); + } else { + tryChangeState(State.CLOSED_LOCALLY); + } + } + CompletableFuture sent = s.get(); + if (closeChannel) { + sent.whenComplete((v, t) -> { + try { + channel.close(); + } catch (IOException e) { + logger.log(ERROR, "Error transitioning to state " + State.CLOSED, e); + } + }); + } + return sent; + } + + @Override + public long request(long n) { + if (n < 0L) { + throw new IllegalArgumentException("The number must not be negative: " + n); + } + return receiver.request(n); + } + + @Override + public String getSubprotocol() { + return subprotocol; + } + + @Override + public boolean isClosed() { + return state.isTerminal(); + } + + @Override + public void abort() throws IOException { + synchronized (stateLock) { + tryChangeState(State.ABORTED); + } + channel.close(); + } + + @Override + public String toString() { + return super.toString() + "[" + state + "]"; + } + + private void checkState() { + if (state.isTerminal() || state == State.CLOSED_LOCALLY) { + throw new IllegalStateException("WebSocket is closed [" + state + "]"); + } + } + + /* + * Wraps the user's listener passed to the constructor into own listener to + * intercept transitions to terminal states (onClose and onError) and to act + * upon exceptions and values from the user's listener. + */ + private Listener wrapListener(Listener listener) { + return new Listener() { + + // Listener's method MUST be invoked in a happen-before order + private final Object visibilityLock = new Object(); + + @Override + public void onOpen(WebSocket webSocket) { + synchronized (visibilityLock) { + listener.onOpen(webSocket); + } + } + + @Override + public CompletionStage onText(WebSocket webSocket, Text message, + MessagePart part) { + synchronized (visibilityLock) { + return listener.onText(webSocket, message, part); + } + } + + @Override + public CompletionStage onBinary(WebSocket webSocket, ByteBuffer message, + MessagePart part) { + synchronized (visibilityLock) { + return listener.onBinary(webSocket, message, part); + } + } + + @Override + public CompletionStage onPing(WebSocket webSocket, ByteBuffer message) { + synchronized (visibilityLock) { + return listener.onPing(webSocket, message); + } + } + + @Override + public CompletionStage onPong(WebSocket webSocket, ByteBuffer message) { + synchronized (visibilityLock) { + return listener.onPong(webSocket, message); + } + } + + @Override + public void onClose(WebSocket webSocket, Optional code, String reason) { + synchronized (stateLock) { + if (state == State.CLOSED_REMOTELY || state.isTerminal()) { + throw new InternalError("Unexpected onClose in state " + state); + } else if (state == State.CLOSED_LOCALLY) { + try { + channel.close(); + } catch (IOException e) { + logger.log(ERROR, "Error transitioning to state " + State.CLOSED, e); + } + tryChangeState(State.CLOSED); + } else if (state == State.CONNECTED) { + tryChangeState(State.CLOSED_REMOTELY); + } + } + synchronized (visibilityLock) { + listener.onClose(webSocket, code, reason); + } + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + // An error doesn't necessarily mean the connection must be + // closed automatically + if (!channel.isOpen()) { + synchronized (stateLock) { + tryChangeState(State.ERROR); + } + } else if (error instanceof ProtocolException + && error.getCause() instanceof WSProtocolException) { + WSProtocolException cause = (WSProtocolException) error.getCause(); + logger.log(WARNING, "Failing connection {0}, reason: ''{1}''", + webSocket, cause.getMessage()); + CloseCode cc = cause.getCloseCode(); + transmitter.sendClose(cc, "").whenComplete((v, t) -> { + synchronized (stateLock) { + tryChangeState(State.ERROR); + } + try { + channel.close(); + } catch (IOException e) { + logger.log(ERROR, e); + } + }); + } + synchronized (visibilityLock) { + listener.onError(webSocket, error); + } + } + }; + } + + private boolean tryChangeState(State newState) { + assert Thread.holdsLock(stateLock); + if (state.isTerminal()) { + return false; + } + state = newState; + if (newState.isTerminal()) { + whenClosed.complete(null); + } + return true; + } + + CompletionStage whenClosed() { + return whenClosed; + } + + /* + * WebSocket connection internal state. + */ + private enum State { + + /* + * Initial WebSocket state. The WebSocket is connected (i.e. remains in + * this state) unless proven otherwise. For example, by reading or + * writing operations on the channel. + */ + CONNECTED, + + /* + * A Close message has been received by the client. No more messages + * will be received. + */ + CLOSED_REMOTELY, + + /* + * A Close message has been sent by the client. No more messages can be + * sent. + */ + CLOSED_LOCALLY, + + /* + * Close messages has been both sent and received (closing handshake) + * and TCP connection closed. Closed _cleanly_ in terms of RFC 6455. + */ + CLOSED, + + /* + * The connection has been aborted by the client. Closed not _cleanly_ + * in terms of RFC 6455. + */ + ABORTED, + + /* + * The connection has been terminated due to a protocol or I/O error. + * Might happen during sending or receiving. + */ + ERROR; + + /* + * Returns `true` if this state is terminal. If WebSocket has transited + * to such a state, if remains in it forever. + */ + boolean isTerminal() { + return this == CLOSED || this == ABORTED || this == ERROR; + } + } +} diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/WSBuilder.java b/jdk/src/java.httpclient/share/classes/java/net/http/WSBuilder.java new file mode 100644 index 00000000000..90d2d79162a --- /dev/null +++ b/jdk/src/java.httpclient/share/classes/java/net/http/WSBuilder.java @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2015, 2016, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package java.net.http; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import static java.lang.String.format; +import static java.util.Objects.requireNonNull; + +final class WSBuilder implements WebSocket.Builder { + + private static final Set FORBIDDEN_HEADERS = + new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + + static { + List headers = List.of("Connection", "Upgrade", + "Sec-WebSocket-Accept", "Sec-WebSocket-Extensions", + "Sec-WebSocket-Key", "Sec-WebSocket-Protocol", + "Sec-WebSocket-Version"); + FORBIDDEN_HEADERS.addAll(headers); + } + + private final URI uri; + private final HttpClient client; + private final LinkedHashMap> headers = new LinkedHashMap<>(); + private final WebSocket.Listener listener; + private Collection subprotocols = Collections.emptyList(); + private long timeout; + private TimeUnit timeUnit; + + WSBuilder(URI uri, HttpClient client, WebSocket.Listener listener) { + checkURI(requireNonNull(uri, "uri")); + requireNonNull(client, "client"); + requireNonNull(listener, "listener"); + this.uri = uri; + this.listener = listener; + this.client = client; + } + + @Override + public WebSocket.Builder header(String name, String value) { + requireNonNull(name, "name"); + requireNonNull(value, "value"); + if (FORBIDDEN_HEADERS.contains(name)) { + throw new IllegalArgumentException( + format("Header '%s' is used in the WebSocket Protocol", name)); + } + List values = headers.computeIfAbsent(name, n -> new LinkedList<>()); + values.add(value); + return this; + } + + @Override + public WebSocket.Builder subprotocols(String mostPreferred, String... lesserPreferred) { + requireNonNull(mostPreferred, "mostPreferred"); + requireNonNull(lesserPreferred, "lesserPreferred"); + this.subprotocols = checkSubprotocols(mostPreferred, lesserPreferred); + return this; + } + + @Override + public WebSocket.Builder connectTimeout(long timeout, TimeUnit unit) { + if (timeout < 0) { + throw new IllegalArgumentException("Negative timeout: " + timeout); + } + requireNonNull(unit, "unit"); + this.timeout = timeout; + this.timeUnit = unit; + return this; + } + + @Override + public CompletableFuture buildAsync() { + return WS.newInstanceAsync(this); + } + + private static URI checkURI(URI uri) { + String s = uri.getScheme(); + if (!("ws".equalsIgnoreCase(s) || "wss".equalsIgnoreCase(s))) { + throw new IllegalArgumentException + ("URI scheme not ws or wss (RFC 6455 3.): " + s); + } + String fragment = uri.getFragment(); + if (fragment != null) { + throw new IllegalArgumentException(format + ("Fragment not allowed in a WebSocket URI (RFC 6455 3.): '%s'", + fragment)); + } + return uri; + } + + URI getUri() { return uri; } + + HttpClient getClient() { return client; } + + Map> getHeaders() { + LinkedHashMap> copy = new LinkedHashMap<>(headers.size()); + headers.forEach((name, values) -> copy.put(name, new LinkedList<>(values))); + return copy; + } + + WebSocket.Listener getListener() { return listener; } + + Collection getSubprotocols() { + return new ArrayList<>(subprotocols); + } + + long getTimeout() { return timeout; } + + TimeUnit getTimeUnit() { return timeUnit; } + + private static Collection checkSubprotocols(String mostPreferred, + String... lesserPreferred) { + checkSubprotocolSyntax(mostPreferred, "mostPreferred"); + LinkedHashSet sp = new LinkedHashSet<>(1 + lesserPreferred.length); + sp.add(mostPreferred); + for (int i = 0; i < lesserPreferred.length; i++) { + String p = lesserPreferred[i]; + String location = format("lesserPreferred[%s]", i); + requireNonNull(p, location); + checkSubprotocolSyntax(p, location); + if (!sp.add(p)) { + throw new IllegalArgumentException(format( + "Duplicate subprotocols (RFC 6455 4.1.): '%s'", p)); + } + } + return sp; + } + + private static void checkSubprotocolSyntax(String subprotocol, String location) { + if (subprotocol.isEmpty()) { + throw new IllegalArgumentException + ("Subprotocol name is empty (RFC 6455 4.1.): " + location); + } + if (!subprotocol.chars().allMatch(c -> 0x21 <= c && c <= 0x7e)) { + throw new IllegalArgumentException + ("Subprotocol name contains illegal characters (RFC 6455 4.1.): " + + location); + } + } +} diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/WSCharsetToolkit.java b/jdk/src/java.httpclient/share/classes/java/net/http/WSCharsetToolkit.java new file mode 100644 index 00000000000..5883ea6265e --- /dev/null +++ b/jdk/src/java.httpclient/share/classes/java/net/http/WSCharsetToolkit.java @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2016, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + */ +package java.net.http; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CharsetEncoder; +import java.nio.charset.CoderResult; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; + +import static java.lang.System.Logger.Level.WARNING; +import static java.net.http.WSUtils.EMPTY_BYTE_BUFFER; +import static java.net.http.WSUtils.logger; +import static java.nio.charset.StandardCharsets.UTF_8; + +/* + * A collection of tools for UTF-8 coding. + */ +final class WSCharsetToolkit { + + private WSCharsetToolkit() { } + + static final class Encoder { + + private final CharsetEncoder encoder = UTF_8.newEncoder(); + + ByteBuffer encode(CharBuffer in) throws CharacterCodingException { + return encoder.encode(in); + } + + // TODO: + // ByteBuffer[] encode(CharBuffer in) throws CharacterCodingException { + // return encoder.encode(in); + // } + } + + static CharBuffer decode(ByteBuffer in) throws CharacterCodingException { + return UTF_8.newDecoder().decode(in); + } + + static final class Decoder { + + private final CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder(); + + { + decoder.onMalformedInput(CodingErrorAction.REPORT); + decoder.onUnmappableCharacter(CodingErrorAction.REPORT); + } + + private ByteBuffer leftovers = EMPTY_BYTE_BUFFER; + + WSShared decode(WSShared in, boolean endOfInput) + throws CharacterCodingException { + ByteBuffer b; + int rem = leftovers.remaining(); + if (rem != 0) { + // TODO: We won't need this wasteful allocation & copying when + // JDK-8155222 has been resolved + b = ByteBuffer.allocate(rem + in.remaining()); + b.put(leftovers).put(in.buffer()).flip(); + } else { + b = in.buffer(); + } + CharBuffer out = CharBuffer.allocate(b.remaining()); + CoderResult r = decoder.decode(b, out, endOfInput); + if (r.isError()) { + r.throwException(); + } + if (b.hasRemaining()) { + leftovers = ByteBuffer.allocate(b.remaining()).put(b).flip(); + } else { + leftovers = EMPTY_BYTE_BUFFER; + } + // Since it's UTF-8, the assumption is leftovers.remaining() < 4 + // (i.e. small). Otherwise a shared buffer should be used + if (!(leftovers.remaining() < 4)) { + logger.log(WARNING, + "The size of decoding leftovers is greater than expected: {0}", + leftovers.remaining()); + } + b.position(b.limit()); // As if we always read to the end + in.dispose(); + // Decoder promises that in the case of endOfInput == true: + // "...any remaining undecoded input will be treated as being + // malformed" + assert !(endOfInput && leftovers.hasRemaining()) : endOfInput + ", " + leftovers; + if (endOfInput) { + r = decoder.flush(out); + decoder.reset(); + if (r.isOverflow()) { + // FIXME: for now I know flush() does nothing. But the + // implementation of UTF8 decoder might change. And if now + // flush() is a no-op, it is not guaranteed to remain so in + // the future + throw new InternalError("Not yet implemented"); + } + } + out.flip(); + return WSShared.wrap(out); + } + } +} diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/WSDisposable.java b/jdk/src/java.httpclient/share/classes/java/net/http/WSDisposable.java new file mode 100644 index 00000000000..bad775e390c --- /dev/null +++ b/jdk/src/java.httpclient/share/classes/java/net/http/WSDisposable.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2016, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package java.net.http; + +interface WSDisposable { + + void dispose(); +} diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/WSDisposableText.java b/jdk/src/java.httpclient/share/classes/java/net/http/WSDisposableText.java new file mode 100644 index 00000000000..08f71236f63 --- /dev/null +++ b/jdk/src/java.httpclient/share/classes/java/net/http/WSDisposableText.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2016, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package java.net.http; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; + +final class WSDisposableText implements WebSocket.Text, WSDisposable { + + private final WSShared text; + + WSDisposableText(WSShared text) { + this.text = text; + } + + @Override + public int length() { + return text.buffer().length(); + } + + @Override + public char charAt(int index) { + return text.buffer().charAt(index); + } + + @Override + public CharSequence subSequence(int start, int end) { + return text.buffer().subSequence(start, end); + } + + @Override + public ByteBuffer asByteBuffer() { + throw new UnsupportedOperationException("To be removed from the API"); + } + + @Override + public String toString() { + return text.buffer().toString(); + } + + @Override + public void dispose() { + text.dispose(); + } +} diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/WSFrame.java b/jdk/src/java.httpclient/share/classes/java/net/http/WSFrame.java new file mode 100644 index 00000000000..86cfac99f47 --- /dev/null +++ b/jdk/src/java.httpclient/share/classes/java/net/http/WSFrame.java @@ -0,0 +1,486 @@ +/* + * Copyright (c) 2016, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package java.net.http; + +import java.nio.ByteBuffer; + +import static java.lang.String.format; +import static java.net.http.WSFrame.Opcode.ofCode; +import static java.net.http.WSUtils.dump; + +/* + * A collection of utilities for reading, writing, and masking frames. + */ +final class WSFrame { + + private WSFrame() { } + + static final int MAX_HEADER_SIZE_BYTES = 2 + 8 + 4; + + enum Opcode { + + CONTINUATION (0x0), + TEXT (0x1), + BINARY (0x2), + NON_CONTROL_0x3(0x3), + NON_CONTROL_0x4(0x4), + NON_CONTROL_0x5(0x5), + NON_CONTROL_0x6(0x6), + NON_CONTROL_0x7(0x7), + CLOSE (0x8), + PING (0x9), + PONG (0xA), + CONTROL_0xB (0xB), + CONTROL_0xC (0xC), + CONTROL_0xD (0xD), + CONTROL_0xE (0xE), + CONTROL_0xF (0xF); + + private static final Opcode[] opcodes; + + static { + Opcode[] values = values(); + opcodes = new Opcode[values.length]; + for (Opcode c : values) { + assert opcodes[c.code] == null + : WSUtils.dump(c, c.code, opcodes[c.code]); + opcodes[c.code] = c; + } + } + + private final byte code; + private final char shiftedCode; + private final String description; + + Opcode(int code) { + this.code = (byte) code; + this.shiftedCode = (char) (code << 8); + this.description = format("%x (%s)", code, name()); + } + + boolean isControl() { + return (code & 0x8) != 0; + } + + static Opcode ofCode(int code) { + return opcodes[code & 0xF]; + } + + @Override + public String toString() { + return description; + } + } + + /* + * A utility to mask payload data. + */ + static final class Masker { + + private final ByteBuffer acc = ByteBuffer.allocate(8); + private final int[] maskBytes = new int[4]; + private int offset; + private long maskLong; + + /* + * Sets up the mask. + */ + Masker mask(int value) { + acc.clear().putInt(value).putInt(value).flip(); + for (int i = 0; i < maskBytes.length; i++) { + maskBytes[i] = acc.get(i); + } + offset = 0; + maskLong = acc.getLong(0); + return this; + } + + /* + * Reads as many bytes as possible from the given input buffer, writing + * the resulting masked bytes to the given output buffer. + * + * src.remaining() <= dst.remaining() // TODO: do we need this restriction? + * 'src' and 'dst' can be the same ByteBuffer + */ + Masker applyMask(ByteBuffer src, ByteBuffer dst) { + if (src.remaining() > dst.remaining()) { + throw new IllegalArgumentException(dump(src, dst)); + } + begin(src, dst); + loop(src, dst); + end(src, dst); + return this; + } + + // Applying the remaining of the mask (strictly not more than 3 bytes) + // byte-wise + private void begin(ByteBuffer src, ByteBuffer dst) { + if (offset > 0) { + for (int i = src.position(), j = dst.position(); + offset < 4 && i <= src.limit() - 1 && j <= dst.limit() - 1; + i++, j++, offset++) { + dst.put(j, (byte) (src.get(i) ^ maskBytes[offset])); + dst.position(j + 1); + src.position(i + 1); + } + offset &= 3; + } + } + + private void loop(ByteBuffer src, ByteBuffer dst) { + int i = src.position(); + int j = dst.position(); + final int srcLim = src.limit() - 8; + final int dstLim = dst.limit() - 8; + for (; i <= srcLim && j <= dstLim; i += 8, j += 8) { + dst.putLong(j, (src.getLong(i) ^ maskLong)); + } + if (i > src.limit()) { + src.position(i - 8); + } else { + src.position(i); + } + if (j > dst.limit()) { + dst.position(j - 8); + } else { + dst.position(j); + } + } + + // Applying the mask to the remaining bytes byte-wise (don't make any + // assumptions on how many, hopefully not more than 7 for 64bit arch) + private void end(ByteBuffer src, ByteBuffer dst) { + for (int i = src.position(), j = dst.position(); + i <= src.limit() - 1 && j <= dst.limit() - 1; + i++, j++, offset = (offset + 1) & 3) { // offset cycle through 0..3 + dst.put(j, (byte) (src.get(i) ^ maskBytes[offset])); + src.position(i + 1); + dst.position(j + 1); + } + } + } + + /* + * A builder of frame headers, capable of writing to a given buffer. + * + * The builder does not enforce any protocol-level rules, it simply writes + * a header structure to the buffer. The order of calls to intermediate + * methods is not significant. + */ + static final class HeaderBuilder { + + private char firstChar; + private long payloadLen; + private int maskingKey; + private boolean mask; + + HeaderBuilder fin(boolean value) { + if (value) { + firstChar |= 0b10000000_00000000; + } else { + firstChar &= ~0b10000000_00000000; + } + return this; + } + + HeaderBuilder rsv1(boolean value) { + if (value) { + firstChar |= 0b01000000_00000000; + } else { + firstChar &= ~0b01000000_00000000; + } + return this; + } + + HeaderBuilder rsv2(boolean value) { + if (value) { + firstChar |= 0b00100000_00000000; + } else { + firstChar &= ~0b00100000_00000000; + } + return this; + } + + HeaderBuilder rsv3(boolean value) { + if (value) { + firstChar |= 0b00010000_00000000; + } else { + firstChar &= ~0b00010000_00000000; + } + return this; + } + + HeaderBuilder opcode(Opcode value) { + firstChar = (char) ((firstChar & 0xF0FF) | value.shiftedCode); + return this; + } + + HeaderBuilder payloadLen(long value) { + payloadLen = value; + firstChar &= 0b11111111_10000000; // Clear previous payload length leftovers + if (payloadLen < 126) { + firstChar |= payloadLen; + } else if (payloadLen < 65535) { + firstChar |= 126; + } else { + firstChar |= 127; + } + return this; + } + + HeaderBuilder mask(int value) { + firstChar |= 0b00000000_10000000; + maskingKey = value; + mask = true; + return this; + } + + HeaderBuilder noMask() { + firstChar &= ~0b00000000_10000000; + mask = false; + return this; + } + + /* + * Writes the header to the given buffer. + * + * The buffer must have at least MAX_HEADER_SIZE_BYTES remaining. The + * buffer's position is incremented by the number of bytes written. + */ + void build(ByteBuffer buffer) { + buffer.putChar(firstChar); + if (payloadLen >= 126) { + if (payloadLen < 65535) { + buffer.putChar((char) payloadLen); + } else { + buffer.putLong(payloadLen); + } + } + if (mask) { + buffer.putInt(maskingKey); + } + } + } + + /* + * A consumer of frame parts. + * + * Guaranteed to be called in the following order by the Frame.Reader: + * + * fin rsv1 rsv2 rsv3 opcode mask payloadLength maskingKey? payloadData+ endFrame + */ + interface Consumer { + + void fin(boolean value); + + void rsv1(boolean value); + + void rsv2(boolean value); + + void rsv3(boolean value); + + void opcode(Opcode value); + + void mask(boolean value); + + void payloadLen(long value); + + void maskingKey(int value); + + /* + * Called when a part of the payload is ready to be consumed. + * + * Though may not yield a complete payload in a single invocation, i.e. + * + * data.remaining() < payloadLen + * + * the sum of `data.remaining()` passed to all invocations of this + * method will be equal to 'payloadLen', reported in + * `void payloadLen(long value)` + * + * No unmasking is done. + */ + void payloadData(WSShared data, boolean isLast); + + void endFrame(); // TODO: remove (payloadData(isLast=true)) should be enough + } + + /* + * A Reader of Frames. + * + * No protocol-level rules are enforced, only frame structure. + */ + static final class Reader { + + private static final int AWAITING_FIRST_BYTE = 1; + private static final int AWAITING_SECOND_BYTE = 2; + private static final int READING_16_LENGTH = 4; + private static final int READING_64_LENGTH = 8; + private static final int READING_MASK = 16; + private static final int READING_PAYLOAD = 32; + + // A private buffer used to simplify multi-byte integers reading + private final ByteBuffer accumulator = ByteBuffer.allocate(8); + private int state = AWAITING_FIRST_BYTE; + private boolean mask; + private long payloadLength; + + /* + * Reads at most one frame from the given buffer invoking the consumer's + * methods corresponding to the frame elements found. + * + * As much of the frame's payload, if any, is read. The buffers position + * is updated to reflect the number of bytes read. + * + * Throws WSProtocolException if the frame is malformed. + */ + void readFrame(WSShared shared, Consumer consumer) { + ByteBuffer input = shared.buffer(); + loop: + while (true) { + byte b; + switch (state) { + case AWAITING_FIRST_BYTE: + if (!input.hasRemaining()) { + break loop; + } + b = input.get(); + consumer.fin( (b & 0b10000000) != 0); + consumer.rsv1((b & 0b01000000) != 0); + consumer.rsv2((b & 0b00100000) != 0); + consumer.rsv3((b & 0b00010000) != 0); + consumer.opcode(ofCode(b)); + state = AWAITING_SECOND_BYTE; + continue loop; + case AWAITING_SECOND_BYTE: + if (!input.hasRemaining()) { + break loop; + } + b = input.get(); + consumer.mask(mask = (b & 0b10000000) != 0); + byte p1 = (byte) (b & 0b01111111); + if (p1 < 126) { + assert p1 >= 0 : p1; + consumer.payloadLen(payloadLength = p1); + state = mask ? READING_MASK : READING_PAYLOAD; + } else if (p1 < 127) { + state = READING_16_LENGTH; + } else { + state = READING_64_LENGTH; + } + continue loop; + case READING_16_LENGTH: + if (!input.hasRemaining()) { + break loop; + } + b = input.get(); + if (accumulator.put(b).position() < 2) { + continue loop; + } + payloadLength = accumulator.flip().getChar(); + if (payloadLength < 126) { + throw notMinimalEncoding(payloadLength, 2); + } + consumer.payloadLen(payloadLength); + accumulator.clear(); + state = mask ? READING_MASK : READING_PAYLOAD; + continue loop; + case READING_64_LENGTH: + if (!input.hasRemaining()) { + break loop; + } + b = input.get(); + if (accumulator.put(b).position() < 8) { + continue loop; + } + payloadLength = accumulator.flip().getLong(); + if (payloadLength < 0) { + throw negativePayload(payloadLength); + } else if (payloadLength < 65535) { + throw notMinimalEncoding(payloadLength, 8); + } + consumer.payloadLen(payloadLength); + accumulator.clear(); + state = mask ? READING_MASK : READING_PAYLOAD; + continue loop; + case READING_MASK: + if (!input.hasRemaining()) { + break loop; + } + b = input.get(); + if (accumulator.put(b).position() != 4) { + continue loop; + } + consumer.maskingKey(accumulator.flip().getInt()); + accumulator.clear(); + state = READING_PAYLOAD; + continue loop; + case READING_PAYLOAD: + // This state does not require any bytes to be available + // in the input buffer in order to proceed + boolean fullyRead; + int limit; + if (payloadLength <= input.remaining()) { + limit = input.position() + (int) payloadLength; + payloadLength = 0; + fullyRead = true; + } else { + limit = input.limit(); + payloadLength -= input.remaining(); + fullyRead = false; + } + // FIXME: consider a case where payloadLen != 0, + // but input.remaining() == 0 + // + // There shouldn't be an invocation of payloadData with + // an empty buffer, as it would be an artifact of + // reading + consumer.payloadData(shared.share(input.position(), limit), fullyRead); + // Update the position manually, since reading the + // payload doesn't advance buffer's position + input.position(limit); + if (fullyRead) { + consumer.endFrame(); + state = AWAITING_FIRST_BYTE; + } + break loop; + default: + throw new InternalError(String.valueOf(state)); + } + } + } + + private static WSProtocolException negativePayload(long payloadLength) { + return new WSProtocolException + ("5.2.", format("Negative 64-bit payload length %s", payloadLength)); + } + + private static WSProtocolException notMinimalEncoding(long payloadLength, int numBytes) { + return new WSProtocolException + ("5.2.", format("Payload length (%s) is not encoded with minimal number (%s) of bytes", + payloadLength, numBytes)); + } + } +} diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/WSFrameConsumer.java b/jdk/src/java.httpclient/share/classes/java/net/http/WSFrameConsumer.java new file mode 100644 index 00000000000..504d72ed462 --- /dev/null +++ b/jdk/src/java.httpclient/share/classes/java/net/http/WSFrameConsumer.java @@ -0,0 +1,289 @@ +/* + * Copyright (c) 2016, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package java.net.http; + +import java.net.http.WSFrame.Opcode; +import java.net.http.WebSocket.MessagePart; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.util.concurrent.atomic.AtomicInteger; + +import static java.lang.String.format; +import static java.lang.System.Logger.Level.TRACE; +import static java.net.http.WSUtils.dump; +import static java.net.http.WSUtils.logger; +import static java.net.http.WebSocket.CloseCode.NOT_CONSISTENT; +import static java.net.http.WebSocket.CloseCode.of; +import static java.util.Objects.requireNonNull; + +/* + * Consumes frame parts and notifies a message consumer, when there is + * sufficient data to produce a message, or part thereof. + * + * Data consumed but not yet translated is accumulated until it's sufficient to + * form a message. + */ +final class WSFrameConsumer implements WSFrame.Consumer { + + private final AtomicInteger invocationOrder = new AtomicInteger(); + + private final WSMessageConsumer output; + private final WSCharsetToolkit.Decoder decoder = new WSCharsetToolkit.Decoder(); + private boolean fin; + private Opcode opcode, originatingOpcode; + private MessagePart part = MessagePart.WHOLE; + private long payloadLen; + private WSShared binaryData; + + WSFrameConsumer(WSMessageConsumer output) { + this.output = requireNonNull(output); + } + + @Override + public void fin(boolean value) { + assert invocationOrder.compareAndSet(0, 1) : dump(invocationOrder, value); + if (logger.isLoggable(TRACE)) { + // Checked for being loggable because of autoboxing of 'value' + logger.log(TRACE, "Reading fin: {0}", value); + } + fin = value; + } + + @Override + public void rsv1(boolean value) { + assert invocationOrder.compareAndSet(1, 2) : dump(invocationOrder, value); + if (logger.isLoggable(TRACE)) { + logger.log(TRACE, "Reading rsv1: {0}", value); + } + if (value) { + throw new WSProtocolException("5.2.", "rsv1 bit is set unexpectedly"); + } + } + + @Override + public void rsv2(boolean value) { + assert invocationOrder.compareAndSet(2, 3) : dump(invocationOrder, value); + if (logger.isLoggable(TRACE)) { + logger.log(TRACE, "Reading rsv2: {0}", value); + } + if (value) { + throw new WSProtocolException("5.2.", "rsv2 bit is set unexpectedly"); + } + } + + @Override + public void rsv3(boolean value) { + assert invocationOrder.compareAndSet(3, 4) : dump(invocationOrder, value); + if (logger.isLoggable(TRACE)) { + logger.log(TRACE, "Reading rsv3: {0}", value); + } + if (value) { + throw new WSProtocolException("5.2.", "rsv3 bit is set unexpectedly"); + } + } + + @Override + public void opcode(Opcode v) { + assert invocationOrder.compareAndSet(4, 5) : dump(invocationOrder, v); + logger.log(TRACE, "Reading opcode: {0}", v); + if (v == Opcode.PING || v == Opcode.PONG || v == Opcode.CLOSE) { + if (!fin) { + throw new WSProtocolException("5.5.", "A fragmented control frame " + v); + } + opcode = v; + } else if (v == Opcode.TEXT || v == Opcode.BINARY) { + if (originatingOpcode != null) { + throw new WSProtocolException + ("5.4.", format("An unexpected frame %s (fin=%s)", v, fin)); + } + opcode = v; + if (!fin) { + originatingOpcode = v; + } + } else if (v == Opcode.CONTINUATION) { + if (originatingOpcode == null) { + throw new WSProtocolException + ("5.4.", format("An unexpected frame %s (fin=%s)", v, fin)); + } + opcode = v; + } else { + throw new WSProtocolException("5.2.", "An unknown opcode " + v); + } + } + + @Override + public void mask(boolean value) { + assert invocationOrder.compareAndSet(5, 6) : dump(invocationOrder, value); + if (logger.isLoggable(TRACE)) { + logger.log(TRACE, "Reading mask: {0}", value); + } + if (value) { + throw new WSProtocolException + ("5.1.", "Received a masked frame from the server"); + } + } + + @Override + public void payloadLen(long value) { + assert invocationOrder.compareAndSet(6, 7) : dump(invocationOrder, value); + if (logger.isLoggable(TRACE)) { + logger.log(TRACE, "Reading payloadLen: {0}", value); + } + if (opcode.isControl()) { + if (value > 125) { + throw new WSProtocolException + ("5.5.", format("A control frame %s has a payload length of %s", + opcode, value)); + } + assert Opcode.CLOSE.isControl(); + if (opcode == Opcode.CLOSE && value == 1) { + throw new WSProtocolException + ("5.5.1.", "A Close frame's status code is only 1 byte long"); + } + } + payloadLen = value; + } + + @Override + public void maskingKey(int value) { + assert false : dump(invocationOrder, value); + } + + @Override + public void payloadData(WSShared data, boolean isLast) { + assert invocationOrder.compareAndSet(7, isLast ? 8 : 7) + : dump(invocationOrder, data, isLast); + if (logger.isLoggable(TRACE)) { + logger.log(TRACE, "Reading payloadData: data={0}, isLast={1}", data, isLast); + } + if (opcode.isControl()) { + if (binaryData != null) { + binaryData.put(data); + data.dispose(); + } else if (!isLast) { + // The first chunk of the message + int remaining = data.remaining(); + // It shouldn't be 125, otherwise the next chunk will be of size + // 0, which is not what Reader promises to deliver (eager + // reading) + assert remaining < 125 : dump(remaining); + WSShared b = WSShared.wrap(ByteBuffer.allocate(125)).put(data); + data.dispose(); + binaryData = b; // Will be disposed by the user + } else { + // The only chunk; will be disposed by the user + binaryData = data.position(data.limit()); // FIXME: remove this hack + } + } else { + part = determinePart(isLast); + boolean text = opcode == Opcode.TEXT || originatingOpcode == Opcode.TEXT; + if (!text) { + output.onBinary(part, data); + } else { + boolean binaryNonEmpty = data.hasRemaining(); + WSShared textData; + try { + textData = decoder.decode(data, part.isLast()); + } catch (CharacterCodingException e) { + throw new WSProtocolException + ("5.6.", "Invalid UTF-8 sequence in frame " + opcode, NOT_CONSISTENT, e); + } + if (!(binaryNonEmpty && !textData.hasRemaining())) { + // If there's a binary data, that result in no text, then we + // don't deliver anything + output.onText(part, new WSDisposableText(textData)); + } + } + } + } + + @Override + public void endFrame() { + assert invocationOrder.compareAndSet(8, 0) : dump(invocationOrder); + if (opcode.isControl()) { + binaryData.flip(); + } + switch (opcode) { + case CLOSE: + WebSocket.CloseCode cc; + String reason; + if (payloadLen == 0) { + cc = null; + reason = ""; + } else { + ByteBuffer b = binaryData.buffer(); + int len = b.remaining(); + assert 2 <= len && len <= 125 : dump(len, payloadLen); + try { + cc = of(b.getChar()); + reason = WSCharsetToolkit.decode(b).toString(); + } catch (IllegalArgumentException e) { + throw new WSProtocolException + ("5.5.1", "Incorrect status code", e); + } catch (CharacterCodingException e) { + throw new WSProtocolException + ("5.5.1", "Close reason is a malformed UTF-8 sequence", e); + } + } + binaryData.dispose(); // Manual dispose + output.onClose(cc, reason); + break; + case PING: + output.onPing(binaryData); + binaryData = null; + break; + case PONG: + output.onPong(binaryData); + binaryData = null; + break; + default: + assert opcode == Opcode.TEXT || opcode == Opcode.BINARY + || opcode == Opcode.CONTINUATION : dump(opcode); + if (fin) { + // It is always the last chunk: + // either TEXT(FIN=TRUE)/BINARY(FIN=TRUE) or CONT(FIN=TRUE) + originatingOpcode = null; + } + break; + } + payloadLen = 0; + opcode = null; + } + + private MessagePart determinePart(boolean isLast) { + boolean lastChunk = fin && isLast; + switch (part) { + case LAST: + case WHOLE: + return lastChunk ? MessagePart.WHOLE : MessagePart.FIRST; + case FIRST: + case PART: + return lastChunk ? MessagePart.LAST : MessagePart.PART; + default: + throw new InternalError(String.valueOf(part)); + } + } +} diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/WSMessageConsumer.java b/jdk/src/java.httpclient/share/classes/java/net/http/WSMessageConsumer.java new file mode 100644 index 00000000000..e80605df10e --- /dev/null +++ b/jdk/src/java.httpclient/share/classes/java/net/http/WSMessageConsumer.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2016, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package java.net.http; + +import java.net.http.WebSocket.CloseCode; +import java.net.http.WebSocket.MessagePart; +import java.nio.ByteBuffer; + +interface WSMessageConsumer { + + void onText(MessagePart part, WSDisposableText data); + + void onBinary(MessagePart part, WSShared data); + + void onPing(WSShared data); + + void onPong(WSShared data); + + void onClose(CloseCode code, CharSequence reason); +} diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/WSMessageSender.java b/jdk/src/java.httpclient/share/classes/java/net/http/WSMessageSender.java new file mode 100644 index 00000000000..95a9b902d36 --- /dev/null +++ b/jdk/src/java.httpclient/share/classes/java/net/http/WSMessageSender.java @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2016, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package java.net.http; + +import java.net.http.WSFrame.HeaderBuilder; +import java.net.http.WSFrame.Masker; +import java.net.http.WSOutgoingMessage.Binary; +import java.net.http.WSOutgoingMessage.Close; +import java.net.http.WSOutgoingMessage.Ping; +import java.net.http.WSOutgoingMessage.Pong; +import java.net.http.WSOutgoingMessage.StreamedText; +import java.net.http.WSOutgoingMessage.Text; +import java.net.http.WSOutgoingMessage.Visitor; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.security.SecureRandom; +import java.util.function.Consumer; + +import static java.net.http.WSFrame.MAX_HEADER_SIZE_BYTES; +import static java.net.http.WSFrame.Opcode.BINARY; +import static java.net.http.WSFrame.Opcode.CLOSE; +import static java.net.http.WSFrame.Opcode.CONTINUATION; +import static java.net.http.WSFrame.Opcode.PING; +import static java.net.http.WSFrame.Opcode.PONG; +import static java.net.http.WSFrame.Opcode.TEXT; +import static java.util.Objects.requireNonNull; + +/* + * A Sender of outgoing messages. Given a message, + * + * 1) constructs the frame + * 2) initiates the channel write + * 3) notifies when the message has been sent + */ +final class WSMessageSender { + + private final Visitor frameBuilderVisitor; + private final Consumer completionEventConsumer; + private final WSWriter writer; + private final ByteBuffer[] buffers = new ByteBuffer[2]; + + WSMessageSender(RawChannel channel, Consumer completionEventConsumer) { + // Single reusable buffer that holds a header + this.buffers[0] = ByteBuffer.allocateDirect(MAX_HEADER_SIZE_BYTES); + this.frameBuilderVisitor = new FrameBuilderVisitor(); + this.completionEventConsumer = completionEventConsumer; + this.writer = new WSWriter(channel, this.completionEventConsumer); + } + + /* + * Tries to send the given message fully. Invoked once per message. + */ + boolean trySendFully(WSOutgoingMessage m) { + requireNonNull(m); + synchronized (this) { + try { + return sendNow(m); + } catch (Exception e) { + completionEventConsumer.accept(e); + return false; + } + } + } + + private boolean sendNow(WSOutgoingMessage m) { + buffers[0].clear(); + m.accept(frameBuilderVisitor); + buffers[0].flip(); + return writer.tryWriteFully(buffers); + } + + /* + * Builds and initiates a write of a frame, from a given message. + */ + class FrameBuilderVisitor implements Visitor { + + private final SecureRandom random = new SecureRandom(); + private final WSCharsetToolkit.Encoder encoder = new WSCharsetToolkit.Encoder(); + private final Masker masker = new Masker(); + private final HeaderBuilder headerBuilder = new HeaderBuilder(); + private boolean previousIsLast = true; + + @Override + public void visit(Text message) { + try { + buffers[1] = encoder.encode(CharBuffer.wrap(message.characters)); + } catch (CharacterCodingException e) { + completionEventConsumer.accept(e); + return; + } + int mask = random.nextInt(); + maskAndRewind(buffers[1], mask); + headerBuilder + .fin(message.isLast) + .opcode(previousIsLast ? TEXT : CONTINUATION) + .payloadLen(buffers[1].remaining()) + .mask(mask) + .build(buffers[0]); + previousIsLast = message.isLast; + } + + @Override + public void visit(StreamedText streamedText) { + throw new IllegalArgumentException("Not yet implemented"); + } + + @Override + public void visit(Binary message) { + buffers[1] = message.bytes; + int mask = random.nextInt(); + maskAndRewind(buffers[1], mask); + headerBuilder + .fin(message.isLast) + .opcode(previousIsLast ? BINARY : CONTINUATION) + .payloadLen(message.bytes.remaining()) + .mask(mask) + .build(buffers[0]); + previousIsLast = message.isLast; + } + + @Override + public void visit(Ping message) { + buffers[1] = message.bytes; + int mask = random.nextInt(); + maskAndRewind(buffers[1], mask); + headerBuilder + .fin(true) + .opcode(PING) + .payloadLen(message.bytes.remaining()) + .mask(mask) + .build(buffers[0]); + } + + @Override + public void visit(Pong message) { + buffers[1] = message.bytes; + int mask = random.nextInt(); + maskAndRewind(buffers[1], mask); + headerBuilder + .fin(true) + .opcode(PONG) + .payloadLen(message.bytes.remaining()) + .mask(mask) + .build(buffers[0]); + } + + @Override + public void visit(Close message) { + buffers[1] = message.bytes; + int mask = random.nextInt(); + maskAndRewind(buffers[1], mask); + headerBuilder + .fin(true) + .opcode(CLOSE) + .payloadLen(buffers[1].remaining()) + .mask(mask) + .build(buffers[0]); + } + + private void maskAndRewind(ByteBuffer b, int mask) { + int oldPos = b.position(); + masker.mask(mask).applyMask(b, b); + b.position(oldPos); + } + } +} diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/WSOpeningHandshake.java b/jdk/src/java.httpclient/share/classes/java/net/http/WSOpeningHandshake.java new file mode 100644 index 00000000000..6a7ddaa882c --- /dev/null +++ b/jdk/src/java.httpclient/share/classes/java/net/http/WSOpeningHandshake.java @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2015, 2016, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package java.net.http; + +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import static java.lang.String.format; +import static java.lang.System.Logger.Level.TRACE; +import static java.net.http.WSUtils.logger; +import static java.net.http.WSUtils.webSocketSpecViolation; + +final class WSOpeningHandshake { + + private static final String HEADER_CONNECTION = "Connection"; + private static final String HEADER_UPGRADE = "Upgrade"; + private static final String HEADER_ACCEPT = "Sec-WebSocket-Accept"; + private static final String HEADER_EXTENSIONS = "Sec-WebSocket-Extensions"; + private static final String HEADER_KEY = "Sec-WebSocket-Key"; + private static final String HEADER_PROTOCOL = "Sec-WebSocket-Protocol"; + private static final String HEADER_VERSION = "Sec-WebSocket-Version"; + private static final String VALUE_VERSION = "13"; // WebSocket's lucky number + + private static final SecureRandom srandom = new SecureRandom(); + + private final MessageDigest sha1; + + { + try { + sha1 = MessageDigest.getInstance("SHA-1"); + } catch (NoSuchAlgorithmException e) { + // Shouldn't happen: + // SHA-1 must be available in every Java platform implementation + throw new InternalError("Minimum platform requirements are not met", e); + } + } + + private final HttpRequest request; + private final Collection subprotocols; + private final String nonce; + + WSOpeningHandshake(WSBuilder b) { + URI httpURI = createHttpUri(b.getUri()); + HttpRequest.Builder requestBuilder = b.getClient().request(httpURI); + if (b.getTimeUnit() != null) { + requestBuilder.timeout(b.getTimeUnit(), b.getTimeout()); + } + Collection s = b.getSubprotocols(); + if (!s.isEmpty()) { + String p = s.stream().collect(Collectors.joining(", ")); + requestBuilder.header(HEADER_PROTOCOL, p); + } + requestBuilder.header(HEADER_VERSION, VALUE_VERSION); + this.nonce = createNonce(); + requestBuilder.header(HEADER_KEY, this.nonce); + this.request = requestBuilder.GET(); + HttpRequestImpl r = (HttpRequestImpl) this.request; + r.isWebSocket(true); + r.setSystemHeader(HEADER_UPGRADE, "websocket"); + r.setSystemHeader(HEADER_CONNECTION, "Upgrade"); + this.subprotocols = s; + } + + private URI createHttpUri(URI webSocketUri) { + // FIXME: check permission for WebSocket URI and translate it into http/https permission + logger.log(TRACE, "->createHttpUri(''{0}'')", webSocketUri); + String httpScheme = webSocketUri.getScheme().equalsIgnoreCase("ws") + ? "http" + : "https"; + try { + URI uri = new URI(httpScheme, + webSocketUri.getUserInfo(), + webSocketUri.getHost(), + webSocketUri.getPort(), + webSocketUri.getPath(), + webSocketUri.getQuery(), + null); + logger.log(TRACE, "<-createHttpUri: ''{0}''", uri); + return uri; + } catch (URISyntaxException e) { + // Shouldn't happen: URI invariant + throw new InternalError("Error translating WebSocket URI to HTTP URI", e); + } + } + + CompletableFuture performAsync() { + // The whole dancing with thenCompose instead of thenApply is because + // WebSocketHandshakeException is a checked exception + return request.responseAsync() + .thenCompose(response -> { + try { + Result result = handleResponse(response); + return CompletableFuture.completedFuture(result); + } catch (WebSocketHandshakeException e) { + return CompletableFuture.failedFuture(e); + } + }); + } + + private Result handleResponse(HttpResponse response) throws WebSocketHandshakeException { + // By this point all redirects, authentications, etc. (if any) must have + // been done by the httpClient used by the WebSocket; so only 101 is + // expected + int statusCode = response.statusCode(); + if (statusCode != 101) { + String m = webSocketSpecViolation("1.3.", + "Unable to complete handshake; HTTP response status code " + + statusCode + ); + throw new WebSocketHandshakeException(m, response); + } + HttpHeaders h = response.headers(); + checkHeader(h, response, HEADER_UPGRADE, v -> v.equalsIgnoreCase("websocket")); + checkHeader(h, response, HEADER_CONNECTION, v -> v.equalsIgnoreCase("Upgrade")); + checkVersion(response, h); + checkAccept(response, h); + checkExtensions(response, h); + String subprotocol = checkAndReturnSubprotocol(response, h); + RawChannel channel = ((HttpResponseImpl) response).rawChannel(); + return new Result(subprotocol, channel); + } + + private void checkExtensions(HttpResponse response, HttpHeaders headers) + throws WebSocketHandshakeException { + List ext = headers.allValues(HEADER_EXTENSIONS); + if (!ext.isEmpty()) { + String m = webSocketSpecViolation("4.1.", + "Server responded with extension(s) though none were requested " + + Arrays.toString(ext.toArray()) + ); + throw new WebSocketHandshakeException(m, response); + } + } + + private String checkAndReturnSubprotocol(HttpResponse response, HttpHeaders headers) + throws WebSocketHandshakeException { + assert response.statusCode() == 101 : response.statusCode(); + List sp = headers.allValues(HEADER_PROTOCOL); + int size = sp.size(); + if (size == 0) { + // In this case the subprotocol requested (if any) by the client + // doesn't matter. If there is no such header in the response, then + // the server doesn't want to use any subprotocol + return null; + } else if (size > 1) { + // We don't know anything about toString implementation of this + // list, so let's create an array + String m = webSocketSpecViolation("4.1.", + "Server responded with multiple subprotocols: " + + Arrays.toString(sp.toArray()) + ); + throw new WebSocketHandshakeException(m, response); + } else { + String selectedSubprotocol = sp.get(0); + if (this.subprotocols.contains(selectedSubprotocol)) { + return selectedSubprotocol; + } else { + String m = webSocketSpecViolation("4.1.", + format("Server responded with a subprotocol " + + "not among those requested: '%s'", + selectedSubprotocol)); + throw new WebSocketHandshakeException(m, response); + } + } + } + + private void checkAccept(HttpResponse response, HttpHeaders headers) + throws WebSocketHandshakeException { + assert response.statusCode() == 101 : response.statusCode(); + String x = nonce + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + sha1.update(x.getBytes(StandardCharsets.ISO_8859_1)); + String expected = Base64.getEncoder().encodeToString(sha1.digest()); + checkHeader(headers, response, HEADER_ACCEPT, actual -> actual.trim().equals(expected)); + } + + private void checkVersion(HttpResponse response, HttpHeaders headers) + throws WebSocketHandshakeException { + assert response.statusCode() == 101 : response.statusCode(); + List versions = headers.allValues(HEADER_VERSION); + if (versions.isEmpty()) { // That's normal and expected + return; + } + String m = webSocketSpecViolation("4.4.", + "Server responded with version(s) " + + Arrays.toString(versions.toArray())); + throw new WebSocketHandshakeException(m, response); + } + + // + // Checks whether there's only one value for the header with the given name + // and the value satisfies the predicate. + // + private static void checkHeader(HttpHeaders headers, + HttpResponse response, + String headerName, + Predicate valuePredicate) + throws WebSocketHandshakeException { + assert response.statusCode() == 101 : response.statusCode(); + List values = headers.allValues(headerName); + if (values.isEmpty()) { + String m = webSocketSpecViolation("4.1.", + format("Server response field '%s' is missing", headerName) + ); + throw new WebSocketHandshakeException(m, response); + } else if (values.size() > 1) { + String m = webSocketSpecViolation("4.1.", + format("Server response field '%s' has multiple values", headerName) + ); + throw new WebSocketHandshakeException(m, response); + } + if (!valuePredicate.test(values.get(0))) { + String m = webSocketSpecViolation("4.1.", + format("Server response field '%s' is incorrect", headerName) + ); + throw new WebSocketHandshakeException(m, response); + } + } + + private static String createNonce() { + byte[] bytes = new byte[16]; + srandom.nextBytes(bytes); + return Base64.getEncoder().encodeToString(bytes); + } + + static final class Result { + + final String subprotocol; + final RawChannel channel; + + private Result(String subprotocol, RawChannel channel) { + this.subprotocol = subprotocol; + this.channel = channel; + } + } +} diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/WSOutgoingMessage.java b/jdk/src/java.httpclient/share/classes/java/net/http/WSOutgoingMessage.java new file mode 100644 index 00000000000..bc3eabd62a4 --- /dev/null +++ b/jdk/src/java.httpclient/share/classes/java/net/http/WSOutgoingMessage.java @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2016, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package java.net.http; + +import java.nio.ByteBuffer; +import java.util.stream.Stream; + +abstract class WSOutgoingMessage { + + interface Visitor { + void visit(Text message); + void visit(StreamedText message); + void visit(Binary message); + void visit(Ping message); + void visit(Pong message); + void visit(Close message); + } + + abstract void accept(Visitor visitor); + + private WSOutgoingMessage() { } + + static final class Text extends WSOutgoingMessage { + + public final boolean isLast; + public final CharSequence characters; + + Text(boolean isLast, CharSequence characters) { + this.isLast = isLast; + this.characters = characters; + } + + @Override + void accept(Visitor visitor) { + visitor.visit(this); + } + + @Override + public String toString() { + return WSUtils.toStringSimple(this) + "[isLast=" + isLast + + ", characters=" + WSUtils.toString(characters) + "]"; + } + } + + static final class StreamedText extends WSOutgoingMessage { + + public final Stream characters; + + StreamedText(Stream characters) { + this.characters = characters; + } + + @Override + void accept(Visitor visitor) { + visitor.visit(this); + } + + @Override + public String toString() { + return WSUtils.toStringSimple(this) + "[characters=" + characters + "]"; + } + } + + static final class Binary extends WSOutgoingMessage { + + public final boolean isLast; + public final ByteBuffer bytes; + + Binary(boolean isLast, ByteBuffer bytes) { + this.isLast = isLast; + this.bytes = bytes; + } + + @Override + void accept(Visitor visitor) { + visitor.visit(this); + } + + @Override + public String toString() { + return WSUtils.toStringSimple(this) + "[isLast=" + isLast + + ", bytes=" + WSUtils.toString(bytes) + "]"; + } + } + + static final class Ping extends WSOutgoingMessage { + + public final ByteBuffer bytes; + + Ping(ByteBuffer bytes) { + this.bytes = bytes; + } + + @Override + void accept(Visitor visitor) { + visitor.visit(this); + } + + @Override + public String toString() { + return WSUtils.toStringSimple(this) + "[" + WSUtils.toString(bytes) + "]"; + } + } + + static final class Pong extends WSOutgoingMessage { + + public final ByteBuffer bytes; + + Pong(ByteBuffer bytes) { + this.bytes = bytes; + } + + @Override + void accept(Visitor visitor) { + visitor.visit(this); + } + + @Override + public String toString() { + return WSUtils.toStringSimple(this) + "[" + WSUtils.toString(bytes) + "]"; + } + } + + static final class Close extends WSOutgoingMessage { + + public final ByteBuffer bytes; + + Close(ByteBuffer bytes) { + this.bytes = bytes; + } + + @Override + void accept(Visitor visitor) { + visitor.visit(this); + } + + @Override + public String toString() { + return WSUtils.toStringSimple(this) + "[" + WSUtils.toString(bytes) + "]"; + } + } +} diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/WSProtocolException.java b/jdk/src/java.httpclient/share/classes/java/net/http/WSProtocolException.java new file mode 100644 index 00000000000..642e96a508d --- /dev/null +++ b/jdk/src/java.httpclient/share/classes/java/net/http/WSProtocolException.java @@ -0,0 +1,68 @@ +package java.net.http; + +import java.net.http.WebSocket.CloseCode; + +import static java.net.http.WebSocket.CloseCode.PROTOCOL_ERROR; +import static java.util.Objects.requireNonNull; + +// +// Special kind of exception closed from the outside world. +// +// Used as a "marker exception" for protocol issues in the incoming data, so the +// implementation could close the connection and specify an appropriate status +// code. +// +// A separate 'section' argument makes it more uncomfortable to be lazy and to +// leave a relevant spec reference empty :-) As a bonus all messages have the +// same style. +// +final class WSProtocolException extends RuntimeException { + + private static final long serialVersionUID = 1L; + private final CloseCode closeCode; + private final String section; + + WSProtocolException(String section, String detail) { + this(section, detail, PROTOCOL_ERROR); + } + + WSProtocolException(String section, String detail, Throwable cause) { + this(section, detail, PROTOCOL_ERROR, cause); + } + + private WSProtocolException(String section, String detail, CloseCode code) { + super(formatMessage(section, detail)); + this.closeCode = requireNonNull(code); + this.section = section; + } + + WSProtocolException(String section, String detail, CloseCode code, + Throwable cause) { + super(formatMessage(section, detail), cause); + this.closeCode = requireNonNull(code); + this.section = section; + } + + private static String formatMessage(String section, String detail) { + if (requireNonNull(section).isEmpty()) { + throw new IllegalArgumentException(); + } + if (requireNonNull(detail).isEmpty()) { + throw new IllegalArgumentException(); + } + return WSUtils.webSocketSpecViolation(section, detail); + } + + CloseCode getCloseCode() { + return closeCode; + } + + public String getSection() { + return section; + } + + @Override + public String toString() { + return super.toString() + "[" + closeCode + "]"; + } +} diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/WSReceiver.java b/jdk/src/java.httpclient/share/classes/java/net/http/WSReceiver.java new file mode 100644 index 00000000000..da69958653d --- /dev/null +++ b/jdk/src/java.httpclient/share/classes/java/net/http/WSReceiver.java @@ -0,0 +1,275 @@ +/* + * Copyright (c) 2016, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package java.net.http; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.ProtocolException; +import java.net.http.WebSocket.Listener; +import java.nio.ByteBuffer; +import java.nio.channels.SelectionKey; +import java.util.Optional; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Supplier; + +import static java.lang.System.Logger.Level.ERROR; +import static java.net.http.WSUtils.EMPTY_BYTE_BUFFER; +import static java.net.http.WSUtils.logger; + +/* + * Receives incoming data from the channel and converts it into a sequence of + * messages, which are then passed to the listener. + */ +final class WSReceiver { + + private final Listener listener; + private final WebSocket webSocket; + private final Supplier> buffersSupplier = + new WSSharedPool<>(() -> ByteBuffer.allocateDirect(32768), 2); + private final RawChannel channel; + private final RawChannel.NonBlockingEvent channelEvent; + private final WSSignalHandler handler; + private final AtomicLong demand = new AtomicLong(); + private final AtomicBoolean readable = new AtomicBoolean(); + private boolean started; + private volatile boolean closed; + private final WSFrame.Reader reader = new WSFrame.Reader(); + private final WSFrameConsumer frameConsumer; + private WSShared buf = WSShared.wrap(EMPTY_BYTE_BUFFER); + private WSShared data; // TODO: initialize with leftovers from the RawChannel + + WSReceiver(Listener listener, WebSocket webSocket, Executor executor, + RawChannel channel) { + this.listener = listener; + this.webSocket = webSocket; + this.channel = channel; + handler = new WSSignalHandler(executor, this::react); + channelEvent = createChannelEvent(); + this.frameConsumer = new WSFrameConsumer(new MessageConsumer()); + } + + private void react() { + synchronized (this) { + while (demand.get() > 0 && !closed) { + try { + if (data == null) { + if (!getData()) { + break; + } + } + reader.readFrame(data, frameConsumer); + if (!data.hasRemaining()) { + data.dispose(); + data = null; + } + // In case of exception we don't need to clean any state, + // since it's the terminal condition anyway. Nothing will be + // retried. + } catch (WSProtocolException e) { + // Translate into ProtocolException + closeExceptionally(new ProtocolException().initCause(e)); + } catch (Exception e) { + closeExceptionally(e); + } + } + } + } + + long request(long n) { + long newDemand = demand.accumulateAndGet(n, (p, i) -> p + i < 0 ? Long.MAX_VALUE : p + i); + handler.signal(); + assert newDemand >= 0 : newDemand; + return newDemand; + } + + private boolean getData() throws IOException { + if (!readable.get()) { + return false; + } + if (!buf.hasRemaining()) { + buf.dispose(); + buf = buffersSupplier.get(); + assert buf.hasRemaining() : buf; + } + int oldPosition = buf.position(); + int oldLimit = buf.limit(); + int numRead = channel.read(buf.buffer()); + if (numRead > 0) { + data = buf.share(oldPosition, oldPosition + numRead); + buf.select(buf.limit(), oldLimit); // Move window to the free region + return true; + } else if (numRead == 0) { + readable.set(false); + channel.registerEvent(channelEvent); + return false; + } else { + assert numRead < 0 : numRead; + throw new WSProtocolException + ("7.2.1.", "Stream ended before a Close frame has been received"); + } + } + + void start() { + synchronized (this) { + if (started) { + throw new IllegalStateException("Already started"); + } + started = true; + try { + channel.registerEvent(channelEvent); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + try { + listener.onOpen(webSocket); + } catch (Exception e) { + closeExceptionally(new RuntimeException("onOpen threw an exception", e)); + } + } + } + + private void close() { // TODO: move to WS.java + closed = true; + } + + private void closeExceptionally(Throwable error) { // TODO: move to WS.java + close(); + try { + listener.onError(webSocket, error); + } catch (Exception e) { + logger.log(ERROR, "onError threw an exception", e); + } + } + + private final class MessageConsumer implements WSMessageConsumer { + + @Override + public void onText(WebSocket.MessagePart part, WSDisposableText data) { + decrementDemand(); + CompletionStage cs; + try { + cs = listener.onText(webSocket, data, part); + } catch (Exception e) { + closeExceptionally(new RuntimeException("onText threw an exception", e)); + return; + } + follow(cs, data, "onText"); + } + + @Override + public void onBinary(WebSocket.MessagePart part, WSShared data) { + decrementDemand(); + CompletionStage cs; + try { + cs = listener.onBinary(webSocket, data.buffer(), part); + } catch (Exception e) { + closeExceptionally(new RuntimeException("onBinary threw an exception", e)); + return; + } + follow(cs, data, "onBinary"); + } + + @Override + public void onPing(WSShared data) { + decrementDemand(); + CompletionStage cs; + try { + cs = listener.onPing(webSocket, data.buffer()); + } catch (Exception e) { + closeExceptionally(new RuntimeException("onPing threw an exception", e)); + return; + } + follow(cs, data, "onPing"); + } + + @Override + public void onPong(WSShared data) { + decrementDemand(); + CompletionStage cs; + try { + cs = listener.onPong(webSocket, data.buffer()); + } catch (Exception e) { + closeExceptionally(new RuntimeException("onPong threw an exception", e)); + return; + } + follow(cs, data, "onPong"); + } + + @Override + public void onClose(WebSocket.CloseCode code, CharSequence reason) { + decrementDemand(); + try { + close(); + listener.onClose(webSocket, Optional.ofNullable(code), reason.toString()); + } catch (Exception e) { + logger.log(ERROR, "onClose threw an exception", e); + } + } + } + + private void follow(CompletionStage cs, WSDisposable d, String source) { + if (cs == null) { + d.dispose(); + } else { + cs.whenComplete((whatever, error) -> { + if (error != null) { + String m = "CompletionStage returned by " + source + " completed exceptionally"; + closeExceptionally(new RuntimeException(m, error)); + } + d.dispose(); + }); + } + } + + private void decrementDemand() { + long newDemand = demand.decrementAndGet(); + assert newDemand >= 0 : newDemand; + } + + private RawChannel.NonBlockingEvent createChannelEvent() { + return new RawChannel.NonBlockingEvent() { + + @Override + public int interestOps() { + return SelectionKey.OP_READ; + } + + @Override + public void handle() { + boolean wasNotReadable = readable.compareAndSet(false, true); + assert wasNotReadable; + handler.signal(); + } + + @Override + public String toString() { + return "Read readiness event [" + channel + "]"; + } + }; + } +} diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/WSShared.java b/jdk/src/java.httpclient/share/classes/java/net/http/WSShared.java new file mode 100644 index 00000000000..5534d85b51e --- /dev/null +++ b/jdk/src/java.httpclient/share/classes/java/net/http/WSShared.java @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2016, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package java.net.http; + +import java.nio.Buffer; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +// +// +-----------+---------------+------------ ~ ------+ +// | shared#1 | shared#2 | non-shared | +// +-----------+---------------+------------ ~ ------+ +// | | +// |<------------------ shared0 ---------- ~ ----->| +// +// +// Objects of the type are not thread-safe. It's the responsibility of the +// client to access shared buffers safely between threads. +// +// It would be perfect if we could extend java.nio.Buffer, but it's not an +// option since Buffer and all its descendants have package-private +// constructors. +// +abstract class WSShared implements WSDisposable { + + protected final AtomicBoolean disposed = new AtomicBoolean(); + protected final T buffer; + + protected WSShared(T buffer) { + this.buffer = Objects.requireNonNull(buffer); + } + + static WSShared wrap(T buffer) { + return new WSShared<>(buffer) { + @Override + WSShared share(int pos, int limit) { + throw new UnsupportedOperationException(); + } + }; + } + + // TODO: should be a terminal operation as after it returns the buffer might + // have escaped (we can't protect it any more) + public T buffer() { + checkDisposed(); + return buffer; + } + + abstract WSShared share(final int pos, final int limit); + + WSShared select(final int pos, final int limit) { + checkRegion(pos, limit, buffer()); + select(pos, limit, buffer()); + return this; + } + + @Override + public void dispose() { + if (!disposed.compareAndSet(false, true)) { + throw new IllegalStateException("Has been disposed previously"); + } + } + + int limit() { + return buffer().limit(); + } + + WSShared limit(int newLimit) { + buffer().limit(newLimit); + return this; + } + + int position() { + return buffer().position(); + } + + WSShared position(int newPosition) { + buffer().position(newPosition); + return this; + } + + int remaining() { + return buffer().remaining(); + } + + boolean hasRemaining() { + return buffer().hasRemaining(); + } + + WSShared flip() { + buffer().flip(); + return this; + } + + WSShared rewind() { + buffer().rewind(); + return this; + } + + WSShared put(WSShared src) { + put(this.buffer(), src.buffer()); + return this; + } + + static void checkRegion(int position, int limit, Buffer buffer) { + if (position < 0 || position > buffer.capacity()) { + throw new IllegalArgumentException("position: " + position); + } + if (limit < 0 || limit > buffer.capacity()) { + throw new IllegalArgumentException("limit: " + limit); + } + if (limit < position) { + throw new IllegalArgumentException + ("limit < position: limit=" + limit + ", position=" + position); + } + } + + void select(int newPos, int newLim, Buffer buffer) { + int oldPos = buffer.position(); + int oldLim = buffer.limit(); + assert 0 <= oldPos && oldPos <= oldLim && oldLim <= buffer.capacity(); + if (oldLim <= newPos) { + buffer().limit(newLim).position(newPos); + } else { + buffer.position(newPos).limit(newLim); + } + } + + // The same as dst.put(src) + static T put(T dst, T src) { + if (dst instanceof ByteBuffer) { + ((ByteBuffer) dst).put((ByteBuffer) src); + } else if (dst instanceof CharBuffer) { + ((CharBuffer) dst).put((CharBuffer) src); + } else { + // We don't work with buffers of other types + throw new IllegalArgumentException(); + } + return dst; + } + + // TODO: Remove when JDK-8150785 has been done + @SuppressWarnings("unchecked") + static T slice(T buffer) { + if (buffer instanceof ByteBuffer) { + return (T) ((ByteBuffer) buffer).slice(); + } else if (buffer instanceof CharBuffer) { + return (T) ((CharBuffer) buffer).slice(); + } else { + // We don't work with buffers of other types + throw new IllegalArgumentException(); + } + } + + // TODO: Remove when JDK-8150785 has been done + @SuppressWarnings("unchecked") + static T duplicate(T buffer) { + if (buffer instanceof ByteBuffer) { + return (T) ((ByteBuffer) buffer).duplicate(); + } else if (buffer instanceof CharBuffer) { + return (T) ((CharBuffer) buffer).duplicate(); + } else { + // We don't work with buffers of other types + throw new IllegalArgumentException(); + } + } + + @Override + public String toString() { + return super.toString() + "[" + WSUtils.toString(buffer()) + "]"; + } + + private void checkDisposed() { + if (disposed.get()) { + throw new IllegalStateException("Has been disposed previously"); + } + } +} diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/WSSharedPool.java b/jdk/src/java.httpclient/share/classes/java/net/http/WSSharedPool.java new file mode 100644 index 00000000000..76f993bda2f --- /dev/null +++ b/jdk/src/java.httpclient/share/classes/java/net/http/WSSharedPool.java @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2016, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package java.net.http; + +import java.nio.Buffer; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +import static java.lang.System.Logger.Level.TRACE; +import static java.net.http.WSShared.duplicate; +import static java.net.http.WSUtils.logger; +import static java.util.Objects.requireNonNull; + +final class WSSharedPool implements Supplier> { + + private final Supplier factory; + private final BlockingQueue queue; + + WSSharedPool(Supplier factory, int maxPoolSize) { + this.factory = requireNonNull(factory); + this.queue = new LinkedBlockingQueue<>(maxPoolSize); + } + + @Override + public Pooled get() { + T b = queue.poll(); + if (b == null) { + logger.log(TRACE, "Pool {0} contains no free buffers", this); + b = requireNonNull(factory.get()); + } + Pooled buf = new Pooled(new AtomicInteger(1), b, duplicate(b)); + logger.log(TRACE, "Pool {0} created new buffer {1}", this, buf); + return buf; + } + + private void put(Pooled b) { + assert b.disposed.get() && b.refCount.get() == 0 + : WSUtils.dump(b.disposed, b.refCount, b); + b.shared.clear(); + boolean accepted = queue.offer(b.getShared()); + if (logger.isLoggable(TRACE)) { + if (accepted) { + logger.log(TRACE, "Pool {0} accepted {1}", this, b); + } else { + logger.log(TRACE, "Pool {0} discarded {1}", this, b); + } + } + } + + @Override + public String toString() { + return super.toString() + "[queue.size=" + queue.size() + "]"; + } + + private final class Pooled extends WSShared { + + private final AtomicInteger refCount; + private final T shared; + + private Pooled(AtomicInteger refCount, T shared, T region) { + super(region); + this.refCount = refCount; + this.shared = shared; + } + + private T getShared() { + return shared; + } + + @Override + @SuppressWarnings("unchecked") + public Pooled share(final int pos, final int limit) { + synchronized (this) { + T buffer = buffer(); + checkRegion(pos, limit, buffer); + final int oldPos = buffer.position(); + final int oldLimit = buffer.limit(); + select(pos, limit, buffer); + T slice = WSShared.slice(buffer); + select(oldPos, oldLimit, buffer); + referenceAndGetCount(); + Pooled buf = new Pooled(refCount, shared, slice); + logger.log(TRACE, "Shared {0} from {1}", buf, this); + return buf; + } + } + + @Override + public void dispose() { + logger.log(TRACE, "Disposed {0}", this); + super.dispose(); + if (dereferenceAndGetCount() == 0) { + WSSharedPool.this.put(this); + } + } + + private int referenceAndGetCount() { + return refCount.updateAndGet(n -> { + if (n != Integer.MAX_VALUE) { + return n + 1; + } else { + throw new IllegalArgumentException + ("Too many references: " + this); + } + }); + } + + private int dereferenceAndGetCount() { + return refCount.updateAndGet(n -> { + if (n > 0) { + return n - 1; + } else { + throw new InternalError(); + } + }); + } + + @Override + public String toString() { + return WSUtils.toStringSimple(this) + "[" + WSUtils.toString(buffer) + + "[refCount=" + refCount + ", disposed=" + disposed + "]]"; + } + } +} diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/WSSignalHandler.java b/jdk/src/java.httpclient/share/classes/java/net/http/WSSignalHandler.java new file mode 100644 index 00000000000..2dc75f83291 --- /dev/null +++ b/jdk/src/java.httpclient/share/classes/java/net/http/WSSignalHandler.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2016, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package java.net.http; + +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicInteger; + +import static java.util.Objects.requireNonNull; + +// +// The problem: +// ------------ +// 1. For every invocation of 'signal()' there must be at least +// 1 invocation of 'handler.run()' that goes after +// 2. There must be no more than 1 thread running the 'handler.run()' +// at any given time +// +// For example, imagine each signal increments (+1) some number. Then the +// handler responds (eventually) the way that makes the number 0. +// +// For each signal there's a response. Several signals may be handled by a +// single response. +// +final class WSSignalHandler { + + // In this state the task is neither submitted nor running. + // No one is handling signals. If a new signal has been received, the task + // has to be submitted to the executor in order to handle this signal. + private static final int DONE = 0; + + // In this state the task is running. + // * If the signaller has found the task in this state it will try to change + // the state to RERUN in order to make the already running task to handle + // the new signal before exiting. + // * If the task has found itself in this state it will exit. + private static final int RUNNING = 1; + + // A signal to the task, that it must rerun on the spot (without being + // resubmitted to the executor). + // If the task has found itself in this state it resets the state to + // RUNNING and repeats the pass. + private static final int RERUN = 2; + + private final AtomicInteger state = new AtomicInteger(DONE); + + private final Executor executor; + private final Runnable task; + + WSSignalHandler(Executor executor, Runnable handler) { + this.executor = requireNonNull(executor); + requireNonNull(handler); + + task = () -> { + while (!Thread.currentThread().isInterrupted()) { + + try { + handler.run(); + } catch (Exception e) { + // Sorry, the task won't be automatically retried; + // hope next signals (if any) will kick off the handling + state.set(DONE); + throw e; + } + + int prev = state.getAndUpdate(s -> { + if (s == RUNNING) { + return DONE; + } else { + return RUNNING; + } + }); + + // Can't be DONE, since only the task itself may transit state + // into DONE (with one exception: RejectedExecution in signal(); + // but in that case we couldn't be here at all) + assert prev == RUNNING || prev == RERUN; + + if (prev == RUNNING) { + break; + } + } + }; + } + + // Invoked by outer code to signal + void signal() { + + int prev = state.getAndUpdate(s -> { + switch (s) { + case RUNNING: + return RERUN; + case DONE: + return RUNNING; + case RERUN: + return RERUN; + default: + throw new InternalError(String.valueOf(s)); + } + }); + + if (prev != DONE) { + // Nothing to do! piggybacking on previous signal + return; + } + try { + executor.execute(task); + } catch (RejectedExecutionException e) { + // Sorry some signal() invocations may have been accepted, but won't + // be done, since the 'task' couldn't be submitted + state.set(DONE); + throw e; + } + } +} diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/WSTransmitter.java b/jdk/src/java.httpclient/share/classes/java/net/http/WSTransmitter.java new file mode 100644 index 00000000000..f9827306646 --- /dev/null +++ b/jdk/src/java.httpclient/share/classes/java/net/http/WSTransmitter.java @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2016, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package java.net.http; + +import java.net.http.WSOutgoingMessage.Binary; +import java.net.http.WSOutgoingMessage.Close; +import java.net.http.WSOutgoingMessage.Ping; +import java.net.http.WSOutgoingMessage.Pong; +import java.net.http.WSOutgoingMessage.StreamedText; +import java.net.http.WSOutgoingMessage.Text; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CoderResult; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import static java.lang.String.format; +import static java.net.http.Pair.pair; + +/* + * Prepares outgoing messages for transmission. Verifies the WebSocket state, + * places the message on the outbound queue, and notifies the signal handler. + */ +final class WSTransmitter { + + private final BlockingQueue>> + backlog = new LinkedBlockingQueue<>(); + private final WSMessageSender sender; + private final WSSignalHandler handler; + private boolean previousMessageSent = true; + private boolean canSendBinary = true; + private boolean canSendText = true; + + WSTransmitter(Executor executor, RawChannel channel, Consumer errorHandler) { + this.handler = new WSSignalHandler(executor, this::handleSignal); + Consumer sendCompletion = (error) -> { + synchronized (this) { + if (error == null) { + previousMessageSent = true; + handler.signal(); + } else { + errorHandler.accept(error); + backlog.forEach(p -> p.second.completeExceptionally(error)); + backlog.clear(); + } + } + }; + this.sender = new WSMessageSender(channel, sendCompletion); + } + + CompletableFuture sendText(CharSequence message, boolean isLast) { + checkAndUpdateText(isLast); + return acceptMessage(new Text(isLast, message)); + } + + CompletableFuture sendText(Stream message) { + checkAndUpdateText(true); + return acceptMessage(new StreamedText(message)); + } + + CompletableFuture sendBinary(ByteBuffer message, boolean isLast) { + checkAndUpdateBinary(isLast); + return acceptMessage(new Binary(isLast, message)); + } + + CompletableFuture sendPing(ByteBuffer message) { + checkSize(message.remaining(), 125); + return acceptMessage(new Ping(message)); + } + + CompletableFuture sendPong(ByteBuffer message) { + checkSize(message.remaining(), 125); + return acceptMessage(new Pong(message)); + } + + CompletableFuture sendClose(WebSocket.CloseCode code, CharSequence reason) { + return acceptMessage(createCloseMessage(code, reason)); + } + + CompletableFuture sendClose() { + return acceptMessage(new Close(ByteBuffer.allocate(0))); + } + + private CompletableFuture acceptMessage(WSOutgoingMessage m) { + CompletableFuture cf = new CompletableFuture<>(); + synchronized (this) { + backlog.offer(pair(m, cf)); + } + handler.signal(); + return cf; + } + + /* Callback for pulling messages from the queue, and initiating the send. */ + private void handleSignal() { + synchronized (this) { + while (!backlog.isEmpty() && previousMessageSent) { + previousMessageSent = false; + Pair> p = backlog.peek(); + boolean sent = sender.trySendFully(p.first); + if (sent) { + backlog.remove(); + p.second.complete(null); + previousMessageSent = true; + } + } + } + } + + private Close createCloseMessage(WebSocket.CloseCode code, CharSequence reason) { + // TODO: move to construction of CloseDetail (JDK-8155621) + ByteBuffer b = ByteBuffer.allocateDirect(125).putChar((char) code.getCode()); + CoderResult result = StandardCharsets.UTF_8.newEncoder() + .encode(CharBuffer.wrap(reason), b, true); + if (result.isError()) { + try { + result.throwException(); + } catch (CharacterCodingException e) { + throw new IllegalArgumentException("Reason is a malformed UTF-16 sequence", e); + } + } else if (result.isOverflow()) { + throw new IllegalArgumentException("Reason is too long"); + } + return new Close(b.flip()); + } + + private void checkSize(int size, int maxSize) { + if (size > maxSize) { + throw new IllegalArgumentException( + format("The message is too long: %s;" + + " expected not longer than %s", size, maxSize) + ); + } + } + + private void checkAndUpdateText(boolean isLast) { + if (!canSendText) { + throw new IllegalStateException("Unexpected text message"); + } + canSendBinary = isLast; + } + + private void checkAndUpdateBinary(boolean isLast) { + if (!canSendBinary) { + throw new IllegalStateException("Unexpected binary message"); + } + canSendText = isLast; + } +} diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/WSUtils.java b/jdk/src/java.httpclient/share/classes/java/net/http/WSUtils.java new file mode 100644 index 00000000000..32928c89e1c --- /dev/null +++ b/jdk/src/java.httpclient/share/classes/java/net/http/WSUtils.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2016, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package java.net.http; + +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.Buffer; +import java.nio.ByteBuffer; +import java.util.Arrays; + +final class WSUtils { + + private WSUtils() { } + + static final System.Logger logger = System.getLogger("java.net.http.WebSocket"); + static final ByteBuffer EMPTY_BYTE_BUFFER = ByteBuffer.allocate(0); + + // + // Helps to trim long names (packages, nested/inner types) in logs/toString + // + static String toStringSimple(Object o) { + return o.getClass().getSimpleName() + "@" + + Integer.toHexString(System.identityHashCode(o)); + } + + // + // 1. It adds a number of remaining bytes; + // 2. Standard Buffer-type toString for CharBuffer (since it adheres to the + // contract of java.lang.CharSequence.toString() which is both not too + // useful and not too private) + // + static String toString(Buffer b) { + return toStringSimple(b) + + "[pos=" + b.position() + + " lim=" + b.limit() + + " cap=" + b.capacity() + + " rem=" + b.remaining() + "]"; + } + + static String toString(CharSequence s) { + return s == null + ? "null" + : toStringSimple(s) + "[len=" + s.length() + "]"; + } + + static String dump(Object... objects) { + return Arrays.toString(objects); + } + + static String webSocketSpecViolation(String section, String detail) { + return "RFC 6455 " + section + " " + detail; + } +} diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/WSWriter.java b/jdk/src/java.httpclient/share/classes/java/net/http/WSWriter.java new file mode 100644 index 00000000000..18cbe4cadda --- /dev/null +++ b/jdk/src/java.httpclient/share/classes/java/net/http/WSWriter.java @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2016, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package java.net.http; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.SelectionKey; +import java.util.function.Consumer; + +import static java.util.Objects.requireNonNull; + +/* + * Writes ByteBuffer[] to the channel in a non-blocking, asynchronous fashion. + * + * A client attempts to write data by calling + * + * boolean tryWriteFully(ByteBuffer[] buffers) + * + * If the attempt was successful and all the data has been written, then the + * method returns `true`. + * + * If the data has been written partially, then the method returns `false`, and + * the writer (this object) attempts to complete the write asynchronously by + * calling, possibly more than once + * + * boolean tryCompleteWrite() + * + * in its own threads. + * + * When the write has been completed asynchronously, the callback is signalled + * with `null`. + * + * If an error occurs in any of these stages it will NOT be thrown from the + * method. Instead `false` will be returned and the exception will be signalled + * to the callback. This is done in order to handle all exceptions in a single + * place. + */ +final class WSWriter { + + private final RawChannel channel; + private final RawChannel.NonBlockingEvent writeReadinessHandler; + private final Consumer completionCallback; + private ByteBuffer[] buffers; + private int offset; + + WSWriter(RawChannel channel, Consumer completionCallback) { + this.channel = channel; + this.completionCallback = completionCallback; + this.writeReadinessHandler = createHandler(); + } + + boolean tryWriteFully(ByteBuffer[] buffers) { + synchronized (this) { + this.buffers = requireNonNull(buffers); + this.offset = 0; + } + return tryCompleteWrite(); + } + + private final boolean tryCompleteWrite() { + try { + return writeNow(); + } catch (IOException e) { + completionCallback.accept(e); + return false; + } + } + + private boolean writeNow() throws IOException { + synchronized (this) { + for (; offset != -1; offset = nextUnwrittenIndex(buffers, offset)) { + long bytesWritten = channel.write(buffers, offset, buffers.length - offset); + if (bytesWritten == 0) { + channel.registerEvent(writeReadinessHandler); + return false; + } + } + return true; + } + } + + private static int nextUnwrittenIndex(ByteBuffer[] buffers, int offset) { + for (int i = offset; i < buffers.length; i++) { + if (buffers[i].hasRemaining()) { + return i; + } + } + return -1; + } + + private RawChannel.NonBlockingEvent createHandler() { + return new RawChannel.NonBlockingEvent() { + + @Override + public int interestOps() { + return SelectionKey.OP_WRITE; + } + + @Override + public void handle() { + if (tryCompleteWrite()) { + completionCallback.accept(null); + } + } + + @Override + public String toString() { + return "Write readiness event [" + channel + "]"; + } + }; + } +} diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/WebSocket.java b/jdk/src/java.httpclient/share/classes/java/net/http/WebSocket.java new file mode 100644 index 00000000000..200f12332a9 --- /dev/null +++ b/jdk/src/java.httpclient/share/classes/java/net/http/WebSocket.java @@ -0,0 +1,1288 @@ +/* + * Copyright (c) 2015, 2016, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package java.net.http; + +import java.io.IOException; +import java.net.ProtocolException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +/** + * A WebSocket client conforming to RFC 6455. + * + *

A {@code WebSocket} provides full-duplex communication over a TCP + * connection. + * + *

To create a {@code WebSocket} use a {@linkplain #newBuilder(URI, Listener) + * builder}. Once a {@code WebSocket} is obtained, it's ready to send and + * receive messages. When the {@code WebSocket} is no longer + * needed it must be closed: a Close message must both be {@linkplain + * #sendClose() sent} and {@linkplain Listener#onClose(WebSocket, Optional, + * String) received}. Or to close abruptly, {@link #abort()} is called. Once + * closed it remains closed, cannot be reopened. + * + *

Messages of type {@code X} are sent through the {@code WebSocket.sendX} + * methods and received through {@link WebSocket.Listener}{@code .onX} methods + * asynchronously. Each of the methods begins the operation and returns a {@link + * CompletionStage} which completes when the operation has completed. + * + *

Messages are received only if {@linkplain #request(long) requested}. + * + *

One outstanding send operation is permitted: if another send operation is + * initiated before the previous one has completed, an {@link + * IllegalStateException IllegalStateException} will be thrown. When sending, a + * message should not be modified until the returned {@code CompletableFuture} + * completes (either normally or exceptionally). + * + *

Messages can be sent and received as a whole or in parts. A whole message + * is a sequence of one or more messages in which the last message is marked + * when it is sent or received. + * + *

If the message is contained in a {@link ByteBuffer}, bytes are considered + * arranged from the {@code buffer}'s {@link ByteBuffer#position() position} to + * the {@code buffer}'s {@link ByteBuffer#limit() limit}. + * + *

All message exchange is run by the threads belonging to the {@linkplain + * HttpClient#executorService() executor service} of {@code WebSocket}'s {@link + * HttpClient}. + * + *

Unless otherwise noted, passing a {@code null} argument to a constructor + * or method of this type will cause a {@link NullPointerException + * NullPointerException} to be thrown. + * + * @since 9 + */ +public interface WebSocket { + + /** + * Creates a builder of {@code WebSocket}s connected to the given URI and + * receiving events with the given {@code Listener}. + * + *

Equivalent to: + *

{@code
+     *     WebSocket.newBuilder(uri, HttpClient.getDefault())
+     * }
+ * + * @param uri + * the WebSocket URI as defined in the WebSocket Protocol + * (with "ws" or "wss" scheme) + * + * @param listener + * the listener + * + * @throws IllegalArgumentException + * if the {@code uri} is not a WebSocket URI + * @throws SecurityException + * if running under a security manager and the caller does + * not have permission to access the + * {@linkplain HttpClient#getDefault() default HttpClient} + * + * @return a builder + */ + static Builder newBuilder(URI uri, Listener listener) { + return newBuilder(uri, HttpClient.getDefault(), listener); + } + + /** + * Creates a builder of {@code WebSocket}s connected to the given URI and + * receiving events with the given {@code Listener}. + * + *

Providing a custom {@code client} allows for finer control over the + * opening handshake. + * + *

Example + *

{@code
+     *     HttpClient client = HttpClient.create()
+     *             .proxy(ProxySelector.of(new InetSocketAddress("proxy.example.com", 80)))
+     *             .build();
+     *     ...
+     *     WebSocket.newBuilder(URI.create("ws://websocket.example.com"), client, listener)...
+     * }
+ * + * @param uri + * the WebSocket URI as defined in the WebSocket Protocol + * (with "ws" or "wss" scheme) + * + * @param client + * the HttpClient + * @param listener + * the listener + * + * @throws IllegalArgumentException + * if the uri is not a WebSocket URI + * + * @return a builder + */ + static Builder newBuilder(URI uri, HttpClient client, Listener listener) { + return new WSBuilder(uri, client, listener); + } + + /** + * A builder for creating {@code WebSocket} instances. + * + *

To build a {@code WebSocket}, instantiate a builder, configure it + * as required by calling intermediate methods (the ones that return the + * builder itself), then finally call {@link #buildAsync()} to get a {@link + * CompletableFuture} with resulting {@code WebSocket}. + * + *

If an intermediate method has not been called, an appropriate + * default value (or behavior) will be used. Unless otherwise noted, a + * repeated call to an intermediate method overwrites the previous value (or + * overrides the previous behaviour), if no exception is thrown. + * + *

Instances of {@code Builder} may not be safe for use by multiple + * threads. + * + * @since 9 + */ + interface Builder { + + /** + * Adds the given name-value pair to the list of additional headers for + * the opening handshake. + * + *

Headers defined in WebSocket Protocol are not allowed to be added. + * + * @param name + * the header name + * @param value + * the header value + * + * @return this builder + * + * @throws IllegalArgumentException + * if the {@code name} is a WebSocket defined header name + */ + Builder header(String name, String value); + + /** + * Includes a request for the given subprotocols during the opening + * handshake. + * + *

Among the requested subprotocols at most one will be chosen by + * the server. When the {@code WebSocket} is connected, the subprotocol + * in use is available from {@link WebSocket#getSubprotocol}. + * Subprotocols may be specified in the order of preference. + * + *

Each of the given subprotocols must conform to the relevant + * rules defined in the WebSocket Protocol. + * + * @param mostPreferred + * the most preferred subprotocol + * @param lesserPreferred + * the lesser preferred subprotocols, with the least preferred + * at the end + * + * @return this builder + * + * @throws IllegalArgumentException + * if any of the WebSocket Protocol rules relevant to + * subprotocols are violated + */ + Builder subprotocols(String mostPreferred, String... lesserPreferred); + + /** + * Sets a timeout for the opening handshake. + * + *

If the opening handshake is not finished within the specified + * timeout then {@link #buildAsync()} completes exceptionally with a + * {@code HttpTimeoutException}. + * + *

If the timeout is not specified then it's deemed infinite. + * + * @param timeout + * the maximum time to wait + * @param unit + * the time unit of the timeout argument + * + * @return this builder + * + * @throws IllegalArgumentException + * if the {@code timeout} is negative + */ + Builder connectTimeout(long timeout, TimeUnit unit); + + /** + * Builds a {@code WebSocket}. + * + *

Returns immediately with a {@code CompletableFuture} + * which completes with the {@code WebSocket} when it is connected, or + * completes exceptionally if an error occurs. + * + *

{@code CompletableFuture} may complete exceptionally with the + * following errors: + *

    + *
  • {@link IOException} + * if an I/O error occurs + *
  • {@link InterruptedException} + * if the operation was interrupted + *
  • {@link SecurityException} + * if a security manager is set, and the caller does not + * have a {@link java.net.URLPermission} for the WebSocket URI + *
  • {@link WebSocketHandshakeException} + * if the opening handshake fails + *
+ * + * @return a {@code CompletableFuture} of {@code WebSocket} + */ + CompletableFuture buildAsync(); + } + + /** + * A listener for events and messages on a {@code WebSocket}. + * + *

Each method below corresponds to a type of event. + *

    + *
  • {@link #onOpen onOpen}
    + * This method is always the first to be invoked. + *
  • {@link #onText(WebSocket, WebSocket.Text, WebSocket.MessagePart) + * onText}, {@link #onBinary(WebSocket, ByteBuffer, WebSocket.MessagePart) + * onBinary}, {@link #onPing(WebSocket, ByteBuffer) onPing} and {@link + * #onPong(WebSocket, ByteBuffer) onPong}
    + * These methods are invoked zero or more times after {@code onOpen}. + *
  • {@link #onClose(WebSocket, Optional, String) onClose}, {@link + * #onError(WebSocket, Throwable) onError}
    + * Only one of these methods is invoked, and that method is invoked last and + * at most once. + *
+ * + *

+     *     onOpen (onText|onBinary|onPing|onPong)* (onClose|onError)?
+     * 
+ * + *

Messages received by the {@code Listener} conform to the WebSocket + * Protocol, otherwise {@code onError} with a {@link ProtocolException} is + * invoked. + * + *

If a whole message is received, then the corresponding method + * ({@code onText} or {@code onBinary}) will be invoked with {@link + * WebSocket.MessagePart#WHOLE WHOLE} marker. Otherwise the method will be + * invoked with {@link WebSocket.MessagePart#FIRST FIRST}, zero or more + * times with {@link WebSocket.MessagePart#FIRST PART} and, finally, with + * {@link WebSocket.MessagePart#LAST LAST} markers. + * + *


+     *     WHOLE|(FIRST PART* LAST)
+     * 
+ * + *

All methods are invoked in a sequential (and + * + * happens-before) order, one after another, possibly by different + * threads. If any of the methods above throws an exception, {@code onError} + * is then invoked with that exception. Exceptions thrown from {@code + * onError} or {@code onClose} are ignored. + * + *

When the method returns, the message is deemed received. After this + * another messages may be received. + * + *

These invocations begin asynchronous processing which might not end + * with the invocation. To provide coordination, methods of {@code + * Listener} return a {@link CompletionStage CompletionStage}. The {@code + * CompletionStage} signals the {@code WebSocket} that the + * processing of a message has ended. For + * convenience, methods may return {@code null}, which means + * the same as returning an already completed {@code CompletionStage}. If + * the returned {@code CompletionStage} completes exceptionally, then {@link + * #onError(WebSocket, Throwable) onError} will be invoked with the + * exception. + * + *

Control of the message passes to the {@code Listener} with the + * invocation of the method. Control of the message returns to the {@code + * WebSocket} at the earliest of, either returning {@code null} from the + * method, or the completion of the {@code CompletionStage} returned from + * the method. The {@code WebSocket} does not access the message while it's + * not in its control. The {@code Listener} must not access the message + * after its control has been returned to the {@code WebSocket}. + * + *

It is the responsibility of the listener to make additional + * {@linkplain WebSocket#request(long) message requests}, when ready, so + * that messages are received eventually. + * + *

Methods above are never invoked with {@code null}s as their + * arguments. + * + * @since 9 + */ + interface Listener { + + /** + * Notifies the {@code Listener} that it is connected to the provided + * {@code WebSocket}. + * + *

The {@code onOpen} method does not correspond to any message + * from the WebSocket Protocol. It is a synthetic event. It is the first + * {@code Listener}'s method to be invoked. No other {@code Listener}'s + * methods are invoked before this one. The method is usually used to + * make an initial {@linkplain WebSocket#request(long) request} for + * messages. + * + *

If an exception is thrown from this method then {@link + * #onError(WebSocket, Throwable) onError} will be invoked with the + * exception. + * + * @implSpec The default implementation {@linkplain WebSocket#request(long) + * requests one message}. + * + * @param webSocket + * the WebSocket + */ + default void onOpen(WebSocket webSocket) { webSocket.request(1); } + + /** + * Receives a Text message. + * + *

The {@code onText} method is invoked zero or more times between + * {@code onOpen} and ({@code onClose} or {@code onError}). + * + *

This message may be a partial UTF-16 sequence. However, the + * concatenation of all messages through the last will be a whole UTF-16 + * sequence. + * + *

If an exception is thrown from this method or the returned {@code + * CompletionStage} completes exceptionally, then {@link + * #onError(WebSocket, Throwable) onError} will be invoked with the + * exception. + * + * @implSpec The default implementation {@linkplain WebSocket#request(long) + * requests one more message}. + * + * @param webSocket + * the WebSocket + * @param message + * the message + * @param part + * the part + * + * @return a CompletionStage that completes when the message processing + * is done; or {@code null} if already done + */ + default CompletionStage onText(WebSocket webSocket, + Text message, + MessagePart part) { + webSocket.request(1); + return null; + } + + /** + * Receives a Binary message. + * + *

The {@code onBinary} method is invoked zero or more times + * between {@code onOpen} and ({@code onClose} or {@code onError}). + * + *

If an exception is thrown from this method or the returned {@code + * CompletionStage} completes exceptionally, then {@link + * #onError(WebSocket, Throwable) onError} will be invoked with this + * exception. + * + * @implSpec The default implementation {@linkplain WebSocket#request(long) + * requests one more message}. + * + * @param webSocket + * the WebSocket + * @param message + * the message + * @param part + * the part + * + * @return a CompletionStage that completes when the message processing + * is done; or {@code null} if already done + */ + default CompletionStage onBinary(WebSocket webSocket, + ByteBuffer message, + MessagePart part) { + webSocket.request(1); + return null; + } + + /** + * Receives a Ping message. + * + *

A Ping message may be sent or received by either client or + * server. It may serve either as a keepalive or as a means to verify + * that the remote endpoint is still responsive. + * + *

The message will consist of not more than {@code 125} bytes: + * {@code message.remaining() <= 125}. + * + *

The {@code onPing} is invoked zero or more times in between + * {@code onOpen} and ({@code onClose} or {@code onError}). + * + *

If an exception is thrown from this method or the returned {@code + * CompletionStage} completes exceptionally, then {@link + * #onError(WebSocket, Throwable) onError} will be invoked with this + * exception. + * + * @implNote + * + *

Replies with a Pong message and requests one more message when + * the Pong has been sent. + * + * @param webSocket + * the WebSocket + * @param message + * the message + * + * @return a CompletionStage that completes when the message processing + * is done; or {@code null} if already done + */ + default CompletionStage onPing(WebSocket webSocket, + ByteBuffer message) { + return webSocket.sendPong(message).thenRun(() -> webSocket.request(1)); + } + + /** + * Receives a Pong message. + * + *

A Pong message may be unsolicited or may be received in response + * to a previously sent Ping. In the latter case, the contents of the + * Pong is identical to the originating Ping. + * + *

The message will consist of not more than {@code 125} bytes: + * {@code message.remaining() <= 125}. + * + *

The {@code onPong} method is invoked zero or more times in + * between {@code onOpen} and ({@code onClose} or {@code onError}). + * + *

If an exception is thrown from this method or the returned {@code + * CompletionStage} completes exceptionally, then {@link + * #onError(WebSocket, Throwable) onError} will be invoked with this + * exception. + * + * @implSpec The default implementation {@linkplain WebSocket#request(long) + * requests one more message}. + * + * @param webSocket + * the WebSocket + * @param message + * the message + * + * @return a CompletionStage that completes when the message processing + * is done; or {@code null} if already done + */ + default CompletionStage onPong(WebSocket webSocket, + ByteBuffer message) { + webSocket.request(1); + return null; + } + + /** + * Receives a Close message. + * + *

Once a Close message is received, the server will not send any + * more messages. + * + *

A Close message may consist of a close code and a reason for + * closing. The reason will have a UTF-8 representation not longer than + * {@code 123} bytes. The reason may be useful for debugging or passing + * information relevant to the connection but is not necessarily human + * readable. + * + *

{@code onClose} is the last invocation on the {@code Listener}. + * It is invoked at most once, but after {@code onOpen}. If an exception + * is thrown from this method, it is ignored. + * + * @implSpec The default implementation does nothing. + * + * @param webSocket + * the WebSocket + * @param code + * an {@code Optional} describing the close code, or + * an empty {@code Optional} if the message doesn't contain it + * @param reason + * the reason of close; can be empty + */ + default void onClose(WebSocket webSocket, Optional code, + String reason) { } + + /** + * Notifies an I/O or protocol error has occurred on the {@code + * WebSocket}. + * + *

The {@code onError} method does not correspond to any message + * from the WebSocket Protocol. It is a synthetic event. {@code onError} + * is the last invocation on the {@code Listener}. It is invoked at most + * once but after {@code onOpen}. If an exception is thrown from this + * method, it is ignored. + * + *

The WebSocket Protocol requires some errors occurs in the + * incoming destination must be fatal to the connection. In such cases + * the implementation takes care of closing the {@code WebSocket}. By + * the time {@code onError} is invoked, no more messages can be sent on + * this {@code WebSocket}. + * + * @apiNote Errors associated with send operations ({@link + * WebSocket#sendText(CharSequence, boolean) sendText}, {@link + * #sendBinary(ByteBuffer, boolean) sendBinary}, {@link + * #sendPing(ByteBuffer) sendPing}, {@link #sendPong(ByteBuffer) + * sendPong} and {@link #sendClose(CloseCode, CharSequence) sendClose}) + * are reported to the {@code CompletionStage} operations return. + * + * @implSpec The default implementation does nothing. + * + * @param webSocket + * the WebSocket + * @param error + * the error + */ + default void onError(WebSocket webSocket, Throwable error) { } + } + + /** + * A marker used by {@link WebSocket.Listener} for partial message + * receiving. + * + * @since 9 + */ + enum MessagePart { + + /** + * The first part of a message in a sequence. + */ + FIRST, + + /** + * A middle part of a message in a sequence. + */ + PART, + + /** + * The last part of a message in a sequence. + */ + LAST, + + /** + * A whole message. The message consists of a single part. + */ + WHOLE; + + /** + * Tells whether a part of a message received with this marker is the + * last part. + * + * @return {@code true} if LAST or WHOLE, {@code false} otherwise + */ + public boolean isLast() { + return this == LAST || this == WHOLE; + } + } + + /** + * Sends a Text message with bytes from the given {@code ByteBuffer}. + * + *

Returns immediately with a {@code CompletableFuture} which + * completes normally when the message has been sent, or completes + * exceptionally if an error occurs. + * + *

This message may be a partial UTF-8 sequence. However, the + * concatenation of all messages through the last must be a whole UTF-8 + * sequence. + * + *

The {@code ByteBuffer} should not be modified until the returned + * {@code CompletableFuture} completes (either normally or exceptionally). + * + *

The returned {@code CompletableFuture} can complete exceptionally + * with: + *

    + *
  • {@link IOException} + * if an I/O error occurs during this operation; or the + * {@code WebSocket} closes while this operation is in progress; + * or the {@code message} is a malformed UTF-8 sequence + *
+ * + * @param message + * the message + * @param isLast + * {@code true} if this is the final part of the message, + * {@code false} otherwise + * + * @return a CompletableFuture of Void + * + * @throws IllegalStateException + * if the WebSocket is closed + * @throws IllegalStateException + * if a Close message has been sent already + * @throws IllegalStateException + * if there is an outstanding send operation + * @throws IllegalStateException + * if a previous Binary message + * was not sent with {@code isLast == true} + */ + CompletableFuture sendText(ByteBuffer message, boolean isLast); + + /** + * Sends a Text message with characters from the given {@code + * CharSequence}. + * + *

Returns immediately with a {@code CompletableFuture} which + * completes normally when the message has been sent, or completes + * exceptionally if an error occurs. + * + *

This message may be a partial UTF-16 sequence. However, the + * concatenation of all messages through the last must be a whole UTF-16 + * sequence. + * + *

The {@code CharSequence} should not be modified until the returned + * {@code CompletableFuture} completes (either normally or exceptionally). + * + *

The returned {@code CompletableFuture} can complete exceptionally + * with: + *

    + *
  • {@link IOException} + * if an I/O error occurs during this operation; or the + * {@code WebSocket} closes while this operation is in progress; + * or the {@code message} is a malformed UTF-16 sequence + *
+ * + * @param message + * the message + * @param isLast + * {@code true} if this is the final part of the message + * {@code false} otherwise + * + * @return a CompletableFuture of Void + * + * @throws IllegalStateException + * if the WebSocket is closed + * @throws IllegalStateException + * if a Close message has been already sent + * @throws IllegalStateException + * if there is an outstanding send operation + * @throws IllegalStateException + * if a previous Binary message was not sent + * with {@code isLast == true} + */ + CompletableFuture sendText(CharSequence message, boolean isLast); + + /** + * Sends a whole Text message with characters from the given {@code + * CharSequence}. + * + *

This is a convenience method. For the general case, use {@link + * #sendText(CharSequence, boolean)}. + * + *

Returns immediately with a {@code CompletableFuture} which + * completes normally when the message has been sent, or completes + * exceptionally if an error occurs. + * + *

The {@code CharSequence} should not be modified until the returned + * {@code CompletableFuture} completes (either normally or exceptionally). + * + *

The returned {@code CompletableFuture} can complete exceptionally + * with: + *

    + *
  • {@link IOException} + * if an I/O error occurs during this operation; or the + * {@code WebSocket} closes while this operation is in progress; + * or the message is a malformed UTF-16 sequence + *
+ * + * @param message + * the message + * + * @return a CompletableFuture of Void + * + * @throws IllegalStateException + * if the WebSocket is closed + * @throws IllegalStateException + * if a Close message has been already sent + * @throws IllegalStateException + * if there is an outstanding send operation + * @throws IllegalStateException + * if a previous Binary message was not sent + * with {@code isLast == true} + */ + default CompletableFuture sendText(CharSequence message) { + return sendText(message, true); + } + + /** + * Sends a whole Text message with characters from {@code + * CharacterSequence}s provided by the given {@code Stream}. + * + *

This is a convenience method. For the general case use {@link + * #sendText(CharSequence, boolean)}. + * + *

Returns immediately with a {@code CompletableFuture} which + * completes normally when the message has been sent, or completes + * exceptionally if an error occurs. + * + *

Streamed character sequences should not be modified until the + * returned {@code CompletableFuture} completes (either normally or + * exceptionally). + * + *

The returned {@code CompletableFuture} can complete exceptionally + * with: + *

    + *
  • {@link IOException} + * if an I/O error occurs during this operation; or the + * {@code WebSocket} closes while this operation is in progress; + * or the message is a malformed UTF-16 sequence + *
+ * + * @param message + * the message + * + * @return a CompletableFuture of Void + * + * @throws IllegalStateException + * if the WebSocket is closed + * @throws IllegalStateException + * if a Close message has been already sent + * @throws IllegalStateException + * if there is an outstanding send operation + * @throws IllegalStateException + * if a previous Binary message was not sent + * with {@code isLast == true} + */ + CompletableFuture sendText(Stream message); + + /** + * Sends a Binary message with bytes from the given {@code ByteBuffer}. + * + *

Returns immediately with a {@code CompletableFuture} which + * completes normally when the message has been sent, or completes + * exceptionally if an error occurs. + * + *

The returned {@code CompletableFuture} can complete exceptionally + * with: + *

    + *
  • {@link IOException} + * if an I/O error occurs during this operation or the + * {@code WebSocket} closes while this operation is in progress + *
+ * + * @param message + * the message + * @param isLast + * {@code true} if this is the final part of the message, + * {@code false} otherwise + * + * @return a CompletableFuture of Void + * + * @throws IllegalStateException + * if the WebSocket is closed + * @throws IllegalStateException + * if a Close message has been already sent + * @throws IllegalStateException + * if there is an outstanding send operation + * @throws IllegalStateException + * if a previous Text message was not sent + * with {@code isLast == true} + */ + CompletableFuture sendBinary(ByteBuffer message, boolean isLast); + + /** + * Sends a Binary message with bytes from the given {@code byte[]}. + * + *

Returns immediately with a {@code CompletableFuture} which + * completes normally when the message has been sent, or completes + * exceptionally if an error occurs. + * + *

The returned {@code CompletableFuture} can complete exceptionally + * with: + *

    + *
  • {@link IOException} + * if an I/O error occurs during this operation or the + * {@code WebSocket} closes while this operation is in progress + *
+ * + * @implSpec This is equivalent to: + *
{@code
+     *     sendBinary(ByteBuffer.wrap(message), isLast)
+     * }
+ * + * @param message + * the message + * @param isLast + * {@code true} if this is the final part of the message, + * {@code false} otherwise + * + * @return a CompletableFuture of Void + * + * @throws IllegalStateException + * if the WebSocket is closed + * @throws IllegalStateException + * if a Close message has been already sent + * @throws IllegalStateException + * if there is an outstanding send operation + * @throws IllegalStateException + * if a previous Text message was not sent + * with {@code isLast == true} + */ + default CompletableFuture sendBinary(byte[] message, boolean isLast) { + Objects.requireNonNull(message, "message"); + return sendBinary(ByteBuffer.wrap(message), isLast); + } + + /** + * Sends a Ping message. + * + *

Returns immediately with a {@code CompletableFuture} which + * completes normally when the message has been sent, or completes + * exceptionally if an error occurs. + * + *

A Ping message may be sent or received by either client or server. + * It may serve either as a keepalive or as a means to verify that the + * remote endpoint is still responsive. + * + *

The message must consist of not more than {@code 125} bytes: {@code + * message.remaining() <= 125}. + * + *

The returned {@code CompletableFuture} can complete exceptionally + * with: + *

    + *
  • {@link IOException} + * if an I/O error occurs during this operation or the + * {@code WebSocket} closes while this operation is in progress + *
+ * + * @param message + * the message + * + * @return a CompletableFuture of Void + * + * @throws IllegalStateException + * if the WebSocket is closed + * @throws IllegalStateException + * if a Close message has been already sent + * @throws IllegalStateException + * if there is an outstanding send operation + * @throws IllegalArgumentException + * if {@code message.remaining() > 125} + */ + CompletableFuture sendPing(ByteBuffer message); + + /** + * Sends a Pong message. + * + *

Returns immediately with a {@code CompletableFuture} which + * completes normally when the message has been sent, or completes + * exceptionally if an error occurs. + * + *

A Pong message may be unsolicited or may be sent in response to a + * previously received Ping. In latter case the contents of the Pong is + * identical to the originating Ping. + * + *

The message must consist of not more than {@code 125} bytes: {@code + * message.remaining() <= 125}. + * + *

The returned {@code CompletableFuture} can complete exceptionally + * with: + *

    + *
  • {@link IOException} + * if an I/O error occurs during this operation or the + * {@code WebSocket} closes while this operation is in progress + *
+ * + * @param message + * the message + * + * @return a CompletableFuture of Void + * + * @throws IllegalStateException + * if the WebSocket is closed + * @throws IllegalStateException + * if a Close message has been already sent + * @throws IllegalStateException + * if there is an outstanding send operation + * @throws IllegalArgumentException + * if {@code message.remaining() > 125} + */ + CompletableFuture sendPong(ByteBuffer message); + + /** + * Sends a Close message with the given close code and the reason. + * + *

Returns immediately with a {@code CompletableFuture} which + * completes normally when the message has been sent, or completes + * exceptionally if an error occurs. + * + *

A Close message may consist of a close code and a reason for closing. + * The reason must have a valid UTF-8 representation not longer than {@code + * 123} bytes. The reason may be useful for debugging or passing information + * relevant to the connection but is not necessarily human readable. + * + *

The returned {@code CompletableFuture} can complete exceptionally + * with: + *

    + *
  • {@link IOException} + * if an I/O error occurs during this operation or the + * {@code WebSocket} closes while this operation is in progress + *
+ * + * @param code + * the close code + * @param reason + * the reason; can be empty + * + * @return a CompletableFuture of Void + * + * @throws IllegalStateException + * if the WebSocket is closed + * @throws IllegalStateException + * if a Close message has been already sent + * @throws IllegalStateException + * if there is an outstanding send operation + * @throws IllegalArgumentException + * if the {@code reason} doesn't have a valid UTF-8 + * representation not longer than {@code 123} bytes + */ + CompletableFuture sendClose(CloseCode code, CharSequence reason); + + /** + * Sends an empty Close message. + * + *

Returns immediately with a {@code CompletableFuture} which + * completes normally when the message has been sent, or completes + * exceptionally if an error occurs. + * + *

The returned {@code CompletableFuture} can complete exceptionally + * with: + *

    + *
  • {@link IOException} + * if an I/O error occurs during this operation or the + * {@code WebSocket} closes while this operation is in progress + *
+ * + * @return a CompletableFuture of Void + * + * @throws IllegalStateException + * if the WebSocket is closed + * @throws IllegalStateException + * if a Close message has been already sent + * @throws IllegalStateException + * if there is an outstanding send operation + */ + CompletableFuture sendClose(); + + /** + * Requests {@code n} more messages to be received by the {@link Listener + * Listener}. + * + *

The actual number might be fewer if either of the endpoints decide to + * close the connection before that or an error occurs. + * + *

A {@code WebSocket} that has just been created, hasn't requested + * anything yet. Usually the initial request for messages is done in {@link + * Listener#onOpen(java.net.http.WebSocket) Listener.onOpen}. + * + * If all requested messages have been received, and the server sends more, + * then these messages are queued. + * + * @implNote This implementation does not distinguish between partial and + * whole messages, because it's not known beforehand how a message will be + * received. + *

If a server sends more messages than requested, the implementation + * queues up these messages on the TCP connection and may eventually force + * the sender to stop sending through TCP flow control. + * + * @param n + * the number of messages + * + * @throws IllegalArgumentException + * if {@code n < 0} + * + * @return resulting unfulfilled demand with this request taken into account + */ + // TODO return void as it's breaking encapsulation (leaking info when exactly something deemed delivered) + // or demand behaves after LONG.MAX_VALUE + long request(long n); + + /** + * Returns a {@linkplain Builder#subprotocols(String, String...) subprotocol} + * in use. + * + * @return a subprotocol, or {@code null} if there is none + */ + String getSubprotocol(); + + /** + * Tells whether the {@code WebSocket} is closed. + * + *

A {@code WebSocket} deemed closed when either the underlying socket + * is closed or the closing handshake is completed. + * + * @return {@code true} if the {@code WebSocket} is closed, + * {@code false} otherwise + */ + boolean isClosed(); + + /** + * Closes the {@code WebSocket} abruptly. + * + *

This method closes the underlying TCP connection. If the {@code + * WebSocket} is already closed then invoking this method has no effect. + * + * @throws IOException + * if an I/O error occurs + */ + void abort() throws IOException; + + /** + * A {@code WebSocket} close status code. + * + *

Some codes + * specified in the WebSocket Protocol are defined as named constants + * here. Others can be {@linkplain #of(int) retrieved on demand}. + * + *

This is a + * value-based class; + * use of identity-sensitive operations (including reference equality + * ({@code ==}), identity hash code, or synchronization) on instances of + * {@code CloseCode} may have unpredictable results and should be avoided. + * + * @since 9 + */ + final class CloseCode { + + /** + * Indicates a normal close, meaning that the purpose for which the + * connection was established has been fulfilled. + * + *

Numerical representation: {@code 1000} + */ + public static final CloseCode NORMAL_CLOSURE + = new CloseCode(1000, "NORMAL_CLOSURE"); + + /** + * Indicates that an endpoint is "going away", such as a server going + * down or a browser having navigated away from a page. + * + *

Numerical representation: {@code 1001} + */ + public static final CloseCode GOING_AWAY + = new CloseCode(1001, "GOING_AWAY"); + + /** + * Indicates that an endpoint is terminating the connection due to a + * protocol error. + * + *

Numerical representation: {@code 1002} + */ + public static final CloseCode PROTOCOL_ERROR + = new CloseCode(1002, "PROTOCOL_ERROR"); + + /** + * Indicates that an endpoint is terminating the connection because it + * has received a type of data it cannot accept (e.g., an endpoint that + * understands only text data MAY send this if it receives a binary + * message). + * + *

Numerical representation: {@code 1003} + */ + public static final CloseCode CANNOT_ACCEPT + = new CloseCode(1003, "CANNOT_ACCEPT"); + + /** + * Indicates that an endpoint is terminating the connection because it + * has received data within a message that was not consistent with the + * type of the message (e.g., non-UTF-8 [RFC3629] data within a text + * message). + * + *

Numerical representation: {@code 1007} + */ + public static final CloseCode NOT_CONSISTENT + = new CloseCode(1007, "NOT_CONSISTENT"); + + /** + * Indicates that an endpoint is terminating the connection because it + * has received a message that violates its policy. This is a generic + * status code that can be returned when there is no other more suitable + * status code (e.g., {@link #CANNOT_ACCEPT} or {@link #TOO_BIG}) or if + * there is a need to hide specific details about the policy. + * + *

Numerical representation: {@code 1008} + */ + public static final CloseCode VIOLATED_POLICY + = new CloseCode(1008, "VIOLATED_POLICY"); + + /** + * Indicates that an endpoint is terminating the connection because it + * has received a message that is too big for it to process. + * + *

Numerical representation: {@code 1009} + */ + public static final CloseCode TOO_BIG + = new CloseCode(1009, "TOO_BIG"); + + /** + * Indicates that an endpoint is terminating the connection because it + * encountered an unexpected condition that prevented it from fulfilling + * the request. + * + *

Numerical representation: {@code 1011} + */ + public static final CloseCode UNEXPECTED_CONDITION + = new CloseCode(1011, "UNEXPECTED_CONDITION"); + + private static final Map cached = Map.ofEntries( + entry(NORMAL_CLOSURE), + entry(GOING_AWAY), + entry(PROTOCOL_ERROR), + entry(CANNOT_ACCEPT), + entry(NOT_CONSISTENT), + entry(VIOLATED_POLICY), + entry(TOO_BIG), + entry(UNEXPECTED_CONDITION) + ); + + /** + * Returns a {@code CloseCode} from its numerical representation. + * + *

The given {@code code} should be in the range {@code 1000 <= code + * <= 4999}, and should not be equal to any of the following codes: + * {@code 1004}, {@code 1005}, {@code 1006} and {@code 1015}. + * + * @param code + * numerical representation + * + * @return a close code corresponding to the provided numerical value + * + * @throws IllegalArgumentException + * if {@code code} violates any of the requirements above + */ + public static CloseCode of(int code) { + if (code < 1000 || code > 4999) { + throw new IllegalArgumentException("Out of range: " + code); + } + if (code == 1004 || code == 1005 || code == 1006 || code == 1015) { + throw new IllegalArgumentException("Reserved: " + code); + } + CloseCode closeCode = cached.get(code); + return closeCode != null ? closeCode : new CloseCode(code, ""); + } + + private final int code; + private final String description; + + private CloseCode(int code, String description) { + assert description != null; + this.code = code; + this.description = description; + } + + /** + * Returns a numerical representation of this close code. + * + * @return a numerical representation + */ + public int getCode() { + return code; + } + + /** + * Compares this close code to the specified object. + * + * @param o + * the object to compare this {@code CloseCode} against + * + * @return {@code true} iff the argument is a close code with the same + * {@linkplain #getCode() numerical representation} as this one + */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof CloseCode)) { + return false; + } + CloseCode that = (CloseCode) o; + return code == that.code; + } + + @Override + public int hashCode() { + return code; + } + + /** + * Returns a human-readable representation of this close code. + * + * @apiNote The representation is not designed to be parsed; the format + * may change unexpectedly. + * + * @return a string representation + */ + @Override + public String toString() { + return code + (description.isEmpty() ? "" : (": " + description)); + } + + private static Map.Entry entry(CloseCode cc) { + return Map.entry(cc.getCode(), cc); + } + } + + /** + * A character sequence that provides access to the characters UTF-8 decoded + * from a message in a {@code ByteBuffer}. + * + * @since 9 + */ + interface Text extends CharSequence { + + // Methods from the CharSequence below are mentioned explicitly for the + // purpose of documentation, so when looking at javadoc it immediately + // obvious what methods Text has + + @Override + int length(); + + @Override + char charAt(int index); + + @Override + CharSequence subSequence(int start, int end); + + /** + * Returns a string containing the characters in this sequence in the + * same order as this sequence. The length of the string will be the + * length of this sequence. + * + * @return a string consisting of exactly this sequence of characters + */ + @Override + // TODO: remove the explicit javadoc above when: + // (JDK-8144034 has been resolved) AND (the comment is still identical + // to CharSequence#toString) + String toString(); + + /** + * Returns a read-only {@code ByteBuffer} containing the message encoded + * in UTF-8. + * + * @return a read-only ByteBuffer + */ + ByteBuffer asByteBuffer(); + } +} diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/WebSocketHandshakeException.java b/jdk/src/java.httpclient/share/classes/java/net/http/WebSocketHandshakeException.java new file mode 100644 index 00000000000..d9f630632fd --- /dev/null +++ b/jdk/src/java.httpclient/share/classes/java/net/http/WebSocketHandshakeException.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2015, 2016, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package java.net.http; + +/** + * An exception used to signal the opening handshake failed. + * + * @since 9 + */ +public final class WebSocketHandshakeException extends Exception { + + private static final long serialVersionUID = 1L; + private final transient HttpResponse response; + + WebSocketHandshakeException(HttpResponse response) { + this(null, response); + } + + WebSocketHandshakeException(String message, HttpResponse response) { + super(statusCodeOrFullMessage(message, response)); + this.response = response; + } + + /** + * // FIXME: terrible toString (+ not always status should be displayed I guess) + */ + private static String statusCodeOrFullMessage(String m, HttpResponse response) { + return (m == null || m.isEmpty()) + ? String.valueOf(response.statusCode()) + : response.statusCode() + ": " + m; + } + + /** + * Returns a HTTP response from the server. + * + *

The value may be unavailable ({@code null}) if this exception has + * been serialized and then read back in. + * + * @return server response + */ + public HttpResponse getResponse() { + return response; + } +} diff --git a/jdk/src/java.httpclient/share/classes/java/net/http/package-info.java b/jdk/src/java.httpclient/share/classes/java/net/http/package-info.java index db2319bfa43..4ad47b879f9 100644 --- a/jdk/src/java.httpclient/share/classes/java/net/http/package-info.java +++ b/jdk/src/java.httpclient/share/classes/java/net/http/package-info.java @@ -33,6 +33,7 @@ *

  • {@link java.net.http.HttpClient}
  • *
  • {@link java.net.http.HttpRequest}
  • *
  • {@link java.net.http.HttpResponse}
  • + *
  • {@link java.net.http.WebSocket}
  • * * * @since 9 diff --git a/jdk/test/java/net/httpclient/BasicWebSocketAPITest.java b/jdk/test/java/net/httpclient/BasicWebSocketAPITest.java new file mode 100644 index 00000000000..4e454c0a533 --- /dev/null +++ b/jdk/test/java/net/httpclient/BasicWebSocketAPITest.java @@ -0,0 +1,332 @@ +/* + * Copyright (c) 2016, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +import org.testng.annotations.Test; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.net.http.WebSocket.CloseCode; +import java.nio.ByteBuffer; +import java.nio.channels.SocketChannel; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.stream.Stream; + +/* + * @test + * @bug 8087113 + * @build TestKit + * @run testng/othervm BasicWebSocketAPITest + */ +public class BasicWebSocketAPITest { + + @Test + public void webSocket() throws Exception { + checkAndClose( + (ws) -> + TestKit.assertThrows(IllegalArgumentException.class, + "(?i).*\\bnegative\\b.*", + () -> ws.request(-1)) + ); + checkAndClose((ws) -> + TestKit.assertNotThrows(() -> ws.request(0)) + ); + checkAndClose((ws) -> + TestKit.assertNotThrows(() -> ws.request(1)) + ); + checkAndClose((ws) -> + TestKit.assertNotThrows(() -> ws.request(Long.MAX_VALUE)) + ); + checkAndClose((ws) -> + TestKit.assertNotThrows(ws::isClosed) + ); + checkAndClose((ws) -> + TestKit.assertNotThrows(ws::getSubprotocol) + ); + checkAndClose( + (ws) -> { + try { + ws.abort(); + } catch (IOException ignored) { } + // No matter what happens during the first abort invocation, + // other invocations must return normally + TestKit.assertNotThrows(ws::abort); + TestKit.assertNotThrows(ws::abort); + } + ); + checkAndClose( + (ws) -> + TestKit.assertThrows(NullPointerException.class, + "message", + () -> ws.sendBinary((byte[]) null, true)) + ); + checkAndClose( + (ws) -> + TestKit.assertThrows(NullPointerException.class, + "message", + () -> ws.sendBinary((ByteBuffer) null, true)) + ); + checkAndClose( + (ws) -> + TestKit.assertThrows(NullPointerException.class, + "message", + () -> ws.sendPing(null)) + ); + checkAndClose( + (ws) -> + TestKit.assertThrows(NullPointerException.class, + "message", + () -> ws.sendPong(null)) + ); + checkAndClose( + (ws) -> + TestKit.assertThrows(NullPointerException.class, + "message", + () -> ws.sendText((CharSequence) null, true)) + ); + checkAndClose( + (ws) -> + TestKit.assertThrows(NullPointerException.class, + "message", + () -> ws.sendText((CharSequence) null)) + ); + checkAndClose( + (ws) -> + TestKit.assertThrows(NullPointerException.class, + "message", + () -> ws.sendText((Stream) null)) + ); + checkAndClose( + (ws) -> + TestKit.assertThrows(NullPointerException.class, + "code", + () -> ws.sendClose(null, "")) + ); + checkAndClose( + (ws) -> + TestKit.assertNotThrows( + () -> ws.sendClose(CloseCode.NORMAL_CLOSURE, "")) + ); + checkAndClose( + (ws) -> + TestKit.assertThrows(NullPointerException.class, + "reason", + () -> ws.sendClose(CloseCode.NORMAL_CLOSURE, null)) + ); + checkAndClose( + (ws) -> + TestKit.assertThrows(NullPointerException.class, + "code|reason", + () -> ws.sendClose(null, null)) + ); + } + + @Test + public void builder() { + URI ws = URI.create("ws://localhost:9001"); + // FIXME: check all 24 cases: + // {null, ws, wss, incorrect} x {null, HttpClient.getDefault(), custom} x {null, listener} + // + // if (any null) or (any incorrect) + // NPE or IAE is thrown + // else + // builder is created + TestKit.assertThrows(NullPointerException.class, + "uri", + () -> WebSocket.newBuilder(null, defaultListener()) + ); + TestKit.assertThrows(NullPointerException.class, + "listener", + () -> WebSocket.newBuilder(ws, null) + ); + URI uri = URI.create("ftp://localhost:9001"); + TestKit.assertThrows(IllegalArgumentException.class, + "(?i).*\\buri\\b\\s+\\bscheme\\b.*", + () -> WebSocket.newBuilder(uri, defaultListener()) + ); + TestKit.assertNotThrows( + () -> WebSocket.newBuilder(ws, defaultListener()) + ); + URI uri1 = URI.create("wss://localhost:9001"); + TestKit.assertNotThrows( + () -> WebSocket.newBuilder(uri1, defaultListener()) + ); + URI uri2 = URI.create("wss://localhost:9001#a"); + TestKit.assertThrows(IllegalArgumentException.class, + "(?i).*\\bfragment\\b.*", + () -> WebSocket.newBuilder(uri2, HttpClient.getDefault(), defaultListener()) + ); + TestKit.assertThrows(NullPointerException.class, + "uri", + () -> WebSocket.newBuilder(null, HttpClient.getDefault(), defaultListener()) + ); + TestKit.assertThrows(NullPointerException.class, + "client", + () -> WebSocket.newBuilder(ws, null, defaultListener()) + ); + TestKit.assertThrows(NullPointerException.class, + "listener", + () -> WebSocket.newBuilder(ws, HttpClient.getDefault(), null) + ); + // FIXME: check timeout works + // (i.e. it directly influences the time WebSocket waits for connection + opening handshake) + TestKit.assertNotThrows( + () -> WebSocket.newBuilder(ws, defaultListener()).connectTimeout(1, TimeUnit.SECONDS) + ); + WebSocket.Builder builder = WebSocket.newBuilder(ws, defaultListener()); + TestKit.assertThrows(IllegalArgumentException.class, + "(?i).*\\bnegative\\b.*", + () -> builder.connectTimeout(-1, TimeUnit.SECONDS) + ); + WebSocket.Builder builder1 = WebSocket.newBuilder(ws, defaultListener()); + TestKit.assertThrows(NullPointerException.class, + "unit", + () -> builder1.connectTimeout(1, null) + ); + // FIXME: check these headers are actually received by the server + TestKit.assertNotThrows( + () -> WebSocket.newBuilder(ws, defaultListener()).header("a", "b") + ); + TestKit.assertNotThrows( + () -> WebSocket.newBuilder(ws, defaultListener()).header("a", "b").header("a", "b") + ); + // FIXME: check all 18 cases: + // {null, websocket(7), custom} x {null, custom} + WebSocket.Builder builder2 = WebSocket.newBuilder(ws, defaultListener()); + TestKit.assertThrows(NullPointerException.class, + "name", + () -> builder2.header(null, "b") + ); + WebSocket.Builder builder3 = WebSocket.newBuilder(ws, defaultListener()); + TestKit.assertThrows(NullPointerException.class, + "value", + () -> builder3.header("a", null) + ); + WebSocket.Builder builder4 = WebSocket.newBuilder(ws, defaultListener()); + TestKit.assertThrows(IllegalArgumentException.class, + () -> builder4.header("Sec-WebSocket-Accept", "") + ); + WebSocket.Builder builder5 = WebSocket.newBuilder(ws, defaultListener()); + TestKit.assertThrows(IllegalArgumentException.class, + () -> builder5.header("Sec-WebSocket-Extensions", "") + ); + WebSocket.Builder builder6 = WebSocket.newBuilder(ws, defaultListener()); + TestKit.assertThrows(IllegalArgumentException.class, + () -> builder6.header("Sec-WebSocket-Key", "") + ); + WebSocket.Builder builder7 = WebSocket.newBuilder(ws, defaultListener()); + TestKit.assertThrows(IllegalArgumentException.class, + () -> builder7.header("Sec-WebSocket-Protocol", "") + ); + WebSocket.Builder builder8 = WebSocket.newBuilder(ws, defaultListener()); + TestKit.assertThrows(IllegalArgumentException.class, + () -> builder8.header("Sec-WebSocket-Version", "") + ); + WebSocket.Builder builder9 = WebSocket.newBuilder(ws, defaultListener()); + TestKit.assertThrows(IllegalArgumentException.class, + () -> builder9.header("Connection", "") + ); + WebSocket.Builder builder10 = WebSocket.newBuilder(ws, defaultListener()); + TestKit.assertThrows(IllegalArgumentException.class, + () -> builder10.header("Upgrade", "") + ); + // FIXME: check 3 cases (1 arg): + // {null, incorrect, custom} + // FIXME: check 12 cases (2 args): + // {null, incorrect, custom} x {(String) null, (String[]) null, incorrect, custom} + // FIXME: check 27 cases (3 args) (the interesting part in null inside var-arg): + // {null, incorrect, custom}^3 + // FIXME: check the server receives them in the order listed + TestKit.assertThrows(NullPointerException.class, + "mostPreferred", + () -> WebSocket.newBuilder(ws, defaultListener()).subprotocols(null) + ); + TestKit.assertThrows(NullPointerException.class, + "lesserPreferred", + () -> WebSocket.newBuilder(ws, defaultListener()).subprotocols("a", null) + ); + TestKit.assertThrows(NullPointerException.class, + "lesserPreferred\\[0\\]", + () -> WebSocket.newBuilder(ws, defaultListener()).subprotocols("a", null, "b") + ); + TestKit.assertThrows(NullPointerException.class, + "lesserPreferred\\[1\\]", + () -> WebSocket.newBuilder(ws, defaultListener()).subprotocols("a", "b", null) + ); + TestKit.assertNotThrows( + () -> WebSocket.newBuilder(ws, defaultListener()).subprotocols("a") + ); + TestKit.assertNotThrows( + () -> WebSocket.newBuilder(ws, defaultListener()).subprotocols("a", "b", "c") + ); + WebSocket.Builder builder11 = WebSocket.newBuilder(ws, defaultListener()); + TestKit.assertThrows(IllegalArgumentException.class, + () -> builder11.subprotocols("") + ); + WebSocket.Builder builder12 = WebSocket.newBuilder(ws, defaultListener()); + TestKit.assertThrows(IllegalArgumentException.class, + () -> builder12.subprotocols("a", "a") + ); + WebSocket.Builder builder13 = WebSocket.newBuilder(ws, defaultListener()); + TestKit.assertThrows(IllegalArgumentException.class, + () -> builder13.subprotocols("a" + ((char) 0x7f)) + ); + } + + private static WebSocket.Listener defaultListener() { + return new WebSocket.Listener() { }; + } + + // + // Automatically closes everything after the check has been performed + // + private static void checkAndClose(Consumer c) { + HandshakePhase HandshakePhase + = new HandshakePhase(new InetSocketAddress("127.0.0.1", 0)); + URI serverURI = HandshakePhase.getURI(); + CompletableFuture cfc = HandshakePhase.afterHandshake(); + WebSocket.Builder b = WebSocket.newBuilder(serverURI, defaultListener()); + CompletableFuture cfw = b.buildAsync(); + + try { + WebSocket ws; + try { + ws = cfw.get(); + } catch (Exception e) { + throw new RuntimeException(e); + } + c.accept(ws); + } finally { + try { + SocketChannel now = cfc.getNow(null); + if (now != null) { + now.close(); + } + } catch (Throwable ignored) { } + } + } +} diff --git a/jdk/test/java/net/httpclient/HandshakePhase.java b/jdk/test/java/net/httpclient/HandshakePhase.java new file mode 100644 index 00000000000..2585ce78f70 --- /dev/null +++ b/jdk/test/java/net/httpclient/HandshakePhase.java @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2016, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.channels.ServerSocketChannel; +import java.nio.channels.SocketChannel; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Base64; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static java.lang.String.format; +import static java.util.Objects.requireNonNull; + +// +// Performs a simple opening handshake and yields the channel. +// +// Client Request: +// +// GET /chat HTTP/1.1 +// Host: server.example.com +// Upgrade: websocket +// Connection: Upgrade +// Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== +// Origin: http://example.com +// Sec-WebSocket-Protocol: chat, superchat +// Sec-WebSocket-Version: 13 +// +// +// Server Response: +// +// HTTP/1.1 101 Switching Protocols +// Upgrade: websocket +// Connection: Upgrade +// Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= +// Sec-WebSocket-Protocol: chat +// +final class HandshakePhase { + + private final ServerSocketChannel ssc; + + HandshakePhase(InetSocketAddress address) { + requireNonNull(address); + try { + ssc = ServerSocketChannel.open(); + ssc.bind(address); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + // + // Returned CF completes normally after the handshake has been performed + // + CompletableFuture afterHandshake( + Function, List> mapping) { + return CompletableFuture.supplyAsync( + () -> { + SocketChannel socketChannel = accept(); + try { + StringBuilder request = new StringBuilder(); + if (!readRequest(socketChannel, request)) { + throw new IllegalStateException(); + } + List strings = Arrays.asList( + request.toString().split("\r\n") + ); + List response = mapping.apply(strings); + writeResponse(socketChannel, response); + return socketChannel; + } catch (Throwable t) { + try { + socketChannel.close(); + } catch (IOException ignored) { } + throw t; + } + }); + } + + CompletableFuture afterHandshake() { + return afterHandshake((request) -> { + List response = new LinkedList<>(); + Iterator iterator = request.iterator(); + if (!iterator.hasNext()) { + throw new IllegalStateException("The request is empty"); + } + if (!"GET / HTTP/1.1".equals(iterator.next())) { + throw new IllegalStateException + ("Unexpected status line: " + request.get(0)); + } + response.add("HTTP/1.1 101 Switching Protocols"); + Map requestHeaders = new HashMap<>(); + while (iterator.hasNext()) { + String header = iterator.next(); + String[] split = header.split(": "); + if (split.length != 2) { + throw new IllegalStateException + ("Unexpected header: " + header + + ", split=" + Arrays.toString(split)); + } + if (requestHeaders.put(split[0], split[1]) != null) { + throw new IllegalStateException + ("Duplicating headers: " + Arrays.toString(split)); + } + } + if (requestHeaders.containsKey("Sec-WebSocket-Protocol")) { + throw new IllegalStateException("Subprotocols are not expected"); + } + if (requestHeaders.containsKey("Sec-WebSocket-Extensions")) { + throw new IllegalStateException("Extensions are not expected"); + } + expectHeader(requestHeaders, "Connection", "Upgrade"); + response.add("Connection: Upgrade"); + expectHeader(requestHeaders, "Upgrade", "websocket"); + response.add("Upgrade: websocket"); + expectHeader(requestHeaders, "Sec-WebSocket-Version", "13"); + String key = requestHeaders.get("Sec-WebSocket-Key"); + if (key == null) { + throw new IllegalStateException("Sec-WebSocket-Key is missing"); + } + MessageDigest sha1 = null; + try { + sha1 = MessageDigest.getInstance("SHA-1"); + } catch (NoSuchAlgorithmException e) { + throw new InternalError(e); + } + String x = key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + sha1.update(x.getBytes(StandardCharsets.ISO_8859_1)); + String v = Base64.getEncoder().encodeToString(sha1.digest()); + response.add("Sec-WebSocket-Accept: " + v); + return response; + }); + } + + private String expectHeader(Map headers, + String name, + String value) { + String v = headers.get(name); + if (!value.equals(v)) { + throw new IllegalStateException( + format("Expected '%s: %s', actual: '%s: %s'", + name, value, name, v) + ); + } + return v; + } + + URI getURI() { + InetSocketAddress a; + try { + a = (InetSocketAddress) ssc.getLocalAddress(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return URI.create("ws://" + a.getHostName() + ":" + a.getPort()); + } + + private int read(SocketChannel socketChannel, ByteBuffer buffer) { + try { + int num = socketChannel.read(buffer); + if (num == -1) { + throw new IllegalStateException("Unexpected EOF"); + } + assert socketChannel.isBlocking() && num > 0; + return num; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private SocketChannel accept() { + SocketChannel socketChannel = null; + try { + socketChannel = ssc.accept(); + socketChannel.configureBlocking(true); + } catch (IOException e) { + if (socketChannel != null) { + try { + socketChannel.close(); + } catch (IOException ignored) { } + } + throw new UncheckedIOException(e); + } + return socketChannel; + } + + private boolean readRequest(SocketChannel socketChannel, + StringBuilder request) { + ByteBuffer buffer = ByteBuffer.allocateDirect(512); + read(socketChannel, buffer); + CharBuffer decoded; + buffer.flip(); + try { + decoded = + StandardCharsets.ISO_8859_1.newDecoder().decode(buffer); + } catch (CharacterCodingException e) { + throw new UncheckedIOException(e); + } + request.append(decoded); + return Pattern.compile("\r\n\r\n").matcher(request).find(); + } + + private void writeResponse(SocketChannel socketChannel, + List response) { + String s = response.stream().collect(Collectors.joining("\r\n")) + + "\r\n\r\n"; + ByteBuffer encoded; + try { + encoded = + StandardCharsets.ISO_8859_1.newEncoder().encode(CharBuffer.wrap(s)); + } catch (CharacterCodingException e) { + throw new UncheckedIOException(e); + } + write(socketChannel, encoded); + } + + private void write(SocketChannel socketChannel, ByteBuffer buffer) { + try { + while (buffer.hasRemaining()) { + socketChannel.write(buffer); + } + } catch (IOException e) { + try { + socketChannel.close(); + } catch (IOException ignored) { } + throw new UncheckedIOException(e); + } + } +}