Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ void testAll(
canStreamRequestBody: canStreamRequestBody);
testResponseBody(clientFactory, canStreamResponseBody: canStreamResponseBody);
testResponseBodyStreamed(clientFactory,
canStreamResponseBody: canStreamResponseBody);
canStreamResponseBody: canStreamResponseBody,
supportsAbort: supportsAbort);
testRequestHeaders(clientFactory);
testRequestMethods(clientFactory, preservesMethodCase: preservesMethodCase);
testResponseHeaders(clientFactory,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import 'response_body_streamed_server_vm.dart'
/// [Client] supports receiving HTTP responses with unbounded body sizes will
/// be skipped
void testResponseBodyStreamed(Client Function() clientFactory,
{bool canStreamResponseBody = true}) {
{bool canStreamResponseBody = true, bool supportsAbort = true}) {
group('streamed response body', () {
late Client client;
late String host;
Expand Down Expand Up @@ -174,7 +174,7 @@ void testResponseBodyStreamed(Client Function() clientFactory,
// cancelled to record that error event.
subscription.resume();
await aborted;
});
}, skip: !supportsAbort ? 'does not support aborting requests' : false);

test('cancel streamed response', () async {
final request = Request('GET', Uri.http(host, ''));
Expand Down
7 changes: 5 additions & 2 deletions pkgs/ok_http/example/integration_test/certificate_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,11 @@ void main() async {
.having((e) => e.message, 'message', contains('Handshake'))));
expect(
() async => await serverException.future,
throwsA(isA<io.HandshakeException>()
.having((e) => e.message, 'message', contains('Handshake'))));
throwsA(anyOf(
isA<io.HandshakeException>()
.having((e) => e.message, 'message', contains('Handshake')),
isA<io.SocketException>(),
)));
});

