From 0a595e6bc4a75694fa12ae27f38bc8d377d2906f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:21:54 +0000 Subject: [PATCH 1/5] Initial plan From 8eaabfab8d248b96888fcb96d2dc2f0590be65e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:31:13 +0000 Subject: [PATCH 2/5] feat: Add force parameter to close method for immediate shutdown Co-authored-by: nielsenko <22237677+nielsenko@users.noreply.github.com> --- lib/src/adapter/adapter.dart | 7 +- lib/src/adapter/io/io_adapter.dart | 2 +- lib/src/relic_server.dart | 19 ++- test/relic_server_graceful_shutdown_test.dart | 129 ++++++++++++++++++ 4 files changed, 148 insertions(+), 9 deletions(-) diff --git a/lib/src/adapter/adapter.dart b/lib/src/adapter/adapter.dart index fe24d321..32cbd977 100644 --- a/lib/src/adapter/adapter.dart +++ b/lib/src/adapter/adapter.dart @@ -98,7 +98,12 @@ abstract class Adapter { /// /// For example, for an HTTP server adapter, this might close the underlying /// server socket. - Future close(); + /// + /// If [force] is true, the adapter will immediately close all connections + /// and shut down without waiting for in-flight requests to complete. + /// If [force] is false (the default), the adapter will wait for all + /// in-flight requests to finish before shutting down. + Future close({final bool force = false}); ConnectionsInfo get connectionsInfo; } diff --git a/lib/src/adapter/io/io_adapter.dart b/lib/src/adapter/io/io_adapter.dart index f01505b2..7ceed8bc 100644 --- a/lib/src/adapter/io/io_adapter.dart +++ b/lib/src/adapter/io/io_adapter.dart @@ -99,7 +99,7 @@ class IOAdapter extends Adapter { } @override - Future close() => _server.close(); + Future close({final bool force = false}) => _server.close(force: force); @override ConnectionsInfo get connectionsInfo { diff --git a/lib/src/relic_server.dart b/lib/src/relic_server.dart index f9198373..b73953d4 100644 --- a/lib/src/relic_server.dart +++ b/lib/src/relic_server.dart @@ -18,7 +18,12 @@ sealed class RelicServer { Future mountAndStart(final Handler handler); /// Close the server - Future close(); + /// + /// If [force] is true, the server will immediately close all connections + /// and shut down without waiting for in-flight requests to complete. + /// If [force] is false (the default), the server will wait for all + /// in-flight requests to finish before shutting down. + Future close({final bool force = false}); /// Returns information about the current connections. Future connectionsInfo(); @@ -65,9 +70,9 @@ final class _RelicServer implements RelicServer { } @override - Future close() async { + Future close({final bool force = false}) async { await _stopListening(); - await (await _adapter).close(); + await (await _adapter).close(force: force); _port = null; } @@ -196,8 +201,8 @@ final class _IsolatedRelicServer extends IsolatedObject : super(() => RelicServer(adapterFactory)); @override - Future close() async { - await evaluate((final r) => r.close()); + Future close({final bool force = false}) async { + await evaluate((final r) => r.close(force: force)); await super.close(); _port = null; } @@ -230,10 +235,10 @@ final class _MultiIsolateRelicServer implements RelicServer { ); @override - Future close() async { + Future close({final bool force = false}) async { final children = List.of(_children); _children.clear(); - await children.map((final c) => c.close()).wait; + await children.map((final c) => c.close(force: force)).wait; } @override diff --git a/test/relic_server_graceful_shutdown_test.dart b/test/relic_server_graceful_shutdown_test.dart index 918e118d..e0dc37b3 100644 --- a/test/relic_server_graceful_shutdown_test.dart +++ b/test/relic_server_graceful_shutdown_test.dart @@ -96,6 +96,135 @@ _startInFlightRequests( } void main() { + group('Given a RelicServer with force close', () { + late RelicServer server; + + setUp(() async { + server = RelicServer( + () => IOAdapter.bind(InternetAddress.loopbackIPv4, port: 0), + ); + }); + + tearDown(() async { + // Server may already be closed by the test + try { + await server.close(force: true); + } catch (_) {} + }); + + test( + 'when server.close(force: true) is called with in-flight requests, ' + 'then server shuts down immediately without waiting for requests', + () async { + final (:responseFutures, :canComplete) = await _startInFlightRequests( + server, + ); + + // Close the server forcefully while requests are in-flight + await server.close(force: true); + + // The server should close immediately, even though requests are blocked + // Now allow the requests to try to complete + canComplete.complete(); + + // Wait for all responses - they should fail or be incomplete + final results = await Future.wait( + responseFutures.map( + (final f) => f.then( + (final r) => r, + onError: (final Object e) => null, + ), + ), + ); + + // At least some requests should have failed due to forced closure + // (exact behavior may vary based on timing, but we expect errors) + final failedCount = results.where((final r) => r == null).length; + expect( + failedCount, + greaterThan(0), + reason: 'Some requests should fail when force closing', + ); + }, + ); + + test( + 'when server.close(force: false) is called with in-flight requests, ' + 'then server waits for all requests to complete', + () async { + final (:responseFutures, :canComplete) = await _startInFlightRequests( + server, + numberOfRequests: 2, + ); + + // Close the server gracefully while requests are in-flight + final closeFuture = server.close(force: false); + + // Allow the requests to complete + canComplete.complete(); + + // Wait for all responses and server close + final (responses, _) = await (responseFutures.wait, closeFuture).wait; + + // All requests should complete successfully + for (var i = 0; i < responses.length; i++) { + expect( + responses[i].statusCode, + HttpStatus.ok, + reason: 'Request $i should have completed with 200 OK', + ); + expect( + responses[i].body, + 'Completed', + reason: 'Request $i should have the expected body', + ); + } + }, + ); + + test( + 'when server.close() is called without force parameter, ' + 'then it defaults to graceful shutdown', + () async { + final (:responseFutures, :canComplete) = await _startInFlightRequests( + server, + numberOfRequests: 2, + ); + + // Close the server without specifying force (should default to false) + final closeFuture = server.close(); + + // Allow the requests to complete + canComplete.complete(); + + // Wait for all responses and server close + final (responses, _) = await (responseFutures.wait, closeFuture).wait; + + // All requests should complete successfully + for (final response in responses) { + expect(response.statusCode, HttpStatus.ok); + expect(response.body, 'Completed'); + } + }, + ); + + test( + 'when server.close(force: true) is called on idle server, ' + 'then server closes immediately', + () async { + await server.mountAndStart( + (final req) => Response.ok(body: Body.fromString('OK')), + ); + + // Close an idle server with force + await expectLater( + server.close(force: true), + completes, + ); + }, + ); + }); + group('Given a RelicServer with in-flight requests', () { late RelicServer server; From 6e452d2e757f0e809870e37d3e5728fe10708676 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:35:16 +0000 Subject: [PATCH 3/5] test: Update _FakeAdapter to match new close signature Co-authored-by: nielsenko <22237677+nielsenko@users.noreply.github.com> --- example/test_force_close.dart | 84 +++++++++++++++++++++++++++++++++ test/router/relic_app_test.dart | 2 +- 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 example/test_force_close.dart diff --git a/example/test_force_close.dart b/example/test_force_close.dart new file mode 100644 index 00000000..f75f2c37 --- /dev/null +++ b/example/test_force_close.dart @@ -0,0 +1,84 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:relic/io_adapter.dart'; +import 'package:relic/relic.dart'; + +void main() async { + print('Testing force close feature...\n'); + + // Example 1: Force close immediately + print('Example 1: Force close (ignores in-flight requests)'); + final server = RelicServer( + () => IOAdapter.bind(InternetAddress.loopbackIPv4, port: 0), + ); + + await server.mountAndStart((req) async { + await Future.delayed(Duration(seconds: 5)); + return Response.ok(body: Body.fromString('Done')); + }); + + print('Server started on port ${server.port}'); + + // Start a long request (don't wait for it) + unawaited( + HttpClient() + .get('localhost', server.port, '/') + .then((req) => req.close()) + .then((response) { + print(' Request completed: ${response.statusCode}'); + }) + .catchError((e) { + print(' Request failed (expected): ${e.runtimeType}'); + }) + ); + + // Wait for request to start + await Future.delayed(Duration(milliseconds: 100)); + + final stopwatch = Stopwatch()..start(); + await server.close(force: true); + stopwatch.stop(); + print(' Force close took ${stopwatch.elapsedMilliseconds}ms'); + print(' ✓ Quick shutdown (did not wait 5 seconds)\n'); + + // Example 2: Graceful close with timeout using Future.any + print('Example 2: Close with timeout (uses Future.any pattern)'); + final server2 = RelicServer( + () => IOAdapter.bind(InternetAddress.loopbackIPv4, port: 0), + ); + + await server2.mountAndStart((req) async { + await Future.delayed(Duration(seconds: 5)); + return Response.ok(body: Body.fromString('Done')); + }); + + print('Server 2 started on port ${server2.port}'); + + // Start a long request + unawaited( + HttpClient() + .get('localhost', server2.port, '/') + .then((req) => req.close()) + .then((response) { + print(' Request completed: ${response.statusCode}'); + }) + .catchError((e) { + print(' Request failed after timeout: ${e.runtimeType}'); + }) + ); + + await Future.delayed(Duration(milliseconds: 100)); + + final stopwatch2 = Stopwatch()..start(); + // Pattern from issue: Close with timeout, then force close if needed + await Future.any([ + server2.close(), // Graceful close (would take 5 seconds) + Future.delayed(Duration(seconds: 2)).then((_) => server2.close(force: true)) + ]); + stopwatch2.stop(); + + print(' Close with 2-second timeout took ${stopwatch2.elapsedMilliseconds}ms'); + print(' ✓ Timeout triggered force close (did not wait 5 seconds)\n'); + + print('All examples completed successfully! ✓'); +} diff --git a/test/router/relic_app_test.dart b/test/router/relic_app_test.dart index 7ff6c7f5..561cf20d 100644 --- a/test/router/relic_app_test.dart +++ b/test/router/relic_app_test.dart @@ -133,5 +133,5 @@ class _FakeAdapter extends Fake implements Adapter { late int port = Random().nextInt(65536); @override - Future close() async {} + Future close({final bool force = false}) async {} } From 77525088af9cd5a405c764359a2b24e3406dd615 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:38:12 +0000 Subject: [PATCH 4/5] chore: Remove example test file with linting issues Co-authored-by: nielsenko <22237677+nielsenko@users.noreply.github.com> --- example/test_force_close.dart | 84 ----------------------------------- 1 file changed, 84 deletions(-) delete mode 100644 example/test_force_close.dart diff --git a/example/test_force_close.dart b/example/test_force_close.dart deleted file mode 100644 index f75f2c37..00000000 --- a/example/test_force_close.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'package:relic/io_adapter.dart'; -import 'package:relic/relic.dart'; - -void main() async { - print('Testing force close feature...\n'); - - // Example 1: Force close immediately - print('Example 1: Force close (ignores in-flight requests)'); - final server = RelicServer( - () => IOAdapter.bind(InternetAddress.loopbackIPv4, port: 0), - ); - - await server.mountAndStart((req) async { - await Future.delayed(Duration(seconds: 5)); - return Response.ok(body: Body.fromString('Done')); - }); - - print('Server started on port ${server.port}'); - - // Start a long request (don't wait for it) - unawaited( - HttpClient() - .get('localhost', server.port, '/') - .then((req) => req.close()) - .then((response) { - print(' Request completed: ${response.statusCode}'); - }) - .catchError((e) { - print(' Request failed (expected): ${e.runtimeType}'); - }) - ); - - // Wait for request to start - await Future.delayed(Duration(milliseconds: 100)); - - final stopwatch = Stopwatch()..start(); - await server.close(force: true); - stopwatch.stop(); - print(' Force close took ${stopwatch.elapsedMilliseconds}ms'); - print(' ✓ Quick shutdown (did not wait 5 seconds)\n'); - - // Example 2: Graceful close with timeout using Future.any - print('Example 2: Close with timeout (uses Future.any pattern)'); - final server2 = RelicServer( - () => IOAdapter.bind(InternetAddress.loopbackIPv4, port: 0), - ); - - await server2.mountAndStart((req) async { - await Future.delayed(Duration(seconds: 5)); - return Response.ok(body: Body.fromString('Done')); - }); - - print('Server 2 started on port ${server2.port}'); - - // Start a long request - unawaited( - HttpClient() - .get('localhost', server2.port, '/') - .then((req) => req.close()) - .then((response) { - print(' Request completed: ${response.statusCode}'); - }) - .catchError((e) { - print(' Request failed after timeout: ${e.runtimeType}'); - }) - ); - - await Future.delayed(Duration(milliseconds: 100)); - - final stopwatch2 = Stopwatch()..start(); - // Pattern from issue: Close with timeout, then force close if needed - await Future.any([ - server2.close(), // Graceful close (would take 5 seconds) - Future.delayed(Duration(seconds: 2)).then((_) => server2.close(force: true)) - ]); - stopwatch2.stop(); - - print(' Close with 2-second timeout took ${stopwatch2.elapsedMilliseconds}ms'); - print(' ✓ Timeout triggered force close (did not wait 5 seconds)\n'); - - print('All examples completed successfully! ✓'); -} From f8f45c708c7b8533765f5ee9bbd73b5987e05781 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Dec 2025 13:21:37 +0000 Subject: [PATCH 5/5] refactor: Move force close tests into existing group and remove duplicates Co-authored-by: nielsenko <22237677+nielsenko@users.noreply.github.com> --- test/relic_server_graceful_shutdown_test.dart | 181 +++++------------- 1 file changed, 52 insertions(+), 129 deletions(-) diff --git a/test/relic_server_graceful_shutdown_test.dart b/test/relic_server_graceful_shutdown_test.dart index e0dc37b3..f36dabc1 100644 --- a/test/relic_server_graceful_shutdown_test.dart +++ b/test/relic_server_graceful_shutdown_test.dart @@ -96,135 +96,6 @@ _startInFlightRequests( } void main() { - group('Given a RelicServer with force close', () { - late RelicServer server; - - setUp(() async { - server = RelicServer( - () => IOAdapter.bind(InternetAddress.loopbackIPv4, port: 0), - ); - }); - - tearDown(() async { - // Server may already be closed by the test - try { - await server.close(force: true); - } catch (_) {} - }); - - test( - 'when server.close(force: true) is called with in-flight requests, ' - 'then server shuts down immediately without waiting for requests', - () async { - final (:responseFutures, :canComplete) = await _startInFlightRequests( - server, - ); - - // Close the server forcefully while requests are in-flight - await server.close(force: true); - - // The server should close immediately, even though requests are blocked - // Now allow the requests to try to complete - canComplete.complete(); - - // Wait for all responses - they should fail or be incomplete - final results = await Future.wait( - responseFutures.map( - (final f) => f.then( - (final r) => r, - onError: (final Object e) => null, - ), - ), - ); - - // At least some requests should have failed due to forced closure - // (exact behavior may vary based on timing, but we expect errors) - final failedCount = results.where((final r) => r == null).length; - expect( - failedCount, - greaterThan(0), - reason: 'Some requests should fail when force closing', - ); - }, - ); - - test( - 'when server.close(force: false) is called with in-flight requests, ' - 'then server waits for all requests to complete', - () async { - final (:responseFutures, :canComplete) = await _startInFlightRequests( - server, - numberOfRequests: 2, - ); - - // Close the server gracefully while requests are in-flight - final closeFuture = server.close(force: false); - - // Allow the requests to complete - canComplete.complete(); - - // Wait for all responses and server close - final (responses, _) = await (responseFutures.wait, closeFuture).wait; - - // All requests should complete successfully - for (var i = 0; i < responses.length; i++) { - expect( - responses[i].statusCode, - HttpStatus.ok, - reason: 'Request $i should have completed with 200 OK', - ); - expect( - responses[i].body, - 'Completed', - reason: 'Request $i should have the expected body', - ); - } - }, - ); - - test( - 'when server.close() is called without force parameter, ' - 'then it defaults to graceful shutdown', - () async { - final (:responseFutures, :canComplete) = await _startInFlightRequests( - server, - numberOfRequests: 2, - ); - - // Close the server without specifying force (should default to false) - final closeFuture = server.close(); - - // Allow the requests to complete - canComplete.complete(); - - // Wait for all responses and server close - final (responses, _) = await (responseFutures.wait, closeFuture).wait; - - // All requests should complete successfully - for (final response in responses) { - expect(response.statusCode, HttpStatus.ok); - expect(response.body, 'Completed'); - } - }, - ); - - test( - 'when server.close(force: true) is called on idle server, ' - 'then server closes immediately', - () async { - await server.mountAndStart( - (final req) => Response.ok(body: Body.fromString('OK')), - ); - - // Close an idle server with force - await expectLater( - server.close(force: true), - completes, - ); - }, - ); - }); - group('Given a RelicServer with in-flight requests', () { late RelicServer server; @@ -360,6 +231,58 @@ void main() { expect(response.statusCode, HttpStatus.ok); } }); + + test( + 'when server.close(force: true) is called with in-flight requests, ' + 'then server shuts down immediately without waiting for requests', + () async { + final (:responseFutures, :canComplete) = await _startInFlightRequests( + server, + ); + + // Close the server forcefully while requests are in-flight + await server.close(force: true); + + // The server should close immediately, even though requests are blocked + // Now allow the requests to try to complete + canComplete.complete(); + + // Wait for all responses - they should fail or be incomplete + final results = await Future.wait( + responseFutures.map( + (final f) => f.then( + (final r) => r, + onError: (final Object e) => null, + ), + ), + ); + + // At least some requests should have failed due to forced closure + // (exact behavior may vary based on timing, but we expect errors) + final failedCount = results.where((final r) => r == null).length; + expect( + failedCount, + greaterThan(0), + reason: 'Some requests should fail when force closing', + ); + }, + ); + + test( + 'when server.close(force: true) is called on idle server, ' + 'then server closes immediately', + () async { + await server.mountAndStart( + (final req) => Response.ok(body: Body.fromString('OK')), + ); + + // Close an idle server with force + await expectLater( + server.close(force: true), + completes, + ); + }, + ); }); group('Given a RelicServer with multi-isolate configuration', () {