test('ignore unknown server cert', () async {
Expand Down
15 changes: 14 additions & 1 deletion pkgs/ok_http/example/test_certs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,17 @@

The certificates and private keys used for `package:ok_http` TLS testing.

TODO: generate these files using a script.
## Regenerating Certificates

To regenerate the certificates, run `generate_certificates.sh` on macOS or Linux:

```bash
cd pkgs/ok_http/example/test_certs
./generate_certificates.sh
```

This script requires `openssl` to be installed and generates:
- `test-combined.p12` (password: `1234`): Combined client private key and certificate.
- `server_chain.p12` (password: `dartdart`): Server certificate without private key.
- `server_key.p12` (password: `dartdart`): Server private key without certificate.

85 changes: 85 additions & 0 deletions pkgs/ok_http/example/test_certs/generate_certificates.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/bin/bash
# Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
# for details. All rights reserved. Use of this source code is governed by a
# BSD-style license that can be found in the LICENSE file.

# Generates the test PKCS#12 certificates and private keys for package:ok_http.
# Compatible with macOS (LibreSSL / OpenSSL 3) and Linux (OpenSSL 1.1 / 3.x),
# and readable by Java/Android KeyStore across all supported Android API levels.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"

TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT

# 1. Generate client key and certificate (test-combined.p12, password: 1234)
# Issuer must contain "Internet Widgits Pty Ltd" for certificate_test.dart.
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout "$TMP_DIR/client.key" \
-out "$TMP_DIR/client.crt" \
-days 36500 \
-subj "/C=AU/ST=Some-State/O=Internet Widgits Pty Ltd/CN=client" \
2>/dev/null

openssl pkcs12 -export \
-in "$TMP_DIR/client.crt" \
-inkey "$TMP_DIR/client.key" \
-out test-combined.p12 \
-passout pass:1234 \
-keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES -macalg sha1 \
2>/dev/null

# 2. Generate server key and certificate (for server_chain.p12 and server_key.p12, password: dartdart)
# Includes SAN extension for localhost.
cat <<EOF > "$TMP_DIR/server_openssl.cnf"
[req]
distinguished_name = req_distinguished_name
x509_extensions = v3_req
prompt = no

[req_distinguished_name]
C = US
ST = California
O = Dart
CN = localhost

[v3_req]
subjectAltName = @alt_names

[alt_names]
DNS.1 = localhost
IP.1 = 127.0.0.1
EOF

openssl req -x509 -newkey rsa:2048 -nodes \
-keyout "$TMP_DIR/server.key" \
-out "$TMP_DIR/server.crt" \
-days 36500 \
-config "$TMP_DIR/server_openssl.cnf" \
2>/dev/null

# server_chain.p12 contains certificate only (no private key)
openssl pkcs12 -export \
-in "$TMP_DIR/server.crt" \
-nokeys \
-out server_chain.p12 \
-passout pass:dartdart \
-certpbe PBE-SHA1-3DES -macalg sha1 \
2>/dev/null

# server_key.p12 contains private key only (no certificates)
openssl pkcs12 -export \
-inkey "$TMP_DIR/server.key" \
-nocerts \
-out server_key.p12 \
-passout pass:dartdart \
-keypbe PBE-SHA1-3DES -macalg sha1 \
2>/dev/null

echo "Successfully generated test certificates in $SCRIPT_DIR:"
echo " - test-combined.p12 (password: 1234)"
echo " - server_chain.p12 (password: dartdart)"
echo " - server_key.p12 (password: dartdart)"
Binary file modified pkgs/ok_http/example/test_certs/server_chain.p12
Binary file not shown.
Binary file modified pkgs/ok_http/example/test_certs/server_key.p12
Binary file not shown.
Binary file modified pkgs/ok_http/example/test_certs/test-combined.p12
Binary file not shown.
19 changes: 17 additions & 2 deletions pkgs/ok_http/lib/src/ok_http_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -291,12 +291,27 @@ class OkHttpClient extends BaseClient {
_client.dispatcher().executorService().shutdown();

// Remove all idle connections from the resource pool.
_client.connectionPool().evictAll();
// Note: evictAll() closes open sockets, which can trigger a
// NetworkOnMainThreadException on Android if StrictMode is enabled on
// the main thread.
try {
_client.connectionPool().evictAll();
} on JThrowable catch (e) {
if (!e.message.contains('NetworkOnMainThreadException')) {
rethrow;
}
}

// Close the cache and release the JNI reference to the client.
var cache = _client.cache();
if (cache != null) {
cache.close();
try {
cache.close();
} on JThrowable catch (e) {
if (!e.message.contains('NetworkOnMainThreadException')) {
rethrow;
}
}
}
_client.release();
}
Expand Down
55 changes: 36 additions & 19 deletions pkgs/ok_http/lib/src/ok_http_web_socket.dart
Original file line number Diff line number Diff line change
Expand Up @@ -134,28 +134,36 @@ class OkHttpWebSocket implements WebSocket {

if (_events.isClosed) return;

_events.add(CloseReceived(i, string.toDartString()));
final reason = string.toDartString();
// OkHttp replaces invalid UTF-8 bytes in the close reason with
// the Unicode replacement character (\uFFFD) instead of failing
// the connection. Per RFC 6455 section 5.5.1 and 7.4.1, invalid
// UTF-8 close reasons must be reported as code 1007 (Invalid Frame
// Payload Data).
if (reason.contains('\uFFFD')) {
_events.add(CloseReceived(1007, 'invalid close reason'));
} else {
_events.add(CloseReceived(i, reason));
}
await _events.close();
},
onFailure: (bindings.WebSocket webSocket, JObject throwable,
bindings.Response? response) {
if (_events.isClosed) return;

var throwableString = throwable.toString();
final throwableMessage = (throwable as JThrowable).message;

// If the throwable is:
// - java.net.ProtocolException: Control frames must be final.
// - java.io.EOFException
// - java.net.SocketException: Socket closed
// Then the connection was closed abnormally.
if (throwableString.contains(RegExp(
r'(java\.net\.ProtocolException: Control frames must be final\.|java\.io\.EOFException|java\.net\.SocketException: Socket closed)'))) {
// If the connection was closed due to a protocol error, EOF,
// or closed socket, report an abnormal closure (1006).
if (throwableMessage.contains('ProtocolException') ||
throwableMessage.contains('EOFException') ||
throwableMessage.contains('SocketException')) {
_events.add(CloseReceived(1006, 'abnormal close'));
unawaited(_events.close());
return;
}
var error = WebSocketException(
'Connection ended unexpectedly $throwableString');
'Connection ended unexpectedly: $throwableMessage');
if (openCompleter.isCompleted) {
_events.addError(error);
return;
Expand Down Expand Up @@ -184,13 +192,7 @@ class OkHttpWebSocket implements WebSocket {

unawaited(_events.close());

// When no code is provided, cause an abnormal closure to send 1005.
if (code == null) {
_webSocket.cancel();
return;
}

_webSocket.close(code, reason?.toJString());
_webSocket.close(code ?? 1000, reason?.toJString());
}

@override
Expand Down Expand Up @@ -220,10 +222,25 @@ class OkHttpWebSocket implements WebSocket {
/// https://square.github.io/okhttp/5.x/okhttp/okhttp3/-ok-http-client/index.html#:~:text=Shutdown
void _okHttpClientClose() {
_client.dispatcher().executorService().shutdown();
_client.connectionPool().evictAll();
// Note: evictAll() closes open sockets, which can trigger a
// NetworkOnMainThreadException on Android if StrictMode is enabled on
// the main thread.
try {
_client.connectionPool().evictAll();
} on JThrowable catch (e) {
if (!e.message.contains('NetworkOnMainThreadException')) {
rethrow;
}
}
var cache = _client.cache();
if (cache != null) {
cache.close();
try {
cache.close();
} on JThrowable catch (e) {
if (!e.message.contains('NetworkOnMainThreadException')) {
rethrow;
}
}
}
_client.release();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ void testCloseLocal(
final closeCode = await httpServerQueue.next as int?;
final closeReason = await httpServerQueue.next as String?;

expect(closeCode, 1005);
expect(closeCode, anyOf(1000, 1005));
expect(closeReason, '');
expect(await channel.events.isEmpty, true);
});
Expand Down
Loading