From 77c2b1fc027e2c873bf7b5352abc4d925c3e33cc Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 23 Oct 2024 15:16:56 +0400 Subject: [PATCH 001/104] Update CI --- .github/workflows/checkout.yml | 43 +++++++++++++++++++++++------- .github/workflows/tests-report.yml | 27 +++++++++++++++++++ .github/workflows/tests.yml | 20 +++++++++++--- dart_test.yaml | 13 +++++++++ 4 files changed, 91 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/tests-report.yml create mode 100644 dart_test.yaml diff --git a/.github/workflows/checkout.yml b/.github/workflows/checkout.yml index 89720bb..c427354 100644 --- a/.github/workflows/checkout.yml +++ b/.github/workflows/checkout.yml @@ -3,6 +3,23 @@ name: Checkout on: workflow_dispatch: push: + branches: + - "main" + - "master" + #- "dev" + #- "develop" + #- "feature/**" + #- "bugfix/**" + #- "hotfix/**" + #- "support/**" + paths: + - "lib/**.dart" + - "test/**.dart" + - "example/**.dart" + - ".github/workflows/*.yml" + - "pubspec.yaml" + - "analysis_options.yaml" + pull_request: branches: - "main" - "master" @@ -16,8 +33,14 @@ on: - "lib/**.dart" - "test/**.dart" - "example/**.dart" - - .github/workflows/*.yml + - ".github/workflows/*.yml" - "pubspec.yaml" + - "analysis_options.yaml" + +permissions: + contents: read + actions: read + checks: write jobs: checkout: @@ -29,7 +52,7 @@ jobs: container: image: dart:stable env: - pub-cache-name: pub + pub-cache: pub PUB_CACHE: /github/home/.pub-cache timeout-minutes: 10 steps: @@ -51,8 +74,8 @@ jobs: uses: actions/cache/restore@v4 with: path: | - ${{ env.PUB_CACHE }} - key: ${{ runner.os }}-spinify-${{ env.pub-cache-name }}-${{ hashFiles('pubspec.yaml') }} + /home/runner/.pub-cache + key: ${{ runner.os }}-pub-${{ env.pub-cache }}-${{ hashFiles('pubspec.yaml') }} - name: ๐Ÿ‘ท Install Dependencies id: install-dependencies @@ -63,16 +86,18 @@ jobs: - name: ๐Ÿ“ฅ Save Pub modules id: cache-pub-save + if: steps.cache-pub-restore.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: | - ${{ env.PUB_CACHE }} - key: ${{ runner.os }}-spinify-${{ env.pub-cache-name }}-${{ hashFiles('pubspec.yaml') }} + /home/runner/.pub-cache + key: ${{ steps.cache-pub-restore.outputs.cache-primary-key }} - - name: ๐Ÿ”Ž Check format + - name: ๐Ÿšฆ Check code format id: check-format timeout-minutes: 1 - run: dart format --set-exit-if-changed -l 80 -o none lib/ test/ + run: | + find lib test -name "*.dart" ! -name "*.*.dart" -print0 | xargs -0 dart format --set-exit-if-changed --line-length 80 -o none lib/ test/ - name: ๐Ÿ“ˆ Check analyzer id: check-analyzer @@ -99,5 +124,5 @@ jobs: timeout-minutes: 2 run: | dart test --color --platform=vm --concurrency=12 \ - --timeout=60s --reporter=github --file-reporter=json:coverage/tests.json \ + --timeout=60s --reporter=github --file-reporter=json:reports/tests.json \ --coverage=coverage -- test/unit_test.dart diff --git a/.github/workflows/tests-report.yml b/.github/workflows/tests-report.yml new file mode 100644 index 0000000..39a1cbc --- /dev/null +++ b/.github/workflows/tests-report.yml @@ -0,0 +1,27 @@ +name: "Tests Report" + +on: + workflow_run: + workflows: ["Tests"] # runs after "Tests" workflow + types: + - completed + +permissions: + contents: read + actions: read + checks: write + +jobs: + report: + name: "๐Ÿš› Tests report" + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Test report + uses: dorny/test-reporter@v1 + with: + artifact: test-results + name: Test Report + path: "**/tests.json" + reporter: flutter-json + fail-on-error: false diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 075383d..ce401d2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,8 +16,9 @@ on: - "lib/**.dart" - "test/**.dart" - "example/**.dart" - - .github/workflows/*.yml + - ".github/workflows/*.yml" - "pubspec.yaml" + - "analysis_options.yaml" pull_request: branches: - "main" @@ -32,8 +33,14 @@ on: - "lib/**.dart" - "test/**.dart" - "example/**.dart" - - .github/workflows/*.yml + - ".github/workflows/*.yml" - "pubspec.yaml" + - "analysis_options.yaml" + +permissions: + contents: read + actions: read + checks: write jobs: build-echo: @@ -167,7 +174,7 @@ jobs: dart pub global activate coverage dart pub global run coverage:test_with_coverage -fb -o coverage -- \ --platform vm --compiler=kernel --coverage=coverage \ - --reporter=github --file-reporter=json:coverage/tests.json \ + --reporter=github --file-reporter=json:reports/tests.json \ --timeout=10m --concurrency=12 --color \ test/unit_test.dart test/smoke_test.dart @@ -216,3 +223,10 @@ jobs: uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos + + - name: ๐Ÿ“ฅ Upload test report + uses: actions/upload-artifact@v4 + if: (success() || failure()) && ${{ github.actor != 'dependabot[bot]' }} + with: + name: test-results + path: reports/tests.json diff --git a/dart_test.yaml b/dart_test.yaml new file mode 100644 index 0000000..d14b29c --- /dev/null +++ b/dart_test.yaml @@ -0,0 +1,13 @@ +timeout: 1x + +platforms: + - vm + +file_reporters: + json: reports/tests.json + +tags: + unit: + timeout: 1x + smoke: + timeout: 2x From 27e4f436c4ca9287380f1cadd610b7a049c439be Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 23 Oct 2024 15:20:48 +0400 Subject: [PATCH 002/104] Add example --- example/echo/main.dart | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/example/echo/main.dart b/example/echo/main.dart index 696fcf6..f14db57 100644 --- a/example/echo/main.dart +++ b/example/echo/main.dart @@ -1,3 +1,19 @@ +// ignore_for_file: avoid_print + import 'package:spinify/spinify.dart'; -void main() => Spinify(); +const url = 'ws://localhost:8000/connection/websocket'; + +void main() { + final client = Spinify( + config: SpinifyConfig( + logger: (level, event, message, context) => print('[$event] $message'), + ), + ); + var prev = client.state; + client.states.listen((next) { + print('$prev -> $next'); + prev = next; + }); + client.connect(url).ignore(); +} From e3429f43d722b1e947968dbc2533e7dbb93ff8b8 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 23 Oct 2024 15:40:25 +0400 Subject: [PATCH 003/104] Update launch.json and Makefile --- .vscode/launch.json | 15 +++++++++++++++ Makefile | 8 ++++++++ example/echo/main.dart | 12 ++++++++++-- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 97a2b09..57b7ded 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,6 +1,21 @@ { "version": "0.2.0", "configurations": [ + { + "name": "[Dart] Example (debug)", + "request": "launch", + "type": "dart", + "flutterMode": "debug", + "cwd": "${workspaceFolder}/example/echo", + "program": "main.dart", + "env": { + "ENVIRONMENT": "local" + }, + "console": "debugConsole", + "runTestsOnDevice": false, + "toolArgs": [], + "args": [] + }, { "name": "[Flutter] Benchmark (debug)", "request": "launch", diff --git a/Makefile b/Makefile index a1b5987..ce48c6a 100644 --- a/Makefile +++ b/Makefile @@ -56,6 +56,14 @@ publish: generate ## Publish the package .PHONY: deploy deploy: publish +.PHONY: echo-go +echo-go: ## Start the echo server + @cd tool/echo && go run echo.go + +.PHONY: echo-dart +echo-dart: ## Start the echo client + @cd example/echo && dart run main.dart + .PHONY: echo-up echo-up: ## Start the echo server @dart run tool/echo_up.dart diff --git a/example/echo/main.dart b/example/echo/main.dart index f14db57..dc34a91 100644 --- a/example/echo/main.dart +++ b/example/echo/main.dart @@ -1,19 +1,27 @@ // ignore_for_file: avoid_print +import 'dart:io' as io; + import 'package:spinify/spinify.dart'; -const url = 'ws://localhost:8000/connection/websocket'; +void main(List args) { + var url = args.firstWhere((a) => a.startsWith('--url='), orElse: () => ''); + if (url.isNotEmpty) url = url.substring(6).trim(); + if (url.isEmpty) url = io.Platform.environment['URL'] ?? ''; + if (url.isEmpty) url = const String.fromEnvironment('URL', defaultValue: ''); + if (url.isEmpty) url = 'ws://localhost:8000/connection/websocket'; -void main() { final client = Spinify( config: SpinifyConfig( logger: (level, event, message, context) => print('[$event] $message'), ), ); + var prev = client.state; client.states.listen((next) { print('$prev -> $next'); prev = next; }); + client.connect(url).ignore(); } From 903a1752dccee24876e55e6a8e3650aadd19da05 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 23 Oct 2024 16:00:07 +0400 Subject: [PATCH 004/104] Update Spinify implementation and deprecate old version --- lib/spinify.dart | 2 +- lib/src/spinify.dart | 0 lib/src/{spinify_impl.dart => spinify_deprecated.dart} | 2 ++ lib/src/subscription_impl.dart | 2 +- pubspec.yaml | 2 +- 5 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 lib/src/spinify.dart rename lib/src/{spinify_impl.dart => spinify_deprecated.dart} (99%) diff --git a/lib/spinify.dart b/lib/spinify.dart index ee59ec8..c568935 100644 --- a/lib/spinify.dart +++ b/lib/spinify.dart @@ -19,7 +19,7 @@ export 'src/model/subscription_config.dart'; export 'src/model/subscription_state.dart'; export 'src/model/subscription_states.dart'; export 'src/model/transport_interface.dart'; -export 'src/spinify_impl.dart' show Spinify; +export 'src/spinify_deprecated.dart' show Spinify; export 'src/spinify_interface.dart'; export 'src/subscription_interface.dart'; export 'src/transport_fake.dart'; diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/src/spinify_impl.dart b/lib/src/spinify_deprecated.dart similarity index 99% rename from lib/src/spinify_impl.dart rename to lib/src/spinify_deprecated.dart index e59e361..1434524 100644 --- a/lib/src/spinify_impl.dart +++ b/lib/src/spinify_deprecated.dart @@ -1,3 +1,5 @@ +@Deprecated('Use new implementation instead') + import 'dart:async'; import 'dart:collection'; diff --git a/lib/src/subscription_impl.dart b/lib/src/subscription_impl.dart index b537011..ca9985c 100644 --- a/lib/src/subscription_impl.dart +++ b/lib/src/subscription_impl.dart @@ -1,4 +1,4 @@ -part of 'spinify_impl.dart'; +part of 'spinify_deprecated.dart'; @internal abstract base class SpinifySubscriptionBase implements SpinifySubscription { diff --git a/pubspec.yaml b/pubspec.yaml index 99f4ab7..e1f3f84 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ description: > Dart client to communicate with Centrifuge and Centrifugo from Dart and Flutter over WebSockets with Protobuf support. -version: 0.0.4 +version: 0.0.5 homepage: https://centrifugal.dev From cae08ee2bc1d123036bd02a03ae512d76eff20b8 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 23 Oct 2024 18:56:12 +0400 Subject: [PATCH 005/104] Refactor deprecated transport files --- lib/spinify.dart | 4 +- .../{ => deprecated}/spinify_deprecated.dart | 44 +- .../{ => deprecated}/subscription_impl.dart | 0 lib/src/{ => deprecated}/transport_fake.dart | 10 +- .../{ => deprecated}/transport_ws_pb_js.dart | 18 +- .../transport_ws_pb_stub.dart | 8 +- .../{ => deprecated}/transport_ws_pb_vm.dart | 16 +- lib/src/model/annotations.dart | 22 +- lib/src/spinify.dart | 472 ++++++++++++++++++ 9 files changed, 542 insertions(+), 52 deletions(-) rename lib/src/{ => deprecated}/spinify_deprecated.dart (97%) rename lib/src/{ => deprecated}/subscription_impl.dart (100%) rename lib/src/{ => deprecated}/transport_fake.dart (98%) rename lib/src/{ => deprecated}/transport_ws_pb_js.dart (97%) rename lib/src/{ => deprecated}/transport_ws_pb_stub.dart (81%) rename lib/src/{ => deprecated}/transport_ws_pb_vm.dart (96%) diff --git a/lib/spinify.dart b/lib/spinify.dart index c568935..70c50cf 100644 --- a/lib/spinify.dart +++ b/lib/spinify.dart @@ -2,6 +2,7 @@ library; export 'package:fixnum/fixnum.dart' show Int64; +export 'src/deprecated/transport_fake.dart'; export 'src/model/channel_event.dart'; export 'src/model/client_info.dart'; export 'src/model/command.dart'; @@ -19,7 +20,6 @@ export 'src/model/subscription_config.dart'; export 'src/model/subscription_state.dart'; export 'src/model/subscription_states.dart'; export 'src/model/transport_interface.dart'; -export 'src/spinify_deprecated.dart' show Spinify; +export 'src/spinify.dart' show Spinify; export 'src/spinify_interface.dart'; export 'src/subscription_interface.dart'; -export 'src/transport_fake.dart'; diff --git a/lib/src/spinify_deprecated.dart b/lib/src/deprecated/spinify_deprecated.dart similarity index 97% rename from lib/src/spinify_deprecated.dart rename to lib/src/deprecated/spinify_deprecated.dart index 1434524..49a3a3b 100644 --- a/lib/src/spinify_deprecated.dart +++ b/lib/src/deprecated/spinify_deprecated.dart @@ -6,33 +6,33 @@ import 'dart:collection'; import 'package:fixnum/fixnum.dart' as fixnum; import 'package:meta/meta.dart'; -import 'model/annotations.dart'; -import 'model/channel_event.dart'; -import 'model/channel_events.dart'; -import 'model/client_info.dart'; -import 'model/command.dart'; -import 'model/config.dart'; -import 'model/constant.dart'; -import 'model/exception.dart'; -import 'model/history.dart'; -import 'model/metric.dart'; -import 'model/presence_stats.dart'; -import 'model/reply.dart'; -import 'model/state.dart'; -import 'model/states_stream.dart'; -import 'model/stream_position.dart'; -import 'model/subscription_config.dart'; -import 'model/subscription_state.dart'; -import 'model/subscription_states.dart'; -import 'model/transport_interface.dart'; -import 'spinify_interface.dart'; -import 'subscription_interface.dart'; +import '../model/annotations.dart'; +import '../model/channel_event.dart'; +import '../model/channel_events.dart'; +import '../model/client_info.dart'; +import '../model/command.dart'; +import '../model/config.dart'; +import '../model/constant.dart'; +import '../model/exception.dart'; +import '../model/history.dart'; +import '../model/metric.dart'; +import '../model/presence_stats.dart'; +import '../model/reply.dart'; +import '../model/state.dart'; +import '../model/states_stream.dart'; +import '../model/stream_position.dart'; +import '../model/subscription_config.dart'; +import '../model/subscription_state.dart'; +import '../model/subscription_states.dart'; +import '../model/transport_interface.dart'; +import '../spinify_interface.dart'; +import '../subscription_interface.dart'; +import '../util/backoff.dart'; import 'transport_ws_pb_stub.dart' // ignore: uri_does_not_exist if (dart.library.js_util) 'transport_ws_pb_js.dart' // ignore: uri_does_not_exist if (dart.library.io) 'transport_ws_pb_vm.dart'; -import 'util/backoff.dart'; part 'subscription_impl.dart'; diff --git a/lib/src/subscription_impl.dart b/lib/src/deprecated/subscription_impl.dart similarity index 100% rename from lib/src/subscription_impl.dart rename to lib/src/deprecated/subscription_impl.dart diff --git a/lib/src/transport_fake.dart b/lib/src/deprecated/transport_fake.dart similarity index 98% rename from lib/src/transport_fake.dart rename to lib/src/deprecated/transport_fake.dart index 9f7b795..224e25e 100644 --- a/lib/src/transport_fake.dart +++ b/lib/src/deprecated/transport_fake.dart @@ -7,11 +7,11 @@ import 'dart:math' as math; import 'package:fixnum/fixnum.dart'; -import 'model/channel_event.dart'; -import 'model/command.dart'; -import 'model/metric.dart'; -import 'model/reply.dart'; -import 'model/transport_interface.dart'; +import '../model/channel_event.dart'; +import '../model/command.dart'; +import '../model/metric.dart'; +import '../model/reply.dart'; +import '../model/transport_interface.dart'; /// Create a fake Spinify transport. SpinifyTransportBuilder $createFakeSpinifyTransport({ diff --git a/lib/src/transport_ws_pb_js.dart b/lib/src/deprecated/transport_ws_pb_js.dart similarity index 97% rename from lib/src/transport_ws_pb_js.dart rename to lib/src/deprecated/transport_ws_pb_js.dart index 83fb3cd..40db48a 100644 --- a/lib/src/transport_ws_pb_js.dart +++ b/lib/src/deprecated/transport_ws_pb_js.dart @@ -9,15 +9,15 @@ import 'package:meta/meta.dart'; import 'package:protobuf/protobuf.dart' as pb; import 'package:web/web.dart' as web; -import 'model/channel_event.dart'; -import 'model/command.dart'; -import 'model/config.dart'; -import 'model/metric.dart'; -import 'model/reply.dart'; -import 'model/transport_interface.dart'; -import 'protobuf/client.pb.dart' as pb; -import 'protobuf/protobuf_codec.dart'; -import 'util/event_queue.dart'; +import '../model/channel_event.dart'; +import '../model/command.dart'; +import '../model/config.dart'; +import '../model/metric.dart'; +import '../model/reply.dart'; +import '../model/transport_interface.dart'; +import '../protobuf/client.pb.dart' as pb; +import '../protobuf/protobuf_codec.dart'; +import '../util/event_queue.dart'; const _BlobCodec _blobCodec = _BlobCodec(); diff --git a/lib/src/transport_ws_pb_stub.dart b/lib/src/deprecated/transport_ws_pb_stub.dart similarity index 81% rename from lib/src/transport_ws_pb_stub.dart rename to lib/src/deprecated/transport_ws_pb_stub.dart index 930f20c..0a4304e 100644 --- a/lib/src/transport_ws_pb_stub.dart +++ b/lib/src/deprecated/transport_ws_pb_stub.dart @@ -1,10 +1,10 @@ // coverage:ignore-file import 'package:meta/meta.dart'; -import 'model/config.dart'; -import 'model/metric.dart'; -import 'model/reply.dart'; -import 'model/transport_interface.dart'; +import '../model/config.dart'; +import '../model/metric.dart'; +import '../model/reply.dart'; +import '../model/transport_interface.dart'; /// Create a WebSocket Protocol Buffers transport. @internal diff --git a/lib/src/transport_ws_pb_vm.dart b/lib/src/deprecated/transport_ws_pb_vm.dart similarity index 96% rename from lib/src/transport_ws_pb_vm.dart rename to lib/src/deprecated/transport_ws_pb_vm.dart index f311d23..a535374 100644 --- a/lib/src/transport_ws_pb_vm.dart +++ b/lib/src/deprecated/transport_ws_pb_vm.dart @@ -5,14 +5,14 @@ import 'dart:io' as io; import 'package:meta/meta.dart'; import 'package:protobuf/protobuf.dart' as pb; -import 'model/channel_event.dart'; -import 'model/command.dart'; -import 'model/config.dart'; -import 'model/metric.dart'; -import 'model/reply.dart'; -import 'model/transport_interface.dart'; -import 'protobuf/client.pb.dart' as pb; -import 'protobuf/protobuf_codec.dart'; +import '../model/channel_event.dart'; +import '../model/command.dart'; +import '../model/config.dart'; +import '../model/metric.dart'; +import '../model/reply.dart'; +import '../model/transport_interface.dart'; +import '../protobuf/client.pb.dart' as pb; +import '../protobuf/protobuf_codec.dart'; /// Create a WebSocket Protocol Buffers transport. @internal diff --git a/lib/src/model/annotations.dart b/lib/src/model/annotations.dart index e92ccfe..797a0a4 100644 --- a/lib/src/model/annotations.dart +++ b/lib/src/model/annotations.dart @@ -8,12 +8,18 @@ const SpinifyAnnotation interactive = SpinifyAnnotation('interactive'); @internal const SpinifyAnnotation sideEffect = SpinifyAnnotation('sideEffect'); -// TODO(plugfox): add more annotations +/// Method that shouldn't throw an any exception. +@internal +const SpinifyAnnotation safe = SpinifyAnnotation('safe'); + +/// Method that can throw an exception. +@internal +const SpinifyAnnotation unsafe = SpinifyAnnotation('unsafe'); /// Annotation for Spinify library. @internal @immutable -final class SpinifyAnnotation { +class SpinifyAnnotation { @literal const SpinifyAnnotation( this.name, { @@ -26,3 +32,15 @@ final class SpinifyAnnotation { /// Annotation metadata. final Map meta; } + +/// Annotation for Spinify library that mark methods as possible to throw +/// exceptions of specified types. +@internal +@immutable +class Throws extends SpinifyAnnotation { + @literal + const Throws(this.exceptions) : super('throws'); + + /// List of exceptions that can be thrown. + final List exceptions; +} diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index e69de29..95fae0f 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -0,0 +1,472 @@ +import 'dart:async'; + +import 'model/annotations.dart'; +import 'model/channel_event.dart'; +import 'model/channel_events.dart'; +import 'model/client_info.dart'; +import 'model/command.dart'; +import 'model/config.dart'; +import 'model/constant.dart'; +import 'model/exception.dart'; +import 'model/history.dart'; +import 'model/metric.dart'; +import 'model/presence_stats.dart'; +import 'model/reply.dart'; +import 'model/state.dart'; +import 'model/states_stream.dart'; +import 'model/stream_position.dart'; +import 'model/subscription_config.dart'; +import 'spinify_interface.dart'; +import 'subscription_interface.dart'; + +/// {@template spinify} +/// Spinify client for Centrifuge. +/// +/// Centrifugo SDKs use WebSocket as the main data transport and send/receive +/// messages encoded according to our bidirectional protocol. +/// That protocol is built on top of the Protobuf schema +/// (both JSON and binary Protobuf formats are supported). +/// It provides asynchronous communication, sending RPC, +/// multiplexing subscriptions to channels, etc. +/// +/// Client SDK wraps the protocol and exposes a set of APIs to developers. +/// +/// Client connection has 4 states: +/// - [SpinifyState$Disconnected] +/// - [SpinifyState$Connecting] +/// - [SpinifyState$Connected] +/// - [SpinifyState$Closed] +/// +/// {@endtemplate} +/// {@category Client} +final class Spinify implements ISpinify { + /// Create a new Spinify client. + /// + /// {@macro spinify} + @safe + Spinify({SpinifyConfig? config}) + : config = config ?? SpinifyConfig.byDefault() { + /// Client initialization (from constructor). + _log( + const SpinifyLogLevel.info(), + 'init', + 'Spinify client initialized', + { + 'config': config, + }, + ); + } + + /// Create client and connect. + /// + /// {@macro spinify} + @safe + factory Spinify.connect(String url, {SpinifyConfig? config}) => + Spinify(config: config)..connect(url).ignore(); + + /// Spinify config. + @safe + @override + final SpinifyConfig config; + + @safe + @override + SpinifyMetrics get metrics => _metrics.freeze(); + + /// TODO: Transport implementation. + dynamic _transport; + + /// Internal mutable metrics. Also it's container for Spinify's state. + final SpinifyMetrics$Mutable _metrics = SpinifyMetrics$Mutable(); + + @safe + @override + SpinifyState get state => _metrics.state; + + @safe + @override + bool get isClosed => _metrics.state.isClosed; + + @safe + @override + late final SpinifyStatesStream states = + SpinifyStatesStream(_statesController.stream); + + @safe + final StreamController _statesController = + StreamController.broadcast(); + + @override + SpinifyChannelEvents get stream => + throw UnimplementedError(); + + /// Log an event with the given [level], [event], [message] and [context]. + @safe + void _log( + SpinifyLogLevel level, + String event, + String message, + Map context, + ) { + try { + config.logger?.call(level, event, message, context); + } on Object {/* ignore */} + } + + /// Set a new state and notify listeners via [states]. + @safe + void _setState(SpinifyState state) { + if (isClosed) return; + final previous = _metrics.state; + _statesController.add(_metrics.state = state); + _log( + const SpinifyLogLevel.config(), + 'state_changed', + 'State changed from $previous to $state', + { + 'previous': previous, + 'state': state, + }, + ); + } + + @unsafe + @override + @Throws([SpinifyConnectionException]) + Future connect(String url) async { + try { + await _interactiveConnect(url); + } on SpinifyConnectionException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyConnectionException( + message: 'Failed to connect to server', + error: error, + ), + stackTrace, + ); + } + } + + /// User initiated connect. + @unsafe + Future _interactiveConnect(String url) async { + throw UnimplementedError(); + } + + /// Library initiated connect. + @unsafe + Future _internalReconnect(String url) async { + throw UnimplementedError(); + } + + /// On connect to the server. + Future _onConnected() async {} + + @safe + @override + Future disconnect() => _interactiveDisconnect(); + + /// User initiated disconnect. + @safe + Future _interactiveDisconnect() => + _internalDisconnect(temporary: false); + + /// Library initiated disconnect. + @safe + Future _internalDisconnect({required bool temporary}) async { + try { + // Close all pending replies with error. + const error = SpinifyReplyException( + replyCode: 0, + replyMessage: 'Client is disconnected', + temporary: true, + ); + late final stackTrace = StackTrace.current; + for (final completer in _replies.values) { + if (completer.isCompleted) continue; + completer.completeError(error, stackTrace); + _log( + const SpinifyLogLevel.warning(), + 'disconnected_reply_error', + 'Reply for command ' + '${completer.command.type}{id: ${completer.command.id}} ' + 'error on disconnect', + { + 'command': completer.command, + 'error': error, + 'stackTrace': stackTrace, + }, + ); + } + _replies.clear(); + } on Object catch (error, stackTrace) { + _log( + const SpinifyLogLevel.warning(), + 'disconnected_error', + 'Error on disconnect', + { + 'error': error, + 'stackTrace': stackTrace, + }, + ); + } finally { + _setState(SpinifyState$Disconnected(temporary: temporary)); + _log( + const SpinifyLogLevel.config(), + 'disconnected', + 'Disconnected from server', + {}, + ); + } + } + + /// Plan to do action when client is connected. + @unsafe + Future _doOnReady(Future Function() action) { + if (state.isConnected) return action(); + return ready().then((_) => action()); + } + + @safe + @override + Future close() async { + if (state.isClosed) return; + try { + _setState(SpinifyState$Closed()); + await _internalDisconnect(temporary: false); + } on Object {/* ignore */} finally { + _statesController.close().ignore(); + _log( + const SpinifyLogLevel.info(), + 'closed', + 'Closed', + { + 'state': state, + }, + ); + } + } + + /// Counter for command messages. + @safe + int _getNextCommandId() { + if (_metrics.commandId == kMaxInt) _metrics.commandId = 1; + return _metrics.commandId++; + } + + @override + SpinifyClientSubscription? getClientSubscription(String channel) { + throw UnimplementedError(); + } + + @override + SpinifyServerSubscription? getServerSubscription(String channel) { + throw UnimplementedError(); + } + + @override + SpinifySubscription? getSubscription(String channel) { + throw UnimplementedError(); + } + + @override + Future history( + String channel, { + int? limit, + SpinifyStreamPosition? since, + bool? reverse, + }) { + throw UnimplementedError(); + } + + @override + SpinifyClientSubscription newSubscription( + String channel, { + SpinifySubscriptionConfig? config, + bool subscribe = false, + }) { + throw UnimplementedError(); + } + + @override + Future> presence(String channel) { + throw UnimplementedError(); + } + + @override + Future presenceStats(String channel) { + throw UnimplementedError(); + } + + @override + Future publish(String channel, List data) { + throw UnimplementedError(); + } + + @override + Future ready() { + throw UnimplementedError(); + } + + @override + Future removeSubscription(SpinifyClientSubscription subscription) { + throw UnimplementedError(); + } + + @override + Future> rpc(String method, [List? data]) { + throw UnimplementedError(); + } + + @unsafe + Future _sendCommandAsync(SpinifyCommand command) async { + _log( + const SpinifyLogLevel.debug(), + 'send_command_async_begin', + 'Comand ${command.type}{id: ${command.id}} sent async begin', + { + 'command': command, + }, + ); + try { + // coverage:ignore-start + assert(command.id > -1, 'Command ID should be greater or equal to 0'); + assert(_transport != null, 'Transport is not connected'); + assert(!state.isClosed, 'State is closed'); + // coverage:ignore-end + await _transport?.send(command); + _log( + const SpinifyLogLevel.config(), + 'send_command_async_success', + 'Command sent ${command.type}{id: ${command.id}} async successfully', + { + 'command': command, + }, + ); + } on Object catch (error, stackTrace) { + _log( + const SpinifyLogLevel.warning(), + 'send_command_async_error', + 'Error sending command ${command.type}{id: ${command.id}} async', + { + 'command': command, + 'error': error, + 'stackTrace': stackTrace, + }, + ); + rethrow; + } + } + + /// Hash map of pending replies. + final Map _replies = {}; + + /// Called when [SpinifyReply] received from the server. + @safe + @sideEffect + Future _onReply(SpinifyReply reply) async { + try { + // coverage:ignore-start + if (reply.id < 0 || reply.id > _metrics.commandId) { + assert( + reply.id >= 0 && reply.id <= _metrics.commandId, + 'Reply ID should be greater or equal to 0 ' + 'and less or equal than command ID'); + return; + } + // coverage:ignore-end + if (reply.isResult) { + // If reply is a result then find pending reply and complete it. + if (reply.id case int id when id > 0) { + final completer = _replies.remove(id); + // coverage:ignore-start + if (completer == null || completer.isCompleted) { + assert( + completer != null, + 'Reply completer not found', + ); + assert( + completer?.isCompleted == false, + 'Reply completer already completed', + ); + return; + } + // coverage:ignore-end + if (reply is SpinifyErrorResult) { + completer.completeError( + SpinifyReplyException( + replyCode: reply.code, + replyMessage: reply.message, + temporary: reply.temporary, + ), + StackTrace.current, + ); + } else { + completer.complete(reply); + } + } + } + // ... + _log( + const SpinifyLogLevel.debug(), + 'reply', + 'Reply ${reply.type}{id: ${reply.id}} received', + { + 'reply': reply, + }, + ); + } on Object catch (error, stackTrace) { + _log( + const SpinifyLogLevel.warning(), + 'reply_error', + 'Error processing reply', + { + 'reply': reply, + 'error': error, + 'stackTrace': stackTrace, + }, + ); + } + } + + @unsafe + @override + @Throws([SpinifySendException]) + Future send(List data) async { + try { + await _doOnReady(() => _sendCommandAsync( + SpinifySendRequest( + timestamp: DateTime.now(), + data: data, + ), + )); + } on SpinifySendException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace(SpinifySendException(error: error), stackTrace); + } + } + + @override + ({ + Map client, + Map server + }) get subscriptions => throw UnimplementedError(); +} + +/// Pending reply. +class _PendingReply { + _PendingReply(this.command) : _completer = Completer(); + + final SpinifyCommand command; + final Completer _completer; + + bool get isCompleted => _completer.isCompleted; + + void complete(SpinifyReply reply) => _completer.complete(reply); + + void completeError(SpinifyReplyException error, StackTrace stackTrace) => + _completer.completeError(error, stackTrace); +} From 9b49c4f0a9c8696565286f64d5461b2fa5c5ee49 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 23 Oct 2024 19:41:02 +0400 Subject: [PATCH 006/104] Refactor nested exception visitor in SpinifyException --- lib/src/model/exception.dart | 14 ++ lib/src/spinify.dart | 388 ++++++++++++++++++++++++----------- 2 files changed, 283 insertions(+), 119 deletions(-) diff --git a/lib/src/model/exception.dart b/lib/src/model/exception.dart index faba366..18d8a0d 100644 --- a/lib/src/model/exception.dart +++ b/lib/src/model/exception.dart @@ -22,6 +22,20 @@ sealed class SpinifyException implements Exception { /// Source error of exception if exists. final Object? error; + /// Visitor pattern for nested exceptions. + /// Callback for each nested exception, starting from the current one. + void visitor(void Function(Object error) fn) { + fn(this); + switch (error) { + case SpinifyException e: + e.visitor(fn); + case Object e: + fn(e); + case null: + break; + } + } + @override int get hashCode => code.hashCode; diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 95fae0f..f7372d6 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:collection'; import 'model/annotations.dart'; import 'model/channel_event.dart'; @@ -47,14 +48,7 @@ final class Spinify implements ISpinify { Spinify({SpinifyConfig? config}) : config = config ?? SpinifyConfig.byDefault() { /// Client initialization (from constructor). - _log( - const SpinifyLogLevel.info(), - 'init', - 'Spinify client initialized', - { - 'config': config, - }, - ); + _init(); } /// Create client and connect. @@ -97,8 +91,39 @@ final class Spinify implements ISpinify { StreamController.broadcast(); @override - SpinifyChannelEvents get stream => - throw UnimplementedError(); + late final SpinifyChannelEvents stream = + SpinifyChannelEvents(_eventController.stream); + final StreamController _eventController = + StreamController.broadcast(); + + Completer? _readyCompleter; + Timer? _refreshTimer; + Timer? _reconnectTimer; + Timer? _healthTimer; + + /// Registry of client subscriptions. + final Map _clientSubscriptionRegistry = + {}; + + /// Registry of server subscriptions. + final Map _serverSubscriptionRegistry = + {}; + + @override + ({ + Map client, + Map server + }) get subscriptions => ( + client: UnmodifiableMapView( + _clientSubscriptionRegistry, + ), + server: UnmodifiableMapView( + _serverSubscriptionRegistry, + ), + ); + + /// Hash map of pending replies. + final Map _replies = {}; /// Log an event with the given [level], [event], [message] and [context]. @safe @@ -130,6 +155,90 @@ final class Spinify implements ISpinify { ); } + /// Counter for command messages. + @safe + int _getNextCommandId() { + if (_metrics.commandId == kMaxInt) _metrics.commandId = 1; + return _metrics.commandId++; + } + + // --- Init --- // + + /// Initialization from constructor + @safe + void _init() { + _setUpHealthCheckTimer(); + _log( + const SpinifyLogLevel.info(), + 'init', + 'Spinify client initialized', + { + 'config': config, + }, + ); + } + + // --- Health checks --- // + + /// Set up health check timer. + @safe + void _setUpHealthCheckTimer() {} + + /// Tear down health check timer. + @safe + void _tearDownHealthCheckTimer() {} + + /// Set up refresh connection timer. + @safe + void _setUpRefreshConnection() {} + + /// Tear down refresh connection timer. + @safe + void _tearDownRefreshConnection() {} + + /// Set up reconnect timer. + @safe + void _setUpReconnectTimer() {} + + /// Tear down reconnect timer. + @safe + void _tearDownReconnectTimer() {} + + // --- Ready --- // + + @unsafe + @override + @Throws([SpinifyConnectionException]) + Future ready() async { + const error = SpinifyConnectionException( + message: 'Connection is closed permanently', + ); + if (state.isConnected) return; + if (state.isClosed) throw error; + try { + await (_readyCompleter ??= Completer()).future; + } on SpinifyConnectionException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyConnectionException( + message: 'Failed to wait for connection', + error: error, + ), + stackTrace, + ); + } + } + + /// Plan to do action when client is connected. + @unsafe + Future _doOnReady(Future Function() action) { + if (state.isConnected) return action(); + return ready().then((_) => action()); + } + + // --- Connection --- // + @unsafe @override @Throws([SpinifyConnectionException]) @@ -152,17 +261,58 @@ final class Spinify implements ISpinify { /// User initiated connect. @unsafe Future _interactiveConnect(String url) async { - throw UnimplementedError(); + if (state.isConnected || state.isConnecting) await _interactiveDisconnect(); + // TODO: Set-up reconnect logic. + await _internalReconnect(url); } /// Library initiated connect. @unsafe Future _internalReconnect(String url) async { - throw UnimplementedError(); + assert(state.isDisconnected, 'State should be disconnected'); + final completer = _readyCompleter = switch (_readyCompleter) { + Completer value when !value.isCompleted => value, + _ => Completer(), + }; + _setState(SpinifyState$Connecting(url: _metrics.reconnectUrl = url)); + assert(state.isConnecting, 'State should be connecting'); + // TODO: Create a new transport + + // Prepare connect request. + final SpinifyConnectRequest request; + { + final token = await config.getToken?.call(); + final payload = await config.getPayload?.call(); + final id = _getNextCommandId(); + final now = DateTime.now(); + request = SpinifyConnectRequest( + id: id, + timestamp: now, + token: token, + data: payload, + subs: { + for (final sub in _serverSubscriptionRegistry.values) + sub.channel: SpinifySubscribeRequest( + id: id, + timestamp: now, + channel: sub.channel, + recover: sub.recoverable, + epoch: sub.epoch, + offset: sub.offset, + token: null, + data: null, + positioned: null, + recoverable: null, + joinLeave: null, + ), + }, + name: config.client.name, + version: config.client.version, + ); + } } - /// On connect to the server. - Future _onConnected() async {} + // --- Disconnection --- // @safe @override @@ -170,12 +320,14 @@ final class Spinify implements ISpinify { /// User initiated disconnect. @safe - Future _interactiveDisconnect() => - _internalDisconnect(temporary: false); + Future _interactiveDisconnect() async { + // TODO: Tear down reconnect logic. + _internalDisconnect(temporary: false); + } /// Library initiated disconnect. @safe - Future _internalDisconnect({required bool temporary}) async { + void _internalDisconnect({required bool temporary}) { try { // Close all pending replies with error. const error = SpinifyReplyException( @@ -201,6 +353,12 @@ final class Spinify implements ISpinify { ); } _replies.clear(); + + // Complete ready completer with error, + // if we still waiting for connection. + if (_readyCompleter case Completer c when !c.isCompleted) { + c.completeError(error, stackTrace); + } } on Object catch (error, stackTrace) { _log( const SpinifyLogLevel.warning(), @@ -216,26 +374,24 @@ final class Spinify implements ISpinify { _log( const SpinifyLogLevel.config(), 'disconnected', - 'Disconnected from server', - {}, + 'Disconnected from server ${temporary ? 'temporarily' : 'permanent'}', + { + 'temporary': temporary, + }, ); } } - /// Plan to do action when client is connected. - @unsafe - Future _doOnReady(Future Function() action) { - if (state.isConnected) return action(); - return ready().then((_) => action()); - } + // --- Close --- // @safe @override Future close() async { if (state.isClosed) return; try { + _internalDisconnect(temporary: false); _setState(SpinifyState$Closed()); - await _internalDisconnect(temporary: false); + _tearDownHealthCheckTimer(); } on Object {/* ignore */} finally { _statesController.close().ignore(); _log( @@ -249,37 +405,90 @@ final class Spinify implements ISpinify { } } - /// Counter for command messages. - @safe - int _getNextCommandId() { - if (_metrics.commandId == kMaxInt) _metrics.commandId = 1; - return _metrics.commandId++; + // --- Send --- // + + @unsafe + Future _sendCommandAsync(SpinifyCommand command) async { + _log( + const SpinifyLogLevel.debug(), + 'send_command_async_begin', + 'Comand ${command.type}{id: ${command.id}} sent async begin', + { + 'command': command, + }, + ); + try { + // coverage:ignore-start + assert(command.id > -1, 'Command ID should be greater or equal to 0'); + assert(_transport != null, 'Transport is not connected'); + assert(!state.isClosed, 'State is closed'); + // coverage:ignore-end + await _transport?.send(command); + _log( + const SpinifyLogLevel.config(), + 'send_command_async_success', + 'Command sent ${command.type}{id: ${command.id}} async successfully', + { + 'command': command, + }, + ); + } on Object catch (error, stackTrace) { + _log( + const SpinifyLogLevel.warning(), + 'send_command_async_error', + 'Error sending command ${command.type}{id: ${command.id}} async', + { + 'command': command, + 'error': error, + 'stackTrace': stackTrace, + }, + ); + rethrow; + } } + @unsafe @override - SpinifyClientSubscription? getClientSubscription(String channel) { - throw UnimplementedError(); + @Throws([SpinifySendException]) + Future send(List data) async { + try { + await _doOnReady(() => _sendCommandAsync( + SpinifySendRequest( + timestamp: DateTime.now(), + data: data, + ), + )); + } on SpinifySendException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace(SpinifySendException(error: error), stackTrace); + } } + // --- Remote Procedure Call --- // + @override - SpinifyServerSubscription? getServerSubscription(String channel) { + Future> rpc(String method, [List? data]) { throw UnimplementedError(); } + // --- Subscriptions and Channels --- // + + @safe @override - SpinifySubscription? getSubscription(String channel) { - throw UnimplementedError(); - } + SpinifySubscription? getSubscription(String channel) => + _clientSubscriptionRegistry[channel] ?? + _serverSubscriptionRegistry[channel]; + @safe @override - Future history( - String channel, { - int? limit, - SpinifyStreamPosition? since, - bool? reverse, - }) { - throw UnimplementedError(); - } + SpinifyClientSubscription? getClientSubscription(String channel) => + _clientSubscriptionRegistry[channel]; + + @safe + @override + SpinifyServerSubscription? getServerSubscription(String channel) => + _serverSubscriptionRegistry[channel]; @override SpinifyClientSubscription newSubscription( @@ -291,77 +500,42 @@ final class Spinify implements ISpinify { } @override - Future> presence(String channel) { + Future removeSubscription(SpinifyClientSubscription subscription) { throw UnimplementedError(); } - @override - Future presenceStats(String channel) { - throw UnimplementedError(); - } + // --- Publish --- // @override Future publish(String channel, List data) { throw UnimplementedError(); } + // --- Presence --- // + @override - Future ready() { + Future> presence(String channel) { throw UnimplementedError(); } @override - Future removeSubscription(SpinifyClientSubscription subscription) { + Future presenceStats(String channel) { throw UnimplementedError(); } + // --- History --- // + @override - Future> rpc(String method, [List? data]) { + Future history( + String channel, { + int? limit, + SpinifyStreamPosition? since, + bool? reverse, + }) { throw UnimplementedError(); } - @unsafe - Future _sendCommandAsync(SpinifyCommand command) async { - _log( - const SpinifyLogLevel.debug(), - 'send_command_async_begin', - 'Comand ${command.type}{id: ${command.id}} sent async begin', - { - 'command': command, - }, - ); - try { - // coverage:ignore-start - assert(command.id > -1, 'Command ID should be greater or equal to 0'); - assert(_transport != null, 'Transport is not connected'); - assert(!state.isClosed, 'State is closed'); - // coverage:ignore-end - await _transport?.send(command); - _log( - const SpinifyLogLevel.config(), - 'send_command_async_success', - 'Command sent ${command.type}{id: ${command.id}} async successfully', - { - 'command': command, - }, - ); - } on Object catch (error, stackTrace) { - _log( - const SpinifyLogLevel.warning(), - 'send_command_async_error', - 'Error sending command ${command.type}{id: ${command.id}} async', - { - 'command': command, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - rethrow; - } - } - - /// Hash map of pending replies. - final Map _replies = {}; + // --- Replies --- // /// Called when [SpinifyReply] received from the server. @safe @@ -430,30 +604,6 @@ final class Spinify implements ISpinify { ); } } - - @unsafe - @override - @Throws([SpinifySendException]) - Future send(List data) async { - try { - await _doOnReady(() => _sendCommandAsync( - SpinifySendRequest( - timestamp: DateTime.now(), - data: data, - ), - )); - } on SpinifySendException { - rethrow; - } on Object catch (error, stackTrace) { - Error.throwWithStackTrace(SpinifySendException(error: error), stackTrace); - } - } - - @override - ({ - Map client, - Map server - }) get subscriptions => throw UnimplementedError(); } /// Pending reply. From 21b8e8e30baf06c476a037badd46718c9552656f Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 23 Oct 2024 20:08:24 +0400 Subject: [PATCH 007/104] Refactor health check and connection timers --- lib/src/spinify.dart | 119 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 103 insertions(+), 16 deletions(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index f7372d6..f450cfb 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -182,27 +182,87 @@ final class Spinify implements ISpinify { /// Set up health check timer. @safe - void _setUpHealthCheckTimer() {} + void _setUpHealthCheckTimer() { + _tearDownHealthCheckTimer(); + _healthTimer = Timer.periodic( + const Duration(seconds: 30), + (_) { + if (_statesController.isClosed) { + _log( + const SpinifyLogLevel.warning(), + 'health_check_error', + 'Health check failed: states controller is closed', + {}, + ); + } + if (_eventController.isClosed) { + _log( + const SpinifyLogLevel.warning(), + 'health_check_error', + 'Health check failed: event controller is closed', + {}, + ); + } + if (!state.isDisconnected && _reconnectTimer == null) { + _log( + const SpinifyLogLevel.warning(), + 'health_check_error', + 'Health check failed: no reconnect timer set', + {}, + ); + } + if (state.isConnected && _refreshTimer == null) { + _log( + const SpinifyLogLevel.warning(), + 'health_check_error', + 'Health check failed: no refresh timer set', + {}, + ); + } + if (!state.isConnected && _refreshTimer != null) { + _log( + const SpinifyLogLevel.warning(), + 'health_check_error', + 'Health check failed: refresh timer set but not connected', + {}, + ); + } + }, + ); + } /// Tear down health check timer. @safe - void _tearDownHealthCheckTimer() {} + void _tearDownHealthCheckTimer() { + _healthTimer?.cancel(); + _healthTimer = null; + } /// Set up refresh connection timer. @safe - void _setUpRefreshConnection() {} + void _setUpRefreshConnection() { + _tearDownRefreshConnection(); + } /// Tear down refresh connection timer. @safe - void _tearDownRefreshConnection() {} + void _tearDownRefreshConnection() { + _refreshTimer?.cancel(); + _refreshTimer = null; + } /// Set up reconnect timer. @safe - void _setUpReconnectTimer() {} + void _setUpReconnectTimer() { + _tearDownReconnectTimer(); + } /// Tear down reconnect timer. @safe - void _tearDownReconnectTimer() {} + void _tearDownReconnectTimer() { + _reconnectTimer?.cancel(); + _reconnectTimer = null; + } // --- Ready --- // @@ -262,7 +322,7 @@ final class Spinify implements ISpinify { @unsafe Future _interactiveConnect(String url) async { if (state.isConnected || state.isConnecting) await _interactiveDisconnect(); - // TODO: Set-up reconnect logic. + _setUpReconnectTimer(); await _internalReconnect(url); } @@ -310,6 +370,10 @@ final class Spinify implements ISpinify { version: config.client.version, ); } + + // ... + + _setUpRefreshConnection(); } // --- Disconnection --- // @@ -321,13 +385,21 @@ final class Spinify implements ISpinify { /// User initiated disconnect. @safe Future _interactiveDisconnect() async { - // TODO: Tear down reconnect logic. - _internalDisconnect(temporary: false); + _tearDownReconnectTimer(); + _internalDisconnect( + code: 0, + reason: 'disconnect interactively called by client', + reconnect: false, + ); } /// Library initiated disconnect. @safe - void _internalDisconnect({required bool temporary}) { + void _internalDisconnect({ + required int code, + required String reason, + required bool reconnect, + }) { try { // Close all pending replies with error. const error = SpinifyReplyException( @@ -370,13 +442,13 @@ final class Spinify implements ISpinify { }, ); } finally { - _setState(SpinifyState$Disconnected(temporary: temporary)); + _setState(SpinifyState$Disconnected(temporary: reconnect)); _log( const SpinifyLogLevel.config(), 'disconnected', - 'Disconnected from server ${temporary ? 'temporarily' : 'permanent'}', + 'Disconnected from server ${reconnect ? 'temporarily' : 'permanent'}', { - 'temporary': temporary, + 'temporary': reconnect, }, ); } @@ -389,9 +461,13 @@ final class Spinify implements ISpinify { Future close() async { if (state.isClosed) return; try { - _internalDisconnect(temporary: false); - _setState(SpinifyState$Closed()); _tearDownHealthCheckTimer(); + _internalDisconnect( + code: 0, + reason: 'close interactively called by client', + reconnect: false, + ); + _setState(SpinifyState$Closed()); } on Object {/* ignore */} finally { _statesController.close().ignore(); _log( @@ -581,8 +657,19 @@ final class Spinify implements ISpinify { completer.complete(reply); } } + } else if (reply is SpinifyPush) { + switch (reply.event) { + case SpinifyDisconnect disconnect: + _internalDisconnect( + code: disconnect.code, + reason: disconnect.reason, + reconnect: disconnect.reconnect, + ); + default: + // TODO: Handle other push events. + } } - // ... + _log( const SpinifyLogLevel.debug(), 'reply', From 95ff85967677d2a2408b6245ab61019ef1fb9263 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 23 Oct 2024 23:35:12 +0400 Subject: [PATCH 008/104] Change transport --- lib/src/deprecated/spinify_deprecated.dart | 16 +- lib/src/deprecated/transport_fake.dart | 5 +- lib/src/deprecated/transport_ws_pb_js.dart | 4 +- lib/src/deprecated/transport_ws_pb_stub.dart | 3 +- lib/src/deprecated/transport_ws_pb_vm.dart | 3 +- lib/src/model/exception.dart | 20 ++ lib/src/model/transport_interface.dart | 57 ++-- lib/src/spinify.dart | 71 ++--- lib/src/web_socket_js.dart | 257 +++++++++++++++++++ lib/src/web_socket_vm.dart | 201 +++++++++++++++ 10 files changed, 560 insertions(+), 77 deletions(-) create mode 100644 lib/src/web_socket_js.dart create mode 100644 lib/src/web_socket_vm.dart diff --git a/lib/src/deprecated/spinify_deprecated.dart b/lib/src/deprecated/spinify_deprecated.dart index 49a3a3b..4f40e24 100644 --- a/lib/src/deprecated/spinify_deprecated.dart +++ b/lib/src/deprecated/spinify_deprecated.dart @@ -28,11 +28,7 @@ import '../model/transport_interface.dart'; import '../spinify_interface.dart'; import '../subscription_interface.dart'; import '../util/backoff.dart'; -import 'transport_ws_pb_stub.dart' - // ignore: uri_does_not_exist - if (dart.library.js_util) 'transport_ws_pb_js.dart' - // ignore: uri_does_not_exist - if (dart.library.io) 'transport_ws_pb_vm.dart'; +import '../web_socket_js.dart'; part 'subscription_impl.dart'; @@ -58,14 +54,14 @@ abstract base class SpinifyBase implements ISpinify { final SpinifyConfig config; late final SpinifyTransportBuilder _createTransport; - ISpinifyTransport? _transport; + dynamic _transport; final SpinifyMetrics$Mutable _metrics = SpinifyMetrics$Mutable(); /// Client initialization (from constructor). @mustCallSuper void _init() { - _createTransport = config.transportBuilder ?? $create$WS$PB$Transport; + _createTransport = config.transportBuilder ?? $createWebSocketClient; config.logger?.call( const SpinifyLogLevel.info(), 'init', @@ -633,10 +629,8 @@ base mixin SpinifyConnectionMixin // Create new transport. _transport = await _createTransport( url: url, - config: config, - metrics: _metrics, - onReply: _onReply, - onDisconnect: _onDisconnected, + headers: config.headers, + protocols: {'centrifuge-protobuf'}, ); // ..onReply = _onReply // ..onDisconnect = () => _onDisconnected().ignore(); diff --git a/lib/src/deprecated/transport_fake.dart b/lib/src/deprecated/transport_fake.dart index 224e25e..819d952 100644 --- a/lib/src/deprecated/transport_fake.dart +++ b/lib/src/deprecated/transport_fake.dart @@ -1,4 +1,4 @@ -// ignore_for_file: avoid_setters_without_getters +/* // ignore_for_file: avoid_setters_without_getters // coverage:ignore-file import 'dart:async'; @@ -16,7 +16,7 @@ import '../model/transport_interface.dart'; /// Create a fake Spinify transport. SpinifyTransportBuilder $createFakeSpinifyTransport({ SpinifyReply? Function(SpinifyCommand command)? overrideCommand, - void Function(ISpinifyTransport? transport)? out, + void Function(dynamic transport)? out, }) => ({ /// URL for the connection @@ -310,3 +310,4 @@ class SpinifyTransportFake implements ISpinifyTransport { _timer = null; } } + */ \ No newline at end of file diff --git a/lib/src/deprecated/transport_ws_pb_js.dart b/lib/src/deprecated/transport_ws_pb_js.dart index 40db48a..764a1ec 100644 --- a/lib/src/deprecated/transport_ws_pb_js.dart +++ b/lib/src/deprecated/transport_ws_pb_js.dart @@ -1,5 +1,4 @@ -// coverage:ignore-file - +/* import 'dart:async'; import 'dart:convert'; import 'dart:js_interop' as js; @@ -444,3 +443,4 @@ final class SpinifyTransport$WS$PB$JS implements ISpinifyTransport { //assert(_socket.readyState == 3, 'Socket is not closed'); } } + */ \ No newline at end of file diff --git a/lib/src/deprecated/transport_ws_pb_stub.dart b/lib/src/deprecated/transport_ws_pb_stub.dart index 0a4304e..6a4020e 100644 --- a/lib/src/deprecated/transport_ws_pb_stub.dart +++ b/lib/src/deprecated/transport_ws_pb_stub.dart @@ -1,4 +1,4 @@ -// coverage:ignore-file +/* // coverage:ignore-file import 'package:meta/meta.dart'; import '../model/config.dart'; @@ -25,3 +25,4 @@ Future $create$WS$PB$Transport({ required void Function({required bool temporary}) onDisconnect, }) => throw UnimplementedError(); + */ \ No newline at end of file diff --git a/lib/src/deprecated/transport_ws_pb_vm.dart b/lib/src/deprecated/transport_ws_pb_vm.dart index a535374..61b8a88 100644 --- a/lib/src/deprecated/transport_ws_pb_vm.dart +++ b/lib/src/deprecated/transport_ws_pb_vm.dart @@ -1,4 +1,4 @@ -import 'dart:async'; +/* import 'dart:async'; import 'dart:convert'; import 'dart:io' as io; @@ -292,3 +292,4 @@ final class SpinifyTransport$WS$PB$VM implements ISpinifyTransport { //assert(_socket.readyState == io.WebSocket.closed, 'Socket is not closed'); } } + */ \ No newline at end of file diff --git a/lib/src/model/exception.dart b/lib/src/model/exception.dart index 18d8a0d..92b1eb8 100644 --- a/lib/src/model/exception.dart +++ b/lib/src/model/exception.dart @@ -149,3 +149,23 @@ final class SpinifyRefreshException extends SpinifyException { error, ); } + +/// Problem relevant to transport layer, connection, +/// data transfer or encoding/decoding issues. +/// {@macro exception} +/// {@category Exception} +final class SpinifyTransportException extends SpinifyException { + /// {@macro exception} + const SpinifyTransportException({ + required String message, + Object? error, + this.data, + }) : super( + 'spinify_transport_exception', + message, + error, + ); + + /// Additional data related to the exception. + final Object? data; +} diff --git a/lib/src/model/transport_interface.dart b/lib/src/model/transport_interface.dart index 9052150..d563bd0 100644 --- a/lib/src/model/transport_interface.dart +++ b/lib/src/model/transport_interface.dart @@ -1,33 +1,40 @@ -import 'command.dart'; -import 'config.dart'; -import 'metric.dart'; -import 'reply.dart'; +import 'dart:async'; -/// Create a Spinify transport -/// (e.g. WebSocket or gRPC with JSON or Protocol Buffers). -typedef SpinifyTransportBuilder = Future Function({ - /// URL for the connection - required String url, +import 'annotations.dart'; - /// Spinify client configuration - required SpinifyConfig config, +/// WebSocket interface. +abstract interface class WebSocket implements Sink> { + /// Stream of incoming messages. + abstract final Stream> stream; - /// Metrics - required SpinifyMetrics$Mutable metrics, + /// Close code. + /// May be `null` if connection still open. + int? get closeCode; - /// Callback for reply messages - required Future Function(SpinifyReply reply) onReply, + /// Close reason. + /// May be `null` if connection still open. + String? get closeReason; - /// Callback for disconnect event - required Future Function({required bool temporary}) onDisconnect, -}); + /// Is connection closed. + /// Returns `true` if connection closed. + /// After connection closed no more messages can be sent or received. + bool get isClosed; -/// Spinify transport interface. -abstract interface class ISpinifyTransport { - /// Send command to the server. - Future send(SpinifyCommand command); + /// Adds [data] to the sink. + /// Must not be called after a call to [close]. + @unsafe + @override + void add(List data); - /// Disconnect from the server. - /// Client if not needed anymore. - Future disconnect([int? code, String? reason]); + @safe + @override + Future close([int? code, String? reason]); } + +/// Create a Spinify transport +/// (e.g. WebSocket or gRPC with JSON or Protocol Buffers). +typedef SpinifyTransportBuilder = Future Function({ + required String url, // e.g. 'ws://localhost:8000/connection/websocket' + Map? headers, // e.g. {'Authorization': 'Bearer '} + Iterable? protocols, // e.g. {'centrifuge-protobuf'} +}); diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index f450cfb..9b58610 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -184,48 +184,48 @@ final class Spinify implements ISpinify { @safe void _setUpHealthCheckTimer() { _tearDownHealthCheckTimer(); + + void warning(String message) => _log( + const SpinifyLogLevel.warning(), + 'health_check_error', + message, + {}, + ); + _healthTimer = Timer.periodic( const Duration(seconds: 30), (_) { if (_statesController.isClosed) { - _log( - const SpinifyLogLevel.warning(), - 'health_check_error', - 'Health check failed: states controller is closed', - {}, - ); + warning('Health check failed: states controller is closed'); } if (_eventController.isClosed) { - _log( - const SpinifyLogLevel.warning(), - 'health_check_error', - 'Health check failed: event controller is closed', - {}, - ); - } - if (!state.isDisconnected && _reconnectTimer == null) { - _log( - const SpinifyLogLevel.warning(), - 'health_check_error', - 'Health check failed: no reconnect timer set', - {}, - ); - } - if (state.isConnected && _refreshTimer == null) { - _log( - const SpinifyLogLevel.warning(), - 'health_check_error', - 'Health check failed: no refresh timer set', - {}, - ); + warning('Health check failed: event controller is closed'); } - if (!state.isConnected && _refreshTimer != null) { - _log( - const SpinifyLogLevel.warning(), - 'health_check_error', - 'Health check failed: refresh timer set but not connected', - {}, - ); + switch (state) { + case SpinifyState$Disconnected state: + if (state.temporary && _reconnectTimer == null) { + warning('Health check failed: no reconnect timer set'); + } + if (state.temporary && _metrics.reconnectUrl == null) { + warning('Health check failed: no reconnect URL set'); + } + if (_refreshTimer != null) { + warning( + 'Health check failed: refresh timer set but not connected'); + } + case SpinifyState$Connecting _: + if (_reconnectTimer == null) { + warning('Health check failed: no reconnect timer set'); + } + if (_refreshTimer == null) { + warning('Health check failed: no refresh timer set'); + } + case SpinifyState$Connected _: + if (_refreshTimer == null) { + warning('Health check failed: no refresh timer set'); + } + case SpinifyState$Closed _: + warning('Health check failed: health check should be stopped'); } }, ); @@ -386,6 +386,7 @@ final class Spinify implements ISpinify { @safe Future _interactiveDisconnect() async { _tearDownReconnectTimer(); + _metrics.reconnectUrl = null; _internalDisconnect( code: 0, reason: 'disconnect interactively called by client', diff --git a/lib/src/web_socket_js.dart b/lib/src/web_socket_js.dart new file mode 100644 index 0000000..b8b9a3c --- /dev/null +++ b/lib/src/web_socket_js.dart @@ -0,0 +1,257 @@ +// coverage:ignore-file + +import 'dart:async'; +import 'dart:convert'; +import 'dart:js_interop' as js; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; +import 'package:web/web.dart' as web; + +import 'model/annotations.dart'; +import 'model/exception.dart'; +import 'model/transport_interface.dart'; + +const _BlobCodec _codec = _BlobCodec(); + +/// Create web socket client for Browser and JS environment. +@unsafe +@internal +@Throws([SpinifyTransportException]) +Future $createWebSocketClient({ + required String url, // e.g. 'ws://localhost:8000/connection/websocket' + Map? headers, // e.g. {'Authorization': 'Bearer '} + Iterable? protocols, // e.g. {'centrifuge-protobuf'} +}) async { + StreamSubscription? onOpen, onError; + // ignore: close_sinks + web.WebSocket? socket; + try { + final s = socket = web.WebSocket( + url, + {...?protocols} + .map((e) => e.toJS) + .toList(growable: false) + .toJS, + ); + final completer = Completer(); + onOpen = s.onOpen.listen( + (event) { + if (completer.isCompleted) return; + completer.complete(WebSocket$JS(socket: s)); + }, + cancelOnError: false, + ); + onError = s.onError.listen( + (event) { + if (completer.isCompleted) return; + completer.completeError( + SpinifyTransportException( + message: 'WebSocket connection failed', + error: event, + ), + StackTrace.current, + ); + }, + cancelOnError: false, + ); + return await completer.future; + } on SpinifyTransportException { + socket?.close(1002, 'Protocol error during connection setup'); + rethrow; + } on Object catch (error, stackTrace) { + socket?.close(1002, 'Protocol error during connection setup'); + Error.throwWithStackTrace( + SpinifyTransportException( + message: 'Failed to connect to $url', + error: error, + ), + stackTrace, + ); + } finally { + onOpen?.cancel().ignore(); + onError?.cancel().ignore(); + } +} + +@internal +class WebSocket$JS implements WebSocket { + WebSocket$JS({required web.WebSocket socket}) : _socket = socket { + final controller = StreamController(); + + stream = controller.stream.asyncMap(_codec.read).transform>( + StreamTransformer, List>.fromHandlers( + handleData: _dataHandler, + handleError: _errorHandler, + handleDone: _doneHandler, + ), + ); + + StreamSubscription? onMessage, onClose; + + var done = false; + void onDone([_]) { + if (done) return; // Ignore multiple calls. + done = true; + controller.close().ignore(); + onMessage?.cancel().ignore(); + onClose?.cancel().ignore(); + } + + onMessage = _socket.onMessage.listen( + controller.add, + cancelOnError: false, + ); + + onClose = _socket.onClose.listen( + (event) { + _closeCode = event.code; + _closeReason = event.reason; + onDone(); + }, + cancelOnError: false, + ); + } + + /// Handle incoming data. + void _dataHandler(List data, EventSink> sink) { + // coverage:ignore-start + if (data.isEmpty) return; + // coverage:ignore-end + sink.add(data); + } + + /// Handle incoming error. + void _errorHandler( + Object error, + StackTrace stackTrace, + EventSink> sink, + ) { + // coverage:ignore-start + switch (error) { + case SpinifyTransportException error: + sink.addError(error, stackTrace); + case ArgumentError error: + sink.addError( + SpinifyTransportException( + message: 'Invalid WebSocket message data type', + error: error, + ), + stackTrace, + ); + case Exception error: + sink.addError( + SpinifyTransportException( + message: switch (error.toString()) { + 'Exception' => 'Unknown WebSocket exception', + String message => message, + }, + error: error, + ), + stackTrace, + ); + default: + sink.addError( + SpinifyTransportException( + message: 'Unknown WebSocket error', + error: error, + ), + stackTrace, + ); + } + // coverage:ignore-end + } + + /// Handle socket close. + void _doneHandler(EventSink> sink) { + sink.close(); + _isClosed = true; + } + + final web.WebSocket _socket; + + @override + int? get closeCode => _closeCode; + int? _closeCode; + + @override + String? get closeReason => _closeReason; + String? _closeReason; + + @override + bool get isClosed => _isClosed; + bool _isClosed = false; + + @override + late final Stream> stream; + + @override + void add(List event) => _socket.send(_codec.write(event)); + + @override + Future close([int? code, String? reason]) async { + _closeCode ??= code; + _closeReason ??= reason; + if (_socket.readyState == 3) + return; + else if (code != null && reason != null) + _socket.close(code, reason); + else if (code != null) + _socket.close(code); + else + _socket.close(); + //assert(_socket.readyState == 3, 'Socket is not closed'); + } +} + +@immutable +final class _BlobCodec { + const _BlobCodec(); + + @internal + js.JSAny write(Object data) { + // return web.Blob([Uint8List.fromList(bytes).toJS].toJS); + switch (data) { + case List bytes: + return Uint8List.fromList(bytes).toJS; + case String text: + return Uint8List.fromList(utf8.encode(text)).toJS; + case TypedData td: + return Uint8List.view( + td.buffer, + td.offsetInBytes, + td.lengthInBytes, + ).toJS; + case ByteBuffer bb: + return bb.asUint8List().toJS; + case web.Blob blob: + return blob; + default: + throw ArgumentError.value(data, 'data', 'Invalid data type.'); + } + } + + @internal + Future> read(js.JSAny? data) async { + switch (data) { + case List bytes: + return bytes; + case String text: + return utf8.encode(text); + case web.Blob blob: + final arrayBuffer = await blob.arrayBuffer().toDart; + return arrayBuffer.toDart.asUint8List(); + case TypedData td: + return Uint8List.view( + td.buffer, + td.offsetInBytes, + td.lengthInBytes, + ); + case ByteBuffer bb: + return bb.asUint8List(); + default: + assert(false, 'Unsupported data type: $data'); + throw ArgumentError.value(data, 'data', 'Invalid data type.'); + } + } +} diff --git a/lib/src/web_socket_vm.dart b/lib/src/web_socket_vm.dart new file mode 100644 index 0000000..3f43b11 --- /dev/null +++ b/lib/src/web_socket_vm.dart @@ -0,0 +1,201 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io' as io; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import 'model/annotations.dart'; +import 'model/exception.dart'; +import 'model/transport_interface.dart'; + +/// Create web socket client for Dart VM (dart:io) environment. +@unsafe +@internal +@Throws([SpinifyTransportException]) +Future $createWebSocketClient({ + required String url, // e.g. 'ws://localhost:8000/connection/websocket' + Map? headers, // e.g. {'Authorization': 'Bearer '} + Iterable? protocols, // e.g. {'centrifuge-protobuf'} +}) async { + io.WebSocket? socket; + try { + // ignore: close_sinks + final s = socket = await io.WebSocket.connect( + url, + headers: headers, + protocols: protocols, + ); + return WebSocket$VM(socket: s); + } on SpinifyTransportException { + socket?.close(1002, 'Protocol error during connection setup').ignore(); + rethrow; + } on Object catch (error, stackTrace) { + socket?.close(1002, 'Protocol error during connection setup').ignore(); + Error.throwWithStackTrace( + SpinifyTransportException( + message: 'Failed to connect to $url', + error: error, + ), + stackTrace, + ); + } +} + +@internal +class WebSocket$VM implements WebSocket { + WebSocket$VM({required io.WebSocket socket}) : _socket = socket { + stream = _socket.transform>( + StreamTransformer>.fromHandlers( + handleData: _dataHandler, + handleError: _errorHandler, + handleDone: _doneHandler, + ), + ); + } + + /// Handle incoming data. + void _dataHandler(Object? data, EventSink> sink) { + final List bytes; + // coverage:ignore-start + switch (data) { + case List b: + bytes = b; + case TypedData td: + bytes = Uint8List.view( + td.buffer, + td.offsetInBytes, + td.lengthInBytes, + ); + case ByteBuffer bb: + bytes = bb.asUint8List(); + case String s: + bytes = utf8.encode(s); + default: + sink.addError( + SpinifyTransportException( + message: 'Invalid WebSocket message', + error: ArgumentError.value(data, 'data', 'Invalid message'), + data: data, + ), + ); + return; + } + if (bytes.isEmpty) return; + // coverage:ignore-end + sink.add(bytes); + } + + /// Handle incoming error. + void _errorHandler( + Object error, + StackTrace stackTrace, + EventSink> sink, + ) { + // coverage:ignore-start + switch (error) { + case SpinifyTransportException error: + sink.addError(error, stackTrace); + case io.WebSocketException error: + sink.addError( + SpinifyTransportException( + message: 'WebSocket error', + error: error, + ), + stackTrace, + ); + case io.SocketException error: + sink.addError( + SpinifyTransportException( + message: 'Socket error', + error: error, + ), + stackTrace, + ); + case io.HandshakeException error: + sink.addError( + SpinifyTransportException( + message: 'Handshake error', + error: error, + ), + stackTrace, + ); + case io.TlsException error: + sink.addError( + SpinifyTransportException( + message: 'TLS error', + error: error, + ), + stackTrace, + ); + case io.HttpException error: + sink.addError( + SpinifyTransportException( + message: 'HTTP error', + error: error, + ), + stackTrace, + ); + case Exception error: + sink.addError( + SpinifyTransportException( + message: switch (error.toString()) { + 'Exception' => 'Unknown WebSocket exception', + String message => message, + }, + error: error, + ), + stackTrace, + ); + default: + sink.addError( + SpinifyTransportException( + message: 'Unknown WebSocket error', + error: error, + ), + stackTrace, + ); + } + // coverage:ignore-end + } + + /// Handle socket close. + void _doneHandler(EventSink> sink) { + sink.close(); + _isClosed = true; + } + + final io.WebSocket _socket; + + @override + int? get closeCode => _socket.closeCode; + + @override + String? get closeReason => _socket.closeReason; + + @override + bool get isClosed => _isClosed; + bool _isClosed = false; + + @override + late final Stream> stream; + + @override + void add(List event) => _socket.add(event); + + @override + Future close([int? code, String? reason]) async { + // coverage:ignore-start + if (_socket.readyState == 3) + return; + else if (code != null && reason != null) + await _socket.close(code, reason); + else if (code != null) + await _socket.close(code); + else + await _socket.close(); + // coverage:ignore-end + // Thats a bug in the dart:io, the socket is not closed immediately + //assert(_socket.readyState == io.WebSocket.closed, 'Socket is not closed'); + } +} From e1a8361b4da9264821b962854559cbc21b27eece Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 23 Oct 2024 23:43:48 +0400 Subject: [PATCH 009/104] Refactor transport implementation and WebSocket connection - Refactor the transport implementation in Spinify to use the WebSocket interface. - Add a new method `_webSocketConnect` to handle WebSocket connection. - Remove the deprecated `web_socket_stub.dart` file. Closes #123 --- lib/src/spinify.dart | 20 ++++++++++++++++++-- lib/src/web_socket_js.dart | 2 +- lib/src/web_socket_stub.dart | 20 ++++++++++++++++++++ lib/src/web_socket_vm.dart | 2 +- test/unit/config_test.dart | 8 +++----- 5 files changed, 43 insertions(+), 9 deletions(-) create mode 100644 lib/src/web_socket_stub.dart diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 9b58610..9337265 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -17,8 +17,14 @@ import 'model/state.dart'; import 'model/states_stream.dart'; import 'model/stream_position.dart'; import 'model/subscription_config.dart'; +import 'model/transport_interface.dart'; import 'spinify_interface.dart'; import 'subscription_interface.dart'; +import 'web_socket_stub.dart' + // ignore: uri_does_not_exist + if (dart.library.js_interop) 'web_socket_js.dart' + // ignore: uri_does_not_exist + if (dart.library.io) 'web_socket_vm.dart'; /// {@template spinify} /// Spinify client for Centrifuge. @@ -67,8 +73,7 @@ final class Spinify implements ISpinify { @override SpinifyMetrics get metrics => _metrics.freeze(); - /// TODO: Transport implementation. - dynamic _transport; + WebSocket? _transport; /// Internal mutable metrics. Also it's container for Spinify's state. final SpinifyMetrics$Mutable _metrics = SpinifyMetrics$Mutable(); @@ -299,6 +304,17 @@ final class Spinify implements ISpinify { // --- Connection --- // + Future _webSocketConnect({ + required String url, + Map? headers, + Iterable? protocols, + }) => + (config.transportBuilder ?? $webSocketConnect)( + url: url, + headers: headers, + protocols: protocols, + ); + @unsafe @override @Throws([SpinifyConnectionException]) diff --git a/lib/src/web_socket_js.dart b/lib/src/web_socket_js.dart index b8b9a3c..380c1cc 100644 --- a/lib/src/web_socket_js.dart +++ b/lib/src/web_socket_js.dart @@ -18,7 +18,7 @@ const _BlobCodec _codec = _BlobCodec(); @unsafe @internal @Throws([SpinifyTransportException]) -Future $createWebSocketClient({ +Future $webSocketConnect({ required String url, // e.g. 'ws://localhost:8000/connection/websocket' Map? headers, // e.g. {'Authorization': 'Bearer '} Iterable? protocols, // e.g. {'centrifuge-protobuf'} diff --git a/lib/src/web_socket_stub.dart b/lib/src/web_socket_stub.dart new file mode 100644 index 0000000..ce3db24 --- /dev/null +++ b/lib/src/web_socket_stub.dart @@ -0,0 +1,20 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +import 'model/annotations.dart'; +import 'model/exception.dart'; +import 'model/transport_interface.dart'; + +/// Stub for WebSocket client. +@unsafe +@internal +@Throws([SpinifyTransportException]) +Future $webSocketConnect({ + required String url, // e.g. 'ws://localhost:8000/connection/websocket' + Map? headers, // e.g. {'Authorization': 'Bearer '} + Iterable? protocols, // e.g. {'centrifuge-protobuf'} +}) => + throw const SpinifyTransportException( + message: 'WebSocket is not supported at current platform', + ); diff --git a/lib/src/web_socket_vm.dart b/lib/src/web_socket_vm.dart index 3f43b11..11a811f 100644 --- a/lib/src/web_socket_vm.dart +++ b/lib/src/web_socket_vm.dart @@ -13,7 +13,7 @@ import 'model/transport_interface.dart'; @unsafe @internal @Throws([SpinifyTransportException]) -Future $createWebSocketClient({ +Future $webSocketConnect({ required String url, // e.g. 'ws://localhost:8000/connection/websocket' Map? headers, // e.g. {'Authorization': 'Bearer '} Iterable? protocols, // e.g. {'centrifuge-protobuf'} diff --git a/test/unit/config_test.dart b/test/unit/config_test.dart index e9a9aba..4df60bb 100644 --- a/test/unit/config_test.dart +++ b/test/unit/config_test.dart @@ -12,12 +12,10 @@ void main() { test('Fields', () { final logBuffer = SpinifyLogBuffer(size: 10); - Future transportBuilder({ + Future transportBuilder({ required String url, - required SpinifyConfig config, - required SpinifyMetrics metrics, - required Future Function(SpinifyReply reply) onReply, - required Future Function({required bool temporary}) onDisconnect, + Map? headers, + Iterable? protocols, }) => throw UnimplementedError(); From ba1d21fabd620cc02b1efe90943edd9e580b626d Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 24 Oct 2024 00:53:53 +0400 Subject: [PATCH 010/104] Refactor transport implementation and WebSocket connection --- lib/spinify.dart | 1 + lib/src/deprecated/spinify_deprecated.dart | 3 +- lib/src/model/transport_interface.dart | 2 +- lib/src/spinify.dart | 13 +++- lib/src/web_socket_fake.dart | 74 ++++++++++++++++++++++ lib/src/web_socket_js.dart | 22 ++++--- lib/src/web_socket_vm.dart | 30 +++++---- pubspec.yaml | 13 ++-- test/unit/server_subscription_test.dart | 5 +- test/unit/spinify_test.dart | 26 ++++---- 10 files changed, 143 insertions(+), 46 deletions(-) create mode 100644 lib/src/web_socket_fake.dart diff --git a/lib/spinify.dart b/lib/spinify.dart index 70c50cf..2e3eca0 100644 --- a/lib/spinify.dart +++ b/lib/spinify.dart @@ -23,3 +23,4 @@ export 'src/model/transport_interface.dart'; export 'src/spinify.dart' show Spinify; export 'src/spinify_interface.dart'; export 'src/subscription_interface.dart'; +export 'src/web_socket_fake.dart'; diff --git a/lib/src/deprecated/spinify_deprecated.dart b/lib/src/deprecated/spinify_deprecated.dart index 4f40e24..8d1e436 100644 --- a/lib/src/deprecated/spinify_deprecated.dart +++ b/lib/src/deprecated/spinify_deprecated.dart @@ -28,7 +28,6 @@ import '../model/transport_interface.dart'; import '../spinify_interface.dart'; import '../subscription_interface.dart'; import '../util/backoff.dart'; -import '../web_socket_js.dart'; part 'subscription_impl.dart'; @@ -61,7 +60,7 @@ abstract base class SpinifyBase implements ISpinify { /// Client initialization (from constructor). @mustCallSuper void _init() { - _createTransport = config.transportBuilder ?? $createWebSocketClient; + _createTransport = config.transportBuilder! /* ?? $createWebSocketClient */; config.logger?.call( const SpinifyLogLevel.info(), 'init', diff --git a/lib/src/model/transport_interface.dart b/lib/src/model/transport_interface.dart index d563bd0..a9e020d 100644 --- a/lib/src/model/transport_interface.dart +++ b/lib/src/model/transport_interface.dart @@ -28,7 +28,7 @@ abstract interface class WebSocket implements Sink> { @safe @override - Future close([int? code, String? reason]); + void close([int? code, String? reason]); } /// Create a Spinify transport diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 9337265..20a68af 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -501,6 +501,7 @@ final class Spinify implements ISpinify { // --- Send --- // @unsafe + @Throws([SpinifySendException]) Future _sendCommandAsync(SpinifyCommand command) async { _log( const SpinifyLogLevel.debug(), @@ -516,7 +517,10 @@ final class Spinify implements ISpinify { assert(_transport != null, 'Transport is not connected'); assert(!state.isClosed, 'State is closed'); // coverage:ignore-end - await _transport?.send(command); + // TODO: Encode command to binary format. + // TODO: Check that transport is not closed and exists. + // TODO: Send command to the server. + //await _transport?.send(command); _log( const SpinifyLogLevel.config(), 'send_command_async_success', @@ -536,7 +540,12 @@ final class Spinify implements ISpinify { 'stackTrace': stackTrace, }, ); - rethrow; + Error.throwWithStackTrace( + SpinifySendException( + message: 'Failed to send command ${command.type}{id: ${command.id}}', + ), + stackTrace, + ); } } diff --git a/lib/src/web_socket_fake.dart b/lib/src/web_socket_fake.dart new file mode 100644 index 0000000..4cbbbef --- /dev/null +++ b/lib/src/web_socket_fake.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +import 'model/exception.dart'; +import 'model/transport_interface.dart'; + +/// Fake WebSocket implementation. +@visibleForTesting +class WebSocket$Fake implements WebSocket { + /// Create a fake WebSocket. + WebSocket$Fake({ + StreamController>? socket, + }) : _socket = socket ?? StreamController>() { + stream = _socket.stream.transform>( + StreamTransformer, List>.fromHandlers( + handleData: _dataHandler, + handleError: _errorHandler, + handleDone: _doneHandler, + ), + ); + } + + final StreamController> _socket; + + /// Handle incoming data. + void _dataHandler(List data, EventSink> sink) => + sink.add(data); + + /// Handle incoming error. + void _errorHandler( + Object error, + StackTrace stackTrace, + EventSink> sink, + ) => + sink.addError( + SpinifyTransportException( + message: 'Fake WebSocket error', + error: error, + ), + stackTrace, + ); + + /// Handle socket close. + void _doneHandler(EventSink> sink) { + sink.close(); + _isClosed = true; + } + + @override + int? get closeCode => _closeCode; + int? _closeCode; + + @override + String? get closeReason => _closeReason; + String? _closeReason; + + @override + bool get isClosed => _isClosed; + bool _isClosed = false; + + @override + late final Stream> stream; + + @override + void add(List event) {} + + @override + void close([int? code, String? reason]) { + _closeCode = code; + _closeReason = reason; + _socket.close().ignore(); + } +} diff --git a/lib/src/web_socket_js.dart b/lib/src/web_socket_js.dart index 380c1cc..5f21ca4 100644 --- a/lib/src/web_socket_js.dart +++ b/lib/src/web_socket_js.dart @@ -192,15 +192,19 @@ class WebSocket$JS implements WebSocket { Future close([int? code, String? reason]) async { _closeCode ??= code; _closeReason ??= reason; - if (_socket.readyState == 3) - return; - else if (code != null && reason != null) - _socket.close(code, reason); - else if (code != null) - _socket.close(code); - else - _socket.close(); - //assert(_socket.readyState == 3, 'Socket is not closed'); + // coverage:ignore-start + try { + if (_socket.readyState == 3) + return; + else if (code != null && reason != null) + _socket.close(code, reason); + else if (code != null) + _socket.close(code); + else + _socket.close(); + //assert(_socket.readyState == 3, 'Socket is not closed'); + } on Object {/* ignore */} + // coverage:ignore-end } } diff --git a/lib/src/web_socket_vm.dart b/lib/src/web_socket_vm.dart index 11a811f..49c9cd4 100644 --- a/lib/src/web_socket_vm.dart +++ b/lib/src/web_socket_vm.dart @@ -168,10 +168,12 @@ class WebSocket$VM implements WebSocket { final io.WebSocket _socket; @override - int? get closeCode => _socket.closeCode; + int? get closeCode => _socket.closeCode ?? _closeCode; + int? _closeCode; @override - String? get closeReason => _socket.closeReason; + String? get closeReason => _socket.closeReason ?? _closeReason; + String? _closeReason; @override bool get isClosed => _isClosed; @@ -185,17 +187,21 @@ class WebSocket$VM implements WebSocket { @override Future close([int? code, String? reason]) async { + _closeCode ??= code; + _closeReason ??= reason; // coverage:ignore-start - if (_socket.readyState == 3) - return; - else if (code != null && reason != null) - await _socket.close(code, reason); - else if (code != null) - await _socket.close(code); - else - await _socket.close(); + try { + if (_socket.readyState == 3) + return; + else if (code != null && reason != null) + _socket.close(code, reason).ignore(); + else if (code != null) + _socket.close(code).ignore(); + else + _socket.close().ignore(); + // Thats a bug in the dart:io, the socket is not closed immediately + //assert(_socket.readyState == io.WebSocket.closed); + } on Object {/* ignore */} // coverage:ignore-end - // Thats a bug in the dart:io, the socket is not closed immediately - //assert(_socket.readyState == io.WebSocket.closed, 'Socket is not closed'); } } diff --git a/pubspec.yaml b/pubspec.yaml index e1f3f84..df09727 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ description: > Dart client to communicate with Centrifuge and Centrifugo from Dart and Flutter over WebSockets with Protobuf support. -version: 0.0.5 +version: 0.1.0-pre.1 homepage: https://centrifugal.dev @@ -38,7 +38,7 @@ platforms: environment: - sdk: '>=3.4.0 <4.0.0' + sdk: '>=3.5.0 <4.0.0' dependencies: @@ -57,12 +57,13 @@ dependencies: stack_trace: ^1.11.0 dev_dependencies: - build_runner: ^2.4.6 - pubspec_generator: ^4.0.0 + build_runner: ^2.4.13 + pubspec_generator: ^4.1.0-pre.1 benchmark_harness: ^2.2.2 lints: ^5.0.0 - test: ^1.24.4 - fake_async: ^1.3.1 + test: ^1.25.8 + fake_async: ^1.3.2 + mockito: ^5.0.0 # https://github.com/dart-lang/mockito/issues/732 # https://github.com/dart-lang/mockito/pull/738 # https://github.com/dart-lang/mockito/issues/755 diff --git a/test/unit/server_subscription_test.dart b/test/unit/server_subscription_test.dart index e64ac48..e48b8a6 100644 --- a/test/unit/server_subscription_test.dart +++ b/test/unit/server_subscription_test.dart @@ -1,5 +1,4 @@ import 'package:fake_async/fake_async.dart'; -import 'package:spinify/spinify.dart'; import 'package:test/test.dart'; void main() { @@ -8,7 +7,7 @@ void main() { 'Emulate server subscription', () => fakeAsync( (async) { - final client = Spinify( + /* final client = Spinify( config: SpinifyConfig( transportBuilder: $createFakeSpinifyTransport( overrideCommand: (command) => switch (command) { @@ -75,7 +74,7 @@ void main() { 'notification:index', isA(), ), - ); + ); */ }, ), ); diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index 1acb051..d0845e9 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -9,14 +9,18 @@ void main() { final buffer = SpinifyLogBuffer(size: 10); Spinify createFakeClient([ - void Function(ISpinifyTransport? transport)? out, - ]) => - Spinify( - config: SpinifyConfig( - transportBuilder: $createFakeSpinifyTransport(out: out), - logger: buffer.add, - ), - ); + void Function(WebSocket$Fake transport)? out, + ]) { + final ws = WebSocket$Fake(); + out?.call(ws); + return Spinify( + config: SpinifyConfig( + transportBuilder: ({required url, headers, protocols}) => + Future.value(ws), + logger: buffer.add, + ), + ); + } test('Create_and_close_client', () async { final client = createFakeClient(); @@ -71,15 +75,15 @@ void main() { test( 'Reconnect_after_disconnected_transport', () => fakeAsync((async) { - ISpinifyTransport? transport; + WebSocket? transport; final client = createFakeClient((t) => transport = t) ..connect('ws://localhost:8000/connection/websocket'); expect(client.state, isA()); async.elapse(client.config.timeout); expect(client.state, isA()); expect(transport, isNotNull); - expect(transport, isA()); - transport!.disconnect(); + expect(transport, isA()); + transport!.close(); async.elapse(const Duration(milliseconds: 50)); expect(client.state, isA()); async.elapse(Duration( From dd9ae73db6007c813193d414764f48efbd41fc8a Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 24 Oct 2024 00:54:21 +0400 Subject: [PATCH 011/104] Update version and dependencies in pubspec.yaml.g.dart --- lib/src/model/pubspec.yaml.g.dart | 37 +++++++++++++++++-------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/lib/src/model/pubspec.yaml.g.dart b/lib/src/model/pubspec.yaml.g.dart index ef217f5..279bacf 100644 --- a/lib/src/model/pubspec.yaml.g.dart +++ b/lib/src/model/pubspec.yaml.g.dart @@ -93,13 +93,13 @@ sealed class Pubspec { static const PubspecVersion version = ( /// Non-canonical string representation of the version as provided /// in the pubspec.yaml file. - representation: r'0.0.4', + representation: r'0.1.0-pre.1', /// Returns a 'canonicalized' representation /// of the application version. /// This represents the version string in accordance with /// Semantic Versioning (SemVer) standards. - canonical: r'0.0.4', + canonical: r'0.1.0-pre.1', /// MAJOR version when you make incompatible API changes. /// The major version number: 1 in "1.2.3". @@ -108,14 +108,14 @@ sealed class Pubspec { /// MINOR version when you add functionality /// in a backward compatible manner. /// The minor version number: 2 in "1.2.3". - minor: 0, + minor: 1, /// PATCH version when you make backward compatible bug fixes. /// The patch version number: 3 in "1.2.3". - patch: 4, + patch: 0, /// The pre-release identifier: "foo" in "1.2.3-foo". - preRelease: [], + preRelease: [r'pre', r'1'], /// The build identifier: "foo" in "1.2.3+foo". build: [], @@ -125,12 +125,12 @@ sealed class Pubspec { static final DateTime timestamp = DateTime.utc( 2024, 10, - 15, - 17, - 34, - 30, - 637, - 667, + 23, + 20, + 54, + 10, + 259, + 414, ); /// Name @@ -326,7 +326,9 @@ sealed class Pubspec { /// /// Current app [topics] /// - /// Package authors can use the topics field to categorize their package. Topics can be used to assist discoverability during search with filters on pub.dev. Pub.dev displays the topics on the package page as well as in the search results. + /// Package authors can use the topics field to categorize their package. + /// Topics can be used to assist discoverability during search with filters on pub.dev. + /// Pub.dev displays the topics on the package page as well as in the search results. /// /// The field consists of a list of names. For example: /// @@ -358,7 +360,7 @@ sealed class Pubspec { /// Environment static const Map environment = { - 'sdk': '>=3.4.0 <4.0.0', + 'sdk': '>=3.5.0 <4.0.0', }; /// Platforms @@ -435,12 +437,13 @@ sealed class Pubspec { /// Developer dependencies static const Map devDependencies = { - 'build_runner': r'^2.4.6', - 'pubspec_generator': r'^4.0.0', + 'build_runner': r'^2.4.13', + 'pubspec_generator': r'^4.1.0-pre.1', 'benchmark_harness': r'^2.2.2', 'lints': r'^5.0.0', - 'test': r'^1.24.4', - 'fake_async': r'^1.3.1', + 'test': r'^1.25.8', + 'fake_async': r'^1.3.2', + 'mockito': r'^5.0.0', }; /// Dependency overrides From 6051bb1ee93b7c8911dddb8806ad2edb9f0fd0c8 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 24 Oct 2024 01:07:55 +0400 Subject: [PATCH 012/104] Add mockito --- test/unit/server_subscription_test.dart | 20 ++++++ test/unit/server_subscription_test.mocks.dart | 66 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 test/unit/server_subscription_test.mocks.dart diff --git a/test/unit/server_subscription_test.dart b/test/unit/server_subscription_test.dart index e48b8a6..5478411 100644 --- a/test/unit/server_subscription_test.dart +++ b/test/unit/server_subscription_test.dart @@ -1,12 +1,32 @@ import 'package:fake_async/fake_async.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:spinify/spinify.dart'; import 'package:test/test.dart'; +import 'server_subscription_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec(as: #MockWebSocket)]) void main() { group('SpinifyServerSubscription', () { test( 'Emulate server subscription', () => fakeAsync( (async) { + final ws = MockWebSocket(); + when(ws.stream).thenAnswer( + (_) => Stream.fromIterable( + const >[ + [0, 0, 0, 0, 0, 0, 0, 0], + ], + ), + ); + when(ws.isClosed).thenReturn(false); + when(ws.add(any)).thenAnswer((_) {}); + when(ws.close()).thenAnswer((_) {}); + // TODO: Encode response data for mocks + // Mike Matiunin , 24 October 2024 + /* final client = Spinify( config: SpinifyConfig( transportBuilder: $createFakeSpinifyTransport( diff --git a/test/unit/server_subscription_test.mocks.dart b/test/unit/server_subscription_test.mocks.dart new file mode 100644 index 0000000..61a09b2 --- /dev/null +++ b/test/unit/server_subscription_test.mocks.dart @@ -0,0 +1,66 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in spinify/test/unit/server_subscription_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:spinify/src/model/transport_interface.dart' as _i2; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +/// A class which mocks [WebSocket]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockWebSocket extends _i1.Mock implements _i2.WebSocket { + @override + _i3.Stream> get stream => (super.noSuchMethod( + Invocation.getter(#stream), + returnValue: _i3.Stream>.empty(), + returnValueForMissingStub: _i3.Stream>.empty(), + ) as _i3.Stream>); + + @override + bool get isClosed => (super.noSuchMethod( + Invocation.getter(#isClosed), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + void add(List? data) => super.noSuchMethod( + Invocation.method( + #add, + [data], + ), + returnValueForMissingStub: null, + ); + + @override + void close([ + int? code, + String? reason, + ]) => + super.noSuchMethod( + Invocation.method( + #close, + [ + code, + reason, + ], + ), + returnValueForMissingStub: null, + ); +} From d0a7cb4e3b4ec7ae466a00a5914642cb4a209a47 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 24 Oct 2024 02:21:02 +0400 Subject: [PATCH 013/104] Refactor WebSocket connection and transport implementation --- lib/src/web_socket_js.dart | 61 ++++++++++++++++++++++++++++++-------- lib/src/web_socket_vm.dart | 4 ++- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/lib/src/web_socket_js.dart b/lib/src/web_socket_js.dart index 5f21ca4..e119360 100644 --- a/lib/src/web_socket_js.dart +++ b/lib/src/web_socket_js.dart @@ -33,8 +33,13 @@ Future $webSocketConnect({ .map((e) => e.toJS) .toList(growable: false) .toJS, - ); + ) + // Change binary type from "blob" to "arraybuffer" + ..binaryType = 'arraybuffer'; + final completer = Completer(); + // The socket API guarantees that only a single open event will be + // emitted. onOpen = s.onOpen.listen( (event) { if (completer.isCompleted) return; @@ -45,6 +50,8 @@ Future $webSocketConnect({ onError = s.onError.listen( (event) { if (completer.isCompleted) return; + // Unfortunately, the underlying WebSocket API doesn't expose any + // specific information about the error itself. completer.completeError( SpinifyTransportException( message: 'WebSocket connection failed', @@ -55,7 +62,19 @@ Future $webSocketConnect({ }, cancelOnError: false, ); - return await completer.future; + + if (s.readyState == web.WebSocket.OPEN) { + completer.complete(WebSocket$JS(socket: s)); + } else if (s.readyState == web.WebSocket.CLOSING || + s.readyState == web.WebSocket.CLOSED) { + completer.completeError( + const SpinifyTransportException( + message: 'WebSocket connection already closed', + ), + StackTrace.current, + ); + } + return await completer.future; // Return the WebSocket instance. } on SpinifyTransportException { socket?.close(1002, 'Protocol error during connection setup'); rethrow; @@ -96,11 +115,13 @@ class WebSocket$JS implements WebSocket { controller.close().ignore(); onMessage?.cancel().ignore(); onClose?.cancel().ignore(); + if (socket.readyState != web.WebSocket.CLOSED) socket.close(); } onMessage = _socket.onMessage.listen( controller.add, cancelOnError: false, + onDone: onDone, ); onClose = _socket.onClose.listen( @@ -110,6 +131,7 @@ class WebSocket$JS implements WebSocket { onDone(); }, cancelOnError: false, + onDone: onDone, ); } @@ -182,6 +204,10 @@ class WebSocket$JS implements WebSocket { bool get isClosed => _isClosed; bool _isClosed = false; + /// The number of bytes of data that have been queued but not yet transmitted + /// to the network. + int? get bufferedAmount => _socket.bufferedAmount; + @override late final Stream> stream; @@ -228,34 +254,45 @@ final class _BlobCodec { ).toJS; case ByteBuffer bb: return bb.asUint8List().toJS; - case web.Blob blob: - return blob; + case js.JSObject blob: + return blob; // if (blob.isA()) default: throw ArgumentError.value(data, 'data', 'Invalid data type.'); } } @internal - Future> read(js.JSAny? data) async { + Future> read(web.MessageEvent message) async { + final data = message.data; + if (data == null) { + return []; + } else if (data.typeofEquals('object') && + (data as js.JSObject).instanceOfString('ArrayBuffer')) { + return (data as js.JSArrayBuffer).toDart.asUint8List(); + } else if (data.typeofEquals('string')) { + return utf8.encode((data as js.JSString).toDart); + } switch (data) { case List bytes: return bytes; case String text: return utf8.encode(text); - case web.Blob blob: - final arrayBuffer = await blob.arrayBuffer().toDart; - return arrayBuffer.toDart.asUint8List(); + case ByteBuffer bb: + return bb.asUint8List(); case TypedData td: return Uint8List.view( td.buffer, td.offsetInBytes, td.lengthInBytes, ); - case ByteBuffer bb: - return bb.asUint8List(); default: - assert(false, 'Unsupported data type: $data'); - throw ArgumentError.value(data, 'data', 'Invalid data type.'); + if (data.isA()) { + final arrayBuffer = await (data as web.Blob).arrayBuffer().toDart; + return arrayBuffer.toDart.asUint8List(); + } else { + assert(false, 'Unsupported data type: $data'); + throw ArgumentError.value(data, 'data', 'Invalid data type.'); + } } } } diff --git a/lib/src/web_socket_vm.dart b/lib/src/web_socket_vm.dart index 49c9cd4..150c362 100644 --- a/lib/src/web_socket_vm.dart +++ b/lib/src/web_socket_vm.dart @@ -25,7 +25,9 @@ Future $webSocketConnect({ url, headers: headers, protocols: protocols, - ); + ) + // Disable ping interval + ..pingInterval = null; return WebSocket$VM(socket: s); } on SpinifyTransportException { socket?.close(1002, 'Protocol error during connection setup').ignore(); From b65cc71f3e7430b715d16ba7dc26f74b9518f3e3 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 24 Oct 2024 02:42:04 +0400 Subject: [PATCH 014/104] Refactor WebSocket connection and transport implementation --- lib/src/web_socket_js.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/src/web_socket_js.dart b/lib/src/web_socket_js.dart index e119360..365c05e 100644 --- a/lib/src/web_socket_js.dart +++ b/lib/src/web_socket_js.dart @@ -27,6 +27,7 @@ Future $webSocketConnect({ // ignore: close_sinks web.WebSocket? socket; try { + final completer = Completer(); final s = socket = web.WebSocket( url, {...?protocols} @@ -37,17 +38,16 @@ Future $webSocketConnect({ // Change binary type from "blob" to "arraybuffer" ..binaryType = 'arraybuffer'; - final completer = Completer(); // The socket API guarantees that only a single open event will be // emitted. - onOpen = s.onOpen.listen( + onOpen = s.onOpen.take(1).listen( (event) { if (completer.isCompleted) return; completer.complete(WebSocket$JS(socket: s)); }, cancelOnError: false, ); - onError = s.onError.listen( + onError = s.onError.take(1).listen( (event) { if (completer.isCompleted) return; // Unfortunately, the underlying WebSocket API doesn't expose any @@ -206,7 +206,7 @@ class WebSocket$JS implements WebSocket { /// The number of bytes of data that have been queued but not yet transmitted /// to the network. - int? get bufferedAmount => _socket.bufferedAmount; + //int? get bufferedAmount => _socket.bufferedAmount; @override late final Stream> stream; From 0c5917816f3cd97a97f4bcc26350a23a96c8398b Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 24 Oct 2024 16:01:58 +0400 Subject: [PATCH 015/104] Add codec test --- lib/spinify.dart | 1 + lib/src/model/codec.dart | 17 ++++ lib/src/model/config.dart | 5 ++ lib/src/protobuf/protobuf_codec.dart | 114 ++++++++++++++------------- lib/src/spinify.dart | 1 + test/unit/codec_test.dart | 34 ++++++++ test/unit_test.dart | 2 + 7 files changed, 121 insertions(+), 53 deletions(-) create mode 100644 lib/src/model/codec.dart create mode 100644 test/unit/codec_test.dart diff --git a/lib/spinify.dart b/lib/spinify.dart index 2e3eca0..2553bb4 100644 --- a/lib/spinify.dart +++ b/lib/spinify.dart @@ -5,6 +5,7 @@ export 'package:fixnum/fixnum.dart' show Int64; export 'src/deprecated/transport_fake.dart'; export 'src/model/channel_event.dart'; export 'src/model/client_info.dart'; +export 'src/model/codec.dart'; export 'src/model/command.dart'; export 'src/model/config.dart'; export 'src/model/exception.dart'; diff --git a/lib/src/model/codec.dart b/lib/src/model/codec.dart new file mode 100644 index 0000000..b1d5616 --- /dev/null +++ b/lib/src/model/codec.dart @@ -0,0 +1,17 @@ +import 'dart:convert'; + +import 'command.dart'; +import 'reply.dart'; + +/// A codec for encoding and decoding Spinify commands and replies. +abstract interface class SpinifyCodec { + /// The protocol used by the codec. + /// e.g. 'centrifuge-protobuf' + abstract final String protocol; + + /// Decodes a Spinify replies from a list of bytes. + abstract final Converter, Iterable> decoder; + + /// Encodes a Spinify command to a list of bytes. + abstract final Converter> encoder; +} diff --git a/lib/src/model/config.dart b/lib/src/model/config.dart index a6f0e5d..c331504 100644 --- a/lib/src/model/config.dart +++ b/lib/src/model/config.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:meta/meta.dart'; +import 'codec.dart'; import 'pubspec.yaml.g.dart'; import 'transport_interface.dart'; @@ -264,6 +265,7 @@ final class SpinifyConfig { Map? headers, this.logger, this.transportBuilder, + this.codec, }) : headers = Map.unmodifiable( headers ?? const {}), client = client ?? @@ -341,6 +343,9 @@ final class SpinifyConfig { /// Callback to build Spinify transport. final SpinifyTransportBuilder? transportBuilder; + /// The Spinify codec to use for encoding and decoding messages. + final SpinifyCodec? codec; + @override String toString() => 'SpinifyConfig{}'; } diff --git a/lib/src/protobuf/protobuf_codec.dart b/lib/src/protobuf/protobuf_codec.dart index d76cde1..6d97204 100644 --- a/lib/src/protobuf/protobuf_codec.dart +++ b/lib/src/protobuf/protobuf_codec.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:meta/meta.dart'; +import 'package:protobuf/protobuf.dart' as pb; import '../model/channel_event.dart'; import '../model/client_info.dart'; @@ -11,9 +12,9 @@ import '../model/reply.dart'; import '../model/stream_position.dart'; import 'client.pb.dart' as pb; -/// SpinifyCommand --> Protobuf Command encoder. +/// SpinifyCommand --> List encoder. final class ProtobufCommandEncoder - extends Converter { + extends Converter> { /// SpinifyCommand --> List encoder. const ProtobufCommandEncoder([this.logger]); @@ -34,7 +35,7 @@ final class ProtobufCommandEncoder final SpinifyLogger? logger; @override - pb.Command convert(SpinifyCommand input) { + List convert(SpinifyCommand input) { final cmd = pb.Command(id: input.id); switch (input) { case SpinifySendRequest send: @@ -125,28 +126,23 @@ final class ProtobufCommandEncoder token: subRefresh.token, ); } - /* assert(() { - print('Command > ${cmd.toProto3Json()}'); - return true; - }()); */ - - /* final buffer = pb.CodedBufferWriter(); - pb.writeToCodedBufferWriter(buffer); - return buffer.toBuffer(); */ - - /* final commandData = cmd.writeToBuffer(); - final length = commandData.lengthInBytes; - final writer = pb.CodedBufferWriter() - ..writeInt32NoTag(length); //..writeRawBytes(commandData); - return writer.toBuffer() + commandData; */ - - return cmd; + final commandData = cmd.writeToBuffer(); + /* final writer = pb.CodedBufferWriter() + ..writeInt32NoTag( + commandData.lengthInBytes); //..writeRawBytes(commandData); + final bytes = writer.toBuffer() + commandData; + return bytes; */ + return (pb.CodedBufferWriter() + ..writeInt32NoTag(commandData.lengthInBytes) + ..writeRawBytes(commandData)) + .toBuffer(); } } -/// Protobuf Reply --> SpinifyReply decoder. -final class ProtobufReplyDecoder extends Converter { - /// List --> SpinifyCommand decoder. +/// Protobuf List --> Iterable decoder. +final class ProtobufReplyDecoder + extends Converter, Iterable> { + /// List --> Iterable decoder. const ProtobufReplyDecoder([this.logger]); /// Logger function to use for logging. @@ -166,38 +162,50 @@ final class ProtobufReplyDecoder extends Converter { final SpinifyLogger? logger; @override - SpinifyReply convert(pb.Reply input) { - //final reader = pb.CodedBufferReader(input); - //while (!reader.isAtEnd()) { - //final reply = pb.Reply(); - //reader.readMessage(reply, pb.ExtensionRegistry.EMPTY); - final reply = input; - - /* assert(() { - print('Reply < ${reply.toProto3Json()}'); - return true; - }()); */ - - if (reply.hasPush()) { - return _decodePush(reply.push); - } else if (reply.hasId() && reply.id > 0) { - return _decodeReply(reply); - } else if (reply.hasError()) { - final error = reply.error; - return SpinifyErrorResult( - id: reply.hasId() ? reply.id : 0, - timestamp: DateTime.now(), - code: error.code, - message: error.message, - temporary: error.temporary, - ); - } else { - return SpinifyServerPing( - timestamp: DateTime.now(), - ); + Iterable convert(List input) sync* { + if (input.isEmpty) return; + final reader = pb.CodedBufferReader(input); + while (!reader.isAtEnd()) { + try { + final message = pb.Reply(); + reader.readMessage(message, pb.ExtensionRegistry.EMPTY); + /* assert(() { + print('Reply < ${message.toProto3Json()}'); + return true; + }()); */ + if (message.hasPush()) { + yield _decodePush(message.push); + } else if (message.hasId() && message.id > 0) { + yield _decodeReply(message); + } else if (message.hasError()) { + final error = message.error; + yield SpinifyErrorResult( + id: message.hasId() ? message.id : 0, + timestamp: DateTime.now(), + code: error.code, + message: error.message, + temporary: error.temporary, + ); + } else { + yield SpinifyServerPing( + timestamp: DateTime.now(), + ); + } + } on Object catch (error, stackTrace) { + logger?.call( + const SpinifyLogLevel.warning(), + 'protobuf_reply_decoder_error', + 'Error decoding reply', + { + 'error': error, + 'stackTrace': stackTrace, + 'input': input, + }, + ); + } } - //} - //assert(reader.isAtEnd(), 'Data is not fully consumed'); + + assert(reader.isAtEnd(), 'Data is not fully consumed'); } /* diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 20a68af..523f1b8 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -487,6 +487,7 @@ final class Spinify implements ISpinify { _setState(SpinifyState$Closed()); } on Object {/* ignore */} finally { _statesController.close().ignore(); + _eventController.close().ignore(); _log( const SpinifyLogLevel.info(), 'closed', diff --git a/test/unit/codec_test.dart b/test/unit/codec_test.dart new file mode 100644 index 0000000..71dfea5 --- /dev/null +++ b/test/unit/codec_test.dart @@ -0,0 +1,34 @@ +import 'package:protobuf/protobuf.dart' as pb; +import 'package:spinify/spinify.dart'; +import 'package:spinify/src/protobuf/client.pb.dart' as pb; +import 'package:spinify/src/protobuf/protobuf_codec.dart'; +import 'package:test/test.dart'; + +void main() => group('Codec', () { + test('Command_encoding', () { + final command = SpinifySendRequest( + timestamp: DateTime(2021, 1, 1), + data: [for (var i = 0; i < 256; i++) i], + ); + const codec = ProtobufCommandEncoder(); + final bytesFromCodec = codec.convert(command); + expect(bytesFromCodec.length, greaterThan(0)); + + // Try read the bytes back. + final reader = pb.CodedBufferReader(bytesFromCodec); + final decoded = pb.Command(); + reader.readMessage(decoded, pb.ExtensionRegistry.EMPTY); + + expect(reader.isAtEnd(), isTrue); + expect(decoded.id, equals(command.id)); + expect(decoded.send.data, equals(command.data)); + + // Compare with direct encoding through protobuf and concatenation. + final commandData = decoded.writeToBuffer(); + final writer = pb.CodedBufferWriter() + ..writeInt32NoTag(commandData.lengthInBytes); + final bytesFromTest = writer.toBuffer() + commandData; + expect(bytesFromCodec.length, equals(bytesFromTest.length)); + expect(bytesFromCodec, equals(bytesFromTest)); + }); + }); diff --git a/test/unit_test.dart b/test/unit_test.dart index 8e1ddd3..94fb4c2 100644 --- a/test/unit_test.dart +++ b/test/unit_test.dart @@ -1,5 +1,6 @@ import 'package:test/test.dart'; +import 'unit/codec_test.dart' as codec_test; import 'unit/config_test.dart' as config_test; import 'unit/jwt_test.dart' as jwt_test; import 'unit/logs_test.dart' as logs_test; @@ -11,6 +12,7 @@ void main() { logs_test.main(); config_test.main(); spinify_test.main(); + codec_test.main(); server_subscription_test.main(); jwt_test.main(); }); From 9fe73fc6cac3e69e86a8b754908334450c5b3f03 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 24 Oct 2024 16:28:38 +0400 Subject: [PATCH 016/104] Refactor encoding benchmarks for protobuf commands --- benchmark/encoding_benchmark.dart | 60 +++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 benchmark/encoding_benchmark.dart diff --git a/benchmark/encoding_benchmark.dart b/benchmark/encoding_benchmark.dart new file mode 100644 index 0000000..a0bc8d3 --- /dev/null +++ b/benchmark/encoding_benchmark.dart @@ -0,0 +1,60 @@ +import 'dart:typed_data'; + +import 'package:benchmark_harness/benchmark_harness.dart'; +import 'package:protobuf/protobuf.dart' as pb; +import 'package:spinify/src/protobuf/client.pb.dart' as pb; + +void main() { + final command = pb.Command( + send: pb.SendRequest( + data: Uint16List.fromList([for (var i = 0; i < 256; i++) i]), + ), + ); + + final a = _EncdingBenchmark$Concatination(command)..report(); + final b = _EncdingBenchmark$Builder(command)..report(); + + if (a.bytes.length != b.bytes.length) { + throw StateError('Bytes length mismatch'); + } + for (var i = 0; i < a.bytes.length; i++) { + if (a.bytes[i] != b.bytes[i]) { + throw StateError('Bytes mismatch at index $i'); + } + } +} + +class _EncdingBenchmark$Concatination extends BenchmarkBase { + _EncdingBenchmark$Concatination(this.command) + : super('Encoding concatination'); + + final pb.Command command; + + List bytes = Uint8List(0); + + @override + void run() { + final commandData = command.writeToBuffer(); + final length = commandData.lengthInBytes; + final writer = pb.CodedBufferWriter()..writeInt32NoTag(length); + bytes = writer.toBuffer() + commandData; + } +} + +class _EncdingBenchmark$Builder extends BenchmarkBase { + _EncdingBenchmark$Builder(this.command) : super('Encoding builder'); + + final pb.Command command; + + List bytes = Uint8List(0); + + @override + void run() { + final commandData = command.writeToBuffer(); + final length = commandData.lengthInBytes; + final writer = pb.CodedBufferWriter() + ..writeInt32NoTag(length) + ..writeRawBytes(commandData); + bytes = writer.toBuffer(); + } +} From d55f6cb3b3ad0c9e2a48a0b3f5d9e1367b3f39a7 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 24 Oct 2024 16:35:19 +0400 Subject: [PATCH 017/104] Refactor _doOnReady method to handle different Spinify connection states --- lib/src/spinify.dart | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 523f1b8..266f961 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -297,10 +297,14 @@ final class Spinify implements ISpinify { /// Plan to do action when client is connected. @unsafe - Future _doOnReady(Future Function() action) { - if (state.isConnected) return action(); - return ready().then((_) => action()); - } + Future _doOnReady(Future Function() action) => switch (state) { + SpinifyState$Connected _ => action(), + SpinifyState$Connecting _ => ready().then((_) => action()), + SpinifyState$Disconnected _ => Future.error( + const SpinifyConnectionException(message: 'Disconnected')), + SpinifyState$Closed _ => + Future.error(const SpinifyConnectionException(message: 'Closed')), + }; // --- Connection --- // From aa5c3018a3235343b3e4885e55259a4dd34b5400 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 24 Oct 2024 16:46:49 +0400 Subject: [PATCH 018/104] Refactor _setState method to handle different Spinify connection states --- lib/src/spinify.dart | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 266f961..70e68c0 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -146,16 +146,32 @@ final class Spinify implements ISpinify { /// Set a new state and notify listeners via [states]. @safe void _setState(SpinifyState state) { - if (isClosed) return; - final previous = _metrics.state; - _statesController.add(_metrics.state = state); + if (isClosed) return; // Client is closed, do not notify about states. + final prev = _metrics.state, next = state; + if (prev.type == next.type) { + // Should we notify about the same state? + switch ((prev, next)) { + case (SpinifyState$Connecting prev, SpinifyState$Connecting next): + if (prev.url == next.url) return; // The same + case (SpinifyState$Disconnected prev, SpinifyState$Disconnected next): + if (prev.temporary == next.temporary) return; // The same + case (SpinifyState$Closed _, SpinifyState$Closed _): + return; // Do not notify about closed states changes. + case (SpinifyState$Connected _, SpinifyState$Connected _): + break; // Always notify about connected states changes. + default: + break; // Notify about other states changes. + } + } + _statesController.add(_metrics.state = next); _log( const SpinifyLogLevel.config(), 'state_changed', - 'State changed from $previous to $state', + 'State changed from $prev to $next', { - 'previous': previous, - 'state': state, + 'prev': prev, + 'next': next, + 'state': next, }, ); } From 2484abcb58157f7e12138b979aaccaa2c2649b5f Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 24 Oct 2024 18:18:04 +0400 Subject: [PATCH 019/104] Refactor SpinifyReplyException constructor to include error parameter --- lib/src/model/exception.dart | 2 + lib/src/protobuf/protobuf_codec.dart | 23 ++++ lib/src/spinify.dart | 198 +++++++++++++++++++++------ 3 files changed, 179 insertions(+), 44 deletions(-) diff --git a/lib/src/model/exception.dart b/lib/src/model/exception.dart index 92b1eb8..4c79153 100644 --- a/lib/src/model/exception.dart +++ b/lib/src/model/exception.dart @@ -66,9 +66,11 @@ final class SpinifyReplyException extends SpinifyException { required this.replyCode, required String replyMessage, required this.temporary, + Object? error, }) : super( 'spinify_reply_exception', replyMessage, + error, ); /// Reply code. diff --git a/lib/src/protobuf/protobuf_codec.dart b/lib/src/protobuf/protobuf_codec.dart index 6d97204..8356f48 100644 --- a/lib/src/protobuf/protobuf_codec.dart +++ b/lib/src/protobuf/protobuf_codec.dart @@ -6,13 +6,35 @@ import 'package:protobuf/protobuf.dart' as pb; import '../model/channel_event.dart'; import '../model/client_info.dart'; +import '../model/codec.dart'; import '../model/command.dart'; import '../model/config.dart'; import '../model/reply.dart'; import '../model/stream_position.dart'; import 'client.pb.dart' as pb; +/// Default protobuf codec for Spinify. +@internal +final class ProtobufCodec implements SpinifyCodec { + /// Default protobuf codec for Spinify. + ProtobufCodec([this.logger]) + : decoder = ProtobufReplyDecoder(logger), + encoder = ProtobufCommandEncoder(logger); + + final SpinifyLogger? logger; + + @override + String get protocol => 'centrifuge-protobuf'; + + @override + final Converter, Iterable> decoder; + + @override + final Converter> encoder; +} + /// SpinifyCommand --> List encoder. +@internal final class ProtobufCommandEncoder extends Converter> { /// SpinifyCommand --> List encoder. @@ -140,6 +162,7 @@ final class ProtobufCommandEncoder } /// Protobuf List --> Iterable decoder. +@internal final class ProtobufReplyDecoder extends Converter, Iterable> { /// List --> Iterable decoder. diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 70e68c0..00052f1 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -5,6 +5,7 @@ import 'model/annotations.dart'; import 'model/channel_event.dart'; import 'model/channel_events.dart'; import 'model/client_info.dart'; +import 'model/codec.dart'; import 'model/command.dart'; import 'model/config.dart'; import 'model/constant.dart'; @@ -18,6 +19,7 @@ import 'model/states_stream.dart'; import 'model/stream_position.dart'; import 'model/subscription_config.dart'; import 'model/transport_interface.dart'; +import 'protobuf/protobuf_codec.dart'; import 'spinify_interface.dart'; import 'subscription_interface.dart'; import 'web_socket_stub.dart' @@ -52,7 +54,8 @@ final class Spinify implements ISpinify { /// {@macro spinify} @safe Spinify({SpinifyConfig? config}) - : config = config ?? SpinifyConfig.byDefault() { + : config = config ?? SpinifyConfig.byDefault(), + _codec = config?.codec ?? ProtobufCodec() { /// Client initialization (from constructor). _init(); } @@ -73,6 +76,10 @@ final class Spinify implements ISpinify { @override SpinifyMetrics get metrics => _metrics.freeze(); + /// Codec to encode and decode messages for the [_transport]. + final SpinifyCodec _codec; + + /// Current WebSocket transport. WebSocket? _transport; /// Internal mutable metrics. Also it's container for Spinify's state. @@ -317,9 +324,13 @@ final class Spinify implements ISpinify { SpinifyState$Connected _ => action(), SpinifyState$Connecting _ => ready().then((_) => action()), SpinifyState$Disconnected _ => Future.error( - const SpinifyConnectionException(message: 'Disconnected')), - SpinifyState$Closed _ => - Future.error(const SpinifyConnectionException(message: 'Closed')), + const SpinifyConnectionException(message: 'Disconnected'), + StackTrace.current, + ), + SpinifyState$Closed _ => Future.error( + const SpinifyConnectionException(message: 'Closed'), + StackTrace.current, + ), }; // --- Connection --- // @@ -357,6 +368,10 @@ final class Spinify implements ISpinify { /// User initiated connect. @unsafe Future _interactiveConnect(String url) async { + if (isClosed) + throw const SpinifyConnectionException( + message: 'Client is closed permanently', + ); if (state.isConnected || state.isConnecting) await _interactiveDisconnect(); _setUpReconnectTimer(); await _internalReconnect(url); @@ -370,46 +385,66 @@ final class Spinify implements ISpinify { Completer value when !value.isCompleted => value, _ => Completer(), }; + if (state.isConnected || state.isConnecting) + _internalDisconnect( + code: 0, + reason: 'reconnect called while already connected or connecting', + reconnect: false, + ); _setState(SpinifyState$Connecting(url: _metrics.reconnectUrl = url)); - assert(state.isConnecting, 'State should be connecting'); - // TODO: Create a new transport - - // Prepare connect request. - final SpinifyConnectRequest request; - { - final token = await config.getToken?.call(); - final payload = await config.getPayload?.call(); - final id = _getNextCommandId(); - final now = DateTime.now(); - request = SpinifyConnectRequest( - id: id, - timestamp: now, - token: token, - data: payload, - subs: { - for (final sub in _serverSubscriptionRegistry.values) - sub.channel: SpinifySubscribeRequest( - id: id, - timestamp: now, - channel: sub.channel, - recover: sub.recoverable, - epoch: sub.epoch, - offset: sub.offset, - token: null, - data: null, - positioned: null, - recoverable: null, - joinLeave: null, - ), - }, - name: config.client.name, - version: config.client.version, + try { + assert(state.isConnecting, 'State should be connecting'); + // Create a new transport + final ws = _transport = await _webSocketConnect( + url: url, + headers: config.headers, + protocols: [_codec.protocol], ); - } - // ... + // Prepare connect request. + final SpinifyConnectRequest request; + { + final token = await config.getToken?.call(); + final payload = await config.getPayload?.call(); + final id = _getNextCommandId(); + final now = DateTime.now(); + request = SpinifyConnectRequest( + id: id, + timestamp: now, + token: token, + data: payload, + subs: { + for (final sub in _serverSubscriptionRegistry.values) + sub.channel: SpinifySubscribeRequest( + id: id, + timestamp: now, + channel: sub.channel, + recover: sub.recoverable, + epoch: sub.epoch, + offset: sub.offset, + token: null, + data: null, + positioned: null, + recoverable: null, + joinLeave: null, + ), + }, + name: config.client.name, + version: config.client.version, + ); + } + + final reply = await _sendCommand(request); - _setUpRefreshConnection(); + // TODO: Handle connect reply. + // Mike Matiunin , 24 October 2024 + // ... + + _setUpRefreshConnection(); + } on Object catch (error, stackTrace) { + // TODO: Handle error. + // Mike Matiunin , 24 October 2024 + } } // --- Disconnection --- // @@ -438,6 +473,10 @@ final class Spinify implements ISpinify { required bool reconnect, }) { try { + // Close transport. + _transport?.close(code, reason); + _transport = null; + // Close all pending replies with error. const error = SpinifyReplyException( replyCode: 0, @@ -570,6 +609,75 @@ final class Spinify implements ISpinify { } } + Future _sendCommand(SpinifyCommand command) async { + _log( + const SpinifyLogLevel.debug(), + 'send_command_begin', + 'Command ${command.type}{id: ${command.id}} sent begin', + { + 'command': command, + }, + ); + try { + // coverage:ignore-start + assert(command.id > -1, 'Command ID should be greater or equal to 0'); + assert(_replies[command.id] == null, 'Command ID should be unique'); + assert(_transport != null, 'Transport is not connected'); + assert(!state.isClosed, 'State is closed'); + // coverage:ignore-end + final pr = _replies[command.id] = _PendingReply(command); + final bytes = _codec.encoder.convert(command); + if (_transport == null) + throw const SpinifySendException(message: 'Transport is not connected'); + _transport?.add(bytes); // await _sendCommandAsync(command); + final result = await pr.future.timeout(config.timeout); + _log( + const SpinifyLogLevel.config(), + 'send_command_success', + 'Command ${command.type}{id: ${command.id}} sent successfully', + { + 'command': command, + 'result': result, + }, + ); + return result; + } on Object catch (error, stackTrace) { + if (_replies.remove(command.id) case _PendingReply pr + when !pr.isCompleted) { + pr.completeError( + SpinifyReplyException( + replyCode: 0, + replyMessage: 'Failed to send command', + temporary: true, + error: error, + ), + stackTrace, + ); + } + _log( + const SpinifyLogLevel.warning(), + 'send_command_error', + 'Error sending command ${command.type}{id: ${command.id}}', + { + 'command': command, + 'error': error, + 'stackTrace': stackTrace, + }, + ); + if (error is SpinifySendException) + rethrow; + else + Error.throwWithStackTrace( + SpinifySendException( + message: + 'Failed to send command ${command.type}{id: ${command.id}}', + error: error, + ), + stackTrace, + ); + } + } + @unsafe @override @Throws([SpinifySendException]) @@ -741,15 +849,17 @@ final class Spinify implements ISpinify { } /// Pending reply. -class _PendingReply { - _PendingReply(this.command) : _completer = Completer(); +class _PendingReply { + _PendingReply(this.command) : _completer = Completer(); final SpinifyCommand command; - final Completer _completer; + final Completer _completer; + + Future get future => _completer.future; bool get isCompleted => _completer.isCompleted; - void complete(SpinifyReply reply) => _completer.complete(reply); + void complete(R reply) => _completer.complete(reply); void completeError(SpinifyReplyException error, StackTrace stackTrace) => _completer.completeError(error, stackTrace); From 99121c09c5404deec6f57ea4984341915cb0c738 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 24 Oct 2024 23:22:20 +0400 Subject: [PATCH 020/104] Add configuration file for issue templates --- .github/ISSUE_TEMPLATE/config.yml | 5 +++++ .github/workflows/checkout.yml | 4 ++++ .github/workflows/tests.yml | 4 ++++ .vscode/extensions.json | 3 +++ 4 files changed, 16 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..c2b01f3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions & Help + url: https://t.me/ru_dart + about: Ask a question about Spinify \ No newline at end of file diff --git a/.github/workflows/checkout.yml b/.github/workflows/checkout.yml index c427354..ee9546b 100644 --- a/.github/workflows/checkout.yml +++ b/.github/workflows/checkout.yml @@ -42,6 +42,10 @@ permissions: actions: read checks: write +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: checkout: name: "Checkout" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ce401d2..c1887e3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -42,6 +42,10 @@ permissions: actions: read checks: write +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: build-echo: name: "Build Echo server" diff --git a/.vscode/extensions.json b/.vscode/extensions.json index e2aea12..a2dafaa 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,8 @@ { "recommendations": [ "dart-code.dart-code", + "github.vscode-github-actions", + "golang.go", + "kangping.protobuf" ] } \ No newline at end of file From a19c0369785cd87be07fb7f33695e47b17570f78 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 25 Oct 2024 20:34:12 +0400 Subject: [PATCH 021/104] Refactor WebSocket$JS to use synchronous StreamController --- lib/src/model/metric.dart | 50 +++++-- lib/src/spinify.dart | 270 ++++++++++++++++++++++++++++++------ lib/src/web_socket_js.dart | 2 +- test/unit/spinify_test.dart | 4 +- 4 files changed, 267 insertions(+), 59 deletions(-) diff --git a/lib/src/model/metric.dart b/lib/src/model/metric.dart index 2a0ffe8..3bc8012 100644 --- a/lib/src/model/metric.dart +++ b/lib/src/model/metric.dart @@ -49,10 +49,16 @@ sealed class SpinifyMetrics implements Comparable { abstract final fixnum.Int64 bytesReceived; /// The total number of messages sent. - abstract final fixnum.Int64 messagesSent; + abstract final fixnum.Int64 chunksSent; - /// The total number of messages received. - abstract final fixnum.Int64 messagesReceived; + /// The total number of bytes chunks received. + abstract final fixnum.Int64 chunksReceived; + + /// The total number of commands encoded. + abstract final fixnum.Int64 commandsEncoded; + + /// The total number of replies decoded. + abstract final fixnum.Int64 repliesDecoded; /* /// The number of subscriptions. @@ -112,8 +118,10 @@ sealed class SpinifyMetrics implements Comparable { 'state': state, 'bytesSent': bytesSent.toString(), 'bytesReceived': bytesReceived.toString(), - 'messagesSent': messagesSent.toString(), - 'messagesReceived': messagesReceived.toString(), + 'chunksSent': chunksSent.toString(), + 'chunksReceived': chunksReceived.toString(), + 'commandsEncoded': commandsEncoded.toString(), + 'repliesDecoded': repliesDecoded.toString(), 'connects': connects, 'lastConnectAt': lastConnectAt?.toUtc().toIso8601String(), 'reconnectUrl': reconnectUrl, @@ -211,8 +219,10 @@ final class SpinifyMetrics$Immutable extends SpinifyMetrics { required this.lastDisconnectAt, required this.bytesReceived, required this.bytesSent, - required this.messagesReceived, - required this.messagesSent, + required this.chunksReceived, + required this.commandsEncoded, + required this.repliesDecoded, + required this.chunksSent, required this.lastPingAt, required this.receivedPings, required this.channels, @@ -258,10 +268,16 @@ final class SpinifyMetrics$Immutable extends SpinifyMetrics { final fixnum.Int64 bytesSent; @override - final fixnum.Int64 messagesReceived; + final fixnum.Int64 chunksReceived; + + @override + final fixnum.Int64 commandsEncoded; + + @override + final fixnum.Int64 repliesDecoded; @override - final fixnum.Int64 messagesSent; + final fixnum.Int64 chunksSent; @override final DateTime? lastPingAt; @@ -365,10 +381,16 @@ final class SpinifyMetrics$Mutable extends SpinifyMetrics { fixnum.Int64 bytesSent = fixnum.Int64.ZERO; @override - fixnum.Int64 messagesReceived = fixnum.Int64.ZERO; + fixnum.Int64 chunksReceived = fixnum.Int64.ZERO; + + @override + fixnum.Int64 commandsEncoded = fixnum.Int64.ZERO; + + @override + fixnum.Int64 repliesDecoded = fixnum.Int64.ZERO; @override - fixnum.Int64 messagesSent = fixnum.Int64.ZERO; + fixnum.Int64 chunksSent = fixnum.Int64.ZERO; @override DateTime? lastPingAt; @@ -395,8 +417,10 @@ final class SpinifyMetrics$Mutable extends SpinifyMetrics { lastDisconnectAt: lastDisconnectAt, bytesReceived: bytesReceived, bytesSent: bytesSent, - messagesReceived: messagesReceived, - messagesSent: messagesSent, + chunksReceived: chunksReceived, + commandsEncoded: commandsEncoded, + repliesDecoded: repliesDecoded, + chunksSent: chunksSent, lastPingAt: lastPingAt, receivedPings: receivedPings, channels: Map.unmodifiable( diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 00052f1..a2291ff 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -100,13 +100,13 @@ final class Spinify implements ISpinify { @safe final StreamController _statesController = - StreamController.broadcast(); + StreamController.broadcast(sync: true); @override late final SpinifyChannelEvents stream = SpinifyChannelEvents(_eventController.stream); final StreamController _eventController = - StreamController.broadcast(); + StreamController.broadcast(sync: true); Completer? _readyCompleter; Timer? _refreshTimer; @@ -221,7 +221,7 @@ final class Spinify implements ISpinify { ); _healthTimer = Timer.periodic( - const Duration(seconds: 30), + const Duration(seconds: 15), (_) { if (_statesController.isClosed) { warning('Health check failed: states controller is closed'); @@ -231,26 +231,27 @@ final class Spinify implements ISpinify { } switch (state) { case SpinifyState$Disconnected state: - if (state.temporary && _reconnectTimer == null) { - warning('Health check failed: no reconnect timer set'); - } - if (state.temporary && _metrics.reconnectUrl == null) { - warning('Health check failed: no reconnect URL set'); + if (state.temporary) { + if (_metrics.reconnectUrl == null) { + warning('Health check failed: no reconnect URL set'); + _setState(SpinifyState$Disconnected(temporary: false)); + } else if (_reconnectTimer == null) { + warning('Health check failed: no reconnect timer set'); + _setUpReconnectTimer(); + } } if (_refreshTimer != null) { warning( 'Health check failed: refresh timer set but not connected'); } case SpinifyState$Connecting _: - if (_reconnectTimer == null) { - warning('Health check failed: no reconnect timer set'); - } - if (_refreshTimer == null) { - warning('Health check failed: no refresh timer set'); + if (_refreshTimer != null) { + warning('Health check failed: refresh timer set during connect'); } case SpinifyState$Connected _: if (_refreshTimer == null) { warning('Health check failed: no refresh timer set'); + _setUpRefreshConnection(); } case SpinifyState$Closed _: warning('Health check failed: health check should be stopped'); @@ -270,6 +271,8 @@ final class Spinify implements ISpinify { @safe void _setUpRefreshConnection() { _tearDownRefreshConnection(); + // TODO: Implement refresh connection timer. + // Mike Matiunin , 25 October 2024 } /// Tear down refresh connection timer. @@ -283,6 +286,8 @@ final class Spinify implements ISpinify { @safe void _setUpReconnectTimer() { _tearDownReconnectTimer(); + // TODO: Implement reconnect timer. + // Mike Matiunin , 25 October 2024 } /// Tear down reconnect timer. @@ -380,26 +385,39 @@ final class Spinify implements ISpinify { /// Library initiated connect. @unsafe Future _internalReconnect(String url) async { - assert(state.isDisconnected, 'State should be disconnected'); - final completer = _readyCompleter = switch (_readyCompleter) { + final readyCompleter = _readyCompleter = switch (_readyCompleter) { Completer value when !value.isCompleted => value, _ => Completer(), }; - if (state.isConnected || state.isConnecting) + if (state.isConnected || state.isConnecting) { _internalDisconnect( code: 0, reason: 'reconnect called while already connected or connecting', reconnect: false, ); - _setState(SpinifyState$Connecting(url: _metrics.reconnectUrl = url)); + } try { - assert(state.isConnecting, 'State should be connecting'); - // Create a new transport - final ws = _transport = await _webSocketConnect( - url: url, - headers: config.headers, - protocols: [_codec.protocol], + if (!state.isDisconnected) { + _log( + const SpinifyLogLevel.warning(), + 'reconnect_error', + 'Failed to reconnect: state is not disconnected', + { + 'state': state, + }, + ); + assert( + false, + 'State should be disconnected', + ); + return; + } + assert( + _transport == null, + 'Transport should be null', ); + _setState(SpinifyState$Connecting(url: _metrics.reconnectUrl = url)); + assert(state.isConnecting, 'State should be connecting'); // Prepare connect request. final SpinifyConnectRequest request; @@ -434,16 +452,168 @@ final class Spinify implements ISpinify { ); } - final reply = await _sendCommand(request); + // Create a new transport + final ws = _transport = await _webSocketConnect( + url: url, + headers: config.headers, + protocols: [_codec.protocol], + ); + + // Create handler for connect reply. + final connectResultCompleter = Completer(); + + // ignore: omit_local_variable_types + void Function(SpinifyReply reply) handleReply = (reply) { + if (connectResultCompleter.isCompleted) { + _log( + const SpinifyLogLevel.warning(), + 'connect_result_error', + 'Connect result completer is already completed', + { + 'reply': reply, + }, + ); + } else if (reply is SpinifyConnectResult) { + connectResultCompleter.complete(reply); + } else if (reply is SpinifyErrorResult) { + connectResultCompleter.completeError(reply); + } else { + connectResultCompleter.completeError( + const SpinifyConnectionException( + message: 'Unexpected reply received', + ), + ); + } + }; - // TODO: Handle connect reply. - // Mike Matiunin , 24 October 2024 - // ... + ws.stream.transform(StreamTransformer.fromHandlers( + handleData: (data, sink) { + _metrics + ..bytesReceived += data.length + ..chunksReceived += 1; + for (final reply in _codec.decoder.convert(data)) { + _metrics.repliesDecoded += 1; + sink.add(reply); + } + }, + )).listen( + (reply) { + assert(() { + if (!identical(ws, _transport)) { + _log( + const SpinifyLogLevel.warning(), + 'wrong_transport_error', + 'Reply received on different and not active transport', + { + 'transport': ws, + 'reply': reply, + }, + ); + } + return true; + }(), '...'); + + handleReply(reply); // Handle replies + }, + onError: (error, stackTrace) {}, + cancelOnError: false, + ); + + await _sendCommandAsync(request); + final result = await connectResultCompleter.future; + + if (!state.isConnecting) { + throw const SpinifyConnectionException( + message: 'Connection is not in connecting state', + ); + } else if (!identical(ws, _transport)) { + throw const SpinifyConnectionException( + message: 'Transport is not the same as created', + ); + } + + _setState(SpinifyState$Connected( + url: url, + client: result.client, + version: result.version, + expires: result.expires, + ttl: result.ttl, + node: result.node, + pingInterval: result.pingInterval, + sendPong: result.sendPong, + session: result.session, + data: result.data, + )); + + handleReply = _onReply; // Switch to normal reply handler _setUpRefreshConnection(); - } on Object catch (error, stackTrace) { - // TODO: Handle error. - // Mike Matiunin , 24 October 2024 + + // Notify ready. + if (readyCompleter.isCompleted) { + throw const SpinifyConnectionException( + message: 'Ready completer is already completed. Why so?', + ); + } else { + readyCompleter.complete(); + } + + _readyCompleter = null; + + _log( + const SpinifyLogLevel.config(), + 'connected', + 'Connected to server with $url successfully', + { + 'url': url, + 'request': request, + 'result': result, + }, + ); + } on Object catch ($error, stackTrace) { + final SpinifyConnectionException error; + if ($error is SpinifyConnectionException) { + error = $error; + } else { + error = SpinifyConnectionException( + message: 'Error connecting to server $url', + error: $error, + ); + } + if (!readyCompleter.isCompleted) + readyCompleter.completeError(error, stackTrace); + _readyCompleter = null; + _log( + const SpinifyLogLevel.error(), + 'connect_error', + 'Error connecting to server $url', + { + 'url': url, + 'error': error, + 'stackTrace': stackTrace, + }, + ); + _transport?.close(); + + switch ($error) { + case SpinifyErrorResult result: + if (result.code == 109) { + // Token expired error. + _setUpReconnectTimer(); // Retry resubscribe + } else if (result.temporary) { + // Temporary error. + _setUpReconnectTimer(); // Retry resubscribe + } else { + // Disable resubscribe timer + _setState(SpinifyState$Disconnected(temporary: false)); + } + case SpinifyConnectionException _: + _setUpReconnectTimer(); // Some spinify exception - retry resubscribe + default: + _setUpReconnectTimer(); // Unknown error - retry resubscribe + } + + Error.throwWithStackTrace(error, stackTrace); } } @@ -577,10 +747,14 @@ final class Spinify implements ISpinify { assert(_transport != null, 'Transport is not connected'); assert(!state.isClosed, 'State is closed'); // coverage:ignore-end - // TODO: Encode command to binary format. - // TODO: Check that transport is not closed and exists. - // TODO: Send command to the server. - //await _transport?.send(command); + final bytes = _codec.encoder.convert(command); + _metrics.commandsEncoded += 1; + if (_transport == null) + throw const SpinifySendException(message: 'Transport is not connected'); + _transport?.add(bytes); + _metrics + ..bytesSent += bytes.length + ..chunksSent += 1; _log( const SpinifyLogLevel.config(), 'send_command_async_success', @@ -600,15 +774,21 @@ final class Spinify implements ISpinify { 'stackTrace': stackTrace, }, ); - Error.throwWithStackTrace( - SpinifySendException( - message: 'Failed to send command ${command.type}{id: ${command.id}}', - ), - stackTrace, - ); + if (error is SpinifySendException) + rethrow; + else + Error.throwWithStackTrace( + SpinifySendException( + message: + 'Failed to send command ${command.type}{id: ${command.id}}', + ), + stackTrace, + ); } } + @unsafe + @Throws([SpinifySendException]) Future _sendCommand(SpinifyCommand command) async { _log( const SpinifyLogLevel.debug(), @@ -625,11 +805,15 @@ final class Spinify implements ISpinify { assert(_transport != null, 'Transport is not connected'); assert(!state.isClosed, 'State is closed'); // coverage:ignore-end - final pr = _replies[command.id] = _PendingReply(command); final bytes = _codec.encoder.convert(command); + _metrics.commandsEncoded += 1; + final pr = _replies[command.id] = _PendingReply(command); if (_transport == null) throw const SpinifySendException(message: 'Transport is not connected'); - _transport?.add(bytes); // await _sendCommandAsync(command); + _transport?.add(bytes); + _metrics + ..bytesSent += bytes.length + ..chunksSent += 1; final result = await pr.future.timeout(config.timeout); _log( const SpinifyLogLevel.config(), @@ -771,7 +955,7 @@ final class Spinify implements ISpinify { /// Called when [SpinifyReply] received from the server. @safe @sideEffect - Future _onReply(SpinifyReply reply) async { + void _onReply(SpinifyReply reply) { try { // coverage:ignore-start if (reply.id < 0 || reply.id > _metrics.commandId) { diff --git a/lib/src/web_socket_js.dart b/lib/src/web_socket_js.dart index 365c05e..3dd465d 100644 --- a/lib/src/web_socket_js.dart +++ b/lib/src/web_socket_js.dart @@ -96,7 +96,7 @@ Future $webSocketConnect({ @internal class WebSocket$JS implements WebSocket { WebSocket$JS({required web.WebSocket socket}) : _socket = socket { - final controller = StreamController(); + final controller = StreamController(sync: true); stream = controller.stream.asyncMap(_codec.read).transform>( StreamTransformer, List>.fromHandlers( diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index d0845e9..d2372c4 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -192,7 +192,7 @@ void main() { 0, ), isA().having( - (m) => m.messagesReceived, + (m) => m.chunksReceived, 'messagesReceived', equals(Int64.ZERO), ), @@ -228,7 +228,7 @@ void main() { 0, ), isA().having( - (m) => m.messagesReceived, + (m) => m.chunksReceived, 'messagesReceived', greaterThan(Int64.ZERO), ), From 3df6e7a4bfe02950c780690a33429e27bfbc37c9 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 25 Oct 2024 20:35:45 +0400 Subject: [PATCH 022/104] Refactor SpinifyMetrics class to include getters for messagesSent and messagesReceived --- lib/src/model/metric.dart | 6 ++++++ test/unit/spinify_test.dart | 8 ++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/src/model/metric.dart b/lib/src/model/metric.dart index 3bc8012..88191d6 100644 --- a/lib/src/model/metric.dart +++ b/lib/src/model/metric.dart @@ -51,9 +51,15 @@ sealed class SpinifyMetrics implements Comparable { /// The total number of messages sent. abstract final fixnum.Int64 chunksSent; + /// The total number of messages sent. + fixnum.Int64 get messagesSent => chunksSent; + /// The total number of bytes chunks received. abstract final fixnum.Int64 chunksReceived; + /// The total number of messages received. + fixnum.Int64 get messagesReceived => chunksReceived; + /// The total number of commands encoded. abstract final fixnum.Int64 commandsEncoded; diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index d2372c4..12296bd 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -197,8 +197,8 @@ void main() { equals(Int64.ZERO), ), isA().having( - (m) => m.messagesSent, - 'messagesSent', + (m) => m.chunksSent, + 'chunksSent', equals(Int64.ZERO), ), ])); @@ -233,8 +233,8 @@ void main() { greaterThan(Int64.ZERO), ), isA().having( - (m) => m.messagesSent, - 'messagesSent', + (m) => m.chunksSent, + 'chunksSent', greaterThan(Int64.ZERO), ), ])); From 2c499c092d4fb8d1467099d720f4563a34b51f7a Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 25 Oct 2024 22:06:00 +0400 Subject: [PATCH 023/104] Refactor SpinifyMetrics class to include getters for messagesSent and messagesReceived --- Makefile | 5 +++ lib/spinify.dart | 1 + lib/src/model/pubspec.yaml.g.dart | 16 +++---- lib/src/protobuf/protobuf_codec.dart | 23 ++++------ lib/src/spinify.dart | 2 +- lib/src/web_socket_fake.dart | 58 ++++++++++++++++++++---- pubspec.yaml | 6 +-- test/unit/codec_test.dart | 2 +- test/unit/codecs.dart | 23 ++++++++++ test/unit/spinify_test.dart | 63 +++++++++++++++++++------- test/unit/spinify_test.mocks.dart | 66 ++++++++++++++++++++++++++++ 11 files changed, 211 insertions(+), 54 deletions(-) create mode 100644 test/unit/codecs.dart create mode 100644 test/unit/spinify_test.mocks.dart diff --git a/Makefile b/Makefile index ce48c6a..efda04c 100644 --- a/Makefile +++ b/Makefile @@ -114,6 +114,11 @@ gen: generate .PHONY: codegen codegen: generate +.PHONY: dart-version +dart-version: ## Show the Dart version + @dart --version + @which dart + .PHONY: diff diff: ## git diff $(call print-target) diff --git a/lib/spinify.dart b/lib/spinify.dart index 2553bb4..aef2f22 100644 --- a/lib/spinify.dart +++ b/lib/spinify.dart @@ -21,6 +21,7 @@ export 'src/model/subscription_config.dart'; export 'src/model/subscription_state.dart'; export 'src/model/subscription_states.dart'; export 'src/model/transport_interface.dart'; +export 'src/protobuf/protobuf_codec.dart'; export 'src/spinify.dart' show Spinify; export 'src/spinify_interface.dart'; export 'src/subscription_interface.dart'; diff --git a/lib/src/model/pubspec.yaml.g.dart b/lib/src/model/pubspec.yaml.g.dart index 279bacf..3fbd5f3 100644 --- a/lib/src/model/pubspec.yaml.g.dart +++ b/lib/src/model/pubspec.yaml.g.dart @@ -125,12 +125,12 @@ sealed class Pubspec { static final DateTime timestamp = DateTime.utc( 2024, 10, - 23, + 25, + 16, + 59, 20, - 54, - 10, - 259, - 414, + 571, + 395, ); /// Name @@ -360,7 +360,7 @@ sealed class Pubspec { /// Environment static const Map environment = { - 'sdk': '>=3.5.0 <4.0.0', + 'sdk': '>=3.4.0 <4.0.0', }; /// Platforms @@ -437,10 +437,10 @@ sealed class Pubspec { /// Developer dependencies static const Map devDependencies = { - 'build_runner': r'^2.4.13', + 'build_runner': r'^2.4.10', 'pubspec_generator': r'^4.1.0-pre.1', 'benchmark_harness': r'^2.2.2', - 'lints': r'^5.0.0', + 'lints': r'>=4.0.0 <6.0.0', 'test': r'^1.25.8', 'fake_async': r'^1.3.2', 'mockito': r'^5.0.0', diff --git a/lib/src/protobuf/protobuf_codec.dart b/lib/src/protobuf/protobuf_codec.dart index 8356f48..dd43c5e 100644 --- a/lib/src/protobuf/protobuf_codec.dart +++ b/lib/src/protobuf/protobuf_codec.dart @@ -1,7 +1,5 @@ -@internal import 'dart:convert'; -import 'package:meta/meta.dart'; import 'package:protobuf/protobuf.dart' as pb; import '../model/channel_event.dart'; @@ -14,14 +12,11 @@ import '../model/stream_position.dart'; import 'client.pb.dart' as pb; /// Default protobuf codec for Spinify. -@internal -final class ProtobufCodec implements SpinifyCodec { +final class SpinifyProtobufCodec implements SpinifyCodec { /// Default protobuf codec for Spinify. - ProtobufCodec([this.logger]) - : decoder = ProtobufReplyDecoder(logger), - encoder = ProtobufCommandEncoder(logger); - - final SpinifyLogger? logger; + SpinifyProtobufCodec([SpinifyLogger? logger]) + : decoder = SpinifyProtobufReplyDecoder(logger), + encoder = SpinifyProtobufCommandEncoder(logger); @override String get protocol => 'centrifuge-protobuf'; @@ -34,11 +29,10 @@ final class ProtobufCodec implements SpinifyCodec { } /// SpinifyCommand --> List encoder. -@internal -final class ProtobufCommandEncoder +final class SpinifyProtobufCommandEncoder extends Converter> { /// SpinifyCommand --> List encoder. - const ProtobufCommandEncoder([this.logger]); + const SpinifyProtobufCommandEncoder([this.logger]); /// Logger function to use for logging. /// If not specified, the logger will be disabled. @@ -162,11 +156,10 @@ final class ProtobufCommandEncoder } /// Protobuf List --> Iterable decoder. -@internal -final class ProtobufReplyDecoder +final class SpinifyProtobufReplyDecoder extends Converter, Iterable> { /// List --> Iterable decoder. - const ProtobufReplyDecoder([this.logger]); + const SpinifyProtobufReplyDecoder([this.logger]); /// Logger function to use for logging. /// If not specified, the logger will be disabled. diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index a2291ff..fda6254 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -55,7 +55,7 @@ final class Spinify implements ISpinify { @safe Spinify({SpinifyConfig? config}) : config = config ?? SpinifyConfig.byDefault(), - _codec = config?.codec ?? ProtobufCodec() { + _codec = config?.codec ?? SpinifyProtobufCodec() { /// Client initialization (from constructor). _init(); } diff --git a/lib/src/web_socket_fake.dart b/lib/src/web_socket_fake.dart index 4cbbbef..fb40ea7 100644 --- a/lib/src/web_socket_fake.dart +++ b/lib/src/web_socket_fake.dart @@ -1,3 +1,5 @@ +// ignore_for_file: use_setters_to_change_properties + import 'dart:async'; import 'package:meta/meta.dart'; @@ -9,10 +11,15 @@ import 'model/transport_interface.dart'; @visibleForTesting class WebSocket$Fake implements WebSocket { /// Create a fake WebSocket. - WebSocket$Fake({ - StreamController>? socket, - }) : _socket = socket ?? StreamController>() { - stream = _socket.stream.transform>( + WebSocket$Fake() { + _init(); + } + + void _init() { + _socket?.close(); + // ignore: close_sinks + final controller = _socket = StreamController>(sync: true); + _stream = controller.stream.transform>( StreamTransformer, List>.fromHandlers( handleData: _dataHandler, handleError: _errorHandler, @@ -21,7 +28,12 @@ class WebSocket$Fake implements WebSocket { ); } - final StreamController> _socket; + StreamController>? _socket; + + Stream>? _stream; + + @override + Stream> get stream => _stream ?? const Stream>.empty(); /// Handle incoming data. void _dataHandler(List data, EventSink> sink) => @@ -45,6 +57,7 @@ class WebSocket$Fake implements WebSocket { void _doneHandler(EventSink> sink) { sink.close(); _isClosed = true; + _onDoneCallback?.call(); } @override @@ -60,15 +73,42 @@ class WebSocket$Fake implements WebSocket { bool _isClosed = false; @override - late final Stream> stream; + void add(List bytes) { + _onAddCallback?.call(bytes, _socket!.sink); + } - @override - void add(List event) {} + /// Add data to the WebSocket. + void Function(List bytes, Sink> sink)? _onAddCallback; + + /// Add callback to handle sending data and allow to respond with reply. + void onAdd(void Function(List bytes, Sink> sink) callback) { + _onAddCallback = callback; + } + + void Function()? _onDoneCallback; + void onDone(void Function() callback) { + _onDoneCallback = callback; + } + + /// Send asynchroniously a reply to the client. + void reply(List bytes) { + _socket!.sink.add(bytes); + } @override void close([int? code, String? reason]) { _closeCode = code; _closeReason = reason; - _socket.close().ignore(); + _socket!.close().ignore(); + } + + /// Reset the WebSocket client. + void reset() { + _closeCode = null; + _closeReason = null; + _isClosed = false; + _onAddCallback = null; + _onDoneCallback = null; + _init(); } } diff --git a/pubspec.yaml b/pubspec.yaml index df09727..557c105 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,7 +38,7 @@ platforms: environment: - sdk: '>=3.5.0 <4.0.0' + sdk: '>=3.4.0 <4.0.0' dependencies: @@ -57,10 +57,10 @@ dependencies: stack_trace: ^1.11.0 dev_dependencies: - build_runner: ^2.4.13 + build_runner: ^2.4.10 pubspec_generator: ^4.1.0-pre.1 benchmark_harness: ^2.2.2 - lints: ^5.0.0 + lints: '>=4.0.0 <6.0.0' test: ^1.25.8 fake_async: ^1.3.2 mockito: ^5.0.0 diff --git a/test/unit/codec_test.dart b/test/unit/codec_test.dart index 71dfea5..33e99b5 100644 --- a/test/unit/codec_test.dart +++ b/test/unit/codec_test.dart @@ -10,7 +10,7 @@ void main() => group('Codec', () { timestamp: DateTime(2021, 1, 1), data: [for (var i = 0; i < 256; i++) i], ); - const codec = ProtobufCommandEncoder(); + const codec = SpinifyProtobufCommandEncoder(); final bytesFromCodec = codec.convert(command); expect(bytesFromCodec.length, greaterThan(0)); diff --git a/test/unit/codecs.dart b/test/unit/codecs.dart new file mode 100644 index 0000000..0b02219 --- /dev/null +++ b/test/unit/codecs.dart @@ -0,0 +1,23 @@ +// ignore_for_file: avoid_classes_with_only_static_members + +import 'package:protobuf/protobuf.dart' as pb; + +abstract final class ProtobufCodec { + /// Encode a protobuf message to a list of bytes. + static List encode(pb.GeneratedMessage msg) { + final bytes = msg.writeToBuffer(); + return (pb.CodedBufferWriter() + ..writeInt32NoTag(bytes.lengthInBytes) + ..writeRawBytes(bytes)) + .toBuffer(); + } + + /// Decode a protobuf message from a list of bytes. + static T decode(T msg, List bytes) { + final reader = pb.CodedBufferReader(bytes); + assert(!reader.isAtEnd(), 'No data to read'); + reader.readMessage(msg, pb.ExtensionRegistry.EMPTY); + assert(reader.isAtEnd(), 'Not all data was read'); + return msg; + } +} diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index 12296bd..ea63bd8 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -1,26 +1,29 @@ +import 'dart:async'; import 'dart:convert'; import 'package:fake_async/fake_async.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; import 'package:spinify/spinify.dart'; +import 'package:spinify/src/protobuf/client.pb.dart' as pb; import 'package:test/test.dart'; +import 'codecs.dart'; + +@GenerateNiceMocks([MockSpec(as: #MockWebSocket)]) void main() { group('Spinify', () { final buffer = SpinifyLogBuffer(size: 10); - Spinify createFakeClient([ - void Function(WebSocket$Fake transport)? out, - ]) { - final ws = WebSocket$Fake(); - out?.call(ws); - return Spinify( - config: SpinifyConfig( - transportBuilder: ({required url, headers, protocols}) => - Future.value(ws), - logger: buffer.add, - ), - ); - } + Spinify createFakeClient([WebSocket Function(String)? transport]) => + Spinify( + config: SpinifyConfig( + transportBuilder: ({required url, headers, protocols}) => + Future.value( + transport?.call(url) ?? WebSocket$Fake()), + logger: buffer.add, + ), + ); test('Create_and_close_client', () async { final client = createFakeClient(); @@ -75,15 +78,40 @@ void main() { test( 'Reconnect_after_disconnected_transport', () => fakeAsync((async) { - WebSocket? transport; - final client = createFakeClient((t) => transport = t) - ..connect('ws://localhost:8000/connection/websocket'); + final transport = WebSocket$Fake(); + final client = createFakeClient((_) => transport); + transport.onAdd( + (_, sink) => Timer( + const Duration(milliseconds: 50), + () => sink.add( + ProtobufCodec.encode( + pb.Reply( + id: 1, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: {}, + ping: null, + pong: false, + session: 'fake', + node: 'fake', + ), + ), + ), + ), + ), + ); + client.connect('ws://localhost:8000/connection/websocket'); expect(client.state, isA()); async.elapse(client.config.timeout); expect(client.state, isA()); expect(transport, isNotNull); expect(transport, isA()); - transport!.close(); + transport.close(); + when(transport.isClosed).thenReturn(true); async.elapse(const Duration(milliseconds: 50)); expect(client.state, isA()); async.elapse(Duration( @@ -91,6 +119,7 @@ void main() { .config.connectionRetryInterval.min.inMilliseconds ~/ 2)); expect(client.state, isA()); + transport.reset(); async.elapse(client.config.connectionRetryInterval.max); expect(client.state, isA()); client.close(); diff --git a/test/unit/spinify_test.mocks.dart b/test/unit/spinify_test.mocks.dart new file mode 100644 index 0000000..9616d8e --- /dev/null +++ b/test/unit/spinify_test.mocks.dart @@ -0,0 +1,66 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in spinify/test/unit/spinify_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:spinify/src/model/transport_interface.dart' as _i2; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +/// A class which mocks [WebSocket]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockWebSocket extends _i1.Mock implements _i2.WebSocket { + @override + _i3.Stream> get stream => (super.noSuchMethod( + Invocation.getter(#stream), + returnValue: _i3.Stream>.empty(), + returnValueForMissingStub: _i3.Stream>.empty(), + ) as _i3.Stream>); + + @override + bool get isClosed => (super.noSuchMethod( + Invocation.getter(#isClosed), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + void add(List? data) => super.noSuchMethod( + Invocation.method( + #add, + [data], + ), + returnValueForMissingStub: null, + ); + + @override + void close([ + int? code, + String? reason, + ]) => + super.noSuchMethod( + Invocation.method( + #close, + [ + code, + reason, + ], + ), + returnValueForMissingStub: null, + ); +} From 935f2c72174a897ec0190233f7b90f4b1c3fd531 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 25 Oct 2024 22:28:03 +0400 Subject: [PATCH 024/104] Refactor SpinifyMetrics class to remove unused import --- lib/spinify.dart | 1 - test/unit/spinify_test.dart | 12 +++++-- {lib/src => test/unit}/web_socket_fake.dart | 40 ++++++++++++++++++--- 3 files changed, 45 insertions(+), 8 deletions(-) rename {lib/src => test/unit}/web_socket_fake.dart (69%) diff --git a/lib/spinify.dart b/lib/spinify.dart index aef2f22..40091ec 100644 --- a/lib/spinify.dart +++ b/lib/spinify.dart @@ -25,4 +25,3 @@ export 'src/protobuf/protobuf_codec.dart'; export 'src/spinify.dart' show Spinify; export 'src/spinify_interface.dart'; export 'src/subscription_interface.dart'; -export 'src/web_socket_fake.dart'; diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index ea63bd8..d5f36b0 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -9,6 +9,7 @@ import 'package:spinify/src/protobuf/client.pb.dart' as pb; import 'package:test/test.dart'; import 'codecs.dart'; +import 'web_socket_fake.dart'; @GenerateNiceMocks([MockSpec(as: #MockWebSocket)]) void main() { @@ -42,7 +43,9 @@ void main() { }); test('Change_client_state', () async { - final client = createFakeClient(); + final transport = WebSocket$Fake(); // ignore: close_sinks + final client = createFakeClient((_) => transport); + expect(transport.isClosed, isFalse); expect(client.state, isA()); await client.connect('ws://localhost:8000/connection/websocket'); expect(client.state, isA()); @@ -50,10 +53,13 @@ void main() { expect(client.state, isA()); await client.close(); expect(client.state, isA()); + expect(client.isClosed, isTrue); + expect(transport.isClosed, isTrue); }); test('Change_client_states', () { - final client = createFakeClient(); + final transport = WebSocket$Fake(); // ignore: close_sinks + final client = createFakeClient((_) => transport); Stream.fromIterable([ () => client.connect('ws://localhost:8000/connection/websocket'), client.disconnect, @@ -82,7 +88,7 @@ void main() { final client = createFakeClient((_) => transport); transport.onAdd( (_, sink) => Timer( - const Duration(milliseconds: 50), + const Duration(milliseconds: 1), () => sink.add( ProtobufCodec.encode( pb.Reply( diff --git a/lib/src/web_socket_fake.dart b/test/unit/web_socket_fake.dart similarity index 69% rename from lib/src/web_socket_fake.dart rename to test/unit/web_socket_fake.dart index fb40ea7..54cf060 100644 --- a/lib/src/web_socket_fake.dart +++ b/test/unit/web_socket_fake.dart @@ -3,9 +3,10 @@ import 'dart:async'; import 'package:meta/meta.dart'; +import 'package:spinify/spinify.dart'; +import 'package:spinify/src/protobuf/client.pb.dart' as pb; -import 'model/exception.dart'; -import 'model/transport_interface.dart'; +import 'codecs.dart'; /// Fake WebSocket implementation. @visibleForTesting @@ -26,6 +27,35 @@ class WebSocket$Fake implements WebSocket { handleDone: _doneHandler, ), ); + + // Default callbacks to handle connects and disconnects. + _onAddCallback = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + Timer(Duration.zero, () { + if (isClosed) return; + if (command.hasConnect()) { + sink.add( + ProtobufCodec.encode( + pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: {}, + ping: 600, + pong: false, + session: 'fake', + node: 'fake', + ), + ), + ), + ); + } + }); + }; } StreamController>? _socket; @@ -81,12 +111,14 @@ class WebSocket$Fake implements WebSocket { void Function(List bytes, Sink> sink)? _onAddCallback; /// Add callback to handle sending data and allow to respond with reply. - void onAdd(void Function(List bytes, Sink> sink) callback) { + void onAdd(void Function(List bytes, Sink> sink)? callback) { _onAddCallback = callback; } void Function()? _onDoneCallback; - void onDone(void Function() callback) { + + /// Add callback to handle socket close event. + void onDone(void Function()? callback) { _onDoneCallback = callback; } From 384ad08da5a5a39797c4717a37271aec95b75eec Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 25 Oct 2024 22:52:18 +0400 Subject: [PATCH 025/104] Refactor SpinifyMetrics class to remove unused import and include getters for messagesSent and messagesReceived Refactor WebSocket$JS to use synchronous StreamController --- lib/src/spinify.dart | 87 ++++++++++++++++++++++++++++++++++++- test/unit/spinify_test.dart | 49 +++++++-------------- 2 files changed, 101 insertions(+), 35 deletions(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index fda6254..75201b0 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -357,7 +357,7 @@ final class Spinify implements ISpinify { Future connect(String url) async { try { await _interactiveConnect(url); - } on SpinifyConnectionException { + } on SpinifyConnectionException catch (error, stackTrace) { rethrow; } on Object catch (error, stackTrace) { Error.throwWithStackTrace( @@ -515,7 +515,88 @@ final class Spinify implements ISpinify { handleReply(reply); // Handle replies }, - onError: (error, stackTrace) {}, + onDone: () { + var WebSocket(:int? closeCode, :String? closeReason) = ws; + var reconnect = true; + switch (closeCode) { + case null || <= 0: + closeCode = closeCode; + closeReason = closeReason; + reconnect = true; + case 1009: + // reconnect is true by default + closeCode = 3; // disconnectCodeMessageSizeLimit; + closeReason = 'message size limit exceeded'; + reconnect = true; + case < 3000: + // We expose codes defined by Centrifuge protocol, + // hiding details about transport-specific error codes. + // We may have extra optional transportCode field in the future. + // reconnect is true by default + closeCode = 1; // connectingCodeTransportClosed; + closeReason = closeReason; + reconnect = true; + case >= 3000 && <= 3499: + // reconnect is true by default + closeCode = closeCode; + closeReason = closeReason; + reconnect = true; + case >= 3500 && <= 3999: + // application terminal codes + closeCode = closeCode; + closeReason = closeReason ?? 'application terminal code'; + reconnect = false; + case >= 4000 && <= 4499: + // custom disconnect codes + // reconnect is true by default + closeCode = closeCode; + closeReason = closeReason; + reconnect = true; + case >= 4500 && <= 4999: + // custom disconnect codes + // application terminal codes + closeCode = closeCode; + closeReason = closeReason ?? 'application terminal code'; + reconnect = false; + case >= 5000: + // reconnect is true by default + closeCode = closeCode; + closeReason = closeReason; + reconnect = true; + default: + closeCode = closeCode; + closeReason = closeReason; + reconnect = false; + } + _internalDisconnect( + code: closeCode ?? 1, + reason: closeReason ?? 'transport closed', + reconnect: reconnect, + ); + _log( + const SpinifyLogLevel.transport(), + 'transport_disconnect', + 'Transport disconnected ' + '${reconnect ? 'temporarily' : 'permanently'} ' + 'with reason: $closeReason', + { + 'code': closeCode, + 'reason': closeReason, + 'reconnect': reconnect, + }, + ); + }, + onError: (Object error, StackTrace stackTrace) { + _log( + const SpinifyLogLevel.warning(), + 'reply_error', + 'Error receiving reply', + { + 'error': error, + 'stackTrace': stackTrace, + }, + ); + }, cancelOnError: false, ); @@ -643,6 +724,8 @@ final class Spinify implements ISpinify { required bool reconnect, }) { try { + _tearDownRefreshConnection(); + // Close transport. _transport?.close(code, reason); _transport = null; diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index d5f36b0..d092823 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -3,12 +3,9 @@ import 'dart:convert'; import 'package:fake_async/fake_async.dart'; import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; import 'package:spinify/spinify.dart'; -import 'package:spinify/src/protobuf/client.pb.dart' as pb; import 'package:test/test.dart'; -import 'codecs.dart'; import 'web_socket_fake.dart'; @GenerateNiceMocks([MockSpec(as: #MockWebSocket)]) @@ -44,13 +41,22 @@ void main() { test('Change_client_state', () async { final transport = WebSocket$Fake(); // ignore: close_sinks - final client = createFakeClient((_) => transport); + final client = createFakeClient((_) => transport..reset()); expect(transport.isClosed, isFalse); expect(client.state, isA()); await client.connect('ws://localhost:8000/connection/websocket'); expect(client.state, isA()); await client.disconnect(); - expect(client.state, isA()); + expect( + client.state, + isA().having( + (s) => s.temporary, + 'temporary', + isFalse, + ), + ); + await client.connect('ws://localhost:8000/connection/websocket'); + expect(client.state, isA()); await client.close(); expect(client.state, isA()); expect(client.isClosed, isTrue); @@ -59,7 +65,7 @@ void main() { test('Change_client_states', () { final transport = WebSocket$Fake(); // ignore: close_sinks - final client = createFakeClient((_) => transport); + final client = createFakeClient((_) => transport..reset()); Stream.fromIterable([ () => client.connect('ws://localhost:8000/connection/websocket'), client.disconnect, @@ -85,39 +91,16 @@ void main() { 'Reconnect_after_disconnected_transport', () => fakeAsync((async) { final transport = WebSocket$Fake(); - final client = createFakeClient((_) => transport); - transport.onAdd( - (_, sink) => Timer( - const Duration(milliseconds: 1), - () => sink.add( - ProtobufCodec.encode( - pb.Reply( - id: 1, - connect: pb.ConnectResult( - client: 'fake', - version: '0.0.1', - expires: false, - ttl: null, - data: null, - subs: {}, - ping: null, - pong: false, - session: 'fake', - node: 'fake', - ), - ), - ), - ), - ), + final client = createFakeClient((_) => transport..reset()); + unawaited( + client.connect('ws://localhost:8000/connection/websocket'), ); - client.connect('ws://localhost:8000/connection/websocket'); expect(client.state, isA()); async.elapse(client.config.timeout); expect(client.state, isA()); expect(transport, isNotNull); expect(transport, isA()); transport.close(); - when(transport.isClosed).thenReturn(true); async.elapse(const Duration(milliseconds: 50)); expect(client.state, isA()); async.elapse(Duration( @@ -125,8 +108,8 @@ void main() { .config.connectionRetryInterval.min.inMilliseconds ~/ 2)); expect(client.state, isA()); - transport.reset(); async.elapse(client.config.connectionRetryInterval.max); + // TODO: Implement reconnecting expect(client.state, isA()); client.close(); expectLater( From 05c33338df5efc28348d694a28a4f33cfd7e0e47 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 25 Oct 2024 23:58:41 +0400 Subject: [PATCH 026/104] Refactor SpinifyMetrics class to include handleDone method for handling WebSocket disconnection --- lib/src/spinify.dart | 157 ++++++++++++++++++++++++------------------- 1 file changed, 86 insertions(+), 71 deletions(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 75201b0..73f06fb 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -486,6 +486,91 @@ final class Spinify implements ISpinify { } }; + void handleDone() { + assert(() { + if (!identical(ws, _transport)) { + _log( + const SpinifyLogLevel.warning(), + 'transport_closed_error', + 'Transport closed on different and not active transport', + { + 'transport': ws, + }, + ); + } + return true; + }(), '...'); + var WebSocket(:int? closeCode, :String? closeReason) = ws; + var reconnect = true; + switch (closeCode) { + case null || <= 0: + closeCode = closeCode; + closeReason = closeReason; + reconnect = true; + case 1009: + // reconnect is true by default + closeCode = 3; // disconnectCodeMessageSizeLimit; + closeReason = 'message size limit exceeded'; + reconnect = true; + case < 3000: + // We expose codes defined by Centrifuge protocol, + // hiding details about transport-specific error codes. + // We may have extra optional transportCode field in the future. + // reconnect is true by default + closeCode = 1; // connectingCodeTransportClosed; + closeReason = closeReason; + reconnect = true; + case >= 3000 && <= 3499: + // reconnect is true by default + closeCode = closeCode; + closeReason = closeReason; + reconnect = true; + case >= 3500 && <= 3999: + // application terminal codes + closeCode = closeCode; + closeReason = closeReason ?? 'application terminal code'; + reconnect = false; + case >= 4000 && <= 4499: + // custom disconnect codes + // reconnect is true by default + closeCode = closeCode; + closeReason = closeReason; + reconnect = true; + case >= 4500 && <= 4999: + // custom disconnect codes + // application terminal codes + closeCode = closeCode; + closeReason = closeReason ?? 'application terminal code'; + reconnect = false; + case >= 5000: + // reconnect is true by default + closeCode = closeCode; + closeReason = closeReason; + reconnect = true; + default: + closeCode = closeCode; + closeReason = closeReason; + reconnect = false; + } + _log( + const SpinifyLogLevel.transport(), + 'transport_disconnect', + 'Transport disconnected ' + '${reconnect ? 'temporarily' : 'permanently'} ' + 'with reason: $closeReason', + { + 'code': closeCode, + 'reason': closeReason, + 'reconnect': reconnect, + }, + ); + _internalDisconnect( + code: closeCode ?? 1, + reason: closeReason ?? 'transport closed', + reconnect: reconnect, + ); + } + ws.stream.transform(StreamTransformer.fromHandlers( handleData: (data, sink) { _metrics @@ -515,77 +600,7 @@ final class Spinify implements ISpinify { handleReply(reply); // Handle replies }, - onDone: () { - var WebSocket(:int? closeCode, :String? closeReason) = ws; - var reconnect = true; - switch (closeCode) { - case null || <= 0: - closeCode = closeCode; - closeReason = closeReason; - reconnect = true; - case 1009: - // reconnect is true by default - closeCode = 3; // disconnectCodeMessageSizeLimit; - closeReason = 'message size limit exceeded'; - reconnect = true; - case < 3000: - // We expose codes defined by Centrifuge protocol, - // hiding details about transport-specific error codes. - // We may have extra optional transportCode field in the future. - // reconnect is true by default - closeCode = 1; // connectingCodeTransportClosed; - closeReason = closeReason; - reconnect = true; - case >= 3000 && <= 3499: - // reconnect is true by default - closeCode = closeCode; - closeReason = closeReason; - reconnect = true; - case >= 3500 && <= 3999: - // application terminal codes - closeCode = closeCode; - closeReason = closeReason ?? 'application terminal code'; - reconnect = false; - case >= 4000 && <= 4499: - // custom disconnect codes - // reconnect is true by default - closeCode = closeCode; - closeReason = closeReason; - reconnect = true; - case >= 4500 && <= 4999: - // custom disconnect codes - // application terminal codes - closeCode = closeCode; - closeReason = closeReason ?? 'application terminal code'; - reconnect = false; - case >= 5000: - // reconnect is true by default - closeCode = closeCode; - closeReason = closeReason; - reconnect = true; - default: - closeCode = closeCode; - closeReason = closeReason; - reconnect = false; - } - _internalDisconnect( - code: closeCode ?? 1, - reason: closeReason ?? 'transport closed', - reconnect: reconnect, - ); - _log( - const SpinifyLogLevel.transport(), - 'transport_disconnect', - 'Transport disconnected ' - '${reconnect ? 'temporarily' : 'permanently'} ' - 'with reason: $closeReason', - { - 'code': closeCode, - 'reason': closeReason, - 'reconnect': reconnect, - }, - ); - }, + onDone: handleDone, onError: (Object error, StackTrace stackTrace) { _log( const SpinifyLogLevel.warning(), From 269a0b4b555e46c81ce407a992a8db030399cb13 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 26 Oct 2024 16:07:02 +0400 Subject: [PATCH 027/104] Add close codes --- lib/spinify.dart | 1 + lib/src/model/codes.dart | 495 ++++++++++++++++++++++++++++++++++++ lib/src/model/state.dart | 15 +- lib/src/spinify.dart | 132 +++++----- lib/src/util/backoff.dart | 2 +- test/unit/spinify_test.dart | 17 +- 6 files changed, 587 insertions(+), 75 deletions(-) create mode 100644 lib/src/model/codes.dart diff --git a/lib/spinify.dart b/lib/spinify.dart index 40091ec..9bd87a0 100644 --- a/lib/spinify.dart +++ b/lib/spinify.dart @@ -6,6 +6,7 @@ export 'src/deprecated/transport_fake.dart'; export 'src/model/channel_event.dart'; export 'src/model/client_info.dart'; export 'src/model/codec.dart'; +export 'src/model/codes.dart'; export 'src/model/command.dart'; export 'src/model/config.dart'; export 'src/model/exception.dart'; diff --git a/lib/src/model/codes.dart b/lib/src/model/codes.dart new file mode 100644 index 0000000..5c13e22 --- /dev/null +++ b/lib/src/model/codes.dart @@ -0,0 +1,495 @@ +import 'package:meta/meta.dart'; + +/// The disconnect codes for the Spinify WebSocket connection. +/// +/// Codes have some rules which should be followed by a client +/// connector implementation. +/// These rules described below. +/// +/// Codes in range 0..2999 should not be used by a Centrifuge library user. +/// Those are reserved for the client-side and transport specific needs. +/// +/// Server may send custom disconnect codes to a client. +/// Custom disconnect codes must be in range 3000..4999. +/// +/// Codes in range >=5000 should not be used also. +/// Those are reserved by Centrifuge. +/// +/// Client should reconnect upon receiving code in range +/// 3000..3499, 4000..4499, >=5000. +/// For codes <3000 reconnect behavior can be adjusted for specific transport. +/// (Default reconnect is true in this implementation). +/// +/// Codes in range 3500..3999 and 4500..4999 are application terminal codes, +/// no automatic reconnect should be made by a client implementation. +/// +/// Library users supposed to use codes in range 4000..4999 for creating custom +/// disconnects. +extension type const SpinifyDisconnectCode(int code) implements int { + // --- 0..2999 Internal client-side and transport specific codes --- // + + /// Disconnect called explicitly by the client. + const SpinifyDisconnectCode.disconnect() : code = 0; + + /// Error Internal means server error, + /// if returned this is a signal that something went wrong with the server + /// itself and client is most probably not guilty. + @literal + const SpinifyDisconnectCode.internalServerError() : code = 100; + + /// Unauthorized indicates that the request is unauthorized. + @literal + const SpinifyDisconnectCode.unauthorized() : code = 101; + + /// Unknown Channel means that the channel name does not exist. + /// Usually this is returned when client uses a channel with a namespace + /// that is not defined in the Centrifugo configuration. + @literal + const SpinifyDisconnectCode.unknownChannel() : code = 102; + + /// Permission Denied means access to the resource is not allowed. + @literal + const SpinifyDisconnectCode.permissionDenied() : code = 103; + + /// Method Not Found indicates that the requested method does not exist. + @literal + const SpinifyDisconnectCode.methodNotFound() : code = 104; + + /// Already Subscribed indicates that the client is already subscribed + /// to the specified channel. In Centrifugo, a client can only have one + /// subscription to a specific channel. + @literal + const SpinifyDisconnectCode.alreadySubscribed() : code = 105; + + /// Limit Exceeded indicates that a server-imposed + /// limit has been exceeded. + /// Server logs should provide more information. + @literal + const SpinifyDisconnectCode.limitExceeded() : code = 106; + + /// Bad Request means the server cannot process the received data + /// because it is malformed. Retrying the request does not make sense. + @literal + const SpinifyDisconnectCode.badRequest() : code = 107; + + /// Not Available indicates that the requested resource is not enabled. + /// This may occur, for example, when trying to access history or presence + /// in a channel that does not support these features. + @literal + const SpinifyDisconnectCode.notAvailable() : code = 108; + + /// Token Expired indicates that the connection token has expired. + /// This is generally handled by updating the token. + @literal + const SpinifyDisconnectCode.tokenExpired() : code = 109; + + /// Expired indicates that the connection has expired + /// (no token involved). + @literal + const SpinifyDisconnectCode.expired() : code = 110; + + /// Too Many Requests means that the server rejected the request + /// due to rate limiting. + @literal + const SpinifyDisconnectCode.tooManyRequests() : code = 111; + + /// Unrecoverable Position indicates that the stream does not contain + /// the required range of publications to fulfill a history query, possibly + /// due to an incorrect epoch being passed. + @literal + const SpinifyDisconnectCode.unrecoverablePosition() : code = 112; + + /// Normalize disconnect code and reason. + static ({int code, String reason, bool reconnect}) normalize( + [int? code, String? reason]) => + switch (code ?? 1) { + // --- Client error codes --- // + + /// Disconnect called explicitly by the client. + 0 => ( + code: 0, + reason: reason ?? 'disconnect called', + reconnect: true, + ), + + /// Disconnect due to malformed protocol message sent by the client. + 2 => ( + code: 2, + reason: reason ?? 'bad protocol', + reconnect: true, + ), + + /// Internal server error means server error, + /// if returned this is a signal that something went wrong with + /// the server itself and client is most probably not guilty. + 100 => ( + code: 100, + reason: reason ?? 'internal server error', + reconnect: true, + ), + + /// Unauthorized indicates that the request is unauthorized. + 101 => ( + code: 101, + reason: reason ?? 'unauthorized', + reconnect: true, + ), + + /// Unknown Channel means that the channel name does not exist. + /// Usually this is returned when the client uses a channel with a + /// namespace that is not defined in Centrifugo configuration. + 102 => ( + code: 102, + reason: reason ?? 'unknown channel', + reconnect: true, + ), + + /// Permission Denied means access to the resource is not allowed. + 103 => ( + code: 103, + reason: reason ?? 'permission denied', + reconnect: true, + ), + + /// Method Not Found indicates that the requested method does not exist. + 104 => ( + code: 104, + reason: reason ?? 'method not found', + reconnect: true, + ), + + /// Already Subscribed indicates that the client is already subscribed + /// to the specified channel. In Centrifugo, a client can only have one + /// subscription to a specific channel. + 105 => ( + code: 105, + reason: reason ?? 'already subscribed', + reconnect: true, + ), + + /// Limit Exceeded indicates that a server-imposed + /// limit has been exceeded. + /// Server logs should provide more information. + 106 => ( + code: 106, + reason: reason ?? 'limit exceeded', + reconnect: true, + ), + + /// Bad Request means the server cannot process the received data + /// because it is malformed. Retrying the request does not make sense. + 107 => ( + code: 107, + reason: reason ?? 'bad request', + reconnect: true, + ), + + /// Not Available indicates that the requested resource is not enabled. + /// This may occur, for example, + /// when trying to access history or presence + /// in a channel that does not support these features. + 108 => ( + code: 108, + reason: reason ?? 'not available', + reconnect: true, + ), + + /// Token Expired indicates that the connection token has expired. + /// This is generally handled by updating the token. + 109 => ( + code: 109, + reason: reason ?? 'token expired', + reconnect: true, + ), + + /// Expired indicates that the connection has expired + /// (no token involved). + 110 => ( + code: 110, + reason: reason ?? 'expired', + reconnect: true, + ), + + /// Too Many Requests means that the server rejected the request + /// due to rate limiting. + 111 => ( + code: 111, + reason: reason ?? 'too many requests', + reconnect: true, + ), + + /// Unrecoverable Position indicates that the stream does not contain + /// the required range of publications to fulfill a history query, + /// possibly due to an incorrect epoch being passed. + 112 => ( + code: 112, + reason: reason ?? 'unrecoverable position', + reconnect: true, + ), + + /// Message size limit exceeded. + 1009 => ( + code: 1009, + reason: reason ?? 'message size limit exceeded', + reconnect: true, + ), + + /// Custom disconnect codes from server. + /// We expose codes defined by Centrifuge protocol, + /// hiding details about transport-specific error codes. + /// Reconnect is true by default. + < 3000 => ( + code: code ?? 1, + reason: reason ?? 'transport closed', + reconnect: true, + ), + + // --- Non-terminal disconnect codes --- // + + /// DisconnectConnectionClosed is a special Disconnect object used when + /// client connection was closed without any advice from a server side. + /// This can be a clean disconnect, + /// or temporary disconnect of the client + /// due to internet connection loss. + /// Server can not distinguish the actual reason of disconnect. + 3000 => ( + code: 3000, + reason: reason ?? 'connection closed', + reconnect: true, + ), + + /// Shutdown code. + 3001 => ( + code: 3001, + reason: reason ?? 'shutdown', + reconnect: true, + ), + + /// DisconnectServerError issued when internal error occurred on server. + 3004 => ( + code: 3004, + reason: reason ?? 'internal server error', + reconnect: true, + ), + + /// DisconnectExpired + 3005 => ( + code: 3005, + reason: reason ?? 'connection expired', + reconnect: true, + ), + + /// DisconnectSubExpired issued when client subscription expired. + 3006 => ( + code: 3006, + reason: reason ?? 'subscription expired', + reconnect: true, + ), + + /// DisconnectSlow issued when client can't read messages fast enough. + 3008 => ( + code: 3008, + reason: reason ?? 'slow', + reconnect: true, + ), + + /// DisconnectWriteError issued when an error occurred + /// while writing to client connection. + 3009 => ( + code: 3009, + reason: reason ?? 'write error', + reconnect: true, + ), + + /// DisconnectInsufficientState issued when Centrifugo detects wrong + /// client position in a channel stream. + /// Disconnect allows client to restore missed + /// publications on reconnect. + /// + /// Insufficient state in channel only happens in channels + /// with positioning/recovery on โ€“ where Centrifugo detects message + /// loss and message order issues. + /// + /// Insufficient state in a stream means that Centrifugo + /// detected message loss from the broker. + /// Generally, rare cases of getting such disconnect code are OK, + /// but if there is an increase in the amount of such codes + /// โ€“ then this can be a signal of Centrifugo-to-Broker + /// communication issue. The root cause should be investigated + /// โ€“ it may be an unstable connection between Centrifugo and broker, + /// or Centrifugo can't keep up with a message stream in a channel, + /// or a broker skips messages for some reason. + 3010 => ( + code: 3010, + reason: reason ?? 'insufficient state', + reconnect: true, + ), + + /// DisconnectForceReconnect issued when server disconnects connection + /// for some reason and whants it to reconnect. + 3011 => ( + code: 3011, + reason: reason ?? 'force reconnect', + reconnect: true, + ), + + /// DisconnectNoPong may be issued when server disconnects bidirectional + /// connection due to no pong received to application-level + /// server-to-client pings in a configured time. + 3012 => ( + code: 3012, + reason: reason ?? 'no pong', + reconnect: true, + ), + + /// DisconnectTooManyRequests may be issued when client sends + /// too many commands to a server. + 3013 => ( + code: 3013, + reason: reason ?? 'too many requests', + reconnect: true, + ), + + /// Custom disconnect codes from server. Reconnect is true by default. + <= 3499 => ( + code: code ?? 0, + reason: reason ?? 'transport closed', + reconnect: true, + ), + + // --- Terminal disconnect codes --- // + + /// DisconnectInvalidToken issued when client came with invalid token. + 3500 => ( + code: 3500, + reason: reason ?? 'invalid token', + reconnect: false, + ), + + /// DisconnectBadRequest issued when client + /// uses malformed protocol frames. + 3501 => ( + code: 3501, + reason: reason ?? 'bad request', + reconnect: false, + ), + + /// DisconnectStale issued to close connection that did not become + /// authenticated in configured interval after dialing. + 3502 => ( + code: 3502, + reason: reason ?? 'stale', + reconnect: false, + ), + + /// DisconnectForceNoReconnect issued when server disconnects connection + /// and asks it to not reconnect again. + 3503 => ( + code: 3503, + reason: reason ?? 'force disconnect', + reconnect: false, + ), + + /// DisconnectConnectionLimit can be issued when client connection + /// exceeds a configured connection limit + /// (per user ID or due to other rule). + 3504 => ( + code: 3504, + reason: reason ?? 'connection limit', + reconnect: false, + ), + + /// DisconnectChannelLimit can be issued when client connection exceeds + /// a configured channel limit. + 3505 => ( + code: 3505, + reason: reason ?? 'channel limit', + reconnect: false, + ), + + /// DisconnectInappropriateProtocol can be issued when client connection + /// format can not handle incoming data. + /// For example, this happens when JSON-based clients receive + /// binary data in a channel. + /// This is usually an indicator of programmer error, + /// JSON clients can not handle binary. + 3506 => ( + code: 3506, + reason: reason ?? 'inappropriate protocol', + reconnect: false, + ), + + /// DisconnectPermissionDenied may be issued when client + /// attempts accessing a server without enough permissions. + 3507 => ( + code: 3507, + reason: reason ?? 'permission denied', + reconnect: false, + ), + + /// DisconnectNotAvailable may be issued when ErrorNotAvailable does not + /// fit message type, for example we issue DisconnectNotAvailable + /// when client sends asynchronous message without MessageHandler + /// set on server side. + 3508 => ( + code: 3508, + reason: reason ?? 'not available', + reconnect: false, + ), + + /// DisconnectTooManyErrors may be issued when client + /// generates too many errors. + 3509 => ( + code: 3509, + reason: reason ?? 'too many errors', + reconnect: false, + ), + + /// Application terminal codes with no reconnect. + <= 3999 => ( + code: code ?? 0, + reason: reason ?? 'application terminal code', + reconnect: false, + ), + + /// Custom disconnect codes. Reconnect is true by default. + <= 4499 => ( + code: code ?? 0, + reason: reason ?? 'transport closed', + reconnect: true, + ), + + /// Application terminal codes with no reconnect. + <= 4999 => ( + code: code ?? 0, + reason: reason ?? 'application terminal code', + reconnect: false, + ), + + /// Internal and reserved by Centrifuge + /// Reconnect is true by default. + >= 5000 => ( + code: code ?? 0, + reason: reason ?? 'transport closed', + reconnect: true, + ), + + /// Custom disconnect codes. + _ => ( + code: code ?? 0, + reason: reason ?? 'transport closed', + reconnect: false, + ), + }; + + /// Reconnect is needed due to specific transport close code. + bool get reconnect => switch (code) { + >= 0000 && <= 2999 => true, // Centrifuge library internal codes (true) + >= 3000 && <= 3499 => true, // Server non-terminal codes (true) + >= 3500 && <= 3999 => false, // Application terminal codes (false) + >= 4000 && <= 4499 => true, // Custom disconnect codes (true) + >= 4500 && <= 4999 => false, // Custom disconnect codes (false) + >= 5000 => true, // Reserved by Centrifuge (true) + _ => false, // Other cases (e.g. negative values) + }; +} diff --git a/lib/src/model/state.dart b/lib/src/model/state.dart index 1d0142d..dda2625 100644 --- a/lib/src/model/state.dart +++ b/lib/src/model/state.dart @@ -69,9 +69,6 @@ sealed class SpinifyState extends _$SpinifyStateBase { /// Permanently closed /// {@macro state} factory SpinifyState.closed({DateTime? timestamp}) = SpinifyState$Closed; - - @override - String toString() => type; } /// Disconnected @@ -132,6 +129,9 @@ final class SpinifyState$Disconnected extends SpinifyState { identical(this, other) || (other is SpinifyState$Disconnected && other.timestamp.isAtSameMomentAs(timestamp)); + + @override + String toString() => 'SpinifyState\$Disconnected{temporary: $temporary}'; } /// Connecting @@ -181,6 +181,9 @@ final class SpinifyState$Connecting extends SpinifyState { identical(this, other) || (other is SpinifyState$Connecting && other.timestamp.isAtSameMomentAs(timestamp)); + + @override + String toString() => 'SpinifyState\$Connecting{url: $url}'; } /// Connected @@ -271,6 +274,9 @@ final class SpinifyState$Connected extends SpinifyState { identical(this, other) || (other is SpinifyState$Connected && other.timestamp.isAtSameMomentAs(timestamp)); + + @override + String toString() => 'SpinifyState\$Connected{url: $url}'; } /// Permanently closed @@ -320,6 +326,9 @@ final class SpinifyState$Closed extends SpinifyState { identical(this, other) || (other is SpinifyState$Closed && other.timestamp.isAtSameMomentAs(timestamp)); + + @override + String toString() => r'SpinifyState$Closed{}'; } /// Pattern matching for [SpinifyState]. diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 73f06fb..9be8179 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -6,6 +6,7 @@ import 'model/channel_event.dart'; import 'model/channel_events.dart'; import 'model/client_info.dart'; import 'model/codec.dart'; +import 'model/codes.dart'; import 'model/command.dart'; import 'model/config.dart'; import 'model/constant.dart'; @@ -22,6 +23,7 @@ import 'model/transport_interface.dart'; import 'protobuf/protobuf_codec.dart'; import 'spinify_interface.dart'; import 'subscription_interface.dart'; +import 'util/backoff.dart'; import 'web_socket_stub.dart' // ignore: uri_does_not_exist if (dart.library.js_interop) 'web_socket_js.dart' @@ -286,8 +288,57 @@ final class Spinify implements ISpinify { @safe void _setUpReconnectTimer() { _tearDownReconnectTimer(); - // TODO: Implement reconnect timer. - // Mike Matiunin , 25 October 2024 + final lastUrl = _metrics.reconnectUrl; + if (lastUrl == null) return; + final attempt = _metrics.reconnectAttempts ?? 0; + final delay = Backoff.nextDelay( + attempt, + config.connectionRetryInterval.min.inMilliseconds, + config.connectionRetryInterval.max.inMilliseconds, + ); + _metrics.nextReconnectAt = DateTime.now().add(delay); + config.logger?.call( + const SpinifyLogLevel.debug(), + 'reconnect_delayed', + 'Setting up reconnect timer to $lastUrl ' + 'after ${delay.inMilliseconds} ms.', + { + 'url': lastUrl, + 'delay': delay, + 'attempt': attempt, + }, + ); + _reconnectTimer = Timer( + delay, + () { + //_nextReconnectionAttempt = null; + if (!state.isDisconnected) return; + _metrics.reconnectAttempts = attempt + 1; + config.logger?.call( + const SpinifyLogLevel.config(), + 'reconnect_attempt', + 'Reconnecting to $lastUrl after ${delay.inMilliseconds} ms.', + { + 'url': lastUrl, + 'delay': delay, + }, + ); + try { + _internalReconnect(lastUrl); + } on Object catch (error, stackTrace) { + _log( + const SpinifyLogLevel.error(), + 'reconnect_error', + 'Error reconnecting to $lastUrl', + { + 'url': lastUrl, + 'error': error, + 'stackTrace': stackTrace, + }, + ); + } + }, + ); } /// Tear down reconnect timer. @@ -501,73 +552,23 @@ final class Spinify implements ISpinify { return true; }(), '...'); var WebSocket(:int? closeCode, :String? closeReason) = ws; - var reconnect = true; - switch (closeCode) { - case null || <= 0: - closeCode = closeCode; - closeReason = closeReason; - reconnect = true; - case 1009: - // reconnect is true by default - closeCode = 3; // disconnectCodeMessageSizeLimit; - closeReason = 'message size limit exceeded'; - reconnect = true; - case < 3000: - // We expose codes defined by Centrifuge protocol, - // hiding details about transport-specific error codes. - // We may have extra optional transportCode field in the future. - // reconnect is true by default - closeCode = 1; // connectingCodeTransportClosed; - closeReason = closeReason; - reconnect = true; - case >= 3000 && <= 3499: - // reconnect is true by default - closeCode = closeCode; - closeReason = closeReason; - reconnect = true; - case >= 3500 && <= 3999: - // application terminal codes - closeCode = closeCode; - closeReason = closeReason ?? 'application terminal code'; - reconnect = false; - case >= 4000 && <= 4499: - // custom disconnect codes - // reconnect is true by default - closeCode = closeCode; - closeReason = closeReason; - reconnect = true; - case >= 4500 && <= 4999: - // custom disconnect codes - // application terminal codes - closeCode = closeCode; - closeReason = closeReason ?? 'application terminal code'; - reconnect = false; - case >= 5000: - // reconnect is true by default - closeCode = closeCode; - closeReason = closeReason; - reconnect = true; - default: - closeCode = closeCode; - closeReason = closeReason; - reconnect = false; - } + final close = SpinifyDisconnectCode.normalize(closeCode, closeReason); _log( const SpinifyLogLevel.transport(), 'transport_disconnect', 'Transport disconnected ' - '${reconnect ? 'temporarily' : 'permanently'} ' - 'with reason: $closeReason', + '${close.reconnect ? 'temporarily' : 'permanently'} ' + 'with reason: ${close.reason}', { - 'code': closeCode, - 'reason': closeReason, - 'reconnect': reconnect, + 'code': close.code, + 'reason': close.reason, + 'reconnect': close.reconnect, }, ); _internalDisconnect( - code: closeCode ?? 1, - reason: closeReason ?? 'transport closed', - reconnect: reconnect, + code: close.code, + reason: close.reason, + reconnect: close.reconnect, ); } @@ -700,7 +701,7 @@ final class Spinify implements ISpinify { // Temporary error. _setUpReconnectTimer(); // Retry resubscribe } else { - // Disable resubscribe timer + // Disable resubscribe timer on permanent errors. _setState(SpinifyState$Disconnected(temporary: false)); } case SpinifyConnectionException _: @@ -745,6 +746,10 @@ final class Spinify implements ISpinify { _transport?.close(code, reason); _transport = null; + // Update metrics. + _metrics.lastDisconnectAt = DateTime.now(); + _metrics.disconnects++; + // Close all pending replies with error. const error = SpinifyReplyException( replyCode: 0, @@ -775,6 +780,9 @@ final class Spinify implements ISpinify { if (_readyCompleter case Completer c when !c.isCompleted) { c.completeError(error, stackTrace); } + + // Reconnect if [reconnect] is true and we have reconnect URL. + if (reconnect && _metrics.reconnectUrl != null) _setUpReconnectTimer(); } on Object catch (error, stackTrace) { _log( const SpinifyLogLevel.warning(), diff --git a/lib/src/util/backoff.dart b/lib/src/util/backoff.dart index c3439c6..bc27811 100644 --- a/lib/src/util/backoff.dart +++ b/lib/src/util/backoff.dart @@ -19,6 +19,6 @@ abstract final class Backoff { if (minDelay >= maxDelay) return Duration(milliseconds: maxDelay); final val = math.min(maxDelay, minDelay * math.pow(2, step.clamp(0, 31))); final interval = _rnd.nextInt(val.toInt()); - return Duration(milliseconds: math.min(maxDelay, minDelay + interval)); + return Duration(milliseconds: (minDelay + interval).clamp(0, maxDelay)); } } diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index d092823..08c1597 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -13,12 +13,11 @@ void main() { group('Spinify', () { final buffer = SpinifyLogBuffer(size: 10); - Spinify createFakeClient([WebSocket Function(String)? transport]) => + Spinify createFakeClient([Future Function(String)? transport]) => Spinify( config: SpinifyConfig( transportBuilder: ({required url, headers, protocols}) => - Future.value( - transport?.call(url) ?? WebSocket$Fake()), + transport?.call(url) ?? Future.value(WebSocket$Fake()), logger: buffer.add, ), ); @@ -41,7 +40,7 @@ void main() { test('Change_client_state', () async { final transport = WebSocket$Fake(); // ignore: close_sinks - final client = createFakeClient((_) => transport..reset()); + final client = createFakeClient((_) async => transport..reset()); expect(transport.isClosed, isFalse); expect(client.state, isA()); await client.connect('ws://localhost:8000/connection/websocket'); @@ -65,7 +64,7 @@ void main() { test('Change_client_states', () { final transport = WebSocket$Fake(); // ignore: close_sinks - final client = createFakeClient((_) => transport..reset()); + final client = createFakeClient((_) async => transport..reset()); Stream.fromIterable([ () => client.connect('ws://localhost:8000/connection/websocket'), client.disconnect, @@ -91,7 +90,7 @@ void main() { 'Reconnect_after_disconnected_transport', () => fakeAsync((async) { final transport = WebSocket$Fake(); - final client = createFakeClient((_) => transport..reset()); + final client = createFakeClient((_) async => transport..reset()); unawaited( client.connect('ws://localhost:8000/connection/websocket'), ); @@ -109,15 +108,15 @@ void main() { 2)); expect(client.state, isA()); async.elapse(client.config.connectionRetryInterval.max); - // TODO: Implement reconnecting expect(client.state, isA()); - client.close(); expectLater( client.states, emitsInOrder([ isA(), - isA() + isA(), + emitsDone, ])); + client.close(); async.elapse(client.config.connectionRetryInterval.max); expect(client.state, isA()); })); From 8ba7ffc2d64db3f969ba9aa22646c087883aae83 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 26 Oct 2024 16:08:56 +0400 Subject: [PATCH 028/104] Added missing literal --- lib/src/model/codes.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/model/codes.dart b/lib/src/model/codes.dart index 5c13e22..38eea1e 100644 --- a/lib/src/model/codes.dart +++ b/lib/src/model/codes.dart @@ -29,6 +29,7 @@ extension type const SpinifyDisconnectCode(int code) implements int { // --- 0..2999 Internal client-side and transport specific codes --- // /// Disconnect called explicitly by the client. + @literal const SpinifyDisconnectCode.disconnect() : code = 0; /// Error Internal means server error, From 662c5692df4c3507ee8348fc5c64e64778158940 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 26 Oct 2024 16:14:21 +0400 Subject: [PATCH 029/104] Refactor SpinifyMetrics class to include handleDone method for handling WebSocket disconnection --- lib/src/model/codes.dart | 773 ++++++++++++++++++++------------------- 1 file changed, 392 insertions(+), 381 deletions(-) diff --git a/lib/src/model/codes.dart b/lib/src/model/codes.dart index 38eea1e..57e219b 100644 --- a/lib/src/model/codes.dart +++ b/lib/src/model/codes.dart @@ -101,387 +101,398 @@ extension type const SpinifyDisconnectCode(int code) implements int { const SpinifyDisconnectCode.unrecoverablePosition() : code = 112; /// Normalize disconnect code and reason. - static ({int code, String reason, bool reconnect}) normalize( - [int? code, String? reason]) => - switch (code ?? 1) { - // --- Client error codes --- // - - /// Disconnect called explicitly by the client. - 0 => ( - code: 0, - reason: reason ?? 'disconnect called', - reconnect: true, - ), - - /// Disconnect due to malformed protocol message sent by the client. - 2 => ( - code: 2, - reason: reason ?? 'bad protocol', - reconnect: true, - ), - - /// Internal server error means server error, - /// if returned this is a signal that something went wrong with - /// the server itself and client is most probably not guilty. - 100 => ( - code: 100, - reason: reason ?? 'internal server error', - reconnect: true, - ), - - /// Unauthorized indicates that the request is unauthorized. - 101 => ( - code: 101, - reason: reason ?? 'unauthorized', - reconnect: true, - ), - - /// Unknown Channel means that the channel name does not exist. - /// Usually this is returned when the client uses a channel with a - /// namespace that is not defined in Centrifugo configuration. - 102 => ( - code: 102, - reason: reason ?? 'unknown channel', - reconnect: true, - ), - - /// Permission Denied means access to the resource is not allowed. - 103 => ( - code: 103, - reason: reason ?? 'permission denied', - reconnect: true, - ), - - /// Method Not Found indicates that the requested method does not exist. - 104 => ( - code: 104, - reason: reason ?? 'method not found', - reconnect: true, - ), - - /// Already Subscribed indicates that the client is already subscribed - /// to the specified channel. In Centrifugo, a client can only have one - /// subscription to a specific channel. - 105 => ( - code: 105, - reason: reason ?? 'already subscribed', - reconnect: true, - ), - - /// Limit Exceeded indicates that a server-imposed - /// limit has been exceeded. - /// Server logs should provide more information. - 106 => ( - code: 106, - reason: reason ?? 'limit exceeded', - reconnect: true, - ), - - /// Bad Request means the server cannot process the received data - /// because it is malformed. Retrying the request does not make sense. - 107 => ( - code: 107, - reason: reason ?? 'bad request', - reconnect: true, - ), - - /// Not Available indicates that the requested resource is not enabled. - /// This may occur, for example, - /// when trying to access history or presence - /// in a channel that does not support these features. - 108 => ( - code: 108, - reason: reason ?? 'not available', - reconnect: true, - ), - - /// Token Expired indicates that the connection token has expired. - /// This is generally handled by updating the token. - 109 => ( - code: 109, - reason: reason ?? 'token expired', - reconnect: true, - ), - - /// Expired indicates that the connection has expired - /// (no token involved). - 110 => ( - code: 110, - reason: reason ?? 'expired', - reconnect: true, - ), - - /// Too Many Requests means that the server rejected the request - /// due to rate limiting. - 111 => ( - code: 111, - reason: reason ?? 'too many requests', - reconnect: true, - ), - - /// Unrecoverable Position indicates that the stream does not contain - /// the required range of publications to fulfill a history query, - /// possibly due to an incorrect epoch being passed. - 112 => ( - code: 112, - reason: reason ?? 'unrecoverable position', - reconnect: true, - ), - - /// Message size limit exceeded. - 1009 => ( - code: 1009, - reason: reason ?? 'message size limit exceeded', - reconnect: true, - ), - - /// Custom disconnect codes from server. - /// We expose codes defined by Centrifuge protocol, - /// hiding details about transport-specific error codes. - /// Reconnect is true by default. - < 3000 => ( - code: code ?? 1, - reason: reason ?? 'transport closed', - reconnect: true, - ), - - // --- Non-terminal disconnect codes --- // - - /// DisconnectConnectionClosed is a special Disconnect object used when - /// client connection was closed without any advice from a server side. - /// This can be a clean disconnect, - /// or temporary disconnect of the client - /// due to internet connection loss. - /// Server can not distinguish the actual reason of disconnect. - 3000 => ( - code: 3000, - reason: reason ?? 'connection closed', - reconnect: true, - ), - - /// Shutdown code. - 3001 => ( - code: 3001, - reason: reason ?? 'shutdown', - reconnect: true, - ), - - /// DisconnectServerError issued when internal error occurred on server. - 3004 => ( - code: 3004, - reason: reason ?? 'internal server error', - reconnect: true, - ), - - /// DisconnectExpired - 3005 => ( - code: 3005, - reason: reason ?? 'connection expired', - reconnect: true, - ), - - /// DisconnectSubExpired issued when client subscription expired. - 3006 => ( - code: 3006, - reason: reason ?? 'subscription expired', - reconnect: true, - ), - - /// DisconnectSlow issued when client can't read messages fast enough. - 3008 => ( - code: 3008, - reason: reason ?? 'slow', - reconnect: true, - ), - - /// DisconnectWriteError issued when an error occurred - /// while writing to client connection. - 3009 => ( - code: 3009, - reason: reason ?? 'write error', - reconnect: true, - ), - - /// DisconnectInsufficientState issued when Centrifugo detects wrong - /// client position in a channel stream. - /// Disconnect allows client to restore missed - /// publications on reconnect. - /// - /// Insufficient state in channel only happens in channels - /// with positioning/recovery on โ€“ where Centrifugo detects message - /// loss and message order issues. - /// - /// Insufficient state in a stream means that Centrifugo - /// detected message loss from the broker. - /// Generally, rare cases of getting such disconnect code are OK, - /// but if there is an increase in the amount of such codes - /// โ€“ then this can be a signal of Centrifugo-to-Broker - /// communication issue. The root cause should be investigated - /// โ€“ it may be an unstable connection between Centrifugo and broker, - /// or Centrifugo can't keep up with a message stream in a channel, - /// or a broker skips messages for some reason. - 3010 => ( - code: 3010, - reason: reason ?? 'insufficient state', - reconnect: true, - ), - - /// DisconnectForceReconnect issued when server disconnects connection - /// for some reason and whants it to reconnect. - 3011 => ( - code: 3011, - reason: reason ?? 'force reconnect', - reconnect: true, - ), - - /// DisconnectNoPong may be issued when server disconnects bidirectional - /// connection due to no pong received to application-level - /// server-to-client pings in a configured time. - 3012 => ( - code: 3012, - reason: reason ?? 'no pong', - reconnect: true, - ), - - /// DisconnectTooManyRequests may be issued when client sends - /// too many commands to a server. - 3013 => ( - code: 3013, - reason: reason ?? 'too many requests', - reconnect: true, - ), - - /// Custom disconnect codes from server. Reconnect is true by default. - <= 3499 => ( - code: code ?? 0, - reason: reason ?? 'transport closed', - reconnect: true, - ), - - // --- Terminal disconnect codes --- // - - /// DisconnectInvalidToken issued when client came with invalid token. - 3500 => ( - code: 3500, - reason: reason ?? 'invalid token', - reconnect: false, - ), - - /// DisconnectBadRequest issued when client - /// uses malformed protocol frames. - 3501 => ( - code: 3501, - reason: reason ?? 'bad request', - reconnect: false, - ), - - /// DisconnectStale issued to close connection that did not become - /// authenticated in configured interval after dialing. - 3502 => ( - code: 3502, - reason: reason ?? 'stale', - reconnect: false, - ), - - /// DisconnectForceNoReconnect issued when server disconnects connection - /// and asks it to not reconnect again. - 3503 => ( - code: 3503, - reason: reason ?? 'force disconnect', - reconnect: false, - ), - - /// DisconnectConnectionLimit can be issued when client connection - /// exceeds a configured connection limit - /// (per user ID or due to other rule). - 3504 => ( - code: 3504, - reason: reason ?? 'connection limit', - reconnect: false, - ), - - /// DisconnectChannelLimit can be issued when client connection exceeds - /// a configured channel limit. - 3505 => ( - code: 3505, - reason: reason ?? 'channel limit', - reconnect: false, - ), - - /// DisconnectInappropriateProtocol can be issued when client connection - /// format can not handle incoming data. - /// For example, this happens when JSON-based clients receive - /// binary data in a channel. - /// This is usually an indicator of programmer error, - /// JSON clients can not handle binary. - 3506 => ( - code: 3506, - reason: reason ?? 'inappropriate protocol', - reconnect: false, - ), - - /// DisconnectPermissionDenied may be issued when client - /// attempts accessing a server without enough permissions. - 3507 => ( - code: 3507, - reason: reason ?? 'permission denied', - reconnect: false, - ), - - /// DisconnectNotAvailable may be issued when ErrorNotAvailable does not - /// fit message type, for example we issue DisconnectNotAvailable - /// when client sends asynchronous message without MessageHandler - /// set on server side. - 3508 => ( - code: 3508, - reason: reason ?? 'not available', - reconnect: false, - ), - - /// DisconnectTooManyErrors may be issued when client - /// generates too many errors. - 3509 => ( - code: 3509, - reason: reason ?? 'too many errors', - reconnect: false, - ), - - /// Application terminal codes with no reconnect. - <= 3999 => ( - code: code ?? 0, - reason: reason ?? 'application terminal code', - reconnect: false, - ), - - /// Custom disconnect codes. Reconnect is true by default. - <= 4499 => ( - code: code ?? 0, - reason: reason ?? 'transport closed', - reconnect: true, - ), - - /// Application terminal codes with no reconnect. - <= 4999 => ( - code: code ?? 0, - reason: reason ?? 'application terminal code', - reconnect: false, - ), - - /// Internal and reserved by Centrifuge - /// Reconnect is true by default. - >= 5000 => ( - code: code ?? 0, - reason: reason ?? 'transport closed', - reconnect: true, - ), - - /// Custom disconnect codes. - _ => ( - code: code ?? 0, - reason: reason ?? 'transport closed', - reconnect: false, - ), - }; + static ({SpinifyDisconnectCode code, String reason, bool reconnect}) + normalize([int? code, String? reason]) => switch (code ?? 1) { + // --- Client error codes --- // + + /// Disconnect called explicitly by the client. + 0 => ( + code: const SpinifyDisconnectCode(0), + reason: reason ?? 'disconnect called', + reconnect: true, + ), + + /// Disconnect due to malformed protocol message sent by the client. + 2 => ( + code: const SpinifyDisconnectCode(2), + reason: reason ?? 'bad protocol', + reconnect: true, + ), + + /// Internal server error means server error, + /// if returned this is a signal that something went wrong with + /// the server itself and client is most probably not guilty. + 100 => ( + code: const SpinifyDisconnectCode(100), + reason: reason ?? 'internal server error', + reconnect: true, + ), + + /// Unauthorized indicates that the request is unauthorized. + 101 => ( + code: const SpinifyDisconnectCode(101), + reason: reason ?? 'unauthorized', + reconnect: true, + ), + + /// Unknown Channel means that the channel name does not exist. + /// Usually this is returned when the client uses a channel with a + /// namespace that is not defined in Centrifugo configuration. + 102 => ( + code: const SpinifyDisconnectCode(102), + reason: reason ?? 'unknown channel', + reconnect: true, + ), + + /// Permission Denied means access to the resource is not allowed. + 103 => ( + code: const SpinifyDisconnectCode(103), + reason: reason ?? 'permission denied', + reconnect: true, + ), + + /// Method Not Found indicates that + /// the requested method does not exist. + 104 => ( + code: const SpinifyDisconnectCode(104), + reason: reason ?? 'method not found', + reconnect: true, + ), + + /// Already Subscribed indicates that the client is + /// already subscribed to the specified channel. + /// In Centrifugo, a client can only have one + /// subscription to a specific channel. + 105 => ( + code: const SpinifyDisconnectCode(105), + reason: reason ?? 'already subscribed', + reconnect: true, + ), + + /// Limit Exceeded indicates that a server-imposed + /// limit has been exceeded. + /// Server logs should provide more information. + 106 => ( + code: const SpinifyDisconnectCode(106), + reason: reason ?? 'limit exceeded', + reconnect: true, + ), + + /// Bad Request means the server cannot process the received data + /// because it is malformed. + /// Retrying the request does not make sense. + 107 => ( + code: const SpinifyDisconnectCode(107), + reason: reason ?? 'bad request', + reconnect: true, + ), + + /// Not Available indicates that the requested + /// resource is not enabled. + /// This may occur, for example, + /// when trying to access history or presence + /// in a channel that does not support these features. + 108 => ( + code: const SpinifyDisconnectCode(108), + reason: reason ?? 'not available', + reconnect: true, + ), + + /// Token Expired indicates that the connection token has expired. + /// This is generally handled by updating the token. + 109 => ( + code: const SpinifyDisconnectCode(109), + reason: reason ?? 'token expired', + reconnect: true, + ), + + /// Expired indicates that the connection has expired + /// (no token involved). + 110 => ( + code: const SpinifyDisconnectCode(110), + reason: reason ?? 'expired', + reconnect: true, + ), + + /// Too Many Requests means that the server rejected the request + /// due to rate limiting. + 111 => ( + code: const SpinifyDisconnectCode(111), + reason: reason ?? 'too many requests', + reconnect: true, + ), + + /// Unrecoverable Position indicates that + /// the stream does not contain + /// the required range of publications to fulfill a history query, + /// possibly due to an incorrect epoch being passed. + 112 => ( + code: const SpinifyDisconnectCode(112), + reason: reason ?? 'unrecoverable position', + reconnect: true, + ), + + /// Message size limit exceeded. + 1009 => ( + code: const SpinifyDisconnectCode(1009), + reason: reason ?? 'message size limit exceeded', + reconnect: true, + ), + + /// Custom disconnect codes from server. + /// We expose codes defined by Centrifuge protocol, + /// hiding details about transport-specific error codes. + /// Reconnect is true by default. + < 3000 => ( + code: SpinifyDisconnectCode(code ?? 0), + reason: reason ?? 'transport closed', + reconnect: true, + ), + + // --- Non-terminal disconnect codes --- // + + /// DisconnectConnectionClosed is a special Disconnect + /// object used when + /// client connection was closed without any advice + /// from a server side. + /// This can be a clean disconnect, + /// or temporary disconnect of the client + /// due to internet connection loss. + /// Server can not distinguish the actual reason of disconnect. + 3000 => ( + code: const SpinifyDisconnectCode(3000), + reason: reason ?? 'connection closed', + reconnect: true, + ), + + /// Shutdown code. + 3001 => ( + code: const SpinifyDisconnectCode(3001), + reason: reason ?? 'shutdown', + reconnect: true, + ), + + /// DisconnectServerError issued when + /// internal error occurred on server. + 3004 => ( + code: const SpinifyDisconnectCode(3004), + reason: reason ?? 'internal server error', + reconnect: true, + ), + + /// DisconnectExpired + 3005 => ( + code: const SpinifyDisconnectCode(3005), + reason: reason ?? 'connection expired', + reconnect: true, + ), + + /// DisconnectSubExpired issued when client subscription expired. + 3006 => ( + code: const SpinifyDisconnectCode(3006), + reason: reason ?? 'subscription expired', + reconnect: true, + ), + + /// DisconnectSlow issued when client + /// can't read messages fast enough. + 3008 => ( + code: const SpinifyDisconnectCode(3008), + reason: reason ?? 'slow', + reconnect: true, + ), + + /// DisconnectWriteError issued when an error occurred + /// while writing to client connection. + 3009 => ( + code: const SpinifyDisconnectCode(3009), + reason: reason ?? 'write error', + reconnect: true, + ), + + /// DisconnectInsufficientState issued when Centrifugo detects wrong + /// client position in a channel stream. + /// Disconnect allows client to restore missed + /// publications on reconnect. + /// + /// Insufficient state in channel only happens in channels + /// with positioning/recovery on โ€“ where Centrifugo detects message + /// loss and message order issues. + /// + /// Insufficient state in a stream means that Centrifugo + /// detected message loss from the broker. + /// Generally, rare cases of getting such disconnect code are OK, + /// but if there is an increase in the amount of such codes + /// โ€“ then this can be a signal of Centrifugo-to-Broker + /// communication issue. The root cause should be investigated โ€“ + /// it may be an unstable connection between Centrifugo and broker, + /// or Centrifugo can't keep up with a message stream in a channel, + /// or a broker skips messages for some reason. + 3010 => ( + code: const SpinifyDisconnectCode(3010), + reason: reason ?? 'insufficient state', + reconnect: true, + ), + + /// DisconnectForceReconnect issued when server disconnects + /// connection for some reason and whants it to reconnect. + 3011 => ( + code: const SpinifyDisconnectCode(3011), + reason: reason ?? 'force reconnect', + reconnect: true, + ), + + /// DisconnectNoPong may be issued when server disconnects + /// bidirectional connection due to no pong received to + /// application-level server-to-client pings in a configured time. + 3012 => ( + code: const SpinifyDisconnectCode(3012), + reason: reason ?? 'no pong', + reconnect: true, + ), + + /// DisconnectTooManyRequests may be issued when client sends + /// too many commands to a server. + 3013 => ( + code: const SpinifyDisconnectCode(3013), + reason: reason ?? 'too many requests', + reconnect: true, + ), + + /// Custom disconnect codes from server. + /// Reconnect is true by default. + <= 3499 => ( + code: SpinifyDisconnectCode(code ?? 0), + reason: reason ?? 'transport closed', + reconnect: true, + ), + + // --- Terminal disconnect codes --- // + + /// DisconnectInvalidToken issued when client + /// came with invalid token. + 3500 => ( + code: const SpinifyDisconnectCode(3500), + reason: reason ?? 'invalid token', + reconnect: false, + ), + + /// DisconnectBadRequest issued when client + /// uses malformed protocol frames. + 3501 => ( + code: const SpinifyDisconnectCode(3501), + reason: reason ?? 'bad request', + reconnect: false, + ), + + /// DisconnectStale issued to close connection that did not become + /// authenticated in configured interval after dialing. + 3502 => ( + code: const SpinifyDisconnectCode(3502), + reason: reason ?? 'stale', + reconnect: false, + ), + + /// DisconnectForceNoReconnect issued when server + /// disconnects connection and asks it to not reconnect again. + 3503 => ( + code: const SpinifyDisconnectCode(3503), + reason: reason ?? 'force disconnect', + reconnect: false, + ), + + /// DisconnectConnectionLimit can be issued when client connection + /// exceeds a configured connection limit + /// (per user ID or due to other rule). + 3504 => ( + code: const SpinifyDisconnectCode(3504), + reason: reason ?? 'connection limit', + reconnect: false, + ), + + /// DisconnectChannelLimit can be issued when client + /// connection exceeds a configured channel limit. + 3505 => ( + code: const SpinifyDisconnectCode(3505), + reason: reason ?? 'channel limit', + reconnect: false, + ), + + /// DisconnectInappropriateProtocol can be issued when + /// client connection format can not handle incoming data. + /// For example, this happens when JSON-based clients receive + /// binary data in a channel. + /// This is usually an indicator of programmer error, + /// JSON clients can not handle binary. + 3506 => ( + code: const SpinifyDisconnectCode(3506), + reason: reason ?? 'inappropriate protocol', + reconnect: false, + ), + + /// DisconnectPermissionDenied may be issued when client + /// attempts accessing a server without enough permissions. + 3507 => ( + code: const SpinifyDisconnectCode(3507), + reason: reason ?? 'permission denied', + reconnect: false, + ), + + /// DisconnectNotAvailable may be issued when ErrorNotAvailable + /// does not fit message type, + /// for example we issue DisconnectNotAvailable + /// when client sends asynchronous message without MessageHandler + /// set on server side. + 3508 => ( + code: const SpinifyDisconnectCode(3508), + reason: reason ?? 'not available', + reconnect: false, + ), + + /// DisconnectTooManyErrors may be issued when client + /// generates too many errors. + 3509 => ( + code: const SpinifyDisconnectCode(3509), + reason: reason ?? 'too many errors', + reconnect: false, + ), + + /// Application terminal codes with no reconnect. + <= 3999 => ( + code: SpinifyDisconnectCode(code ?? 0), + reason: reason ?? 'application terminal code', + reconnect: false, + ), + + /// Custom disconnect codes. Reconnect is true by default. + <= 4499 => ( + code: SpinifyDisconnectCode(code ?? 0), + reason: reason ?? 'transport closed', + reconnect: true, + ), + + /// Application terminal codes with no reconnect. + <= 4999 => ( + code: SpinifyDisconnectCode(code ?? 0), + reason: reason ?? 'application terminal code', + reconnect: false, + ), + + /// Internal and reserved by Centrifuge + /// Reconnect is true by default. + >= 5000 => ( + code: SpinifyDisconnectCode(code ?? 0), + reason: reason ?? 'transport closed', + reconnect: true, + ), + + /// Custom disconnect codes. + _ => ( + code: SpinifyDisconnectCode(code ?? 0), + reason: reason ?? 'transport closed', + reconnect: false, + ), + }; /// Reconnect is needed due to specific transport close code. bool get reconnect => switch (code) { From 98537081ac4185182c209781d95706f06d049732 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 26 Oct 2024 16:18:07 +0400 Subject: [PATCH 030/104] Normalize disconnect code and reason in SpinifyDisconnectCode --- lib/src/model/codes.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/model/codes.dart b/lib/src/model/codes.dart index 57e219b..01972a1 100644 --- a/lib/src/model/codes.dart +++ b/lib/src/model/codes.dart @@ -101,6 +101,7 @@ extension type const SpinifyDisconnectCode(int code) implements int { const SpinifyDisconnectCode.unrecoverablePosition() : code = 112; /// Normalize disconnect code and reason. + @experimental static ({SpinifyDisconnectCode code, String reason, bool reconnect}) normalize([int? code, String? reason]) => switch (code ?? 1) { // --- Client error codes --- // From 47c309cc8f551dddd25d7e5e406ccb0256dfa33c Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 26 Oct 2024 16:19:02 +0400 Subject: [PATCH 031/104] Refactor error handling in Spinify connect method --- lib/src/spinify.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 9be8179..055e8aa 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -408,7 +408,7 @@ final class Spinify implements ISpinify { Future connect(String url) async { try { await _interactiveConnect(url); - } on SpinifyConnectionException catch (error, stackTrace) { + } on SpinifyConnectionException { rethrow; } on Object catch (error, stackTrace) { Error.throwWithStackTrace( From 274b1322fb843b93a4a357ab53cbafe205671fc5 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 26 Oct 2024 16:41:22 +0400 Subject: [PATCH 032/104] Update tests --- lib/src/model/codes.dart | 11 ++++ lib/src/spinify.dart | 22 ++++--- test/unit/spinify_test.dart | 109 +++++++++++++++++++++++---------- test/unit/web_socket_fake.dart | 1 + 4 files changed, 104 insertions(+), 39 deletions(-) diff --git a/lib/src/model/codes.dart b/lib/src/model/codes.dart index 01972a1..cec22d2 100644 --- a/lib/src/model/codes.dart +++ b/lib/src/model/codes.dart @@ -100,6 +100,10 @@ extension type const SpinifyDisconnectCode(int code) implements int { @literal const SpinifyDisconnectCode.unrecoverablePosition() : code = 112; + /// Normal closure. + @literal + const SpinifyDisconnectCode.normalClosure() : code = 1000; + /// Normalize disconnect code and reason. @experimental static ({SpinifyDisconnectCode code, String reason, bool reconnect}) @@ -233,6 +237,13 @@ extension type const SpinifyDisconnectCode(int code) implements int { reconnect: true, ), + /// Normal closure. + 1000 => ( + code: const SpinifyDisconnectCode(1000), + reason: reason ?? 'normal closure', + reconnect: true, + ), + /// Message size limit exceeded. 1009 => ( code: const SpinifyDisconnectCode(1009), diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 055e8aa..ea1156f 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -83,6 +83,7 @@ final class Spinify implements ISpinify { /// Current WebSocket transport. WebSocket? _transport; + StreamSubscription? _replySubscription; /// Internal mutable metrics. Also it's container for Spinify's state. final SpinifyMetrics$Mutable _metrics = SpinifyMetrics$Mutable(); @@ -442,8 +443,8 @@ final class Spinify implements ISpinify { }; if (state.isConnected || state.isConnecting) { _internalDisconnect( - code: 0, - reason: 'reconnect called while already connected or connecting', + code: const SpinifyDisconnectCode.normalClosure(), + reason: 'normal closure', reconnect: false, ); } @@ -467,6 +468,10 @@ final class Spinify implements ISpinify { _transport == null, 'Transport should be null', ); + assert( + _replySubscription == null, + 'Reply subscription should be null', + ); _setState(SpinifyState$Connecting(url: _metrics.reconnectUrl = url)); assert(state.isConnecting, 'State should be connecting'); @@ -572,7 +577,8 @@ final class Spinify implements ISpinify { ); } - ws.stream.transform(StreamTransformer.fromHandlers( + _replySubscription = + ws.stream.transform(StreamTransformer.fromHandlers( handleData: (data, sink) { _metrics ..bytesReceived += data.length @@ -726,8 +732,8 @@ final class Spinify implements ISpinify { _tearDownReconnectTimer(); _metrics.reconnectUrl = null; _internalDisconnect( - code: 0, - reason: 'disconnect interactively called by client', + code: const SpinifyDisconnectCode.normalClosure(), + reason: 'normal closure', reconnect: false, ); } @@ -743,6 +749,8 @@ final class Spinify implements ISpinify { _tearDownRefreshConnection(); // Close transport. + _replySubscription?.cancel().ignore(); + _replySubscription = null; _transport?.close(code, reason); _transport = null; @@ -815,8 +823,8 @@ final class Spinify implements ISpinify { try { _tearDownHealthCheckTimer(); _internalDisconnect( - code: 0, - reason: 'close interactively called by client', + code: const SpinifyDisconnectCode.normalClosure(), + reason: 'normal closure', reconnect: false, ); _setState(SpinifyState$Closed()); diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index 08c1597..3f032d7 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -60,6 +60,7 @@ void main() { expect(client.state, isA()); expect(client.isClosed, isTrue); expect(transport.isClosed, isTrue); + expect(transport.closeCode, equals(1000)); }); test('Change_client_states', () { @@ -87,39 +88,83 @@ void main() { }); test( - 'Reconnect_after_disconnected_transport', - () => fakeAsync((async) { - final transport = WebSocket$Fake(); - final client = createFakeClient((_) async => transport..reset()); - unawaited( - client.connect('ws://localhost:8000/connection/websocket'), - ); - expect(client.state, isA()); - async.elapse(client.config.timeout); - expect(client.state, isA()); - expect(transport, isNotNull); - expect(transport, isA()); - transport.close(); - async.elapse(const Duration(milliseconds: 50)); - expect(client.state, isA()); - async.elapse(Duration( - milliseconds: client - .config.connectionRetryInterval.min.inMilliseconds ~/ + 'Reconnect_after_disconnected_transport', + () => fakeAsync( + (async) { + final transport = WebSocket$Fake(); + final client = createFakeClient((_) async => transport..reset()); + const url = 'ws://localhost:8000/connection/websocket'; + unawaited(client.connect(url)); + expect( + client.state, + isA().having( + (s) => s.url, + 'url', + equals(url), + ), + ); + async.elapse(client.config.timeout); + expect( + client.state, + isA().having( + (s) => s.url, + 'url', + equals(url), + ), + ); + expect(transport, isNotNull); + expect(transport, isA()); + transport.close(); + async.elapse(const Duration(milliseconds: 50)); + expect( + client.state, + isA().having( + (s) => s.temporary, + 'temporary', + isTrue, + ), + ); + async.elapse(Duration( + milliseconds: + client.config.connectionRetryInterval.min.inMilliseconds ~/ 2)); - expect(client.state, isA()); - async.elapse(client.config.connectionRetryInterval.max); - expect(client.state, isA()); - expectLater( - client.states, - emitsInOrder([ - isA(), - isA(), - emitsDone, - ])); - client.close(); - async.elapse(client.config.connectionRetryInterval.max); - expect(client.state, isA()); - })); + expect( + client.state, + isA().having( + (s) => s.temporary, + 'temporary', + isTrue, + ), + ); + async.elapse(client.config.connectionRetryInterval.max); + expect( + client.state, + isA().having( + (s) => s.url, + 'url', + equals(url), + ), + ); + expectLater( + client.states, + emitsInOrder( + [ + isA().having( + (s) => s.temporary, + 'temporary', + isFalse, + ), + isA(), + emitsDone, + ], + ), + ); + client.close(); + async.elapse(client.config.connectionRetryInterval.max); + expect(client.state, isA()); + }, + ), + ); test( 'Rpc_requests', diff --git a/test/unit/web_socket_fake.dart b/test/unit/web_socket_fake.dart index 54cf060..a1d42d1 100644 --- a/test/unit/web_socket_fake.dart +++ b/test/unit/web_socket_fake.dart @@ -131,6 +131,7 @@ class WebSocket$Fake implements WebSocket { void close([int? code, String? reason]) { _closeCode = code; _closeReason = reason; + _isClosed = true; _socket!.close().ignore(); } From 5aac5085a9dc97d0ca0e0b522f12391ae317f33f Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 26 Oct 2024 16:43:22 +0400 Subject: [PATCH 033/104] Normalize disconnect code and reason in SpinifyDisconnectCode --- lib/src/model/codes.dart | 11 +++++++++++ lib/src/spinify.dart | 8 ++++++++ 2 files changed, 19 insertions(+) diff --git a/lib/src/model/codes.dart b/lib/src/model/codes.dart index cec22d2..0fe3616 100644 --- a/lib/src/model/codes.dart +++ b/lib/src/model/codes.dart @@ -104,6 +104,10 @@ extension type const SpinifyDisconnectCode(int code) implements int { @literal const SpinifyDisconnectCode.normalClosure() : code = 1000; + /// Abnormal closure. + @literal + const SpinifyDisconnectCode.abnormalClosure() : code = 1006; + /// Normalize disconnect code and reason. @experimental static ({SpinifyDisconnectCode code, String reason, bool reconnect}) @@ -244,6 +248,13 @@ extension type const SpinifyDisconnectCode(int code) implements int { reconnect: true, ), + /// Abnormal closure. + 1006 => ( + code: const SpinifyDisconnectCode(1006), + reason: reason ?? 'abnormal closure', + reconnect: true, + ), + /// Message size limit exceeded. 1009 => ( code: const SpinifyDisconnectCode(1009), diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index ea1156f..56919f3 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -247,6 +247,14 @@ final class Spinify implements ISpinify { warning( 'Health check failed: refresh timer set but not connected'); } + if (_transport != null || _replySubscription != null) { + warning('Health check failed: transport is not closed'); + _internalDisconnect( + code: const SpinifyDisconnectCode.abnormalClosure(), + reason: 'abnormal closure', + reconnect: false, + ); + } case SpinifyState$Connecting _: if (_refreshTimer != null) { warning('Health check failed: refresh timer set during connect'); From 81646068116a27e147ec73e7daaae55e2ae530bb Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 26 Oct 2024 17:13:15 +0400 Subject: [PATCH 034/104] Add test for server subscriptions --- lib/src/spinify.dart | 21 +- test/unit/spinify_test.dart | 354 ++++++++++++++++++++++----------- test/unit/web_socket_fake.dart | 63 +++--- 3 files changed, 284 insertions(+), 154 deletions(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 56919f3..8b14fb7 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -667,9 +667,11 @@ final class Spinify implements ISpinify { ); } else { readyCompleter.complete(); + _readyCompleter = null; } - _readyCompleter = null; + _metrics.lastConnectAt = DateTime.now(); + _metrics.connects++; _log( const SpinifyLogLevel.config(), @@ -756,9 +758,11 @@ final class Spinify implements ISpinify { try { _tearDownRefreshConnection(); - // Close transport. + // Unsuscribe from reply messages. + // To ignore last messages and done event from transport. _replySubscription?.cancel().ignore(); _replySubscription = null; + // Close transport. _transport?.close(code, reason); _transport = null; @@ -1005,9 +1009,16 @@ final class Spinify implements ISpinify { // --- Remote Procedure Call --- // @override - Future> rpc(String method, [List? data]) { - throw UnimplementedError(); - } + Future> rpc(String method, [List? data]) => _doOnReady( + () => _sendCommand( + SpinifyRPCRequest( + id: _getNextCommandId(), + timestamp: DateTime.now(), + method: method, + data: data ?? const [], + ), + ).then>((reply) => reply.data), + ); // --- Subscriptions and Channels --- // diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index 3f032d7..d3e70dc 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -4,13 +4,16 @@ import 'dart:convert'; import 'package:fake_async/fake_async.dart'; import 'package:mockito/annotations.dart'; import 'package:spinify/spinify.dart'; +import 'package:spinify/src/protobuf/client.pb.dart' as pb; import 'package:test/test.dart'; +import 'codecs.dart'; import 'web_socket_fake.dart'; @GenerateNiceMocks([MockSpec(as: #MockWebSocket)]) void main() { group('Spinify', () { + const url = 'ws://localhost:8000/connection/websocket'; final buffer = SpinifyLogBuffer(size: 10); Spinify createFakeClient([Future Function(String)? transport]) => @@ -22,70 +25,82 @@ void main() { ), ); - test('Create_and_close_client', () async { - final client = createFakeClient(); - expect(client.isClosed, isFalse); - expect(client.state, isA()); - await client.close(); - expect(client.state, isA()); - expect(client.isClosed, isTrue); - }); + test( + 'Create_and_close_client', + () async { + final client = createFakeClient(); + expect(client.isClosed, isFalse); + expect(client.state, isA()); + await client.close(); + expect(client.state, isA()); + expect(client.isClosed, isTrue); + }, + ); - test('Create_and_close_multiple_clients', () async { - final clients = List.generate(10, (_) => createFakeClient()); - expect(clients.every((client) => !client.isClosed), isTrue); - await Future.wait(clients.map((client) => client.close())); - expect(clients.every((client) => client.isClosed), isTrue); - }); + test( + 'Create_and_close_multiple_clients', + () async { + final clients = List.generate(10, (_) => createFakeClient()); + expect(clients.every((client) => !client.isClosed), isTrue); + await Future.wait(clients.map((client) => client.close())); + expect(clients.every((client) => client.isClosed), isTrue); + }, + ); - test('Change_client_state', () async { - final transport = WebSocket$Fake(); // ignore: close_sinks - final client = createFakeClient((_) async => transport..reset()); - expect(transport.isClosed, isFalse); - expect(client.state, isA()); - await client.connect('ws://localhost:8000/connection/websocket'); - expect(client.state, isA()); - await client.disconnect(); - expect( - client.state, - isA().having( - (s) => s.temporary, - 'temporary', - isFalse, - ), - ); - await client.connect('ws://localhost:8000/connection/websocket'); - expect(client.state, isA()); - await client.close(); - expect(client.state, isA()); - expect(client.isClosed, isTrue); - expect(transport.isClosed, isTrue); - expect(transport.closeCode, equals(1000)); - }); + test( + 'Change_client_state', + () async { + final transport = WebSocket$Fake(); // ignore: close_sinks + final client = createFakeClient((_) async => transport..reset()); + expect(transport.isClosed, isFalse); + expect(client.state, isA()); + await client.connect(url); + expect(client.state, isA()); + await client.disconnect(); + expect( + client.state, + isA().having( + (s) => s.temporary, + 'temporary', + isFalse, + ), + ); + await client.connect(url); + expect(client.state, isA()); + await client.close(); + expect(client.state, isA()); + expect(client.isClosed, isTrue); + expect(transport.isClosed, isTrue); + expect(transport.closeCode, equals(1000)); + }, + ); - test('Change_client_states', () { - final transport = WebSocket$Fake(); // ignore: close_sinks - final client = createFakeClient((_) async => transport..reset()); - Stream.fromIterable([ - () => client.connect('ws://localhost:8000/connection/websocket'), - client.disconnect, - () => client.connect('ws://localhost:8000/connection/websocket'), - client.disconnect, - client.close, - ]).asyncMap(Future.new).drain(); - expect(client.state, isA()); - expectLater( - client.states, - emitsInOrder([ - isA(), - isA(), - isA(), - isA(), - isA(), - isA(), - isA() - ])); - }); + test( + 'Change_client_states', + () { + final transport = WebSocket$Fake(); // ignore: close_sinks + final client = createFakeClient((_) async => transport..reset()); + Stream.fromIterable([ + () => client.connect(url), + client.disconnect, + () => client.connect(url), + client.disconnect, + client.close, + ]).asyncMap(Future.new).drain(); + expect(client.state, isA()); + expectLater( + client.states, + emitsInOrder([ + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + isA() + ])); + }, + ); test( 'Reconnect_after_disconnected_transport', @@ -93,7 +108,6 @@ void main() { (async) { final transport = WebSocket$Fake(); final client = createFakeClient((_) async => transport..reset()); - const url = 'ws://localhost:8000/connection/websocket'; unawaited(client.connect(url)); expect( client.state, @@ -167,68 +181,178 @@ void main() { ); test( - 'Rpc_requests', - () => fakeAsync((async) { - final client = createFakeClient() - ..connect('ws://localhost:8000/connection/websocket'); - expect(client.state, isA()); - async.elapse(client.config.timeout); - expect(client.state, isA()); + 'Rpc_requests', + () => fakeAsync( + (async) { + final ws = WebSocket$Fake(); // ignore: close_sinks + final client = createFakeClient((_) async => ws..reset()) + ..connect(url); + expect(client.state, isA()); + async.elapse(client.config.timeout); + expect(client.state, isA()); - // Send a request - expect( - client.rpc('echo', utf8.encode('Hello, World!')), - completion(isA>().having( - (data) => utf8.decode(data), - 'data', - equals('Hello, World!'), - )), - ); - async.elapse(client.config.timeout); - expect(client.state, isA()); + // Intercept the onAdd callback for echo RPC + var fn = ws.onAdd; + ws.onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + if (command.hasRpc()) { + expect(command.rpc.method, anyOf('echo', 'getCurrentYear')); + switch (command.rpc.method) { + case 'echo': + final data = utf8.decode(command.rpc.data); + final reply = pb.Reply( + id: command.id, + rpc: pb.RPCResult( + data: utf8.encode(data), + ), + ); + scheduleMicrotask( + () => sink.add(ProtobufCodec.encode(reply))); + default: + return fn(bytes, sink); + } + } else { + fn(bytes, sink); + } + }; - // Send 1000 requests - for (var i = 0; i < 1000; i++) { - expect( - client.rpc('echo', utf8.encode(i.toString())), - completion(isA>().having( - (data) => utf8.decode(data), - 'data', - equals(i.toString()), - )), - ); + // Send a request + expect( + client.rpc('echo', utf8.encode('Hello, World!')), + completion(isA>().having( + (data) => utf8.decode(data), + 'data', + equals('Hello, World!'), + )), + ); + async.elapse(client.config.timeout); + expect(client.state, isA()); + + // Send 1000 requests + for (var i = 0; i < 1000; i++) { + expect( + client.rpc('echo', utf8.encode(i.toString())), + completion(isA>().having( + (data) => utf8.decode(data), + 'data', + equals(i.toString()), + )), + ); + } + + async.elapse(client.config.timeout); + expect(client.state, isA()); + client.disconnect(); + async.elapse(client.config.timeout); + expect(client.state, isA()); + client.connect(url); + async.elapse(client.config.timeout); + expect(client.state, isA()); + + // Intercept the onAdd callback for getCurrentYear RPC + ws.onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + if (command.hasRpc()) { + expect(command.rpc.method, anyOf('echo', 'getCurrentYear')); + switch (command.rpc.method) { + case 'getCurrentYear': + final reply = pb.Reply( + id: command.id, + rpc: pb.RPCResult( + data: utf8 + .encode(jsonEncode({'year': DateTime.now().year})), + ), + ); + scheduleMicrotask( + () => sink.add(ProtobufCodec.encode(reply))); + default: + return fn(bytes, sink); } + } else { + fn(bytes, sink); + } + }; - async.elapse(client.config.timeout); - expect(client.state, isA()); - client.disconnect(); - async.elapse(client.config.timeout); - expect(client.state, isA()); - client.connect('ws://localhost:8000/connection/websocket'); - async.elapse(client.config.timeout); - expect(client.state, isA()); + // Another request + expect( + client.rpc('getCurrentYear', []), + completion(isA>().having( + (data) => jsonDecode(utf8.decode(data))['year'], + 'year', + DateTime.now().year, + )), + ); + async.elapse(client.config.timeout); - // Another request - expect( - client.rpc('getCurrentYear', []), - completion(isA>().having( - (data) => jsonDecode(utf8.decode(data))['year'], - 'year', - DateTime.now().year, - )), - ); - async.elapse(client.config.timeout); + expect(client.state, isA()); + client.close(); + async.elapse(client.config.timeout); + expect(client.state, isA()); + }, + ), + ); - expect(client.state, isA()); - client.close(); - async.elapse(client.config.timeout); - expect(client.state, isA()); - })); + test( + 'Server_subscriptions', + () => fakeAsync( + (async) { + final ws = WebSocket$Fake(); // ignore: close_sinks + final client = createFakeClient((_) async => ws..reset()); + + ws.onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + sink.add( + ProtobufCodec.encode( + pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: { + 'public:chat': pb.SubscribeResult( + expires: false, + ttl: null, + data: [], + ), + 'personal:user#42': pb.SubscribeResult( + expires: false, + ttl: null, + data: [], + ), + }, + ping: 600, + pong: false, + session: 'fake', + node: 'fake', + ), + ), + ), + ); + } + }); + }; + + client.connect(url); + async.elapse(client.config.timeout); + expect(client.state, isA()); + expect(client.subscriptions, hasLength(2)); + expect(client.getServerSubscription('public:chat'), isNotNull); + expect(client.getServerSubscription('personal:user#42'), isNotNull); + client.close(); + }, + ), + ); test( 'Metrics', () => fakeAsync((async) { - final client = createFakeClient(); + final ws = WebSocket$Fake(); // ignore: close_sinks + final client = createFakeClient((_) async => ws..reset()); expect(() => client.metrics, returnsNormally); expect( client.metrics, @@ -264,7 +388,7 @@ void main() { equals(Int64.ZERO), ), ])); - client.connect('ws://localhost:8000/connection/websocket'); + client.connect(url); async.elapse(client.config.timeout); expect( client.metrics, diff --git a/test/unit/web_socket_fake.dart b/test/unit/web_socket_fake.dart index a1d42d1..8c7bdb0 100644 --- a/test/unit/web_socket_fake.dart +++ b/test/unit/web_socket_fake.dart @@ -27,35 +27,35 @@ class WebSocket$Fake implements WebSocket { handleDone: _doneHandler, ), ); + onAdd = _defaultOnAddCallback; + } - // Default callbacks to handle connects and disconnects. - _onAddCallback = (bytes, sink) { - final command = ProtobufCodec.decode(pb.Command(), bytes); - Timer(Duration.zero, () { - if (isClosed) return; - if (command.hasConnect()) { - sink.add( - ProtobufCodec.encode( - pb.Reply( - id: command.id, - connect: pb.ConnectResult( - client: 'fake', - version: '0.0.1', - expires: false, - ttl: null, - data: null, - subs: {}, - ping: 600, - pong: false, - session: 'fake', - node: 'fake', - ), + // Default callbacks to handle connects and disconnects. + static void _defaultOnAddCallback(List bytes, Sink> sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + sink.add( + ProtobufCodec.encode( + pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: {}, + ping: 600, + pong: false, + session: 'fake', + node: 'fake', ), ), - ); - } - }); - }; + ), + ); + } + }); } StreamController>? _socket; @@ -104,16 +104,12 @@ class WebSocket$Fake implements WebSocket { @override void add(List bytes) { - _onAddCallback?.call(bytes, _socket!.sink); + onAdd(bytes, _socket!.sink); } - /// Add data to the WebSocket. - void Function(List bytes, Sink> sink)? _onAddCallback; - /// Add callback to handle sending data and allow to respond with reply. - void onAdd(void Function(List bytes, Sink> sink)? callback) { - _onAddCallback = callback; - } + void Function(List bytes, Sink> sink) onAdd = + _defaultOnAddCallback; void Function()? _onDoneCallback; @@ -140,7 +136,6 @@ class WebSocket$Fake implements WebSocket { _closeCode = null; _closeReason = null; _isClosed = false; - _onAddCallback = null; _onDoneCallback = null; _init(); } From 14b857bbcd0c5faeef9c7ba5c8067c6a92de0ca6 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 26 Oct 2024 18:12:44 +0400 Subject: [PATCH 035/104] Refactor SpinifyReplyResult mixin and add test for server subscriptions --- lib/src/model/codes.dart | 17 +- lib/src/model/reply.dart | 4 +- lib/src/spinify.dart | 1023 +++++++++++++++++++++++++++++++++-- test/unit/spinify_test.dart | 4 + 4 files changed, 1012 insertions(+), 36 deletions(-) diff --git a/lib/src/model/codes.dart b/lib/src/model/codes.dart index 0fe3616..cb8c773 100644 --- a/lib/src/model/codes.dart +++ b/lib/src/model/codes.dart @@ -26,12 +26,18 @@ import 'package:meta/meta.dart'; /// Library users supposed to use codes in range 4000..4999 for creating custom /// disconnects. extension type const SpinifyDisconnectCode(int code) implements int { - // --- 0..2999 Internal client-side and transport specific codes --- // + // --- 0..99 Internal client-side and transport specific codes --- // /// Disconnect called explicitly by the client. @literal const SpinifyDisconnectCode.disconnect() : code = 0; + /// Disconnect due to omitted ping from the server. + @literal + const SpinifyDisconnectCode.noPingFromServer() : code = 99; + + // --- 0..99 Internal server-side transport specific codes --- // + /// Error Internal means server error, /// if returned this is a signal that something went wrong with the server /// itself and client is most probably not guilty. @@ -100,6 +106,8 @@ extension type const SpinifyDisconnectCode(int code) implements int { @literal const SpinifyDisconnectCode.unrecoverablePosition() : code = 112; + // --- Web socket closures --- // + /// Normal closure. @literal const SpinifyDisconnectCode.normalClosure() : code = 1000; @@ -128,6 +136,13 @@ extension type const SpinifyDisconnectCode(int code) implements int { reconnect: true, ), + /// Disconnect due to omitted ping from the server. + 99 => ( + code: const SpinifyDisconnectCode(99), + reason: reason ?? 'no ping from server', + reconnect: true, + ), + /// Internal server error means server error, /// if returned this is a signal that something went wrong with /// the server itself and client is most probably not guilty. diff --git a/lib/src/model/reply.dart b/lib/src/model/reply.dart index c696728..9880ada 100644 --- a/lib/src/model/reply.dart +++ b/lib/src/model/reply.dart @@ -57,8 +57,8 @@ sealed class SpinifyReply implements Comparable { } /// Reply result of a command. -base mixin SpinifyReplyResult on SpinifyReply { - @override +base mixin SpinifyReplyResult { + /// This is a result of a command. bool get isResult => true; } diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 8b14fb7..672b68b 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -1,6 +1,9 @@ import 'dart:async'; import 'dart:collection'; +import 'package:fixnum/fixnum.dart' as fixnum; +import 'package:meta/meta.dart'; + import 'model/annotations.dart'; import 'model/channel_event.dart'; import 'model/channel_events.dart'; @@ -19,6 +22,8 @@ import 'model/state.dart'; import 'model/states_stream.dart'; import 'model/stream_position.dart'; import 'model/subscription_config.dart'; +import 'model/subscription_state.dart'; +import 'model/subscription_states.dart'; import 'model/transport_interface.dart'; import 'protobuf/protobuf_codec.dart'; import 'spinify_interface.dart'; @@ -115,14 +120,15 @@ final class Spinify implements ISpinify { Timer? _refreshTimer; Timer? _reconnectTimer; Timer? _healthTimer; + Timer? _pingTimer; /// Registry of client subscriptions. - final Map _clientSubscriptionRegistry = - {}; + final Map + _clientSubscriptionRegistry = {}; /// Registry of server subscriptions. - final Map _serverSubscriptionRegistry = - {}; + final Map + _serverSubscriptionRegistry = {}; @override ({ @@ -142,6 +148,8 @@ final class Spinify implements ISpinify { /// Log an event with the given [level], [event], [message] and [context]. @safe + @protected + @nonVirtual void _log( SpinifyLogLevel level, String event, @@ -155,6 +163,8 @@ final class Spinify implements ISpinify { /// Set a new state and notify listeners via [states]. @safe + @protected + @nonVirtual void _setState(SpinifyState state) { if (isClosed) return; // Client is closed, do not notify about states. final prev = _metrics.state, next = state; @@ -188,6 +198,8 @@ final class Spinify implements ISpinify { /// Counter for command messages. @safe + @protected + @nonVirtual int _getNextCommandId() { if (_metrics.commandId == kMaxInt) _metrics.commandId = 1; return _metrics.commandId++; @@ -197,6 +209,8 @@ final class Spinify implements ISpinify { /// Initialization from constructor @safe + @protected + @nonVirtual void _init() { _setUpHealthCheckTimer(); _log( @@ -213,6 +227,8 @@ final class Spinify implements ISpinify { /// Set up health check timer. @safe + @protected + @nonVirtual void _setUpHealthCheckTimer() { _tearDownHealthCheckTimer(); @@ -273,6 +289,8 @@ final class Spinify implements ISpinify { /// Tear down health check timer. @safe + @protected + @nonVirtual void _tearDownHealthCheckTimer() { _healthTimer?.cancel(); _healthTimer = null; @@ -280,6 +298,8 @@ final class Spinify implements ISpinify { /// Set up refresh connection timer. @safe + @protected + @nonVirtual void _setUpRefreshConnection() { _tearDownRefreshConnection(); // TODO: Implement refresh connection timer. @@ -288,6 +308,8 @@ final class Spinify implements ISpinify { /// Tear down refresh connection timer. @safe + @protected + @nonVirtual void _tearDownRefreshConnection() { _refreshTimer?.cancel(); _refreshTimer = null; @@ -295,6 +317,8 @@ final class Spinify implements ISpinify { /// Set up reconnect timer. @safe + @protected + @nonVirtual void _setUpReconnectTimer() { _tearDownReconnectTimer(); final lastUrl = _metrics.reconnectUrl; @@ -306,7 +330,7 @@ final class Spinify implements ISpinify { config.connectionRetryInterval.max.inMilliseconds, ); _metrics.nextReconnectAt = DateTime.now().add(delay); - config.logger?.call( + _log( const SpinifyLogLevel.debug(), 'reconnect_delayed', 'Setting up reconnect timer to $lastUrl ' @@ -323,7 +347,7 @@ final class Spinify implements ISpinify { //_nextReconnectionAttempt = null; if (!state.isDisconnected) return; _metrics.reconnectAttempts = attempt + 1; - config.logger?.call( + _log( const SpinifyLogLevel.config(), 'reconnect_attempt', 'Reconnecting to $lastUrl after ${delay.inMilliseconds} ms.', @@ -352,11 +376,69 @@ final class Spinify implements ISpinify { /// Tear down reconnect timer. @safe + @protected + @nonVirtual void _tearDownReconnectTimer() { _reconnectTimer?.cancel(); _reconnectTimer = null; } + /// Start or restart keepalive timer, + /// you should restart it after each received ping message. + /// Or connection will be closed by timeout. + @safe + @protected + @nonVirtual + void _setUpPingTimer() { + _tearDownPingTimer(); + // coverage:ignore-start + if (isClosed || !state.isConnected) return; + // coverage:ignore-end + if (state case SpinifyState$Connected(:Duration? pingInterval) + when pingInterval != null && pingInterval > Duration.zero) { + _pingTimer = Timer( + pingInterval + config.serverPingDelay, + () async { + // Reconnect if no pong received. + if (state case SpinifyState$Connected(:String url)) { + config.logger?.call( + const SpinifyLogLevel.warning(), + 'no_pong_reconnect', + 'No pong from server - reconnecting', + { + 'url': url, + 'pingInterval': pingInterval, + 'serverPingDelay': config.serverPingDelay, + }, + ); + try { + _internalDisconnect( + code: const SpinifyDisconnectCode.noPingFromServer(), + reason: 'no ping from server', + reconnect: true, + ); + } finally { + _internalReconnect(url).ignore(); + } + } + /* disconnect( + SpinifyConnectingCode.noPing, + 'No ping from server', + ); */ + }, + ); + } + } + + /// Tear down ping timer. + @safe + @protected + @nonVirtual + void _tearDownPingTimer() { + _pingTimer?.cancel(); + _pingTimer = null; + } + // --- Ready --- // @unsafe @@ -385,6 +467,7 @@ final class Spinify implements ISpinify { /// Plan to do action when client is connected. @unsafe + @nonVirtual Future _doOnReady(Future Function() action) => switch (state) { SpinifyState$Connected _ => action(), SpinifyState$Connecting _ => ready().then((_) => action()), @@ -400,6 +483,9 @@ final class Spinify implements ISpinify { // --- Connection --- // + @unsafe + @protected + @nonVirtual Future _webSocketConnect({ required String url, Map? headers, @@ -413,6 +499,7 @@ final class Spinify implements ISpinify { @unsafe @override + @nonVirtual @Throws([SpinifyConnectionException]) Future connect(String url) async { try { @@ -432,6 +519,8 @@ final class Spinify implements ISpinify { /// User initiated connect. @unsafe + @protected + @nonVirtual Future _interactiveConnect(String url) async { if (isClosed) throw const SpinifyConnectionException( @@ -444,6 +533,8 @@ final class Spinify implements ISpinify { /// Library initiated connect. @unsafe + @protected + @nonVirtual Future _internalReconnect(String url) async { final readyCompleter = _readyCompleter = switch (_readyCompleter) { Completer value when !value.isCompleted => value, @@ -734,12 +825,16 @@ final class Spinify implements ISpinify { @safe @override + @nonVirtual Future disconnect() => _interactiveDisconnect(); /// User initiated disconnect. @safe + @protected + @nonVirtual Future _interactiveDisconnect() async { _tearDownReconnectTimer(); + _tearDownPingTimer(); _metrics.reconnectUrl = null; _internalDisconnect( code: const SpinifyDisconnectCode.normalClosure(), @@ -750,6 +845,8 @@ final class Spinify implements ISpinify { /// Library initiated disconnect. @safe + @protected + @nonVirtual void _internalDisconnect({ required int code, required String reason, @@ -830,6 +927,7 @@ final class Spinify implements ISpinify { @safe @override + @nonVirtual Future close() async { if (state.isClosed) return; try { @@ -857,6 +955,8 @@ final class Spinify implements ISpinify { // --- Send --- // @unsafe + @protected + @nonVirtual @Throws([SpinifySendException]) Future _sendCommandAsync(SpinifyCommand command) async { _log( @@ -914,6 +1014,8 @@ final class Spinify implements ISpinify { } @unsafe + @protected + @nonVirtual @Throws([SpinifySendException]) Future _sendCommand(SpinifyCommand command) async { _log( @@ -990,6 +1092,7 @@ final class Spinify implements ISpinify { @unsafe @override + @nonVirtual @Throws([SpinifySendException]) Future send(List data) async { try { @@ -1008,7 +1111,9 @@ final class Spinify implements ISpinify { // --- Remote Procedure Call --- // + @unsafe @override + @nonVirtual Future> rpc(String method, [List? data]) => _doOnReady( () => _sendCommand( SpinifyRPCRequest( @@ -1024,21 +1129,26 @@ final class Spinify implements ISpinify { @safe @override + @nonVirtual SpinifySubscription? getSubscription(String channel) => _clientSubscriptionRegistry[channel] ?? _serverSubscriptionRegistry[channel]; @safe @override + @nonVirtual SpinifyClientSubscription? getClientSubscription(String channel) => _clientSubscriptionRegistry[channel]; @safe @override + @nonVirtual SpinifyServerSubscription? getServerSubscription(String channel) => _serverSubscriptionRegistry[channel]; + @safe @override + @nonVirtual SpinifyClientSubscription newSubscription( String channel, { SpinifySubscriptionConfig? config, @@ -1054,6 +1164,7 @@ final class Spinify implements ISpinify { // --- Publish --- // + @unsafe @override Future publish(String channel, List data) { throw UnimplementedError(); @@ -1061,19 +1172,25 @@ final class Spinify implements ISpinify { // --- Presence --- // + @unsafe @override + @nonVirtual Future> presence(String channel) { throw UnimplementedError(); } + @unsafe @override + @nonVirtual Future presenceStats(String channel) { throw UnimplementedError(); } // --- History --- // + @unsafe @override + @nonVirtual Future history( String channel, { int? limit, @@ -1085,38 +1202,125 @@ final class Spinify implements ISpinify { // --- Replies --- // + @safe + @sideEffect + @nonVirtual + void _onEvent(SpinifyChannelEvent event) { + _eventController.add(event); // Add event to the broadcast stream. + _log( + const SpinifyLogLevel.debug(), + 'push_received', + 'Push ${event.type} received', + { + 'event': event, + }, + ); + switch (event) { + case SpinifyChannelEvent(channel: ''): + /* ignore push without channel */ + break; + case SpinifyDisconnect disconnect: + _internalDisconnect( + code: disconnect.code, + reason: disconnect.reason, + reconnect: disconnect.reconnect, + ); + case SpinifySubscribe _: + // Add server subscription to the registry on subscribe event. + _serverSubscriptionRegistry.putIfAbsent( + event.channel, + () => _SpinifyServerSubscriptionImpl( + client: this, + channel: event.channel, + recoverable: event.recoverable, + epoch: event.since.epoch, + offset: event.since.offset, + )) + ..onEvent(event) + .._setState(SpinifySubscriptionState.subscribed(data: event.data)); + case SpinifyUnsubscribe _: + // Remove server subscription from the registry. + _serverSubscriptionRegistry.remove(event.channel) + ?..onEvent(event) + .._setState(SpinifySubscriptionState.unsubscribed()); + // Unsubscribe client subscription on unsubscribe event. + if (_clientSubscriptionRegistry[event.channel] + case _SpinifyClientSubscriptionImpl subscription) { + subscription.onEvent(event); + if (event.code < 2500) { + // Unsubscribe client subscription on unsubscribe event. + subscription + ._unsubscribe( + code: event.code, + reason: event.reason, + sendUnsubscribe: false, + ) + .ignore(); + } else { + // Resubscribe client subscription on unsubscribe event. + subscription._resubscribe().ignore(); + } + } + default: + // Notify subscription about new event. + final sub = _serverSubscriptionRegistry[event.channel] ?? + _clientSubscriptionRegistry[event.channel]; + if (sub != null) { + sub.onEvent(event); + if (event is SpinifyPublication && sub.recoverable) { + // Update subscription offset on publication. + if (event.offset case fixnum.Int64 newOffset when newOffset > 0) + sub.offset = newOffset; + } + } else { + _log( + const SpinifyLogLevel.warning(), + 'subscription_not_found_error', + 'Subscription ${event.channel} not found for event', + { + 'channel': event.channel, + 'event': event, + }, + ); + } + } + } + /// Called when [SpinifyReply] received from the server. @safe @sideEffect + @nonVirtual void _onReply(SpinifyReply reply) { try { // coverage:ignore-start if (reply.id < 0 || reply.id > _metrics.commandId) { - assert( - reply.id >= 0 && reply.id <= _metrics.commandId, - 'Reply ID should be greater or equal to 0 ' - 'and less or equal than command ID'); + _log( + const SpinifyLogLevel.warning(), + 'reply_id_error', + 'Reply ID out of range', + { + 'reply': reply, + }, + ); return; } // coverage:ignore-end + + // If reply is a result then find pending reply and complete it. if (reply.isResult) { - // If reply is a result then find pending reply and complete it. if (reply.id case int id when id > 0) { final completer = _replies.remove(id); - // coverage:ignore-start if (completer == null || completer.isCompleted) { - assert( - completer != null, - 'Reply completer not found', - ); - assert( - completer?.isCompleted == false, - 'Reply completer already completed', + _log( + const SpinifyLogLevel.warning(), + 'reply_completer_error', + 'Reply completer not found or already completed', + { + 'reply': reply, + }, ); return; - } - // coverage:ignore-end - if (reply is SpinifyErrorResult) { + } else if (reply is SpinifyErrorResult) { completer.completeError( SpinifyReplyException( replyCode: reply.code, @@ -1129,17 +1333,113 @@ final class Spinify implements ISpinify { completer.complete(reply); } } - } else if (reply is SpinifyPush) { - switch (reply.event) { - case SpinifyDisconnect disconnect: - _internalDisconnect( - code: disconnect.code, - reason: disconnect.reason, - reconnect: disconnect.reconnect, - ); - default: - // TODO: Handle other push events. - } + } + + // Handle different types of replies. + switch (reply) { + case SpinifyPush push: + _onEvent(push.event); + case SpinifyServerPing _: + final command = SpinifyPingRequest(timestamp: DateTime.now()); + _metrics + ..lastPingAt = command.timestamp + ..receivedPings = _metrics.receivedPings + 1; + if (state case SpinifyState$Connected(:bool sendPong) when sendPong) { + // No need to handle error in a special way - + // if pong can't be sent but connection is closed anyway. + _sendCommandAsync(command).ignore(); + } + _log( + const SpinifyLogLevel.debug(), + 'server_ping_received', + 'Ping from server received, pong sent', + { + 'ping': reply, + 'pong': command, + }, + ); + _setUpPingTimer(); + case SpinifyConnectResult _: + // Update server subscriptions. + final newServerSubs = + reply.subs ?? {}; + for (final entry in newServerSubs.entries) { + final MapEntry( + key: channel, + value: value + ) = entry; + final sub = _serverSubscriptionRegistry.putIfAbsent( + channel, + () => _SpinifyServerSubscriptionImpl( + client: this, + channel: channel, + recoverable: value.recoverable, + epoch: value.since.epoch, + offset: value.since.offset, + )) + .._setState( + SpinifySubscriptionState.subscribed(data: value.data)); + + // Notify about new publications. + for (var publication in value.publications) { + // If publication has wrong channel, fix it. + // Thats a workaround because we do not have channel + // in the publication in this server SpinifyConnectResult reply. + if (publication.channel != channel) { + // coverage:ignore-start + assert( + publication.channel.isEmpty, + 'Publication contains wrong channel', + ); + // coverage:ignore-end + publication = publication.copyWith(channel: channel); + } + _eventController.add(publication); + sub.onEvent(publication); + // Update subscription offset on publication. + if (sub.recoverable) { + if (publication.offset case fixnum.Int64 newOffset + when newOffset > sub.offset) { + sub.offset = newOffset; + } + } + } + } + + // Remove server subscriptions that are not in the new list. + final currentServerSubs = _serverSubscriptionRegistry.keys.toSet(); + for (final key in currentServerSubs) { + if (newServerSubs.containsKey(key)) continue; + _serverSubscriptionRegistry.remove(key) + ?.._setState(SpinifySubscriptionState.unsubscribed()) + ..close(); + } + + // We should resubscribe client subscriptions here. + for (final subscription in _clientSubscriptionRegistry.values) + subscription._resubscribe().ignore(); + case SpinifyErrorResult _: + break; + case SpinifySubscribeResult _: + break; + case SpinifyUnsubscribeResult _: + break; + case SpinifyPublishResult _: + break; + case SpinifyPresenceResult _: + break; + case SpinifyPresenceStatsResult _: + break; + case SpinifyHistoryResult _: + break; + case SpinifyPingResult _: + break; + case SpinifyRPCResult _: + break; + case SpinifyRefreshResult _: + break; + case SpinifySubRefreshResult _: + break; } _log( @@ -1181,3 +1481,660 @@ class _PendingReply { void completeError(SpinifyReplyException error, StackTrace stackTrace) => _completer.completeError(error, stackTrace); } + +abstract base class _SpinifySubscriptionBase implements SpinifySubscription { + _SpinifySubscriptionBase({ + required Spinify client, + required this.channel, + required this.recoverable, + required this.epoch, + required this.offset, + }) : _clientWR = WeakReference(client), + _clientConfig = client.config { + _metrics = _client._metrics.channels + .putIfAbsent(channel, SpinifyMetrics$Channel$Mutable.new); + } + + @override + final String channel; + + /// Spinify client weak reference. + final WeakReference _clientWR; + + /// Spinify client + Spinify get _client { + final target = _clientWR.target; + // coverage:ignore-start + if (target == null) { + throw SpinifySubscriptionException( + channel: channel, + message: 'Spinify client is do not exist anymore', + ); + } + // coverage:ignore-end + return target; + } + + /// Spinify channel metrics. + late final SpinifyMetrics$Channel$Mutable _metrics; + + /// Spinify client configuration. + final SpinifyConfig _clientConfig; + + /// Spinify logger. + SpinifyLogger? get _logger => _clientConfig.logger; + + final StreamController _stateController = + StreamController.broadcast(); + + final StreamController _eventController = + StreamController.broadcast(); + + Future _sendCommand( + SpinifyCommand Function(int nextId) builder, + ) => + _client._doOnReady( + () => _client._sendCommand( + builder(_client._getNextCommandId()), + ), + ); + + @override + bool recoverable; + + @override + String epoch; + + @override + fixnum.Int64 offset; + + @override + SpinifySubscriptionState get state => _metrics.state; + + @override + SpinifySubscriptionStates get states => + SpinifySubscriptionStates(_stateController.stream); + + @override + SpinifyChannelEvents get stream => + SpinifyChannelEvents(_eventController.stream); + + @sideEffect + @mustCallSuper + void onEvent(SpinifyChannelEvent event) { + // coverage:ignore-start + assert( + event.channel == channel, + 'Subscription "$channel" received event for another channel', + ); + // coverage:ignore-end + _eventController.add(event); + _logger?.call( + const SpinifyLogLevel.debug(), + 'subscription_event_received', + 'Subscription "$channel" received ${event.type} event', + { + 'channel': channel, + 'subscription': this, + 'event': event, + if (event is SpinifyPublication) 'publication': event, + }, + ); + } + + @mustCallSuper + void _setState(SpinifySubscriptionState state) { + final previous = _metrics.state; + if (previous == state) return; + _stateController.add(_metrics.state = state); + _logger?.call( + const SpinifyLogLevel.config(), + 'subscription_state_changed', + 'Subscription "$channel" state changed to ${state.type}', + { + 'channel': channel, + 'subscription': this, + 'previous': previous, + 'state': state, + }, + ); + } + + @mustCallSuper + @interactive + void close() { + _stateController.close().ignore(); + _eventController.close().ignore(); + // coverage:ignore-start + assert(state.isUnsubscribed, + 'Subscription "$channel" is not unsubscribed before closing'); + // coverage:ignore-end + } + + @override + @interactive + Future ready() async { + if (_client.isClosed) + throw SpinifySubscriptionException( + channel: channel, + message: 'Client is closed', + ); + if (_metrics.state.isSubscribed) return; + if (_stateController.isClosed) + throw SpinifySubscriptionException( + channel: channel, + message: 'Subscription is closed permanently', + ); + final state = await _stateController.stream + .firstWhere((state) => !state.isSubscribing); + if (!state.isSubscribed) + throw SpinifySubscriptionException( + channel: channel, + message: 'Subscription failed to subscribe', + ); + } + + @override + @interactive + Future history({ + int? limit, + SpinifyStreamPosition? since, + bool? reverse, + }) => + _sendCommand( + (id) => SpinifyHistoryRequest( + id: id, + channel: channel, + timestamp: DateTime.now(), + limit: limit, + since: since, + reverse: reverse, + ), + ).then( + (reply) => SpinifyHistory( + publications: List.unmodifiable( + reply.publications.map((pub) => pub.copyWith(channel: channel))), + since: reply.since, + ), + ); + + @override + @interactive + Future> presence() => + _sendCommand( + (id) => SpinifyPresenceRequest( + id: id, + channel: channel, + timestamp: DateTime.now(), + ), + ).then>((reply) => reply.presence); + + @override + @interactive + Future presenceStats() => + _sendCommand( + (id) => SpinifyPresenceStatsRequest( + id: id, + channel: channel, + timestamp: DateTime.now(), + ), + ).then( + (reply) => SpinifyPresenceStats( + channel: channel, + clients: reply.numClients, + users: reply.numUsers, + ), + ); + + @override + @interactive + Future publish(List data) => _sendCommand( + (id) => SpinifyPublishRequest( + id: id, + channel: channel, + timestamp: DateTime.now(), + data: data, + ), + ); +} + +final class _SpinifyServerSubscriptionImpl extends _SpinifySubscriptionBase + implements SpinifyServerSubscription { + _SpinifyServerSubscriptionImpl({ + required super.client, + required super.channel, + required super.recoverable, + required super.epoch, + required super.offset, + }); + + @override + SpinifyChannelEvents get stream => + _client.stream.filter(channel: channel); +} + +final class _SpinifyClientSubscriptionImpl extends _SpinifySubscriptionBase + implements SpinifyClientSubscription { + _SpinifyClientSubscriptionImpl({ + required super.client, + required super.channel, + required this.config, + }) : super( + recoverable: config.recoverable, + epoch: config.since?.epoch ?? '', + offset: config.since?.offset ?? fixnum.Int64.ZERO, + ); + + @override + final SpinifySubscriptionConfig config; + + /// Whether the subscription should recover. + bool _recover = false; + + /// Interactively subscribes to the channel. + @override + @interactive + Future subscribe() async { + // Check if the client is connected + switch (_client.state) { + case SpinifyState$Connected _: + break; + case SpinifyState$Connecting _: + case SpinifyState$Disconnected _: + await _client.ready(); + case SpinifyState$Closed _: + throw SpinifySubscriptionException( + channel: channel, + message: 'Client is closed', + ); + } + + // Check if the subscription is already subscribed + switch (state) { + case SpinifySubscriptionState$Subscribed _: + return; + case SpinifySubscriptionState$Subscribing _: + await ready(); + case SpinifySubscriptionState$Unsubscribed _: + await _resubscribe(); + } + } + + /// Interactively unsubscribes from the channel. + @override + @interactive + Future unsubscribe([ + int code = 0, + String reason = 'unsubscribe called', + ]) => + _unsubscribe( + code: code, + reason: reason, + sendUnsubscribe: true, + ); + + /// Unsubscribes from the channel. + Future _unsubscribe({ + required int code, + required String reason, + required bool sendUnsubscribe, + }) async { + final currentState = _metrics.state; + _tearDownResubscribeTimer(); + _tearDownRefreshSubscriptionTimer(); + if (currentState.isUnsubscribed) return; + _setState(SpinifySubscriptionState$Unsubscribed()); + _metrics.lastUnsubscribeAt = DateTime.now(); + _metrics.unsubscribes++; + try { + if (sendUnsubscribe && + currentState.isSubscribed && + _client.state.isConnected) { + await _sendCommand( + (id) => SpinifyUnsubscribeRequest( + id: id, + channel: channel, + timestamp: DateTime.now(), + ), + ); + } + } on Object catch (error, stackTrace) { + _logger?.call( + const SpinifyLogLevel.error(), + 'subscription_unsubscribe_error', + 'Subscription "$channel" failed to unsubscribe', + { + 'channel': channel, + 'subscription': this, + 'error': error, + 'stackTrace': stackTrace, + }, + ); + _client._transport?.close(4, 'unsubscribe error'); + if (error is SpinifyException) rethrow; + Error.throwWithStackTrace( + SpinifySubscriptionException( + channel: channel, + message: 'Error while unsubscribing', + error: error, + ), + stackTrace, + ); + } + } + + /// `SubscriptionImpl{}._resubscribe()` from `centrifuge` package + Future _resubscribe() async { + if (!_metrics.state.isUnsubscribed) return; + try { + _setState(SpinifySubscriptionState$Subscribing()); + + final token = await config.getToken?.call(); + // Token can be null if it is not required for subscription. + if (token != null && token.length <= 5) { + throw SpinifySubscriptionException( + channel: channel, + message: 'Subscription token is empty', + ); + } + + final data = await config.getPayload?.call(); + + final recover = + _recover && offset > fixnum.Int64.ZERO && epoch.isNotEmpty; + + final result = await _sendCommand( + (id) => SpinifySubscribeRequest( + id: id, + channel: channel, + timestamp: DateTime.now(), + token: token, + recoverable: recoverable, + recover: recover, + offset: recover ? offset : null, + epoch: recover ? epoch : null, + positioned: config.positioned, + joinLeave: config.joinLeave, + data: data, + ), + ); + + if (state.isUnsubscribed) { + _logger?.call( + const SpinifyLogLevel.debug(), + 'subscription_resubscribe_skipped', + 'Subscription "$channel" resubscribe skipped, ' + 'subscription is unsubscribed.', + { + 'channel': channel, + 'subscription': this, + }, + ); + await _unsubscribe( + code: 0, + reason: 'resubscribe skipped', + sendUnsubscribe: false, + ); + } + + // If subscription is recoverable and server sends recoverable flag + // then we should update epoch and offset values. + if (result.recoverable) { + _recover = true; + epoch = result.since.epoch; + offset = result.since.offset; + } + + _setState(SpinifySubscriptionState$Subscribed(data: result.data)); + + // Set up refresh subscription timer if needed. + if (result.expires) { + if (result.ttl case DateTime ttl when ttl.isAfter(DateTime.now())) { + _setUpRefreshSubscriptionTimer(ttl: ttl); + } else { + // coverage:ignore-start + assert( + false, + 'Subscription "$channel" has invalid TTL: ${result.ttl}', + ); + // coverage:ignore-end + } + } + + // Handle received publications and update offset. + for (final pub in result.publications) { + _client._eventController.add(pub); + onEvent(pub); + if (pub.offset case fixnum.Int64 value when value > offset) { + offset = value; + } + } + + _onSubscribed(); // Successful subscription completed + + _logger?.call( + const SpinifyLogLevel.config(), + 'subscription_subscribed', + 'Subscription "$channel" subscribed', + { + 'channel': channel, + 'subscription': this, + }, + ); + } on Object catch (error, stackTrace) { + _logger?.call( + const SpinifyLogLevel.error(), + 'subscription_resubscribe_error', + 'Subscription "$channel" failed to resubscribe', + { + 'channel': channel, + 'subscription': this, + 'error': error, + 'stackTrace': stackTrace, + }, + ); + switch (error) { + case SpinifyErrorResult result: + if (result.code == 109) { + _setUpResubscribeTimer(); // Token expired error, retry resubscribe + } else if (result.temporary) { + _setUpResubscribeTimer(); // Temporary error, retry resubscribe + } else { + // Disable resubscribe timer and unsubscribe + _unsubscribe( + code: result.code, + reason: result.message, + sendUnsubscribe: false, + ).ignore(); + } + case SpinifySubscriptionException _: + _setUpResubscribeTimer(); // Some spinify exception, retry resubscribe + rethrow; + default: + _setUpResubscribeTimer(); // Unknown error, retry resubscribe + } + Error.throwWithStackTrace( + SpinifySubscriptionException( + channel: channel, + message: 'Failed to resubscribe to "$channel"', + error: error, + ), + stackTrace, + ); + } + } + + /// Successful subscription completed. + void _onSubscribed() { + _tearDownResubscribeTimer(); + _metrics.lastSubscribeAt = DateTime.now(); + _metrics.subscribes++; + } + + /// Resubscribe timer. + Timer? _resubscribeTimer; + + /// Set up resubscribe timer. + void _setUpResubscribeTimer() { + _resubscribeTimer?.cancel(); + final attempt = _metrics.resubscribeAttempts ?? 0; + final delay = Backoff.nextDelay( + attempt, + _client.config.connectionRetryInterval.min.inMilliseconds, + _client.config.connectionRetryInterval.max.inMilliseconds, + ); + _metrics.resubscribeAttempts = attempt + 1; + if (delay <= Duration.zero) { + if (!state.isUnsubscribed) return; + _logger?.call( + const SpinifyLogLevel.config(), + 'subscription_resubscribe_attempt', + 'Resubscibing to $channel immediately.', + { + 'channel': channel, + 'delay': delay, + 'subscription': this, + 'attempts': attempt, + }, + ); + Future.sync(subscribe).ignore(); + return; + } + _logger?.call( + const SpinifyLogLevel.debug(), + 'subscription_resubscribe_delayed', + 'Setting up resubscribe timer for $channel ' + 'after ${delay.inMilliseconds} ms.', + { + 'channel': channel, + 'delay': delay, + 'subscription': this, + 'attempts': attempt, + }, + ); + _metrics.nextResubscribeAt = DateTime.now().add(delay); + _resubscribeTimer = Timer(delay, () { + if (!state.isUnsubscribed) return; + _logger?.call( + const SpinifyLogLevel.debug(), + 'subscription_resubscribe_attempt', + 'Resubscribing to $channel after ${delay.inMilliseconds} ms.', + { + 'channel': channel, + 'subscription': this, + 'attempts': attempt, + }, + ); + Future.sync(_resubscribe).ignore(); + }); + } + + /// Tear down resubscribe timer. + void _tearDownResubscribeTimer() { + _metrics + ..resubscribeAttempts = 0 + ..nextResubscribeAt = null; + _resubscribeTimer?.cancel(); + _resubscribeTimer = null; + } + + /// Refresh subscription timer. + Timer? _refreshTimer; + + /// Set up refresh subscription timer. + void _setUpRefreshSubscriptionTimer({required DateTime ttl}) { + _tearDownRefreshSubscriptionTimer(); + _metrics.ttl = ttl; + _refreshTimer = Timer(ttl.difference(DateTime.now()), _refreshToken); + } + + /// Tear down refresh subscription timer. + void _tearDownRefreshSubscriptionTimer() { + _refreshTimer?.cancel(); + _refreshTimer = null; + _metrics.ttl = null; + } + + /// Refresh subscription token. + void _refreshToken() => runZonedGuarded( + () async { + _tearDownRefreshSubscriptionTimer(); + if (!state.isSubscribed || !_client.state.isConnected) return; + final token = await config.getToken?.call(); + if (token == null || token.isEmpty) { + throw SpinifySubscriptionException( + channel: channel, + message: 'Token is empty', + ); + } + final result = await _sendCommand( + (id) => SpinifySubRefreshRequest( + id: id, + channel: channel, + timestamp: DateTime.now(), + token: token, + ), + ); + + DateTime? newTtl; + if (result.expires) { + if (result.ttl case DateTime ttl when ttl.isAfter(DateTime.now())) { + newTtl = ttl; + _setUpRefreshSubscriptionTimer(ttl: ttl); + } else { + // coverage:ignore-start + assert( + false, + 'Subscription "$channel" has invalid TTL: ${result.ttl}', + ); + // coverage:ignore-end + } + } + + _logger?.call( + const SpinifyLogLevel.debug(), + 'subscription_refresh_token', + 'Subscription "$channel" token refreshed', + { + 'channel': channel, + 'subscription': this, + if (newTtl != null) 'ttl': newTtl, + }, + ); + }, + (error, stackTrace) { + _logger?.call( + const SpinifyLogLevel.error(), + 'subscription_refresh_token_error', + 'Subscription "$channel" failed to refresh token', + { + 'channel': channel, + 'subscription': this, + 'error': error, + 'stackTrace': stackTrace, + }, + ); + + // Calculate new TTL for refresh subscription timer + late final ttl = + DateTime.now().add(Backoff.nextDelay(0, 5 * 1000, 10 * 1000)); + switch (error) { + case SpinifyErrorResult result: + if (result.temporary) { + _setUpRefreshSubscriptionTimer(ttl: ttl); + } else { + // Disable refresh subscription timer and unsubscribe + _unsubscribe( + code: result.code, + reason: result.message, + sendUnsubscribe: true, + ).ignore(); + } + case SpinifySubscriptionException _: + _setUpRefreshSubscriptionTimer(ttl: ttl); + default: + _setUpRefreshSubscriptionTimer(ttl: ttl); + } + }, + ); +} diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index d3e70dc..5f7bd78 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -343,6 +343,10 @@ void main() { expect(client.subscriptions, hasLength(2)); expect(client.getServerSubscription('public:chat'), isNotNull); expect(client.getServerSubscription('personal:user#42'), isNotNull); + expect(client.getSubscription('public:chat'), isNotNull); + expect(client.getSubscription('personal:user#42'), isNotNull); + expect(client.getServerSubscription('unknown'), isNull); + expect(client.getSubscription('unknown'), isNull); client.close(); }, ), From 12ae7d5980750f214c400412160d435d38ce32c1 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 26 Oct 2024 18:27:44 +0400 Subject: [PATCH 036/104] Refactor Spinify connect method error handling --- lib/src/spinify.dart | 9 +++++++-- test/unit/spinify_test.dart | 8 ++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 672b68b..c8b544a 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -747,6 +747,7 @@ final class Spinify implements ISpinify { data: result.data, )); + _onReply(result); // Handle connect reply handleReply = _onReply; // Switch to normal reply handler _setUpRefreshConnection(); @@ -1310,11 +1311,15 @@ final class Spinify implements ISpinify { if (reply.isResult) { if (reply.id case int id when id > 0) { final completer = _replies.remove(id); - if (completer == null || completer.isCompleted) { + if (completer == null) { + // Thats okay, we can send some commands asynchronously + // and do not wait for reply. + // E.g. connection command or ping command. + } else if (completer.isCompleted) { _log( const SpinifyLogLevel.warning(), 'reply_completer_error', - 'Reply completer not found or already completed', + 'Reply completer already completed', { 'reply': reply, }, diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index 5f7bd78..b48ee66 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -297,7 +297,7 @@ void main() { () => fakeAsync( (async) { final ws = WebSocket$Fake(); // ignore: close_sinks - final client = createFakeClient((_) async => ws..reset()); + final client = createFakeClient((_) async => ws); ws.onAdd = (bytes, sink) { final command = ProtobufCodec.decode(pb.Command(), bytes); @@ -340,7 +340,7 @@ void main() { client.connect(url); async.elapse(client.config.timeout); expect(client.state, isA()); - expect(client.subscriptions, hasLength(2)); + expect(client.subscriptions.server, hasLength(2)); expect(client.getServerSubscription('public:chat'), isNotNull); expect(client.getServerSubscription('personal:user#42'), isNotNull); expect(client.getSubscription('public:chat'), isNotNull); @@ -428,7 +428,7 @@ void main() { greaterThan(Int64.ZERO), ), ])); - client + /* client ..newSubscription('channel') ..close(); async.elapse(client.config.timeout); @@ -464,7 +464,7 @@ void main() { expect( client.metrics.channels['channel'], isA().having((c) => c.toString(), - 'subscriptions', equals(r'SpinifyMetrics$Channel{}'))); + 'subscriptions', equals(r'SpinifyMetrics$Channel{}'))); */ })); }); } From ef7bad193c70a433b566868e4a048739f79692db Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 26 Oct 2024 18:43:16 +0400 Subject: [PATCH 037/104] Refactor Makefile to generate coverage report for unit tests only --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index efda04c..297491f 100644 --- a/Makefile +++ b/Makefile @@ -79,7 +79,7 @@ coverage: get ## Generate the coverage report --platform vm --compiler=kernel --coverage=coverage \ --reporter=expanded --file-reporter=json:coverage/tests.json \ --timeout=10m --concurrency=12 --color \ - test/unit_test.dart test/smoke_test.dart + test/unit_test.dart # @dart test --concurrency=6 --platform vm --coverage=coverage test/ # @dart run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib @mv coverage/lcov.info coverage/lcov.base.info From b31c9b27471ef7ac357ea2b007d20a7df70f2caf Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 30 Oct 2024 10:05:40 +0400 Subject: [PATCH 038/104] Refactor Spinify connect method to enforce channel validation --- lib/src/spinify.dart | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index c8b544a..0ea8ca1 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -1155,6 +1155,26 @@ final class Spinify implements ISpinify { SpinifySubscriptionConfig? config, bool subscribe = false, }) { + assert( + channel.isNotEmpty, + 'Channel should not be empty', + ); + assert( + !_clientSubscriptionRegistry.containsKey(channel), + 'Client subscription already exists', + ); + assert( + channel.trim() == channel, + 'Channel should not have leading or trailing spaces', + ); + assert( + channel.length <= 255, + 'Channel should not be longer than 255 characters', + ); + assert( + channel.codeUnits.every((code) => code >= 0 || code <= 0x7f), + 'Channel should contain only ASCII characters', + ); throw UnimplementedError(); } From 4b02d5552424227138b0575a1d6f2cac1308e185 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 30 Oct 2024 10:12:35 +0400 Subject: [PATCH 039/104] Refactor Spinify presence and presenceStats methods to handle subscription not found --- lib/src/spinify.dart | 90 +++++++++++++++++++++++--------------------- 1 file changed, 47 insertions(+), 43 deletions(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 0ea8ca1..fb1074a 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -1196,30 +1196,55 @@ final class Spinify implements ISpinify { @unsafe @override @nonVirtual - Future> presence(String channel) { - throw UnimplementedError(); - } + @Throws([SpinifySubscriptionException]) + Future> presence(String channel) => + getSubscription(channel)?.presence() ?? + Future.error( + SpinifySubscriptionException( + channel: channel, + message: 'Subscription not found', + ), + StackTrace.current, + ); @unsafe @override @nonVirtual - Future presenceStats(String channel) { - throw UnimplementedError(); - } + @Throws([SpinifySubscriptionException]) + Future presenceStats(String channel) => + getSubscription(channel)?.presenceStats() ?? + Future.error( + SpinifySubscriptionException( + channel: channel, + message: 'Subscription not found', + ), + StackTrace.current, + ); // --- History --- // @unsafe @override @nonVirtual + @Throws([SpinifySubscriptionException]) Future history( String channel, { int? limit, SpinifyStreamPosition? since, bool? reverse, - }) { - throw UnimplementedError(); - } + }) => + getSubscription(channel)?.history( + limit: limit, + since: since, + reverse: reverse, + ) ?? + Future.error( + SpinifySubscriptionException( + channel: channel, + message: 'Subscription not found', + ), + StackTrace.current, + ); // --- Replies --- // @@ -1514,8 +1539,7 @@ abstract base class _SpinifySubscriptionBase implements SpinifySubscription { required this.recoverable, required this.epoch, required this.offset, - }) : _clientWR = WeakReference(client), - _clientConfig = client.config { + }) : _client = client { _metrics = _client._metrics.channels .putIfAbsent(channel, SpinifyMetrics$Channel$Mutable.new); } @@ -1523,32 +1547,12 @@ abstract base class _SpinifySubscriptionBase implements SpinifySubscription { @override final String channel; - /// Spinify client weak reference. - final WeakReference _clientWR; - /// Spinify client - Spinify get _client { - final target = _clientWR.target; - // coverage:ignore-start - if (target == null) { - throw SpinifySubscriptionException( - channel: channel, - message: 'Spinify client is do not exist anymore', - ); - } - // coverage:ignore-end - return target; - } + final Spinify _client; /// Spinify channel metrics. late final SpinifyMetrics$Channel$Mutable _metrics; - /// Spinify client configuration. - final SpinifyConfig _clientConfig; - - /// Spinify logger. - SpinifyLogger? get _logger => _clientConfig.logger; - final StreamController _stateController = StreamController.broadcast(); @@ -1594,7 +1598,7 @@ abstract base class _SpinifySubscriptionBase implements SpinifySubscription { ); // coverage:ignore-end _eventController.add(event); - _logger?.call( + _client._log( const SpinifyLogLevel.debug(), 'subscription_event_received', 'Subscription "$channel" received ${event.type} event', @@ -1612,7 +1616,7 @@ abstract base class _SpinifySubscriptionBase implements SpinifySubscription { final previous = _metrics.state; if (previous == state) return; _stateController.add(_metrics.state = state); - _logger?.call( + _client._log( const SpinifyLogLevel.config(), 'subscription_state_changed', 'Subscription "$channel" state changed to ${state.type}', @@ -1824,7 +1828,7 @@ final class _SpinifyClientSubscriptionImpl extends _SpinifySubscriptionBase ); } } on Object catch (error, stackTrace) { - _logger?.call( + _client._log( const SpinifyLogLevel.error(), 'subscription_unsubscribe_error', 'Subscription "$channel" failed to unsubscribe', @@ -1885,7 +1889,7 @@ final class _SpinifyClientSubscriptionImpl extends _SpinifySubscriptionBase ); if (state.isUnsubscribed) { - _logger?.call( + _client._log( const SpinifyLogLevel.debug(), 'subscription_resubscribe_skipped', 'Subscription "$channel" resubscribe skipped, ' @@ -1937,7 +1941,7 @@ final class _SpinifyClientSubscriptionImpl extends _SpinifySubscriptionBase _onSubscribed(); // Successful subscription completed - _logger?.call( + _client._log( const SpinifyLogLevel.config(), 'subscription_subscribed', 'Subscription "$channel" subscribed', @@ -1947,7 +1951,7 @@ final class _SpinifyClientSubscriptionImpl extends _SpinifySubscriptionBase }, ); } on Object catch (error, stackTrace) { - _logger?.call( + _client._log( const SpinifyLogLevel.error(), 'subscription_resubscribe_error', 'Subscription "$channel" failed to resubscribe', @@ -2011,7 +2015,7 @@ final class _SpinifyClientSubscriptionImpl extends _SpinifySubscriptionBase _metrics.resubscribeAttempts = attempt + 1; if (delay <= Duration.zero) { if (!state.isUnsubscribed) return; - _logger?.call( + _client._log( const SpinifyLogLevel.config(), 'subscription_resubscribe_attempt', 'Resubscibing to $channel immediately.', @@ -2025,7 +2029,7 @@ final class _SpinifyClientSubscriptionImpl extends _SpinifySubscriptionBase Future.sync(subscribe).ignore(); return; } - _logger?.call( + _client._log( const SpinifyLogLevel.debug(), 'subscription_resubscribe_delayed', 'Setting up resubscribe timer for $channel ' @@ -2040,7 +2044,7 @@ final class _SpinifyClientSubscriptionImpl extends _SpinifySubscriptionBase _metrics.nextResubscribeAt = DateTime.now().add(delay); _resubscribeTimer = Timer(delay, () { if (!state.isUnsubscribed) return; - _logger?.call( + _client._log( const SpinifyLogLevel.debug(), 'subscription_resubscribe_attempt', 'Resubscribing to $channel after ${delay.inMilliseconds} ms.', @@ -2116,7 +2120,7 @@ final class _SpinifyClientSubscriptionImpl extends _SpinifySubscriptionBase } } - _logger?.call( + _client._log( const SpinifyLogLevel.debug(), 'subscription_refresh_token', 'Subscription "$channel" token refreshed', @@ -2128,7 +2132,7 @@ final class _SpinifyClientSubscriptionImpl extends _SpinifySubscriptionBase ); }, (error, stackTrace) { - _logger?.call( + _client._log( const SpinifyLogLevel.error(), 'subscription_refresh_token_error', 'Subscription "$channel" failed to refresh token', From c897f219bc1e92ce32d9008ea4ee229e45c9bcde Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 30 Oct 2024 10:13:53 +0400 Subject: [PATCH 040/104] Refactor Spinify.dart to use synchronous broadcasting for state and event controllers --- lib/src/spinify.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index fb1074a..1c57121 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -1554,10 +1554,10 @@ abstract base class _SpinifySubscriptionBase implements SpinifySubscription { late final SpinifyMetrics$Channel$Mutable _metrics; final StreamController _stateController = - StreamController.broadcast(); + StreamController.broadcast(sync: true); final StreamController _eventController = - StreamController.broadcast(); + StreamController.broadcast(sync: true); Future _sendCommand( SpinifyCommand Function(int nextId) builder, From ce2dbc5e32ddecf8b1a9e492ce24901429136941 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 30 Oct 2024 10:16:37 +0400 Subject: [PATCH 041/104] Refactor Spinify.dart to remove unused code and add internal annotation --- lib/src/spinify.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 1c57121..ab6f212 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -198,7 +198,6 @@ final class Spinify implements ISpinify { /// Counter for command messages. @safe - @protected @nonVirtual int _getNextCommandId() { if (_metrics.commandId == kMaxInt) _metrics.commandId = 1; @@ -1588,6 +1587,9 @@ abstract base class _SpinifySubscriptionBase implements SpinifySubscription { SpinifyChannelEvents get stream => SpinifyChannelEvents(_eventController.stream); + /// Receives notification about new event from the client. + /// Available only for internal use. + @internal @sideEffect @mustCallSuper void onEvent(SpinifyChannelEvent event) { From ddf3d4c6005d0282e39c0c3ee11758fa84bcb704 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 30 Oct 2024 10:17:46 +0400 Subject: [PATCH 042/104] Refactor codec_test.dart to remove unused import --- test/unit/codec_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/test/unit/codec_test.dart b/test/unit/codec_test.dart index 33e99b5..7c139cc 100644 --- a/test/unit/codec_test.dart +++ b/test/unit/codec_test.dart @@ -1,7 +1,6 @@ import 'package:protobuf/protobuf.dart' as pb; import 'package:spinify/spinify.dart'; import 'package:spinify/src/protobuf/client.pb.dart' as pb; -import 'package:spinify/src/protobuf/protobuf_codec.dart'; import 'package:test/test.dart'; void main() => group('Codec', () { From 7b65fc1a09bd862809dd57e8111fff401e4494bd Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 30 Oct 2024 10:18:04 +0400 Subject: [PATCH 043/104] Refactor Pubspec.dart to update timestamp --- lib/src/model/pubspec.yaml.g.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/src/model/pubspec.yaml.g.dart b/lib/src/model/pubspec.yaml.g.dart index 3fbd5f3..1e0e9f1 100644 --- a/lib/src/model/pubspec.yaml.g.dart +++ b/lib/src/model/pubspec.yaml.g.dart @@ -125,12 +125,12 @@ sealed class Pubspec { static final DateTime timestamp = DateTime.utc( 2024, 10, - 25, - 16, - 59, - 20, - 571, - 395, + 30, + 6, + 17, + 56, + 982, + 146, ); /// Name From ae865431d4e7172204ba0af391fc2c01357936d3 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 30 Oct 2024 10:33:49 +0400 Subject: [PATCH 044/104] Refactor Spinify.dart to use synchronous broadcasting for state and event controllers --- lib/src/spinify.dart | 2 +- test/unit/spinify_test.dart | 35 ++++++++++++++++++++++++++++++++++ test/unit/web_socket_fake.dart | 12 +++++------- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index ab6f212..7ebbfa4 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -400,7 +400,7 @@ final class Spinify implements ISpinify { () async { // Reconnect if no pong received. if (state case SpinifyState$Connected(:String url)) { - config.logger?.call( + _log( const SpinifyLogLevel.warning(), 'no_pong_reconnect', 'No pong from server - reconnecting', diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index b48ee66..7b378fd 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -466,5 +466,40 @@ void main() { isA().having((c) => c.toString(), 'subscriptions', equals(r'SpinifyMetrics$Channel{}'))); */ })); + + test( + 'Ping_pong', + () => fakeAsync( + (async) { + late WebSocket$Fake ws; // ignore: close_sinks + var serverPingCount = 0; + var serverPongCount = 0; + final client = createFakeClient((_) async { + ws = WebSocket$Fake(); + final fn = ws.onAdd; + ws.onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + if (command.hasPing()) { + serverPingCount++; + scheduleMicrotask(() { + sink.add(ProtobufCodec.encode(pb.Reply(id: command.id))); + serverPongCount++; + }); + } else { + fn(bytes, sink); + } + }; + return ws; + }); + unawaited(client.connect(url)); + async.elapse(client.config.timeout); + expect(client.state, isA()); + async.elapse(client.config.serverPingDelay * 10); + expect(serverPingCount, greaterThan(0)); + expect(serverPongCount, equals(serverPingCount)); + client.close(); + }, + ), + ); }); } diff --git a/test/unit/web_socket_fake.dart b/test/unit/web_socket_fake.dart index 8c7bdb0..61bfe5e 100644 --- a/test/unit/web_socket_fake.dart +++ b/test/unit/web_socket_fake.dart @@ -28,6 +28,7 @@ class WebSocket$Fake implements WebSocket { ), ); onAdd = _defaultOnAddCallback; + onDone = _defaultOnDoneCallback; } // Default callbacks to handle connects and disconnects. @@ -58,6 +59,8 @@ class WebSocket$Fake implements WebSocket { }); } + static void _defaultOnDoneCallback() {} + StreamController>? _socket; Stream>? _stream; @@ -87,7 +90,7 @@ class WebSocket$Fake implements WebSocket { void _doneHandler(EventSink> sink) { sink.close(); _isClosed = true; - _onDoneCallback?.call(); + onDone.call(); } @override @@ -111,12 +114,8 @@ class WebSocket$Fake implements WebSocket { void Function(List bytes, Sink> sink) onAdd = _defaultOnAddCallback; - void Function()? _onDoneCallback; - /// Add callback to handle socket close event. - void onDone(void Function()? callback) { - _onDoneCallback = callback; - } + void Function() onDone = _defaultOnDoneCallback; /// Send asynchroniously a reply to the client. void reply(List bytes) { @@ -136,7 +135,6 @@ class WebSocket$Fake implements WebSocket { _closeCode = null; _closeReason = null; _isClosed = false; - _onDoneCallback = null; _init(); } } From 39401347740d1c0d23340bd3a45e8e0606eebea1 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 30 Oct 2024 10:55:03 +0400 Subject: [PATCH 045/104] Check ping - pong --- test/unit/spinify_test.dart | 104 +++++++++++++++++++++++++++++++----- 1 file changed, 90 insertions(+), 14 deletions(-) diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index 7b378fd..92ac030 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -471,25 +471,46 @@ void main() { 'Ping_pong', () => fakeAsync( (async) { - late WebSocket$Fake ws; // ignore: close_sinks var serverPingCount = 0; var serverPongCount = 0; final client = createFakeClient((_) async { - ws = WebSocket$Fake(); - final fn = ws.onAdd; - ws.onAdd = (bytes, sink) { - final command = ProtobufCodec.decode(pb.Command(), bytes); - if (command.hasPing()) { - serverPingCount++; - scheduleMicrotask(() { - sink.add(ProtobufCodec.encode(pb.Reply(id: command.id))); + Timer? pingTimer; + return WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: {}, + ping: 600, + pong: true, + session: 'fake', + node: 'fake', + ), + ); + scheduleMicrotask(() { + sink.add(ProtobufCodec.encode(reply)); + pingTimer = Timer.periodic( + Duration(milliseconds: reply.connect.ping), + (_) { + serverPingCount++; + sink.add(ProtobufCodec.encode(pb.Reply())); + }, + ); + }); + } else if (command.hasPing()) { serverPongCount++; - }); - } else { - fn(bytes, sink); + } } - }; - return ws; + ..onDone = () { + pingTimer?.cancel(); + }; }); unawaited(client.connect(url)); async.elapse(client.config.timeout); @@ -501,5 +522,60 @@ void main() { }, ), ); + + test( + 'Ping_without_pong', + () => fakeAsync( + (async) { + var serverPingCount = 0, serverPongCount = 0; + final client = createFakeClient((_) async { + Timer? pingTimer; + return WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: {}, + ping: 600, + pong: false, + session: 'fake', + node: 'fake', + ), + ); + scheduleMicrotask(() { + sink.add(ProtobufCodec.encode(reply)); + pingTimer = Timer.periodic( + Duration(milliseconds: reply.connect.ping), + (_) { + serverPingCount++; + sink.add(ProtobufCodec.encode(pb.Reply())); + }, + ); + }); + } else if (command.hasPing()) { + serverPongCount++; + } + } + ..onDone = () { + pingTimer?.cancel(); + }; + }); + unawaited(client.connect(url)); + async.elapse(client.config.timeout); + expect(client.state, isA()); + async.elapse(client.config.serverPingDelay * 10); + expect(serverPingCount, greaterThan(0)); + expect(serverPongCount, isZero); + client.close(); + }, + ), + ); }); } From 1a6d3b30d9459a71326e0b5bc2176374020c218e Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 30 Oct 2024 11:10:57 +0400 Subject: [PATCH 046/104] Refactor Spinify.dart to start refresh connection timer and expect ping messages --- lib/src/spinify.dart | 3 +- test/unit/spinify_test.dart | 77 +++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 7ebbfa4..343cd37 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -749,7 +749,8 @@ final class Spinify implements ISpinify { _onReply(result); // Handle connect reply handleReply = _onReply; // Switch to normal reply handler - _setUpRefreshConnection(); + _setUpRefreshConnection(); // Start refresh connection timer + _setUpPingTimer(); // Start expecting ping messages // Notify ready. if (readyCompleter.isCompleted) { diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index 92ac030..e69308b 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -577,5 +577,82 @@ void main() { }, ), ); + + test( + 'Missing_pings', + () => fakeAsync( + (async) { + final webSockets = []; + var serverPingCount = 0, serverPongCount = 0; + final client = createFakeClient((_) async { + final ws = WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: {}, + ping: 600, + pong: true, + session: 'fake', + node: 'fake', + ), + ); + scheduleMicrotask(() { + sink.add(ProtobufCodec.encode(reply)); + }); + } else if (command.hasPing()) { + serverPongCount++; + } + } + ..onDone = () {}; + webSockets.add(ws); + return ws; + }); + expectLater( + client.states, + emitsInOrder( + [ + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + ], + ), + ); + unawaited(client.connect(url)); + async.elapse(client.config.timeout); + expect(client.state, isA()); + final pingInterval = + (client.state as SpinifyState$Connected).pingInterval!; + async.elapse( + (pingInterval + + client.config.timeout + + client.config.serverPingDelay) * + 10, + ); + expect(webSockets.length, greaterThan(1)); + expect(serverPingCount, isZero); + expect(serverPongCount, isZero); + client.close(); + async.elapse(const Duration(seconds: 1)); + expect(webSockets.every((ws) => ws.isClosed), isTrue); + }, + ), + ); }); } From 51e38d244b3254f2df36f105a2a158c0d2b2f8b9 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 30 Oct 2024 11:35:39 +0400 Subject: [PATCH 047/104] Refactor exception.dart to add SpinifyRPCException --- lib/src/model/exception.dart | 14 ++++++++++++++ lib/src/spinify.dart | 30 ++++++++++++++++++++++++------ test/unit/spinify_test.dart | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/lib/src/model/exception.dart b/lib/src/model/exception.dart index 4c79153..227981e 100644 --- a/lib/src/model/exception.dart +++ b/lib/src/model/exception.dart @@ -124,6 +124,20 @@ final class SpinifySendException extends SpinifyException { ); } +/// {@macro exception} +/// {@category Exception} +final class SpinifyRPCException extends SpinifyException { + /// {@macro exception} + const SpinifyRPCException({ + String? message, + Object? error, + }) : super( + 'spinify_rpc_exception', + message ?? 'Failed to call remote procedure', + error, + ); +} + /// {@macro exception} /// {@category Exception} final class SpinifyFetchException extends SpinifyException { diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 343cd37..ebf564f 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -1115,7 +1115,24 @@ final class Spinify implements ISpinify { @unsafe @override @nonVirtual - Future> rpc(String method, [List? data]) => _doOnReady( + @Throws([SpinifyRPCException]) + Future> rpc(String method, [List? data]) async { + try { + return await _doOnReady(() => _sendCommand( + SpinifyRPCRequest( + id: _getNextCommandId(), + timestamp: DateTime.now(), + method: method, + data: data ?? const [], + ), + )).then>((reply) => reply.data); + } on SpinifyRPCException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace(SpinifyRPCException(error: error), stackTrace); + } + } + /* => _doOnReady( () => _sendCommand( SpinifyRPCRequest( id: _getNextCommandId(), @@ -1124,7 +1141,7 @@ final class Spinify implements ISpinify { data: data ?? const [], ), ).then>((reply) => reply.data), - ); + ); */ // --- Subscriptions and Channels --- // @@ -1196,7 +1213,7 @@ final class Spinify implements ISpinify { @unsafe @override @nonVirtual - @Throws([SpinifySubscriptionException]) + @Throws([SpinifyConnectionException, SpinifySubscriptionException]) Future> presence(String channel) => getSubscription(channel)?.presence() ?? Future.error( @@ -1210,7 +1227,7 @@ final class Spinify implements ISpinify { @unsafe @override @nonVirtual - @Throws([SpinifySubscriptionException]) + @Throws([SpinifyConnectionException, SpinifySubscriptionException]) Future presenceStats(String channel) => getSubscription(channel)?.presenceStats() ?? Future.error( @@ -1226,7 +1243,7 @@ final class Spinify implements ISpinify { @unsafe @override @nonVirtual - @Throws([SpinifySubscriptionException]) + @Throws([SpinifyConnectionException, SpinifySubscriptionException]) Future history( String channel, { int? limit, @@ -1632,8 +1649,8 @@ abstract base class _SpinifySubscriptionBase implements SpinifySubscription { ); } - @mustCallSuper @interactive + @mustCallSuper void close() { _stateController.close().ignore(); _eventController.close().ignore(); @@ -1643,6 +1660,7 @@ abstract base class _SpinifySubscriptionBase implements SpinifySubscription { // coverage:ignore-end } + @unsafe @override @interactive Future ready() async { diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index e69308b..78597c3 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -654,5 +654,37 @@ void main() { }, ), ); + + test( + 'ready', + () => fakeAsync((async) { + final client = createFakeClient(); + expectLater(client.ready(), completes); + client.connect(url); + //expectLater(client.ready(), completes); + async.elapse(client.config.timeout); + expect(client.state, isA()); + expectLater(client.ready(), completes); + async.elapse(client.config.timeout); + client.close(); + }), + ); + + test('do_not_ready', () { + final client = createFakeClient(); + expectLater( + client.ready(), + throwsA(isA()), + ); + expectLater( + client.send([1, 2, 3]), + throwsA(isA()), + ); + expectLater( + client.rpc('echo', [1, 2, 3]), + throwsA(isA()), + ); + client.close(); + }); }); } From d0e79dd95879cbe5c282070e62f00e5338776a5a Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 30 Oct 2024 11:36:03 +0400 Subject: [PATCH 048/104] Refactor Spinify_test.dart to update exception types in client.send and client.rpc --- test/unit/spinify_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index 78597c3..8a01a91 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -678,11 +678,11 @@ void main() { ); expectLater( client.send([1, 2, 3]), - throwsA(isA()), + throwsA(isA()), ); expectLater( client.rpc('echo', [1, 2, 3]), - throwsA(isA()), + throwsA(isA()), ); client.close(); }); From 1ec10ac2d93f83ee19bf776f1504e6da178f9186 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 30 Oct 2024 11:47:51 +0400 Subject: [PATCH 049/104] Refactor Spinify.dart to handle subscription errors and prevent duplicate subscriptions --- lib/src/spinify.dart | 33 +++++++++++++++++++++++++++------ test/unit/spinify_test.dart | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index ebf564f..120259a 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -1176,10 +1176,6 @@ final class Spinify implements ISpinify { channel.isNotEmpty, 'Channel should not be empty', ); - assert( - !_clientSubscriptionRegistry.containsKey(channel), - 'Client subscription already exists', - ); assert( channel.trim() == channel, 'Channel should not have leading or trailing spaces', @@ -1189,10 +1185,35 @@ final class Spinify implements ISpinify { 'Channel should not be longer than 255 characters', ); assert( - channel.codeUnits.every((code) => code >= 0 || code <= 0x7f), + channel.codeUnits.every((code) => code >= 0 && code <= 0x7f), 'Channel should contain only ASCII characters', ); - throw UnimplementedError(); + + final sub = _clientSubscriptionRegistry[channel] ?? + _serverSubscriptionRegistry[channel]; + if (sub != null) { + _log( + const SpinifyLogLevel.warning(), + 'subscription_exists_error', + 'Subscription already exists', + { + 'channel': channel, + 'subscription': sub, + }, + ); + throw SpinifySubscriptionException( + channel: channel, + message: 'Subscription already exists', + ); + } + final newSub = + _clientSubscriptionRegistry[channel] = _SpinifyClientSubscriptionImpl( + client: this, + channel: channel, + config: config ?? const SpinifySubscriptionConfig.byDefault(), + ); + if (subscribe) newSub.subscribe(); + return newSub; } @override diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index 8a01a91..fe6a570 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -686,5 +686,38 @@ void main() { ); client.close(); }); + + test('subscribtion_asserts', () { + final client = createFakeClient(); + expect( + () => client.newSubscription(''), + throwsA(isA()), + ); + expect( + () => client.newSubscription(' '), + throwsA(isA()), + ); + expect( + () => client.newSubscription(String.fromCharCode(0x7f + 1)), + throwsA(isA()), + ); + expect( + () => client.newSubscription('๐Ÿ˜€, ๐ŸŒ, ๐ŸŽ‰, ๐Ÿ‘‹'), + throwsA(isA()), + ); + expect( + () => client.newSubscription('channel' * 100), + throwsA(isA()), + ); + expect( + () => client.newSubscription('channel'), + returnsNormally, + ); + expect( + () => client.newSubscription('channel'), + throwsA(isA()), + ); + client.close(); + }); }); } From 96a8eb7b09daded6c12be7a5069b65c9bc6cc970 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 30 Oct 2024 11:49:25 +0400 Subject: [PATCH 050/104] Refactor Spinify.dart to handle subscription removal and error handling --- lib/src/spinify.dart | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 120259a..7191f4c 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -1217,8 +1217,43 @@ final class Spinify implements ISpinify { } @override - Future removeSubscription(SpinifyClientSubscription subscription) { - throw UnimplementedError(); + Future removeSubscription( + SpinifyClientSubscription subscription) async { + final subFromRegistry = + _clientSubscriptionRegistry.remove(subscription.channel); + try { + await subFromRegistry?.unsubscribe(); + // coverage:ignore-start + assert( + subFromRegistry != null, + 'Subscription not found in the registry', + ); + assert( + identical(subFromRegistry, subscription), + 'Subscription should be the same instance as in the registry', + ); + // coverage:ignore-end + } on Object catch (error, stackTrace) { + _log( + const SpinifyLogLevel.warning(), + 'subscription_remove_error', + 'Error removing subscription', + { + 'channel': subscription.channel, + 'subscription': subscription, + }, + ); + Error.throwWithStackTrace( + SpinifySubscriptionException( + channel: subscription.channel, + message: 'Error while unsubscribing', + error: error, + ), + stackTrace, + ); + } finally { + subFromRegistry?.close(); + } } // --- Publish --- // From d72894a99c3e09aa7f1a6173e7f483cf4bfe8477 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 30 Oct 2024 11:49:55 +0400 Subject: [PATCH 051/104] Refactor Spinify.dart to handle subscription removal and error handling --- lib/src/spinify.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 7191f4c..0eb6dee 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -1260,9 +1260,15 @@ final class Spinify implements ISpinify { @unsafe @override - Future publish(String channel, List data) { - throw UnimplementedError(); - } + Future publish(String channel, List data) => + getSubscription(channel)?.publish(data) ?? + Future.error( + SpinifySubscriptionException( + channel: channel, + message: 'Subscription not found', + ), + StackTrace.current, + ); // --- Presence --- // From 26544f1efae020ab24d66a9f5605b2790c8b25e4 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 30 Oct 2024 13:06:17 +0400 Subject: [PATCH 052/104] Refactor SpinifyProtobufReplyDecoder to handle error messages in Spinify.dart --- lib/src/protobuf/protobuf_codec.dart | 2 + test/unit/codec_test.dart | 288 +++++++++++++++++++++++++++ 2 files changed, 290 insertions(+) diff --git a/lib/src/protobuf/protobuf_codec.dart b/lib/src/protobuf/protobuf_codec.dart index dd43c5e..9d6fe79 100644 --- a/lib/src/protobuf/protobuf_codec.dart +++ b/lib/src/protobuf/protobuf_codec.dart @@ -194,6 +194,7 @@ final class SpinifyProtobufReplyDecoder } else if (message.hasId() && message.id > 0) { yield _decodeReply(message); } else if (message.hasError()) { + // coverage:ignore-start final error = message.error; yield SpinifyErrorResult( id: message.hasId() ? message.id : 0, @@ -202,6 +203,7 @@ final class SpinifyProtobufReplyDecoder message: error.message, temporary: error.temporary, ); + // coverage:ignore-end } else { yield SpinifyServerPing( timestamp: DateTime.now(), diff --git a/test/unit/codec_test.dart b/test/unit/codec_test.dart index 7c139cc..0de55bb 100644 --- a/test/unit/codec_test.dart +++ b/test/unit/codec_test.dart @@ -30,4 +30,292 @@ void main() => group('Codec', () { expect(bytesFromCodec.length, equals(bytesFromTest.length)); expect(bytesFromCodec, equals(bytesFromTest)); }); + + test('Protobuf_commands', () { + final commands = [ + SpinifyConnectRequest( + id: 1, + timestamp: DateTime(2021, 1, 1), + token: 'token', + data: const [1, 2, 3], + name: 'name', + version: '1.2.3', + subs: { + 'channel': SpinifySubscribeRequest( + id: 2, + timestamp: DateTime(2021, 1, 1), + channel: 'channel', + data: const [4, 5, 6], + epoch: 'epoch', + joinLeave: true, + offset: Int64.ZERO, + positioned: true, + recover: true, + recoverable: true, + token: 'token', + ), + }, + ), + SpinifySubscribeRequest( + channel: 'channel', + data: const [1, 2, 3], + epoch: 'epoch', + id: 1, + joinLeave: true, + offset: Int64.ZERO, + positioned: true, + recover: true, + recoverable: true, + timestamp: DateTime(2021, 1, 1), + token: 'token', + ), + SpinifyUnsubscribeRequest( + channel: 'channel', + id: 1, + timestamp: DateTime(2021, 1, 1), + ), + SpinifyPublishRequest( + channel: 'channel', + data: const [1, 2, 3], + id: 1, + timestamp: DateTime(2021, 1, 1), + ), + SpinifyPresenceRequest( + channel: 'channel', + id: 1, + timestamp: DateTime(2021, 1, 1), + ), + SpinifyPresenceStatsRequest( + channel: 'channel', + id: 1, + timestamp: DateTime(2021, 1, 1), + ), + SpinifyHistoryRequest( + channel: 'channel', + id: 1, + limit: 1, + since: (epoch: 'epoch', offset: Int64.ZERO), + timestamp: DateTime(2021, 1, 1), + reverse: false, + ), + SpinifyPingRequest(timestamp: DateTime(2021, 1, 1)), + SpinifySendRequest( + data: const [1, 2, 3], + timestamp: DateTime(2021, 1, 1), + ), + SpinifyRPCRequest( + data: const [1, 2, 3], + id: 1, + method: 'method', + timestamp: DateTime(2021, 1, 1), + ), + SpinifyRefreshRequest( + id: 1, + timestamp: DateTime(2021, 1, 1), + token: 'token', + ), + SpinifySubRefreshRequest( + id: 1, + timestamp: DateTime(2021, 1, 1), + token: 'token', + channel: 'channel', + ), + ]; + final codec = SpinifyProtobufCodec(); + for (final command in commands) { + expect( + codec.encoder.convert(command), + allOf( + isNotEmpty, + isA>(), + ), + ); + } + }); + + test('Protobuf_replies', () { + final replies = [ + pb.Reply(), + pb.Reply() + ..id = 1 + ..error = pb.Error() + ..error.message = 'message' + ..error.code = 1 + ..error.temporary = true, + pb.Reply() + ..id = 1 + ..connect = pb.ConnectResult() + ..connect.expires = true + ..connect.ttl = 1 + ..connect.version = 'version' + ..connect.client = 'client' + ..connect.data = [1, 2, 3] + ..connect.node = 'node' + ..connect.ping = 600 + ..connect.pong = true + ..connect.session = 'session' + ..connect.subs.addAll({ + 'channel': pb.SubscribeResult() + ..expires = true + ..ttl = 1 + ..recoverable = true + ..epoch = 'epoch' + }), + pb.Reply() + ..id = 1 + ..subscribe = pb.SubscribeResult() + ..subscribe.expires = true + ..subscribe.ttl = 1 + ..subscribe.recoverable = true + ..subscribe.epoch = 'epoch' + ..subscribe.recovered = true + ..subscribe.data = [1, 2, 3] + ..subscribe.positioned = true + ..subscribe.wasRecovering = true, + pb.Reply() + ..id = 1 + ..unsubscribe = pb.UnsubscribeResult(), + pb.Reply() + ..id = 1 + ..publish = pb.PublishResult(), + pb.Reply() + ..id = 1 + ..presence = pb.PresenceResult() + ..presence.presence.addAll({ + 'client': pb.ClientInfo() + ..client = 'client' + ..user = 'user' + ..chanInfo = [1, 2, 3] + }), + pb.Reply() + ..id = 1 + ..presenceStats = pb.PresenceStatsResult() + ..presenceStats.numClients = 1 + ..presenceStats.numUsers = 1, + pb.Reply() + ..id = 1 + ..history = pb.HistoryResult() + ..history.epoch = 'epoch' + ..history.offset = Int64.ZERO + ..history.publications.addAll({ + pb.Publication() + ..data = [1, 2, 3] + ..info = pb.ClientInfo() + ..info.client = 'client' + ..info.user = 'user' + ..info.chanInfo = [1, 2, 3] + }), + pb.Reply() + ..id = 1 + ..rpc = pb.RPCResult() + ..rpc.data = [1, 2, 3], + pb.Reply() + ..id = 1 + ..refresh = pb.RefreshResult() + ..refresh.expires = true + ..refresh.ttl = 1 + ..refresh.client = 'client' + ..refresh.version = 'version', + pb.Reply() + ..id = 1 + ..subRefresh = pb.SubRefreshResult() + ..subRefresh.expires = true + ..subRefresh.ttl = 1, + pb.Reply() + ..push = pb.Push() + ..push.pub = pb.Publication() + ..push.pub.data = [1, 2, 3] + ..push.pub.offset = Int64.ZERO + ..push.pub.tags.addAll({'tag': 'tag'}) + ..push.pub.info = pb.ClientInfo() + ..push.pub.info.client = 'client' + ..push.pub.info.user = 'user' + ..push.pub.info.chanInfo = [1, 2, 3], + pb.Reply() + ..push = pb.Push() + ..push.join = pb.Join() + ..push.join.info = pb.ClientInfo() + ..push.join.info.client = 'client' + ..push.join.info.user = 'user' + ..push.join.info.chanInfo = [1, 2, 3], + pb.Reply() + ..push = pb.Push() + ..push.leave = pb.Leave() + ..push.leave.info = pb.ClientInfo(), + pb.Reply() + ..push = pb.Push() + ..push.unsubscribe = pb.Unsubscribe() + ..push.unsubscribe.code = 1 + ..push.unsubscribe.reason = 'reason', + pb.Reply() + ..push = pb.Push() + ..push.message = pb.Message() + ..push.message.data = [1, 2, 3], + pb.Reply() + ..push = pb.Push() + ..push.subscribe = pb.Subscribe() + ..push.subscribe.recoverable = true + ..push.subscribe.epoch = 'epoch' + ..push.subscribe.data = [1, 2, 3] + ..push.subscribe.positioned = true, + pb.Reply() + ..push = pb.Push() + ..push.connect = pb.Connect() + ..push.connect.expires = true + ..push.connect.ttl = 1 + ..push.connect.version = 'version' + ..push.connect.client = 'client' + ..push.connect.data = [1, 2, 3] + ..push.connect.node = 'node' + ..push.connect.ping = 600 + ..push.connect.pong = true + ..push.connect.session = 'session' + ..push.connect.subs.addAll({ + 'channel': pb.SubscribeResult() + ..expires = true + ..ttl = 1 + ..recoverable = true + ..epoch = 'epoch' + }), + pb.Reply() + ..push = pb.Push() + ..push.disconnect = pb.Disconnect() + ..push.disconnect.code = 1 + ..push.disconnect.reason = 'reason', + pb.Reply() + ..push = pb.Push() + ..push.refresh = pb.Refresh() + ..push.refresh.expires = true + ..push.refresh.ttl = 1, + ]; + final codec = SpinifyProtobufCodec(); + for (final reply in replies) { + final replyData = reply.writeToBuffer(); + final writer = pb.CodedBufferWriter() + ..writeInt32NoTag(replyData.lengthInBytes) + ..writeRawBytes(replyData); + final bytes = writer.toBuffer(); + expect( + codec.decoder.convert(bytes).single, + isA(), + ); + } + }); + + test('Unknown_replies', () { + final codec = SpinifyProtobufCodec(); + expect( + codec.decoder.convert([]), + isEmpty, + ); + final replyData = (pb.Reply()..push = pb.Push()).writeToBuffer(); + final writer = pb.CodedBufferWriter() + ..writeInt32NoTag(replyData.lengthInBytes) + ..writeRawBytes(replyData); + final bytes = writer.toBuffer(); + expect( + codec.decoder.convert(bytes), + isEmpty, + ); + }); }); From a1a01e6a548619e75be82654d56e9b72e6fc7afa Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 30 Oct 2024 13:10:20 +0400 Subject: [PATCH 053/104] Cover protobuf codec --- lib/src/protobuf/protobuf_codec.dart | 2 ++ test/unit/codec_test.dart | 12 +++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/src/protobuf/protobuf_codec.dart b/lib/src/protobuf/protobuf_codec.dart index 9d6fe79..57d3c84 100644 --- a/lib/src/protobuf/protobuf_codec.dart +++ b/lib/src/protobuf/protobuf_codec.dart @@ -210,6 +210,7 @@ final class SpinifyProtobufReplyDecoder ); } } on Object catch (error, stackTrace) { + // coverage:ignore-start logger?.call( const SpinifyLogLevel.warning(), 'protobuf_reply_decoder_error', @@ -220,6 +221,7 @@ final class SpinifyProtobufReplyDecoder 'input': input, }, ); + // coverage:ignore-end } } diff --git a/test/unit/codec_test.dart b/test/unit/codec_test.dart index 0de55bb..fd7f12b 100644 --- a/test/unit/codec_test.dart +++ b/test/unit/codec_test.dart @@ -171,7 +171,17 @@ void main() => group('Codec', () { ..subscribe.recovered = true ..subscribe.data = [1, 2, 3] ..subscribe.positioned = true - ..subscribe.wasRecovering = true, + ..subscribe.wasRecovering = true + ..subscribe.publications.addAll({ + pb.Publication() + ..data = [1, 2, 3] + ..offset = Int64.ZERO + ..tags.addAll({'tag': 'tag'}) + ..info = pb.ClientInfo() + ..info.client = 'client' + ..info.user = 'user' + ..info.chanInfo = [1, 2, 3] + }), pb.Reply() ..id = 1 ..unsubscribe = pb.UnsubscribeResult(), From 987c58b3f77c4831e407935bf45b6a5b17b6b01d Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 30 Oct 2024 13:21:31 +0400 Subject: [PATCH 054/104] Refactor SpinifyServerSubscription test to use a fake WebSocket and handle server subscription --- test/unit/server_subscription_test.dart | 151 ++++++++++-------- test/unit/server_subscription_test.mocks.dart | 66 -------- 2 files changed, 80 insertions(+), 137 deletions(-) delete mode 100644 test/unit/server_subscription_test.mocks.dart diff --git a/test/unit/server_subscription_test.dart b/test/unit/server_subscription_test.dart index 5478411..b25a8f6 100644 --- a/test/unit/server_subscription_test.dart +++ b/test/unit/server_subscription_test.dart @@ -1,86 +1,93 @@ +import 'dart:async'; + import 'package:fake_async/fake_async.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; import 'package:spinify/spinify.dart'; +import 'package:spinify/src/protobuf/client.pb.dart' as pb; import 'package:test/test.dart'; -import 'server_subscription_test.mocks.dart'; +import 'codecs.dart'; +import 'web_socket_fake.dart'; + +//import 'server_subscription_test.mocks.dart'; -@GenerateNiceMocks([MockSpec(as: #MockWebSocket)]) +//@GenerateNiceMocks([MockSpec(as: #MockWebSocket)]) void main() { group('SpinifyServerSubscription', () { + const url = 'ws://localhost:8000/connection/websocket'; + final buffer = SpinifyLogBuffer(size: 10); + + Spinify createFakeClient([Future Function(String)? transport]) => + Spinify( + config: SpinifyConfig( + transportBuilder: ({required url, headers, protocols}) => + transport?.call(url) ?? Future.value(WebSocket$Fake()), + logger: buffer.add, + ), + ); + test( - 'Emulate server subscription', + 'Emulate_server_subscription', () => fakeAsync( (async) { - final ws = MockWebSocket(); - when(ws.stream).thenAnswer( - (_) => Stream.fromIterable( - const >[ - [0, 0, 0, 0, 0, 0, 0, 0], - ], - ), - ); - when(ws.isClosed).thenReturn(false); - when(ws.add(any)).thenAnswer((_) {}); - when(ws.close()).thenAnswer((_) {}); - // TODO: Encode response data for mocks - // Mike Matiunin , 24 October 2024 - - /* final client = Spinify( - config: SpinifyConfig( - transportBuilder: $createFakeSpinifyTransport( - overrideCommand: (command) => switch (command) { - SpinifyConnectRequest request => SpinifyConnectResult( - id: request.id, - timestamp: DateTime.now(), - client: 'fake', - version: '0.0.1', - expires: false, - ttl: null, - data: null, - subs: { - 'notification:index': SpinifySubscribeResult( - id: request.id, - timestamp: DateTime.now(), - data: const [], - expires: false, - ttl: null, - positioned: false, - publications: [ - SpinifyPublication( - channel: 'notification:index', - data: const [], - info: SpinifyClientInfo( - client: 'fake', - user: 'fake', - channelInfo: const [], - connectionInfo: const [], + final client = createFakeClient( + (_) async => WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + sink.add( + ProtobufCodec.encode( + pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: { + 'notification:index': pb.SubscribeResult( + data: const [], + epoch: '...', + offset: Int64.ZERO, + expires: false, + ttl: null, + positioned: false, + publications: [ + pb.Publication( + data: const [], + info: pb.ClientInfo( + client: 'fake', + user: 'fake', + ), + tags: const { + 'type': 'notification', + }, + ), + ], + recoverable: false, + recovered: false, + wasRecovering: false, ), - timestamp: DateTime.now(), - tags: const { - 'type': 'notification', - }, - offset: Int64.ZERO, - ), - ], - recoverable: false, - recovered: false, - since: (epoch: '...', offset: Int64.ZERO), - wasRecovering: false, + }, + ping: 600, + pong: false, + session: 'fake', + node: 'fake', + ), ), - }, - pingInterval: const Duration(seconds: 25), - sendPong: false, - session: 'fake', - node: 'fake', - ), - _ => null, - }, - ), - ), - )..connect('ws://localhost:8000/connection/websocket'); + ), + ); + } + }); + }, + ); + + client.connect(url).ignore(); async.elapse(client.config.timeout); + expect(client.state.isConnected, isTrue); + expect(client.subscriptions.server, isNotEmpty); + expect(client.subscriptions.server['notification:index'], isNotNull); expect( client.subscriptions.server, isA>() @@ -94,7 +101,9 @@ void main() { 'notification:index', isA(), ), - ); */ + ); + + client.close(); }, ), ); diff --git a/test/unit/server_subscription_test.mocks.dart b/test/unit/server_subscription_test.mocks.dart deleted file mode 100644 index 61a09b2..0000000 --- a/test/unit/server_subscription_test.mocks.dart +++ /dev/null @@ -1,66 +0,0 @@ -// Mocks generated by Mockito 5.4.4 from annotations -// in spinify/test/unit/server_subscription_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i3; - -import 'package:mockito/mockito.dart' as _i1; -import 'package:spinify/src/model/transport_interface.dart' as _i2; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class - -/// A class which mocks [WebSocket]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockWebSocket extends _i1.Mock implements _i2.WebSocket { - @override - _i3.Stream> get stream => (super.noSuchMethod( - Invocation.getter(#stream), - returnValue: _i3.Stream>.empty(), - returnValueForMissingStub: _i3.Stream>.empty(), - ) as _i3.Stream>); - - @override - bool get isClosed => (super.noSuchMethod( - Invocation.getter(#isClosed), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); - - @override - void add(List? data) => super.noSuchMethod( - Invocation.method( - #add, - [data], - ), - returnValueForMissingStub: null, - ); - - @override - void close([ - int? code, - String? reason, - ]) => - super.noSuchMethod( - Invocation.method( - #close, - [ - code, - reason, - ], - ), - returnValueForMissingStub: null, - ); -} From 1d879f8e3fd5489d9b73a801bf8b108cbd2a0486 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 30 Oct 2024 13:23:13 +0400 Subject: [PATCH 055/104] Refactor SpinifyServerSubscription test to include additional assertions --- test/unit/server_subscription_test.dart | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/unit/server_subscription_test.dart b/test/unit/server_subscription_test.dart index b25a8f6..962503e 100644 --- a/test/unit/server_subscription_test.dart +++ b/test/unit/server_subscription_test.dart @@ -88,6 +88,18 @@ void main() { expect(client.state.isConnected, isTrue); expect(client.subscriptions.server, isNotEmpty); expect(client.subscriptions.server['notification:index'], isNotNull); + expect( + client.getServerSubscription('notification:index'), + same(client.subscriptions.server['notification:index']), + ); + expect( + client.getClientSubscription('notification:index'), + isNull, + ); + expect( + client.subscriptions.client['notification:index'], + isNull, + ); expect( client.subscriptions.server, isA>() From c758ade017679da245b6ce75124e5ed1d837b3e1 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 30 Oct 2024 14:31:45 +0400 Subject: [PATCH 056/104] Refactor Spinify.dart to handle subscription removal and error handling --- lib/src/spinify.dart | 94 ++++++++++++++++++++++++++++++++++++- lib/src/web_socket_vm.dart | 2 + test/unit/spinify_test.dart | 54 +++++++++++++++++++++ 3 files changed, 148 insertions(+), 2 deletions(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 0eb6dee..79f1d2b 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -168,6 +168,7 @@ final class Spinify implements ISpinify { void _setState(SpinifyState state) { if (isClosed) return; // Client is closed, do not notify about states. final prev = _metrics.state, next = state; + // coverage:ignore-start if (prev.type == next.type) { // Should we notify about the same state? switch ((prev, next)) { @@ -183,6 +184,7 @@ final class Spinify implements ISpinify { break; // Notify about other states changes. } } + // coverage:ignore-end _statesController.add(_metrics.state = next); _log( const SpinifyLogLevel.config(), @@ -301,8 +303,96 @@ final class Spinify implements ISpinify { @nonVirtual void _setUpRefreshConnection() { _tearDownRefreshConnection(); - // TODO: Implement refresh connection timer. - // Mike Matiunin , 25 October 2024 + if (state + case SpinifyState$Connected( + :String url, + :bool expires, + :DateTime? ttl, + :String? node, + :Duration? pingInterval, + :bool? sendPong, + :String? session, + :List? data, + ) when expires && ttl != null) { + final duration = ttl.difference(DateTime.now()) - config.timeout; + if (duration < Duration.zero) { + // coverage:ignore-start + _log( + const SpinifyLogLevel.warning(), + 'refresh_connection_cancelled', + 'Spinify token TTL is too short for refresh connection', + { + 'url': url, + 'duration': duration, + 'ttl': ttl, + }, + ); + assert(false, 'Token TTL is too short'); + // coverage:ignore-end + return; + } + _refreshTimer = Timer(duration, () async { + if (!state.isConnected) return; + final token = await config.getToken?.call(); + if (token == null || token.isEmpty) { + _log( + const SpinifyLogLevel.warning(), + 'refresh_connection_cancelled', + 'Spinify token is null or empty for refresh connection', + { + 'url': url, + 'token': token, + }, + ); + return; + } + final request = SpinifyRefreshRequest( + id: _getNextCommandId(), + timestamp: DateTime.now(), + token: token, + ); + final SpinifyRefreshResult result; + try { + result = await _sendCommand(request); + _setState(SpinifyState$Connected( + url: url, + client: result.client, + version: result.version, + expires: result.expires, + ttl: result.ttl, + node: node, + pingInterval: pingInterval, + sendPong: sendPong, + session: session, + data: data, + )); + } on Object catch (error, stackTrace) { + _log( + const SpinifyLogLevel.error(), + 'refresh_connection_error', + 'Error refreshing connection', + { + 'url': url, + 'command': request, + 'error': error, + 'stackTrace': stackTrace, + }, + ); + return; + } finally { + if (state.isConnected) _setUpRefreshConnection(); + } + _log( + const SpinifyLogLevel.config(), + 'refresh_connection_success', + 'Successfully refreshed connection to $url', + { + 'request': request, + 'result': result, + }, + ); + }); + } } /// Tear down refresh connection timer. diff --git a/lib/src/web_socket_vm.dart b/lib/src/web_socket_vm.dart index 150c362..b4be8a7 100644 --- a/lib/src/web_socket_vm.dart +++ b/lib/src/web_socket_vm.dart @@ -1,3 +1,5 @@ +// coverage:ignore-file + import 'dart:async'; import 'dart:convert'; import 'dart:io' as io; diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index fe6a570..873e39b 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -25,6 +25,11 @@ void main() { ), ); + test('Constructor', () { + expect(Spinify.new, returnsNormally); + expect(() => Spinify(config: SpinifyConfig()), returnsNormally); + }); + test( 'Create_and_close_client', () async { @@ -37,6 +42,55 @@ void main() { }, ); + test( + 'Connect', + () => fakeAsync((async) { + final client = Spinify.connect( + url, + config: SpinifyConfig( + transportBuilder: ({required url, headers, protocols}) async => + WebSocket$Fake(), + logger: buffer.add, + ), + ); + expect(client.state, isA()); + async.elapse(client.config.timeout); + expect(client.state, isA()); + client.close(); + }), + ); + + test( + 'Disconnect_disconnected', + () { + final client = createFakeClient(); + expectLater( + client.states, + emitsInOrder( + [ + isA(), + emitsDone, + ], + ), + ); + return fakeAsync((async) { + expect(client.state.isDisconnected, isTrue); + expect(client.state.isClosed, isFalse); + async.elapse(client.config.timeout); + expect(client.state.isDisconnected, isTrue); + for (var i = 0; i < 10; i++) { + client.disconnect(); + async.elapse(client.config.timeout); + expect(client.state.isDisconnected, isTrue); + } + client.close(); + expect(client.state.isClosed, isTrue); + client.close(); + expect(client.state.isClosed, isTrue); + }); + }, + ); + test( 'Create_and_close_multiple_clients', () async { From 49b2bfba0e262cb4ede468762cce4bb70cca0cd2 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 30 Oct 2024 15:17:00 +0400 Subject: [PATCH 057/104] Refactor Spinify.dart to handle subscription removal and error handling --- lib/src/spinify.dart | 7 +++-- test/unit/spinify_test.dart | 55 +++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 79f1d2b..c9b4fc5 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -232,6 +232,7 @@ final class Spinify implements ISpinify { @nonVirtual void _setUpHealthCheckTimer() { _tearDownHealthCheckTimer(); + // coverage:ignore-start void warning(String message) => _log( const SpinifyLogLevel.warning(), @@ -286,6 +287,8 @@ final class Spinify implements ISpinify { } }, ); + + // coverage:ignore-end } /// Tear down health check timer. @@ -314,9 +317,9 @@ final class Spinify implements ISpinify { :String? session, :List? data, ) when expires && ttl != null) { + // coverage:ignore-start final duration = ttl.difference(DateTime.now()) - config.timeout; if (duration < Duration.zero) { - // coverage:ignore-start _log( const SpinifyLogLevel.warning(), 'refresh_connection_cancelled', @@ -328,9 +331,9 @@ final class Spinify implements ISpinify { }, ); assert(false, 'Token TTL is too short'); - // coverage:ignore-end return; } + // coverage:ignore-end _refreshTimer = Timer(duration, () async { if (!state.isConnected) return; final token = await config.getToken?.call(); diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index 873e39b..2999af5 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -773,5 +773,60 @@ void main() { ); client.close(); }); + + test('Auto_refresh', () { + late Timer pingTimer; + return fakeAsync((async) { + final client = createFakeClient( + (_) async => WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: true, + ttl: 3600, + data: null, + subs: {}, + ping: 600, + pong: true, + session: 'fake', + node: 'fake', + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + pingTimer = Timer.periodic( + Duration(milliseconds: reply.connect.ping), + (_) { + sink.add(ProtobufCodec.encode(pb.Reply())); + }, + ); + } else if (command.hasRefresh()) { + final reply = pb.RefreshResult( + client: 'fake', + version: '0.0.1', + expires: true, + ttl: 3600, + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } + }); + } + ..onDone = () { + pingTimer.cancel(); + }, + ); + + client.connect(url); + async.elapse(const Duration(hours: 1)); + client.close(); + }); + }); }); } From 55e04a86bc132dd38ccc8476d723bc77cff229de Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 30 Oct 2024 15:28:49 +0400 Subject: [PATCH 058/104] Refactor Spinify.dart to handle subscription removal, error handling, and token refresh --- test/unit/spinify_test.dart | 127 +++++++++++++++++++++--------------- 1 file changed, 73 insertions(+), 54 deletions(-) diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index 2999af5..f15025d 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -16,9 +16,13 @@ void main() { const url = 'ws://localhost:8000/connection/websocket'; final buffer = SpinifyLogBuffer(size: 10); - Spinify createFakeClient([Future Function(String)? transport]) => + Spinify createFakeClient({ + Future Function(String)? transport, + Future Function()? getToken, + }) => Spinify( config: SpinifyConfig( + getToken: getToken, transportBuilder: ({required url, headers, protocols}) => transport?.call(url) ?? Future.value(WebSocket$Fake()), logger: buffer.add, @@ -105,7 +109,8 @@ void main() { 'Change_client_state', () async { final transport = WebSocket$Fake(); // ignore: close_sinks - final client = createFakeClient((_) async => transport..reset()); + final client = + createFakeClient(transport: (_) async => transport..reset()); expect(transport.isClosed, isFalse); expect(client.state, isA()); await client.connect(url); @@ -133,7 +138,8 @@ void main() { 'Change_client_states', () { final transport = WebSocket$Fake(); // ignore: close_sinks - final client = createFakeClient((_) async => transport..reset()); + final client = + createFakeClient(transport: (_) async => transport..reset()); Stream.fromIterable([ () => client.connect(url), client.disconnect, @@ -161,7 +167,8 @@ void main() { () => fakeAsync( (async) { final transport = WebSocket$Fake(); - final client = createFakeClient((_) async => transport..reset()); + final client = + createFakeClient(transport: (_) async => transport..reset()); unawaited(client.connect(url)); expect( client.state, @@ -239,7 +246,7 @@ void main() { () => fakeAsync( (async) { final ws = WebSocket$Fake(); // ignore: close_sinks - final client = createFakeClient((_) async => ws..reset()) + final client = createFakeClient(transport: (_) async => ws..reset()) ..connect(url); expect(client.state, isA()); async.elapse(client.config.timeout); @@ -351,7 +358,7 @@ void main() { () => fakeAsync( (async) { final ws = WebSocket$Fake(); // ignore: close_sinks - final client = createFakeClient((_) async => ws); + final client = createFakeClient(transport: (_) async => ws); ws.onAdd = (bytes, sink) { final command = ProtobufCodec.decode(pb.Command(), bytes); @@ -410,7 +417,8 @@ void main() { 'Metrics', () => fakeAsync((async) { final ws = WebSocket$Fake(); // ignore: close_sinks - final client = createFakeClient((_) async => ws..reset()); + final client = + createFakeClient(transport: (_) async => ws..reset()); expect(() => client.metrics, returnsNormally); expect( client.metrics, @@ -527,7 +535,7 @@ void main() { (async) { var serverPingCount = 0; var serverPongCount = 0; - final client = createFakeClient((_) async { + final client = createFakeClient(transport: (_) async { Timer? pingTimer; return WebSocket$Fake() ..onAdd = (bytes, sink) { @@ -582,7 +590,7 @@ void main() { () => fakeAsync( (async) { var serverPingCount = 0, serverPongCount = 0; - final client = createFakeClient((_) async { + final client = createFakeClient(transport: (_) async { Timer? pingTimer; return WebSocket$Fake() ..onAdd = (bytes, sink) { @@ -638,7 +646,7 @@ void main() { (async) { final webSockets = []; var serverPingCount = 0, serverPongCount = 0; - final client = createFakeClient((_) async { + final client = createFakeClient(transport: (_) async { final ws = WebSocket$Fake() ..onAdd = (bytes, sink) { final command = ProtobufCodec.decode(pb.Command(), bytes); @@ -776,56 +784,67 @@ void main() { test('Auto_refresh', () { late Timer pingTimer; - return fakeAsync((async) { - final client = createFakeClient( - (_) async => WebSocket$Fake() - ..onAdd = (bytes, sink) { - final command = ProtobufCodec.decode(pb.Command(), bytes); - scheduleMicrotask(() { - if (command.hasConnect()) { - final reply = pb.Reply( - id: command.id, - connect: pb.ConnectResult( - client: 'fake', - version: '0.0.1', - expires: true, - ttl: 3600, - data: null, - subs: {}, - ping: 600, - pong: true, - session: 'fake', - node: 'fake', - ), - ); - final bytes = ProtobufCodec.encode(reply); - sink.add(bytes); - pingTimer = Timer.periodic( - Duration(milliseconds: reply.connect.ping), - (_) { - sink.add(ProtobufCodec.encode(pb.Reply())); - }, - ); - } else if (command.hasRefresh()) { - final reply = pb.RefreshResult( + var pings = 0, refreshes = 0; + final client = createFakeClient( + getToken: () async => 'token', + transport: (_) async => WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( client: 'fake', version: '0.0.1', expires: true, - ttl: 3600, + ttl: 600, + data: null, + subs: {}, + ping: 120, + pong: true, + session: 'fake', + node: 'fake', + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + pingTimer = Timer.periodic( + Duration(milliseconds: reply.connect.ping), + (_) { + sink.add(ProtobufCodec.encode(pb.Reply())); + pings++; + }, + ); + } else if (command.hasRefresh()) { + if (command.refresh.token.isEmpty) return; + final reply = pb.Reply() + ..id = command.id + ..refresh = pb.RefreshResult( + client: 'fake', + version: '0.0.1', + expires: true, + ttl: 600, ); - final bytes = ProtobufCodec.encode(reply); - sink.add(bytes); - } - }); - } - ..onDone = () { - pingTimer.cancel(); - }, - ); - + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + refreshes++; + } + }); + } + ..onDone = () { + pingTimer.cancel(); + }, + ); + return fakeAsync((async) { client.connect(url); - async.elapse(const Duration(hours: 1)); + async.elapse(const Duration(hours: 3)); + expect(client.state.isConnected, isTrue); + expect(client.isClosed, isFalse); client.close(); + expect(client.state.isClosed, isTrue); + expect(pings, greaterThanOrEqualTo(3 * 60 * 60 ~/ 120)); + expect(refreshes, greaterThanOrEqualTo(3 * 60 * 60 ~/ 600)); }); }); }); From cf9e0f6e36ec7176e526ca2ba074b448e5348d20 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 30 Oct 2024 15:50:36 +0400 Subject: [PATCH 059/104] Refactor Spinify.dart to handle invalid ping interval --- lib/src/protobuf/protobuf_codec.dart | 1 + test/unit/spinify_test.dart | 186 ++++++++++++++++----------- 2 files changed, 112 insertions(+), 75 deletions(-) diff --git a/lib/src/protobuf/protobuf_codec.dart b/lib/src/protobuf/protobuf_codec.dart index 57d3c84..005abd9 100644 --- a/lib/src/protobuf/protobuf_codec.dart +++ b/lib/src/protobuf/protobuf_codec.dart @@ -460,6 +460,7 @@ final class SpinifyProtobufReplyDecoder pingInterval = Duration(seconds: ping); } else { assert(false, 'Ping interval is invalid'); // coverage:ignore-line + pingInterval = const Duration(seconds: 25); } return SpinifyConnectResult( id: id, diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index f15025d..c2e5afc 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -164,81 +164,117 @@ void main() { test( 'Reconnect_after_disconnected_transport', - () => fakeAsync( - (async) { - final transport = WebSocket$Fake(); - final client = - createFakeClient(transport: (_) async => transport..reset()); - unawaited(client.connect(url)); - expect( - client.state, - isA().having( - (s) => s.url, - 'url', - equals(url), - ), - ); - async.elapse(client.config.timeout); - expect( - client.state, - isA().having( - (s) => s.url, - 'url', - equals(url), - ), - ); - expect(transport, isNotNull); - expect(transport, isA()); - transport.close(); - async.elapse(const Duration(milliseconds: 50)); - expect( - client.state, - isA().having( - (s) => s.temporary, - 'temporary', - isTrue, - ), - ); - async.elapse(Duration( - milliseconds: - client.config.connectionRetryInterval.min.inMilliseconds ~/ - 2)); - expect( - client.state, - isA().having( - (s) => s.temporary, - 'temporary', - isTrue, - ), - ); - async.elapse(client.config.connectionRetryInterval.max); - expect( - client.state, - isA().having( - (s) => s.url, - 'url', - equals(url), - ), - ); - expectLater( - client.states, - emitsInOrder( - [ - isA().having( - (s) => s.temporary, - 'temporary', - isFalse, - ), - isA(), - emitsDone, - ], - ), - ); - client.close(); - async.elapse(client.config.connectionRetryInterval.max); - expect(client.state, isA()); - }, - ), + () { + late WebSocket$Fake transport; + final client = createFakeClient( + transport: (_) async => transport = WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: { + 'notifications:index': pb.SubscribeResult( + expires: false, + ttl: null, + data: [ + 0 + ], + publications: [ + pb.Publication( + info: pb.ClientInfo( + user: 'fake', + client: 'fake', + chanInfo: [1, 2, 3], + connInfo: [1, 2, 3], + ), + data: [1, 2, 3], + ) + ]), + }, + ping: 600, + pong: false, + session: 'fake', + node: 'fake', + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } + }); + }); + return fakeAsync( + (async) { + unawaited(client.connect(url)); + async.elapse(client.config.timeout); + expect( + client.state, + isA().having( + (s) => s.url, + 'url', + equals(url), + ), + ); + expect(transport, isNotNull); + expect(transport, isA()); + transport.close(); + async.elapse(const Duration(milliseconds: 50)); + expect( + client.state, + isA().having( + (s) => s.temporary, + 'temporary', + isTrue, + ), + ); + async.elapse(Duration( + milliseconds: + client.config.connectionRetryInterval.min.inMilliseconds ~/ + 2)); + expect( + client.state, + isA().having( + (s) => s.temporary, + 'temporary', + isTrue, + ), + ); + async.elapse(client.config.connectionRetryInterval.max); + expect( + client.state, + isA().having( + (s) => s.url, + 'url', + equals(url), + ), + ); + expectLater( + client.states, + emitsInOrder( + [ + isA().having( + (s) => s.temporary, + 'temporary', + isFalse, + ), + isA(), + emitsDone, + ], + ), + ); + client.close(); + async.elapse(client.config.connectionRetryInterval.max); + expect(client.state, isA()); + }, + ); + }, ); test( From 72eb3fbf4fae96bf59547950ea6cda234533ee4b Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Tue, 5 Nov 2024 00:53:05 +0400 Subject: [PATCH 060/104] Refactor WebSocket$Fake and Spinify to improve null safety and connection handling --- lib/src/spinify.dart | 20 +- test/unit/spinify_test.dart | 1668 +++++++++++++++++--------------- test/unit/web_socket_fake.dart | 8 +- 3 files changed, 906 insertions(+), 790 deletions(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index c9b4fc5..78cfbd4 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -234,12 +234,15 @@ final class Spinify implements ISpinify { _tearDownHealthCheckTimer(); // coverage:ignore-start - void warning(String message) => _log( - const SpinifyLogLevel.warning(), - 'health_check_error', - message, - {}, - ); + void warning(String message) { + //debugger(); + _log( + const SpinifyLogLevel.warning(), + 'health_check_error', + message, + {}, + ); + } _healthTimer = Timer.periodic( const Duration(seconds: 15), @@ -891,7 +894,10 @@ final class Spinify implements ISpinify { 'stackTrace': stackTrace, }, ); - _transport?.close(); + + final transport = _transport; // Close transport + if (transport != null && !transport.isClosed) transport.close(); + _transport = null; switch ($error) { case SpinifyErrorResult result: diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index c2e5afc..7f93748 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -12,521 +12,523 @@ import 'web_socket_fake.dart'; @GenerateNiceMocks([MockSpec(as: #MockWebSocket)]) void main() { - group('Spinify', () { - const url = 'ws://localhost:8000/connection/websocket'; - final buffer = SpinifyLogBuffer(size: 10); - - Spinify createFakeClient({ - Future Function(String)? transport, - Future Function()? getToken, - }) => - Spinify( - config: SpinifyConfig( - getToken: getToken, - transportBuilder: ({required url, headers, protocols}) => - transport?.call(url) ?? Future.value(WebSocket$Fake()), - logger: buffer.add, - ), - ); + group( + 'Spinify', + () { + const url = 'ws://localhost:8000/connection/websocket'; + final buffer = SpinifyLogBuffer(size: 10); - test('Constructor', () { - expect(Spinify.new, returnsNormally); - expect(() => Spinify(config: SpinifyConfig()), returnsNormally); - }); + Spinify createFakeClient({ + Future Function(String)? transport, + Future Function()? getToken, + }) => + Spinify( + config: SpinifyConfig( + getToken: getToken, + transportBuilder: ({required url, headers, protocols}) => + transport?.call(url) ?? Future.value(WebSocket$Fake()), + logger: buffer.add, + ), + ); - test( - 'Create_and_close_client', - () async { - final client = createFakeClient(); - expect(client.isClosed, isFalse); - expect(client.state, isA()); - await client.close(); - expect(client.state, isA()); - expect(client.isClosed, isTrue); - }, - ); - - test( - 'Connect', - () => fakeAsync((async) { - final client = Spinify.connect( - url, - config: SpinifyConfig( - transportBuilder: ({required url, headers, protocols}) async => - WebSocket$Fake(), - logger: buffer.add, - ), - ); - expect(client.state, isA()); - async.elapse(client.config.timeout); - expect(client.state, isA()); - client.close(); - }), - ); + test('Constructor', () { + expect(Spinify.new, returnsNormally); + expect(() => Spinify(config: SpinifyConfig()), returnsNormally); + }); - test( - 'Disconnect_disconnected', - () { - final client = createFakeClient(); - expectLater( - client.states, - emitsInOrder( - [ - isA(), - emitsDone, - ], - ), - ); - return fakeAsync((async) { - expect(client.state.isDisconnected, isTrue); - expect(client.state.isClosed, isFalse); + test( + 'Create_and_close_client', + () async { + final client = createFakeClient(); + expect(client.isClosed, isFalse); + expect(client.state, isA()); + await client.close(); + expect(client.state, isA()); + expect(client.isClosed, isTrue); + }, + ); + + test( + 'Connect', + () => fakeAsync((async) { + final client = Spinify.connect( + url, + config: SpinifyConfig( + transportBuilder: ({required url, headers, protocols}) async => + WebSocket$Fake(), + logger: buffer.add, + ), + ); + expect(client.state, isA()); async.elapse(client.config.timeout); - expect(client.state.isDisconnected, isTrue); - for (var i = 0; i < 10; i++) { - client.disconnect(); - async.elapse(client.config.timeout); - expect(client.state.isDisconnected, isTrue); - } - client.close(); - expect(client.state.isClosed, isTrue); + expect(client.state, isA()); client.close(); - expect(client.state.isClosed, isTrue); - }); - }, - ); - - test( - 'Create_and_close_multiple_clients', - () async { - final clients = List.generate(10, (_) => createFakeClient()); - expect(clients.every((client) => !client.isClosed), isTrue); - await Future.wait(clients.map((client) => client.close())); - expect(clients.every((client) => client.isClosed), isTrue); - }, - ); - - test( - 'Change_client_state', - () async { - final transport = WebSocket$Fake(); // ignore: close_sinks - final client = - createFakeClient(transport: (_) async => transport..reset()); - expect(transport.isClosed, isFalse); - expect(client.state, isA()); - await client.connect(url); - expect(client.state, isA()); - await client.disconnect(); - expect( - client.state, - isA().having( - (s) => s.temporary, - 'temporary', - isFalse, - ), - ); - await client.connect(url); - expect(client.state, isA()); - await client.close(); - expect(client.state, isA()); - expect(client.isClosed, isTrue); - expect(transport.isClosed, isTrue); - expect(transport.closeCode, equals(1000)); - }, - ); - - test( - 'Change_client_states', - () { - final transport = WebSocket$Fake(); // ignore: close_sinks - final client = - createFakeClient(transport: (_) async => transport..reset()); - Stream.fromIterable([ - () => client.connect(url), - client.disconnect, - () => client.connect(url), - client.disconnect, - client.close, - ]).asyncMap(Future.new).drain(); - expect(client.state, isA()); - expectLater( + }), + ); + + test( + 'Disconnect_disconnected', + () { + final client = createFakeClient(); + expectLater( client.states, - emitsInOrder([ - isA(), - isA(), - isA(), - isA(), - isA(), - isA(), - isA() - ])); - }, - ); - - test( - 'Reconnect_after_disconnected_transport', - () { - late WebSocket$Fake transport; - final client = createFakeClient( - transport: (_) async => transport = WebSocket$Fake() - ..onAdd = (bytes, sink) { - final command = ProtobufCodec.decode(pb.Command(), bytes); - scheduleMicrotask(() { - if (command.hasConnect()) { - final reply = pb.Reply( - id: command.id, - connect: pb.ConnectResult( - client: 'fake', - version: '0.0.1', - expires: false, - ttl: null, - data: null, - subs: { - 'notifications:index': pb.SubscribeResult( - expires: false, - ttl: null, - data: [ - 0 - ], - publications: [ - pb.Publication( - info: pb.ClientInfo( - user: 'fake', - client: 'fake', - chanInfo: [1, 2, 3], - connInfo: [1, 2, 3], - ), - data: [1, 2, 3], - ) - ]), - }, - ping: 600, - pong: false, - session: 'fake', - node: 'fake', - ), - ); - final bytes = ProtobufCodec.encode(reply); - sink.add(bytes); - } - }); - }); - return fakeAsync( - (async) { - unawaited(client.connect(url)); + emitsInOrder( + [ + isA(), + emitsDone, + ], + ), + ); + return fakeAsync((async) { + expect(client.state.isDisconnected, isTrue); + expect(client.state.isClosed, isFalse); async.elapse(client.config.timeout); - expect( - client.state, - isA().having( - (s) => s.url, - 'url', - equals(url), - ), - ); - expect(transport, isNotNull); - expect(transport, isA()); - transport.close(); - async.elapse(const Duration(milliseconds: 50)); - expect( - client.state, - isA().having( - (s) => s.temporary, - 'temporary', - isTrue, - ), - ); - async.elapse(Duration( - milliseconds: - client.config.connectionRetryInterval.min.inMilliseconds ~/ - 2)); - expect( - client.state, - isA().having( - (s) => s.temporary, - 'temporary', - isTrue, - ), - ); - async.elapse(client.config.connectionRetryInterval.max); - expect( - client.state, - isA().having( - (s) => s.url, - 'url', - equals(url), - ), - ); - expectLater( - client.states, - emitsInOrder( - [ - isA().having( - (s) => s.temporary, - 'temporary', - isFalse, - ), - isA(), - emitsDone, - ], - ), - ); + expect(client.state.isDisconnected, isTrue); + for (var i = 0; i < 10; i++) { + client.disconnect(); + async.elapse(client.config.timeout); + expect(client.state.isDisconnected, isTrue); + } client.close(); - async.elapse(client.config.connectionRetryInterval.max); - expect(client.state, isA()); - }, - ); - }, - ); - - test( - 'Rpc_requests', - () => fakeAsync( - (async) { - final ws = WebSocket$Fake(); // ignore: close_sinks - final client = createFakeClient(transport: (_) async => ws..reset()) - ..connect(url); - expect(client.state, isA()); - async.elapse(client.config.timeout); - expect(client.state, isA()); + expect(client.state.isClosed, isTrue); + client.close(); + expect(client.state.isClosed, isTrue); + }); + }, + ); - // Intercept the onAdd callback for echo RPC - var fn = ws.onAdd; - ws.onAdd = (bytes, sink) { - final command = ProtobufCodec.decode(pb.Command(), bytes); - if (command.hasRpc()) { - expect(command.rpc.method, anyOf('echo', 'getCurrentYear')); - switch (command.rpc.method) { - case 'echo': - final data = utf8.decode(command.rpc.data); - final reply = pb.Reply( - id: command.id, - rpc: pb.RPCResult( - data: utf8.encode(data), - ), - ); - scheduleMicrotask( - () => sink.add(ProtobufCodec.encode(reply))); - default: - return fn(bytes, sink); - } - } else { - fn(bytes, sink); - } - }; + test( + 'Create_and_close_multiple_clients', + () async { + final clients = List.generate(10, (_) => createFakeClient()); + expect(clients.every((client) => !client.isClosed), isTrue); + await Future.wait(clients.map((client) => client.close())); + expect(clients.every((client) => client.isClosed), isTrue); + }, + ); - // Send a request + test( + 'Change_client_state', + () async { + final transport = WebSocket$Fake(); // ignore: close_sinks + final client = + createFakeClient(transport: (_) async => transport..reset()); + expect(transport.isClosed, isFalse); + expect(client.state, isA()); + await client.connect(url); + expect(client.state, isA()); + await client.disconnect(); expect( - client.rpc('echo', utf8.encode('Hello, World!')), - completion(isA>().having( - (data) => utf8.decode(data), - 'data', - equals('Hello, World!'), - )), + client.state, + isA().having( + (s) => s.temporary, + 'temporary', + isFalse, + ), ); - async.elapse(client.config.timeout); + await client.connect(url); expect(client.state, isA()); + await client.close(); + expect(client.state, isA()); + expect(client.isClosed, isTrue); + expect(transport.isClosed, isTrue); + expect(transport.closeCode, equals(1000)); + }, + ); + + test( + 'Change_client_states', + () { + final transport = WebSocket$Fake(); // ignore: close_sinks + final client = + createFakeClient(transport: (_) async => transport..reset()); + Stream.fromIterable([ + () => client.connect(url), + client.disconnect, + () => client.connect(url), + client.disconnect, + client.close, + ]).asyncMap(Future.new).drain(); + expect(client.state, isA()); + expectLater( + client.states, + emitsInOrder([ + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + isA() + ])); + }, + ); + + test( + 'Reconnect_after_disconnected_transport', + () { + late WebSocket$Fake transport; + final client = createFakeClient( + transport: (_) async => transport = WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: { + 'notifications:index': pb.SubscribeResult( + expires: false, + ttl: null, + data: [ + 0 + ], + publications: [ + pb.Publication( + info: pb.ClientInfo( + user: 'fake', + client: 'fake', + chanInfo: [1, 2, 3], + connInfo: [1, 2, 3], + ), + data: [1, 2, 3], + ) + ]), + }, + ping: 600, + pong: false, + session: 'fake', + node: 'fake', + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } + }); + }); + return fakeAsync( + (async) { + unawaited(client.connect(url)); + async.elapse(client.config.timeout); + expect( + client.state, + isA().having( + (s) => s.url, + 'url', + equals(url), + ), + ); + expect(transport, isNotNull); + expect(transport, isA()); + transport.close(); + async.elapse(const Duration(milliseconds: 50)); + expect( + client.state, + isA().having( + (s) => s.temporary, + 'temporary', + isTrue, + ), + ); + async.elapse(Duration( + milliseconds: client + .config.connectionRetryInterval.min.inMilliseconds ~/ + 2)); + expect( + client.state, + isA().having( + (s) => s.temporary, + 'temporary', + isTrue, + ), + ); + async.elapse(client.config.connectionRetryInterval.max); + expect( + client.state, + isA().having( + (s) => s.url, + 'url', + equals(url), + ), + ); + expectLater( + client.states, + emitsInOrder( + [ + isA().having( + (s) => s.temporary, + 'temporary', + isFalse, + ), + isA(), + emitsDone, + ], + ), + ); + client.close(); + async.elapse(client.config.connectionRetryInterval.max); + expect(client.state, isA()); + }, + ); + }, + ); + + test( + 'Rpc_requests', + () => fakeAsync( + (async) { + final ws = WebSocket$Fake(); // ignore: close_sinks + final client = createFakeClient(transport: (_) async => ws..reset()) + ..connect(url); + expect(client.state, isA()); + async.elapse(client.config.timeout); + expect(client.state, isA()); + + // Intercept the onAdd callback for echo RPC + var fn = ws.onAdd; + ws.onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + if (command.hasRpc()) { + expect(command.rpc.method, anyOf('echo', 'getCurrentYear')); + switch (command.rpc.method) { + case 'echo': + final data = utf8.decode(command.rpc.data); + final reply = pb.Reply( + id: command.id, + rpc: pb.RPCResult( + data: utf8.encode(data), + ), + ); + scheduleMicrotask( + () => sink.add(ProtobufCodec.encode(reply))); + default: + return fn(bytes, sink); + } + } else { + fn(bytes, sink); + } + }; - // Send 1000 requests - for (var i = 0; i < 1000; i++) { + // Send a request expect( - client.rpc('echo', utf8.encode(i.toString())), + client.rpc('echo', utf8.encode('Hello, World!')), completion(isA>().having( (data) => utf8.decode(data), 'data', - equals(i.toString()), + equals('Hello, World!'), )), ); - } - - async.elapse(client.config.timeout); - expect(client.state, isA()); - client.disconnect(); - async.elapse(client.config.timeout); - expect(client.state, isA()); - client.connect(url); - async.elapse(client.config.timeout); - expect(client.state, isA()); + async.elapse(client.config.timeout); + expect(client.state, isA()); - // Intercept the onAdd callback for getCurrentYear RPC - ws.onAdd = (bytes, sink) { - final command = ProtobufCodec.decode(pb.Command(), bytes); - if (command.hasRpc()) { - expect(command.rpc.method, anyOf('echo', 'getCurrentYear')); - switch (command.rpc.method) { - case 'getCurrentYear': - final reply = pb.Reply( - id: command.id, - rpc: pb.RPCResult( - data: utf8 - .encode(jsonEncode({'year': DateTime.now().year})), - ), - ); - scheduleMicrotask( - () => sink.add(ProtobufCodec.encode(reply))); - default: - return fn(bytes, sink); - } - } else { - fn(bytes, sink); + // Send 1000 requests + for (var i = 0; i < 1000; i++) { + expect( + client.rpc('echo', utf8.encode(i.toString())), + completion(isA>().having( + (data) => utf8.decode(data), + 'data', + equals(i.toString()), + )), + ); } - }; - // Another request - expect( - client.rpc('getCurrentYear', []), - completion(isA>().having( - (data) => jsonDecode(utf8.decode(data))['year'], - 'year', - DateTime.now().year, - )), - ); - async.elapse(client.config.timeout); + async.elapse(client.config.timeout); + expect(client.state, isA()); + client.disconnect(); + async.elapse(client.config.timeout); + expect(client.state, isA()); + client.connect(url); + async.elapse(client.config.timeout); + expect(client.state, isA()); - expect(client.state, isA()); - client.close(); - async.elapse(client.config.timeout); - expect(client.state, isA()); - }, - ), - ); - - test( - 'Server_subscriptions', - () => fakeAsync( - (async) { - final ws = WebSocket$Fake(); // ignore: close_sinks - final client = createFakeClient(transport: (_) async => ws); - - ws.onAdd = (bytes, sink) { - final command = ProtobufCodec.decode(pb.Command(), bytes); - scheduleMicrotask(() { - if (command.hasConnect()) { - sink.add( - ProtobufCodec.encode( - pb.Reply( + // Intercept the onAdd callback for getCurrentYear RPC + ws.onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + if (command.hasRpc()) { + expect(command.rpc.method, anyOf('echo', 'getCurrentYear')); + switch (command.rpc.method) { + case 'getCurrentYear': + final reply = pb.Reply( id: command.id, - connect: pb.ConnectResult( - client: 'fake', - version: '0.0.1', - expires: false, - ttl: null, - data: null, - subs: { - 'public:chat': pb.SubscribeResult( - expires: false, - ttl: null, - data: [], - ), - 'personal:user#42': pb.SubscribeResult( - expires: false, - ttl: null, - data: [], - ), - }, - ping: 600, - pong: false, - session: 'fake', - node: 'fake', + rpc: pb.RPCResult( + data: utf8 + .encode(jsonEncode({'year': DateTime.now().year})), ), - ), - ), - ); + ); + scheduleMicrotask( + () => sink.add(ProtobufCodec.encode(reply))); + default: + return fn(bytes, sink); + } + } else { + fn(bytes, sink); } - }); - }; + }; - client.connect(url); - async.elapse(client.config.timeout); - expect(client.state, isA()); - expect(client.subscriptions.server, hasLength(2)); - expect(client.getServerSubscription('public:chat'), isNotNull); - expect(client.getServerSubscription('personal:user#42'), isNotNull); - expect(client.getSubscription('public:chat'), isNotNull); - expect(client.getSubscription('personal:user#42'), isNotNull); - expect(client.getServerSubscription('unknown'), isNull); - expect(client.getSubscription('unknown'), isNull); - client.close(); - }, - ), - ); + // Another request + expect( + client.rpc('getCurrentYear', []), + completion(isA>().having( + (data) => jsonDecode(utf8.decode(data))['year'], + 'year', + DateTime.now().year, + )), + ); + async.elapse(client.config.timeout); - test( - 'Metrics', - () => fakeAsync((async) { - final ws = WebSocket$Fake(); // ignore: close_sinks - final client = - createFakeClient(transport: (_) async => ws..reset()); - expect(() => client.metrics, returnsNormally); - expect( - client.metrics, - allOf([ - isA().having( - (m) => m.state.isConnected, - 'isConnected', - isFalse, - ), - isA().having( - (m) => m.state, - 'state', - equals(client.state), - ), - isA().having( - (m) => m.connects, - 'connects', - 0, - ), - isA().having( - (m) => m.disconnects, - 'disconnects', - 0, - ), - isA().having( - (m) => m.chunksReceived, - 'messagesReceived', - equals(Int64.ZERO), - ), - isA().having( - (m) => m.chunksSent, - 'chunksSent', - equals(Int64.ZERO), - ), - ])); - client.connect(url); - async.elapse(client.config.timeout); - expect( - client.metrics, - allOf([ - isA().having( - (m) => m.state.isConnected, - 'isConnected', - isTrue, - ), - isA().having( - (m) => m.state, - 'state', - equals(client.state), - ), - isA().having( - (m) => m.connects, - 'connects', - 1, - ), - isA().having( - (m) => m.disconnects, - 'disconnects', - 0, - ), - isA().having( - (m) => m.chunksReceived, - 'messagesReceived', - greaterThan(Int64.ZERO), - ), - isA().having( - (m) => m.chunksSent, - 'chunksSent', - greaterThan(Int64.ZERO), + expect(client.state, isA()); + client.close(); + async.elapse(client.config.timeout); + expect(client.state, isA()); + }, + ), + ); + + test( + 'Server_subscriptions', + () => fakeAsync( + (async) { + final ws = WebSocket$Fake(); // ignore: close_sinks + final client = createFakeClient(transport: (_) async => ws); + + ws.onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + sink.add( + ProtobufCodec.encode( + pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: { + 'public:chat': pb.SubscribeResult( + expires: false, + ttl: null, + data: [], + ), + 'personal:user#42': pb.SubscribeResult( + expires: false, + ttl: null, + data: [], + ), + }, + ping: 600, + pong: false, + session: 'fake', + node: 'fake', + ), + ), ), - ])); - /* client + ); + } + }); + }; + + client.connect(url); + async.elapse(client.config.timeout); + expect(client.state, isA()); + expect(client.subscriptions.server, hasLength(2)); + expect(client.getServerSubscription('public:chat'), isNotNull); + expect(client.getServerSubscription('personal:user#42'), isNotNull); + expect(client.getSubscription('public:chat'), isNotNull); + expect(client.getSubscription('personal:user#42'), isNotNull); + expect(client.getServerSubscription('unknown'), isNull); + expect(client.getSubscription('unknown'), isNull); + client.close(); + }, + ), + ); + + test( + 'Metrics', + () => fakeAsync((async) { + final ws = WebSocket$Fake(); // ignore: close_sinks + final client = + createFakeClient(transport: (_) async => ws..reset()); + expect(() => client.metrics, returnsNormally); + expect( + client.metrics, + allOf([ + isA().having( + (m) => m.state.isConnected, + 'isConnected', + isFalse, + ), + isA().having( + (m) => m.state, + 'state', + equals(client.state), + ), + isA().having( + (m) => m.connects, + 'connects', + 0, + ), + isA().having( + (m) => m.disconnects, + 'disconnects', + 0, + ), + isA().having( + (m) => m.chunksReceived, + 'messagesReceived', + equals(Int64.ZERO), + ), + isA().having( + (m) => m.chunksSent, + 'chunksSent', + equals(Int64.ZERO), + ), + ])); + client.connect(url); + async.elapse(client.config.timeout); + expect( + client.metrics, + allOf([ + isA().having( + (m) => m.state.isConnected, + 'isConnected', + isTrue, + ), + isA().having( + (m) => m.state, + 'state', + equals(client.state), + ), + isA().having( + (m) => m.connects, + 'connects', + 1, + ), + isA().having( + (m) => m.disconnects, + 'disconnects', + 0, + ), + isA().having( + (m) => m.chunksReceived, + 'messagesReceived', + greaterThan(Int64.ZERO), + ), + isA().having( + (m) => m.chunksSent, + 'chunksSent', + greaterThan(Int64.ZERO), + ), + ])); + /* client ..newSubscription('channel') ..close(); async.elapse(client.config.timeout); @@ -563,325 +565,429 @@ void main() { client.metrics.channels['channel'], isA().having((c) => c.toString(), 'subscriptions', equals(r'SpinifyMetrics$Channel{}'))); */ - })); - - test( - 'Ping_pong', - () => fakeAsync( - (async) { - var serverPingCount = 0; - var serverPongCount = 0; - final client = createFakeClient(transport: (_) async { - Timer? pingTimer; - return WebSocket$Fake() - ..onAdd = (bytes, sink) { - final command = ProtobufCodec.decode(pb.Command(), bytes); - if (command.hasConnect()) { - final reply = pb.Reply( - id: command.id, - connect: pb.ConnectResult( - client: 'fake', - version: '0.0.1', - expires: false, - ttl: null, - data: null, - subs: {}, - ping: 600, - pong: true, - session: 'fake', - node: 'fake', - ), - ); - scheduleMicrotask(() { - sink.add(ProtobufCodec.encode(reply)); - pingTimer = Timer.periodic( - Duration(milliseconds: reply.connect.ping), - (_) { - serverPingCount++; - sink.add(ProtobufCodec.encode(pb.Reply())); - }, + })); + + test( + 'Ping_pong', + () => fakeAsync( + (async) { + var serverPingCount = 0; + var serverPongCount = 0; + final client = createFakeClient(transport: (_) async { + Timer? pingTimer; + return WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: {}, + ping: 600, + pong: true, + session: 'fake', + node: 'fake', + ), ); - }); - } else if (command.hasPing()) { - serverPongCount++; + scheduleMicrotask(() { + sink.add(ProtobufCodec.encode(reply)); + pingTimer = Timer.periodic( + Duration(milliseconds: reply.connect.ping), + (_) { + serverPingCount++; + sink.add(ProtobufCodec.encode(pb.Reply())); + }, + ); + }); + } else if (command.hasPing()) { + serverPongCount++; + } } - } - ..onDone = () { - pingTimer?.cancel(); - }; - }); - unawaited(client.connect(url)); - async.elapse(client.config.timeout); - expect(client.state, isA()); - async.elapse(client.config.serverPingDelay * 10); - expect(serverPingCount, greaterThan(0)); - expect(serverPongCount, equals(serverPingCount)); - client.close(); - }, - ), - ); - - test( - 'Ping_without_pong', - () => fakeAsync( - (async) { - var serverPingCount = 0, serverPongCount = 0; - final client = createFakeClient(transport: (_) async { - Timer? pingTimer; - return WebSocket$Fake() - ..onAdd = (bytes, sink) { - final command = ProtobufCodec.decode(pb.Command(), bytes); - if (command.hasConnect()) { - final reply = pb.Reply( - id: command.id, - connect: pb.ConnectResult( - client: 'fake', - version: '0.0.1', - expires: false, - ttl: null, - data: null, - subs: {}, - ping: 600, - pong: false, - session: 'fake', - node: 'fake', - ), - ); - scheduleMicrotask(() { - sink.add(ProtobufCodec.encode(reply)); - pingTimer = Timer.periodic( - Duration(milliseconds: reply.connect.ping), - (_) { - serverPingCount++; - sink.add(ProtobufCodec.encode(pb.Reply())); - }, + ..onDone = () { + pingTimer?.cancel(); + }; + }); + unawaited(client.connect(url)); + async.elapse(client.config.timeout); + expect(client.state, isA()); + async.elapse(client.config.serverPingDelay * 10); + expect(serverPingCount, greaterThan(0)); + expect(serverPongCount, equals(serverPingCount)); + client.close(); + }, + ), + ); + + test( + 'Ping_without_pong', + () => fakeAsync( + (async) { + var serverPingCount = 0, serverPongCount = 0; + final client = createFakeClient(transport: (_) async { + Timer? pingTimer; + return WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: {}, + ping: 600, + pong: false, + session: 'fake', + node: 'fake', + ), ); - }); - } else if (command.hasPing()) { - serverPongCount++; + scheduleMicrotask(() { + sink.add(ProtobufCodec.encode(reply)); + pingTimer = Timer.periodic( + Duration(milliseconds: reply.connect.ping), + (_) { + serverPingCount++; + sink.add(ProtobufCodec.encode(pb.Reply())); + }, + ); + }); + } else if (command.hasPing()) { + serverPongCount++; + } } - } - ..onDone = () { - pingTimer?.cancel(); - }; - }); - unawaited(client.connect(url)); + ..onDone = () { + pingTimer?.cancel(); + }; + }); + unawaited(client.connect(url)); + async.elapse(client.config.timeout); + expect(client.state, isA()); + async.elapse(client.config.serverPingDelay * 10); + expect(serverPingCount, greaterThan(0)); + expect(serverPongCount, isZero); + client.close(); + }, + ), + ); + + test( + 'Missing_pings', + () => fakeAsync( + (async) { + final webSockets = []; + var serverPingCount = 0, serverPongCount = 0; + final client = createFakeClient(transport: (_) async { + final ws = WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: {}, + ping: 600, + pong: true, + session: 'fake', + node: 'fake', + ), + ); + scheduleMicrotask(() { + sink.add(ProtobufCodec.encode(reply)); + }); + } else if (command.hasPing()) { + serverPongCount++; + } + } + ..onDone = () {}; + webSockets.add(ws); + return ws; + }); + expectLater( + client.states, + emitsInOrder( + [ + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + ], + ), + ); + unawaited(client.connect(url)); + async.elapse(client.config.timeout); + expect(client.state, isA()); + final pingInterval = + (client.state as SpinifyState$Connected).pingInterval!; + async.elapse( + (pingInterval + + client.config.timeout + + client.config.serverPingDelay) * + 10, + ); + expect(webSockets.length, greaterThan(1)); + expect(serverPingCount, isZero); + expect(serverPongCount, isZero); + client.close(); + async.elapse(const Duration(seconds: 1)); + expect(webSockets.every((ws) => ws.isClosed), isTrue); + }, + ), + ); + + test( + 'ready', + () => fakeAsync((async) { + final client = createFakeClient(); + expectLater(client.ready(), completes); + client.connect(url); + //expectLater(client.ready(), completes); async.elapse(client.config.timeout); expect(client.state, isA()); - async.elapse(client.config.serverPingDelay * 10); - expect(serverPingCount, greaterThan(0)); - expect(serverPongCount, isZero); + expectLater(client.ready(), completes); + async.elapse(client.config.timeout); client.close(); - }, - ), - ); - - test( - 'Missing_pings', - () => fakeAsync( - (async) { - final webSockets = []; - var serverPingCount = 0, serverPongCount = 0; - final client = createFakeClient(transport: (_) async { - final ws = WebSocket$Fake() - ..onAdd = (bytes, sink) { - final command = ProtobufCodec.decode(pb.Command(), bytes); + }), + ); + + test('do_not_ready', () { + final client = createFakeClient(); + expectLater( + client.ready(), + throwsA(isA()), + ); + expectLater( + client.send([1, 2, 3]), + throwsA(isA()), + ); + expectLater( + client.rpc('echo', [1, 2, 3]), + throwsA(isA()), + ); + client.close(); + }); + + test('subscribtion_asserts', () { + final client = createFakeClient(); + expect( + () => client.newSubscription(''), + throwsA(isA()), + ); + expect( + () => client.newSubscription(' '), + throwsA(isA()), + ); + expect( + () => client.newSubscription(String.fromCharCode(0x7f + 1)), + throwsA(isA()), + ); + expect( + () => client.newSubscription('๐Ÿ˜€, ๐ŸŒ, ๐ŸŽ‰, ๐Ÿ‘‹'), + throwsA(isA()), + ); + expect( + () => client.newSubscription('channel' * 100), + throwsA(isA()), + ); + expect( + () => client.newSubscription('channel'), + returnsNormally, + ); + expect( + () => client.newSubscription('channel'), + throwsA(isA()), + ); + client.close(); + }); + + test('Auto_refresh', () { + late Timer pingTimer; + var pings = 0, refreshes = 0; + final client = createFakeClient( + getToken: () async => 'token', + transport: (_) async => WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { if (command.hasConnect()) { final reply = pb.Reply( id: command.id, connect: pb.ConnectResult( client: 'fake', version: '0.0.1', - expires: false, - ttl: null, + expires: true, + ttl: 600, data: null, subs: {}, - ping: 600, + ping: 120, pong: true, session: 'fake', node: 'fake', ), ); - scheduleMicrotask(() { - sink.add(ProtobufCodec.encode(reply)); - }); - } else if (command.hasPing()) { - serverPongCount++; + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + pingTimer = Timer.periodic( + Duration(milliseconds: reply.connect.ping), + (_) { + sink.add(ProtobufCodec.encode(pb.Reply())); + pings++; + }, + ); + } else if (command.hasRefresh()) { + if (command.refresh.token.isEmpty) return; + final reply = pb.Reply() + ..id = command.id + ..refresh = pb.RefreshResult( + client: 'fake', + version: '0.0.1', + expires: true, + ttl: 600, + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + refreshes++; } - } - ..onDone = () {}; - webSockets.add(ws); - return ws; - }); - expectLater( - client.states, - emitsInOrder( - [ - isA(), - isA(), - isA(), - isA(), - isA(), - isA(), - isA(), - isA(), - isA(), - isA(), - isA(), - isA(), - ], - ), - ); - unawaited(client.connect(url)); - async.elapse(client.config.timeout); - expect(client.state, isA()); - final pingInterval = - (client.state as SpinifyState$Connected).pingInterval!; - async.elapse( - (pingInterval + - client.config.timeout + - client.config.serverPingDelay) * - 10, - ); - expect(webSockets.length, greaterThan(1)); - expect(serverPingCount, isZero); - expect(serverPongCount, isZero); + }); + } + ..onDone = () { + pingTimer.cancel(); + }, + ); + return fakeAsync((async) { + client.connect(url); + async.elapse(const Duration(hours: 3)); + expect(client.state.isConnected, isTrue); + expect(client.isClosed, isFalse); client.close(); - async.elapse(const Duration(seconds: 1)); - expect(webSockets.every((ws) => ws.isClosed), isTrue); - }, - ), - ); + expect(client.state.isClosed, isTrue); + expect(pings, greaterThanOrEqualTo(3 * 60 * 60 ~/ 120)); + expect(refreshes, greaterThanOrEqualTo(3 * 60 * 60 ~/ 600)); + }); + }); - test( - 'ready', - () => fakeAsync((async) { - final client = createFakeClient(); - expectLater(client.ready(), completes); - client.connect(url); - //expectLater(client.ready(), completes); - async.elapse(client.config.timeout); - expect(client.state, isA()); - expectLater(client.ready(), completes); - async.elapse(client.config.timeout); - client.close(); - }), - ); - - test('do_not_ready', () { - final client = createFakeClient(); - expectLater( - client.ready(), - throwsA(isA()), - ); - expectLater( - client.send([1, 2, 3]), - throwsA(isA()), - ); - expectLater( - client.rpc('echo', [1, 2, 3]), - throwsA(isA()), - ); - client.close(); - }); - - test('subscribtion_asserts', () { - final client = createFakeClient(); - expect( - () => client.newSubscription(''), - throwsA(isA()), - ); - expect( - () => client.newSubscription(' '), - throwsA(isA()), - ); - expect( - () => client.newSubscription(String.fromCharCode(0x7f + 1)), - throwsA(isA()), - ); - expect( - () => client.newSubscription('๐Ÿ˜€, ๐ŸŒ, ๐ŸŽ‰, ๐Ÿ‘‹'), - throwsA(isA()), - ); - expect( - () => client.newSubscription('channel' * 100), - throwsA(isA()), - ); - expect( - () => client.newSubscription('channel'), - returnsNormally, - ); - expect( - () => client.newSubscription('channel'), - throwsA(isA()), - ); - client.close(); - }); - - test('Auto_refresh', () { - late Timer pingTimer; - var pings = 0, refreshes = 0; - final client = createFakeClient( - getToken: () async => 'token', - transport: (_) async => WebSocket$Fake() - ..onAdd = (bytes, sink) { - final command = ProtobufCodec.decode(pb.Command(), bytes); - scheduleMicrotask(() { - if (command.hasConnect()) { - final reply = pb.Reply( - id: command.id, - connect: pb.ConnectResult( - client: 'fake', - version: '0.0.1', - expires: true, - ttl: 600, - data: null, - subs: {}, - ping: 120, - pong: true, - session: 'fake', - node: 'fake', - ), - ); - final bytes = ProtobufCodec.encode(reply); - sink.add(bytes); - pingTimer = Timer.periodic( - Duration(milliseconds: reply.connect.ping), - (_) { - sink.add(ProtobufCodec.encode(pb.Reply())); - pings++; - }, - ); - } else if (command.hasRefresh()) { - if (command.refresh.token.isEmpty) return; - final reply = pb.Reply() - ..id = command.id - ..refresh = pb.RefreshResult( - client: 'fake', - version: '0.0.1', - expires: true, - ttl: 600, - ); - final bytes = ProtobufCodec.encode(reply); - sink.add(bytes); - refreshes++; - } - }); - } - ..onDone = () { - pingTimer.cancel(); + test( + 'Connection_error_retry', + () => fakeAsync( + (async) { + late Timer pingTimer; + var pings = 0, retries = 0; + late final client = createFakeClient( + getToken: () async => 'token', + transport: (_) async { + late WebSocket$Fake ws; + return ws = WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + if (retries < 2) { + final reply = pb.Reply( + id: command.id, + error: pb.Error( + code: 3000, + message: 'Fake connection error', + temporary: true, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + retries++; + } else { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: true, + ttl: 600, + data: null, + subs: {}, + ping: 120, + pong: false, + session: 'fake', + node: 'fake', + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + pingTimer = Timer.periodic( + Duration(milliseconds: reply.connect.ping), + (timer) { + if (ws.isClosed) { + timer.cancel(); + } else { + sink.add(ProtobufCodec.encode(pb.Reply())); + pings++; + } + }, + ); + } + } else if (command.hasRefresh()) { + if (command.refresh.token.isEmpty) return; + final reply = pb.Reply() + ..id = command.id + ..refresh = pb.RefreshResult( + client: 'fake', + version: '0.0.1', + expires: true, + ttl: 600, + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } + }); + } + ..onDone = () { + pingTimer.cancel(); + }; + }, + ); + expectLater( + client.connect(url), + completion(throwsA(isA())), + ); + client.states.forEach((s) => print(' *** State: $s')); + /* async.elapse(client.config.timeout); + expect( + client.state, + isA().having( + (s) => s.temporary, + 'temporary', + isTrue, + )); */ + async.elapse(const Duration(hours: 3)); + /* expect(client.state.isConnected, isTrue); + expect(client.isClosed, isFalse); */ + client.close(); + async.elapse(const Duration(minutes: 1)); + expect(client.state.isClosed, isTrue); + /* expect(pings, greaterThanOrEqualTo(1)); + expect(retries, equals(2)); */ }, + ), + skip: true, ); - return fakeAsync((async) { - client.connect(url); - async.elapse(const Duration(hours: 3)); - expect(client.state.isConnected, isTrue); - expect(client.isClosed, isFalse); - client.close(); - expect(client.state.isClosed, isTrue); - expect(pings, greaterThanOrEqualTo(3 * 60 * 60 ~/ 120)); - expect(refreshes, greaterThanOrEqualTo(3 * 60 * 60 ~/ 600)); - }); - }); - }); + }, + ); } diff --git a/test/unit/web_socket_fake.dart b/test/unit/web_socket_fake.dart index 61bfe5e..a7febee 100644 --- a/test/unit/web_socket_fake.dart +++ b/test/unit/web_socket_fake.dart @@ -119,7 +119,7 @@ class WebSocket$Fake implements WebSocket { /// Send asynchroniously a reply to the client. void reply(List bytes) { - _socket!.sink.add(bytes); + _socket?.sink.add(bytes); } @override @@ -127,7 +127,11 @@ class WebSocket$Fake implements WebSocket { _closeCode = code; _closeReason = reason; _isClosed = true; - _socket!.close().ignore(); + final socket = _socket; + if (socket != null && !socket.isClosed) { + _socket?.close().ignore(); + _socket = null; + } } /// Reset the WebSocket client. From 8dab277cc17d74e92d1cf76f9579a47ece3bbcb7 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Tue, 5 Nov 2024 00:53:54 +0400 Subject: [PATCH 061/104] Update .gitignore to exclude reports and .reports directories --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index cdce770..0f205e5 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,8 @@ coverage/ .coverage/ /test/**/*.json /test/.test_coverage.dart +reports/ +.reports/ # Centifuge centrifugo-config.json From e26ed44278ace0458c349c5e15993f5b593d2259 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 7 Nov 2024 19:08:10 +0400 Subject: [PATCH 062/104] Refactor Makefile to simplify build pipeline and add CI support --- Makefile | 17 ++++++----------- lib/src/spinify.dart | 12 ++++++++++++ test/unit/spinify_test.dart | 22 +++++++++++----------- 3 files changed, 29 insertions(+), 22 deletions(-) diff --git a/Makefile b/Makefile index 297491f..e807e91 100644 --- a/Makefile +++ b/Makefile @@ -1,20 +1,15 @@ -ifeq ($(OS),Windows_NT) - SHELL = cmd - RM = del /Q - MKDIR = mkdir - PWD = $(shell $(PWD)) -else - SHELL = /bin/bash -e -o pipefail - RM = rm -f - MKDIR = mkdir -p - PWD = pwd -endif +SHELL :=/bin/bash -e -o pipefail +PWD :=$(shell pwd) .DEFAULT_GOAL := all .PHONY: all all: ## build pipeline all: generate format check test +.PHONY: ci +ci: ## CI build pipeline +ci: all + .PHONY: precommit precommit: ## validate the branch before commit precommit: all diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 78cfbd4..b5250ee 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -709,6 +709,18 @@ final class Spinify implements ISpinify { protocols: [_codec.protocol], ); + if (isClosed) { + _log( + const SpinifyLogLevel.warning(), + 'closed_during_connect_error', + 'Client is closed during connect', + {}, + ); + throw const SpinifyConnectionException( + message: 'Client is closed during connect', + ); + } + // Create handler for connect reply. final connectResultCompleter = Completer(); diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index 7f93748..eff4969 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -886,6 +886,7 @@ void main() { }); }); + // Retry connection after temporary error test( 'Connection_error_retry', () => fakeAsync( @@ -967,26 +968,25 @@ void main() { client.connect(url), completion(throwsA(isA())), ); - client.states.forEach((s) => print(' *** State: $s')); + //client.states.forEach((s) => print(' *** State: $s')); /* async.elapse(client.config.timeout); - expect( - client.state, - isA().having( - (s) => s.temporary, - 'temporary', - isTrue, + expect( + client.state, + isA().having( + (s) => s.temporary, + 'temporary', + isTrue, )); */ - async.elapse(const Duration(hours: 3)); + //async.elapse(const Duration(hours: 3)); /* expect(client.state.isConnected, isTrue); - expect(client.isClosed, isFalse); */ + expect(client.isClosed, isFalse); */ client.close(); async.elapse(const Duration(minutes: 1)); expect(client.state.isClosed, isTrue); /* expect(pings, greaterThanOrEqualTo(1)); - expect(retries, equals(2)); */ + expect(retries, equals(2)); */ }, ), - skip: true, ); }, ); From 25a0086a0f8ad5c3bf5c1fc25903421d1d0f0521 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 7 Nov 2024 20:45:30 +0400 Subject: [PATCH 063/104] Refactor Spinify.dart to improve null safety, connection handling, and error handling --- lib/src/spinify.dart | 159 +++++++++++++++++++++++++-------- test/unit/spinify_test.dart | 60 +++++++++++-- test/unit/web_socket_fake.dart | 11 +-- 3 files changed, 180 insertions(+), 50 deletions(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index b5250ee..9f712fb 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -631,10 +631,6 @@ final class Spinify implements ISpinify { @protected @nonVirtual Future _internalReconnect(String url) async { - final readyCompleter = _readyCompleter = switch (_readyCompleter) { - Completer value when !value.isCompleted => value, - _ => Completer(), - }; if (state.isConnected || state.isConnecting) { _internalDisconnect( code: const SpinifyDisconnectCode.normalClosure(), @@ -642,6 +638,10 @@ final class Spinify implements ISpinify { reconnect: false, ); } + final readyCompleter = _readyCompleter = switch (_readyCompleter) { + Completer value when !value.isCompleted => value, + _ => Completer(), + }; try { if (!state.isDisconnected) { _log( @@ -669,11 +669,82 @@ final class Spinify implements ISpinify { _setState(SpinifyState$Connecting(url: _metrics.reconnectUrl = url)); assert(state.isConnecting, 'State should be connecting'); + void checkStillConnecting() { + // coverage:ignore-start + if (isClosed) { + _log( + const SpinifyLogLevel.warning(), + 'closed_during_connect_error', + 'Client is closed during connect', + {}, + ); + throw const SpinifyConnectionException( + message: 'Client is closed during connect', + ); + } else if (!state.isConnecting) { + _log( + const SpinifyLogLevel.warning(), + 'state_changed_during_connect_error', + 'State changed during connect', + { + 'state': state, + }, + ); + throw const SpinifyConnectionException( + message: 'State changed during connect', + ); + } else if (!identical(url, _metrics.reconnectUrl)) { + _log( + const SpinifyLogLevel.warning(), + 'url_changed_during_connect_error', + 'URL changed during connect', + { + 'url': url, + 'reconnectUrl': _metrics.reconnectUrl, + }, + ); + throw const SpinifyConnectionException( + message: 'URL changed during connect', + ); + } else if (readyCompleter.isCompleted) { + _log( + const SpinifyLogLevel.warning(), + 'ready_completer_completed_error', + 'Ready completer is already completed', + { + 'readyCompleter': readyCompleter, + }, + ); + throw const SpinifyConnectionException( + message: 'Ready completer is already completed', + ); + } else if (!identical(_readyCompleter, readyCompleter)) { + _log( + const SpinifyLogLevel.warning(), + 'ready_completer_changed_error', + 'Ready completer changed during connect', + { + 'readyCompleter': _readyCompleter, + 'newReadyCompleter': readyCompleter, + }, + ); + throw const SpinifyConnectionException( + message: 'Ready completer changed during connect', + ); + } + // coverage:ignore-end + } + + checkStillConnecting(); + // Prepare connect request. final SpinifyConnectRequest request; { final token = await config.getToken?.call(); final payload = await config.getPayload?.call(); + + checkStillConnecting(); + final id = _getNextCommandId(); final now = DateTime.now(); request = SpinifyConnectRequest( @@ -702,6 +773,8 @@ final class Spinify implements ISpinify { ); } + checkStillConnecting(); + // Create a new transport final ws = _transport = await _webSocketConnect( url: url, @@ -709,17 +782,7 @@ final class Spinify implements ISpinify { protocols: [_codec.protocol], ); - if (isClosed) { - _log( - const SpinifyLogLevel.warning(), - 'closed_during_connect_error', - 'Client is closed during connect', - {}, - ); - throw const SpinifyConnectionException( - message: 'Client is closed during connect', - ); - } + checkStillConnecting(); // Create handler for connect reply. final connectResultCompleter = Completer(); @@ -829,8 +892,13 @@ final class Spinify implements ISpinify { ); await _sendCommandAsync(request); + + checkStillConnecting(); + final result = await connectResultCompleter.future; + checkStillConnecting(); + if (!state.isConnecting) { throw const SpinifyConnectionException( message: 'Connection is not in connecting state', @@ -945,14 +1013,29 @@ final class Spinify implements ISpinify { @protected @nonVirtual Future _interactiveDisconnect() async { - _tearDownReconnectTimer(); - _tearDownPingTimer(); - _metrics.reconnectUrl = null; - _internalDisconnect( - code: const SpinifyDisconnectCode.normalClosure(), - reason: 'normal closure', - reconnect: false, - ); + try { + _tearDownReconnectTimer(); + _tearDownPingTimer(); + _metrics.reconnectUrl = null; + _internalDisconnect( + code: const SpinifyDisconnectCode.normalClosure(), + reason: 'normal closure', + reconnect: false, + ); + } on Object catch (error, stackTrace) { + // coverage:ignore-start + // Normally we should not get here. + _log( + const SpinifyLogLevel.warning(), + 'disconnect_error', + 'Error on disconnect', + { + 'error': error, + 'stackTrace': stackTrace, + }, + ); + // coverage:ignore-end + } } /// Library initiated disconnect. @@ -982,10 +1065,10 @@ final class Spinify implements ISpinify { // Close all pending replies with error. const error = SpinifyReplyException( replyCode: 0, - replyMessage: 'Client is disconnected', + replyMessage: 'Disconnected', temporary: true, ); - late final stackTrace = StackTrace.current; + const stackTrace = StackTrace.empty; for (final completer in _replies.values) { if (completer.isCompleted) continue; completer.completeError(error, stackTrace); @@ -1007,7 +1090,12 @@ final class Spinify implements ISpinify { // Complete ready completer with error, // if we still waiting for connection. if (_readyCompleter case Completer c when !c.isCompleted) { - c.completeError(error, stackTrace); + c.completeError( + const SpinifyConnectionException( + message: 'Disconnected during connection', + ), + stackTrace, + ); } // Reconnect if [reconnect] is true and we have reconnect URL. @@ -1022,17 +1110,16 @@ final class Spinify implements ISpinify { 'stackTrace': stackTrace, }, ); - } finally { - _setState(SpinifyState$Disconnected(temporary: reconnect)); - _log( - const SpinifyLogLevel.config(), - 'disconnected', - 'Disconnected from server ${reconnect ? 'temporarily' : 'permanent'}', - { - 'temporary': reconnect, - }, - ); } + _setState(SpinifyState$Disconnected(temporary: reconnect)); + _log( + const SpinifyLogLevel.config(), + 'disconnected', + 'Disconnected from server ${reconnect ? 'temporarily' : 'permanent'}', + { + 'temporary': reconnect, + }, + ); } // --- Close --- // diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index eff4969..d47715f 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -886,8 +886,49 @@ void main() { }); }); - // Retry connection after temporary error + test('Error_future', () { + final fakeException = Exception('Fake error'); + expect(fakeException, isA()); + Future.error(fakeException).ignore(); + unawaited( + expectLater( + Future.error(fakeException), + throwsA(isA()), + ), + ); + unawaited( + expectLater( + Future.delayed(const Duration(milliseconds: 5), () { + throw fakeException; + }), + throwsA(isA()), + ), + ); + }); + test( + 'Disconnect_during_connection', + () async { + final client = createFakeClient(); + unawaited( + expectLater( + client.connect(url).onError((error, stackTrace) { + Error.throwWithStackTrace(error!, stackTrace); + }), + throwsA(anything), + ), + ); + await client.disconnect(); + expect(client.state.isDisconnected, isTrue); + expect(client.state.isClosed, isFalse); + await client.close(); + expect(client.state.isClosed, isTrue); + }, + skip: true, + ); + + // Retry connection after temporary error + /* test( 'Connection_error_retry', () => fakeAsync( (async) { @@ -966,28 +1007,29 @@ void main() { ); expectLater( client.connect(url), - completion(throwsA(isA())), + throwsA(isA()), ); //client.states.forEach((s) => print(' *** State: $s')); - /* async.elapse(client.config.timeout); + async.elapse(client.config.timeout); expect( client.state, isA().having( (s) => s.temporary, 'temporary', isTrue, - )); */ + )); //async.elapse(const Duration(hours: 3)); - /* expect(client.state.isConnected, isTrue); - expect(client.isClosed, isFalse); */ + expect(client.state.isConnected, isTrue); + expect(client.isClosed, isFalse); client.close(); async.elapse(const Duration(minutes: 1)); expect(client.state.isClosed, isTrue); - /* expect(pings, greaterThanOrEqualTo(1)); - expect(retries, equals(2)); */ + expect(pings, greaterThanOrEqualTo(1)); + expect(retries, equals(2)); }, ), - ); + skip: true, + ); */ }, ); } diff --git a/test/unit/web_socket_fake.dart b/test/unit/web_socket_fake.dart index a7febee..eefde7e 100644 --- a/test/unit/web_socket_fake.dart +++ b/test/unit/web_socket_fake.dart @@ -32,9 +32,10 @@ class WebSocket$Fake implements WebSocket { } // Default callbacks to handle connects and disconnects. - static void _defaultOnAddCallback(List bytes, Sink> sink) { + void _defaultOnAddCallback(List bytes, Sink> sink) { final command = ProtobufCodec.decode(pb.Command(), bytes); - scheduleMicrotask(() { + Future.delayed(const Duration(milliseconds: 5), () { + if (isClosed) return; // Connection is closed, ignore command processing. if (command.hasConnect()) { sink.add( ProtobufCodec.encode( @@ -59,7 +60,7 @@ class WebSocket$Fake implements WebSocket { }); } - static void _defaultOnDoneCallback() {} + void _defaultOnDoneCallback() {} StreamController>? _socket; @@ -111,11 +112,11 @@ class WebSocket$Fake implements WebSocket { } /// Add callback to handle sending data and allow to respond with reply. - void Function(List bytes, Sink> sink) onAdd = + late void Function(List bytes, Sink> sink) onAdd = _defaultOnAddCallback; /// Add callback to handle socket close event. - void Function() onDone = _defaultOnDoneCallback; + late void Function() onDone = _defaultOnDoneCallback; /// Send asynchroniously a reply to the client. void reply(List bytes) { From c87744a84e7759b5792f149791f858d5ab11797b Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 7 Nov 2024 20:46:52 +0400 Subject: [PATCH 064/104] Refactor Spinify_test.dart to handle disconnection during connection --- test/unit/spinify_test.dart | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index d47715f..f7a29ff 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -909,20 +909,24 @@ void main() { test( 'Disconnect_during_connection', () async { - final client = createFakeClient(); - unawaited( - expectLater( - client.connect(url).onError((error, stackTrace) { - Error.throwWithStackTrace(error!, stackTrace); - }), - throwsA(anything), - ), - ); - await client.disconnect(); - expect(client.state.isDisconnected, isTrue); - expect(client.state.isClosed, isFalse); - await client.close(); - expect(client.state.isClosed, isTrue); + try { + final client = createFakeClient(); + unawaited( + expectLater( + client.connect(url).onError((error, stackTrace) { + Error.throwWithStackTrace(error!, stackTrace); + }), + throwsA(anything), + ), + ); + await client.disconnect(); + expect(client.state.isDisconnected, isTrue); + expect(client.state.isClosed, isFalse); + await client.close(); + expect(client.state.isClosed, isTrue); + } on Object { + print('Wat?'); + } }, skip: true, ); From 496867f5ddb01a295d1507a4aac3c29de9ab1ea9 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 7 Nov 2024 21:24:44 +0400 Subject: [PATCH 065/104] Refactor Spinify_test.dart to remove skipped test case --- lib/src/spinify.dart | 864 ++++++++++++++++---------------- lib/src/util/async_guarded.dart | 43 ++ test/unit/spinify_test.dart | 1 - 3 files changed, 484 insertions(+), 424 deletions(-) create mode 100644 lib/src/util/async_guarded.dart diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 9f712fb..aed61d7 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -28,6 +28,7 @@ import 'model/transport_interface.dart'; import 'protobuf/protobuf_codec.dart'; import 'spinify_interface.dart'; import 'subscription_interface.dart'; +import 'util/async_guarded.dart'; import 'util/backoff.dart'; import 'web_socket_stub.dart' // ignore: uri_does_not_exist @@ -539,26 +540,26 @@ final class Spinify implements ISpinify { @unsafe @override @Throws([SpinifyConnectionException]) - Future ready() async { - const error = SpinifyConnectionException( - message: 'Connection is closed permanently', - ); - if (state.isConnected) return; - if (state.isClosed) throw error; - try { - await (_readyCompleter ??= Completer()).future; - } on SpinifyConnectionException { - rethrow; - } on Object catch (error, stackTrace) { - Error.throwWithStackTrace( - SpinifyConnectionException( - message: 'Failed to wait for connection', - error: error, - ), - stackTrace, - ); - } - } + Future ready() => asyncGuarded(() async { + const error = SpinifyConnectionException( + message: 'Connection is closed permanently', + ); + if (state.isConnected) return; + if (state.isClosed) throw error; + try { + await (_readyCompleter ??= Completer()).future; + } on SpinifyConnectionException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyConnectionException( + message: 'Failed to wait for connection', + error: error, + ), + stackTrace, + ); + } + }); /// Plan to do action when client is connected. @unsafe @@ -630,376 +631,377 @@ final class Spinify implements ISpinify { @unsafe @protected @nonVirtual - Future _internalReconnect(String url) async { - if (state.isConnected || state.isConnecting) { - _internalDisconnect( - code: const SpinifyDisconnectCode.normalClosure(), - reason: 'normal closure', - reconnect: false, - ); - } - final readyCompleter = _readyCompleter = switch (_readyCompleter) { - Completer value when !value.isCompleted => value, - _ => Completer(), - }; - try { - if (!state.isDisconnected) { - _log( - const SpinifyLogLevel.warning(), - 'reconnect_error', - 'Failed to reconnect: state is not disconnected', - { - 'state': state, - }, - ); - assert( - false, - 'State should be disconnected', - ); - return; - } - assert( - _transport == null, - 'Transport should be null', - ); - assert( - _replySubscription == null, - 'Reply subscription should be null', - ); - _setState(SpinifyState$Connecting(url: _metrics.reconnectUrl = url)); - assert(state.isConnecting, 'State should be connecting'); - - void checkStillConnecting() { - // coverage:ignore-start - if (isClosed) { - _log( - const SpinifyLogLevel.warning(), - 'closed_during_connect_error', - 'Client is closed during connect', - {}, - ); - throw const SpinifyConnectionException( - message: 'Client is closed during connect', - ); - } else if (!state.isConnecting) { - _log( - const SpinifyLogLevel.warning(), - 'state_changed_during_connect_error', - 'State changed during connect', - { - 'state': state, - }, - ); - throw const SpinifyConnectionException( - message: 'State changed during connect', - ); - } else if (!identical(url, _metrics.reconnectUrl)) { - _log( - const SpinifyLogLevel.warning(), - 'url_changed_during_connect_error', - 'URL changed during connect', - { - 'url': url, - 'reconnectUrl': _metrics.reconnectUrl, - }, + Future _internalReconnect(String url) => asyncGuarded(() async { + if (state.isConnected || state.isConnecting) { + _internalDisconnect( + code: const SpinifyDisconnectCode.normalClosure(), + reason: 'normal closure', + reconnect: false, ); - throw const SpinifyConnectionException( - message: 'URL changed during connect', - ); - } else if (readyCompleter.isCompleted) { - _log( - const SpinifyLogLevel.warning(), - 'ready_completer_completed_error', - 'Ready completer is already completed', - { - 'readyCompleter': readyCompleter, - }, - ); - throw const SpinifyConnectionException( - message: 'Ready completer is already completed', - ); - } else if (!identical(_readyCompleter, readyCompleter)) { - _log( - const SpinifyLogLevel.warning(), - 'ready_completer_changed_error', - 'Ready completer changed during connect', - { - 'readyCompleter': _readyCompleter, - 'newReadyCompleter': readyCompleter, - }, + } + final readyCompleter = _readyCompleter = switch (_readyCompleter) { + Completer value when !value.isCompleted => value, + _ => Completer(), + }; + try { + if (!state.isDisconnected) { + _log( + const SpinifyLogLevel.warning(), + 'reconnect_error', + 'Failed to reconnect: state is not disconnected', + { + 'state': state, + }, + ); + assert( + false, + 'State should be disconnected', + ); + return; + } + assert( + _transport == null, + 'Transport should be null', ); - throw const SpinifyConnectionException( - message: 'Ready completer changed during connect', + assert( + _replySubscription == null, + 'Reply subscription should be null', ); - } - // coverage:ignore-end - } + _setState(SpinifyState$Connecting(url: _metrics.reconnectUrl = url)); + assert(state.isConnecting, 'State should be connecting'); - checkStillConnecting(); + void checkStillConnecting() { + // coverage:ignore-start + if (isClosed) { + _log( + const SpinifyLogLevel.warning(), + 'closed_during_connect_error', + 'Client is closed during connect', + {}, + ); + throw const SpinifyConnectionException( + message: 'Client is closed during connect', + ); + } else if (!state.isConnecting) { + _log( + const SpinifyLogLevel.warning(), + 'state_changed_during_connect_error', + 'State changed during connect', + { + 'state': state, + }, + ); + throw const SpinifyConnectionException( + message: 'State changed during connect', + ); + } else if (!identical(url, _metrics.reconnectUrl)) { + _log( + const SpinifyLogLevel.warning(), + 'url_changed_during_connect_error', + 'URL changed during connect', + { + 'url': url, + 'reconnectUrl': _metrics.reconnectUrl, + }, + ); + throw const SpinifyConnectionException( + message: 'URL changed during connect', + ); + } else if (readyCompleter.isCompleted) { + _log( + const SpinifyLogLevel.warning(), + 'ready_completer_completed_error', + 'Ready completer is already completed', + { + 'readyCompleter': readyCompleter, + }, + ); + throw const SpinifyConnectionException( + message: 'Ready completer is already completed', + ); + } else if (!identical(_readyCompleter, readyCompleter)) { + _log( + const SpinifyLogLevel.warning(), + 'ready_completer_changed_error', + 'Ready completer changed during connect', + { + 'readyCompleter': _readyCompleter, + 'newReadyCompleter': readyCompleter, + }, + ); + throw const SpinifyConnectionException( + message: 'Ready completer changed during connect', + ); + } + // coverage:ignore-end + } - // Prepare connect request. - final SpinifyConnectRequest request; - { - final token = await config.getToken?.call(); - final payload = await config.getPayload?.call(); + checkStillConnecting(); - checkStillConnecting(); + // Prepare connect request. + final SpinifyConnectRequest request; + { + final token = await config.getToken?.call(); + final payload = await config.getPayload?.call(); - final id = _getNextCommandId(); - final now = DateTime.now(); - request = SpinifyConnectRequest( - id: id, - timestamp: now, - token: token, - data: payload, - subs: { - for (final sub in _serverSubscriptionRegistry.values) - sub.channel: SpinifySubscribeRequest( - id: id, - timestamp: now, - channel: sub.channel, - recover: sub.recoverable, - epoch: sub.epoch, - offset: sub.offset, - token: null, - data: null, - positioned: null, - recoverable: null, - joinLeave: null, - ), - }, - name: config.client.name, - version: config.client.version, - ); - } + checkStillConnecting(); - checkStillConnecting(); + final id = _getNextCommandId(); + final now = DateTime.now(); + request = SpinifyConnectRequest( + id: id, + timestamp: now, + token: token, + data: payload, + subs: { + for (final sub in _serverSubscriptionRegistry.values) + sub.channel: SpinifySubscribeRequest( + id: id, + timestamp: now, + channel: sub.channel, + recover: sub.recoverable, + epoch: sub.epoch, + offset: sub.offset, + token: null, + data: null, + positioned: null, + recoverable: null, + joinLeave: null, + ), + }, + name: config.client.name, + version: config.client.version, + ); + } - // Create a new transport - final ws = _transport = await _webSocketConnect( - url: url, - headers: config.headers, - protocols: [_codec.protocol], - ); + checkStillConnecting(); - checkStillConnecting(); + // Create a new transport + final ws = _transport = await _webSocketConnect( + url: url, + headers: config.headers, + protocols: [_codec.protocol], + ); - // Create handler for connect reply. - final connectResultCompleter = Completer(); + checkStillConnecting(); - // ignore: omit_local_variable_types - void Function(SpinifyReply reply) handleReply = (reply) { - if (connectResultCompleter.isCompleted) { - _log( - const SpinifyLogLevel.warning(), - 'connect_result_error', - 'Connect result completer is already completed', - { - 'reply': reply, - }, - ); - } else if (reply is SpinifyConnectResult) { - connectResultCompleter.complete(reply); - } else if (reply is SpinifyErrorResult) { - connectResultCompleter.completeError(reply); - } else { - connectResultCompleter.completeError( - const SpinifyConnectionException( - message: 'Unexpected reply received', - ), - ); - } - }; + // Create handler for connect reply. + final connectResultCompleter = Completer(); - void handleDone() { - assert(() { - if (!identical(ws, _transport)) { + // ignore: omit_local_variable_types + void Function(SpinifyReply reply) handleReply = (reply) { + if (connectResultCompleter.isCompleted) { + _log( + const SpinifyLogLevel.warning(), + 'connect_result_error', + 'Connect result completer is already completed', + { + 'reply': reply, + }, + ); + } else if (reply is SpinifyConnectResult) { + connectResultCompleter.complete(reply); + } else if (reply is SpinifyErrorResult) { + connectResultCompleter.completeError(reply); + } else { + connectResultCompleter.completeError( + const SpinifyConnectionException( + message: 'Unexpected reply received', + ), + ); + } + }; + + void handleDone() { + assert(() { + if (!identical(ws, _transport)) { + _log( + const SpinifyLogLevel.warning(), + 'transport_closed_error', + 'Transport closed on different and not active transport', + { + 'transport': ws, + }, + ); + } + return true; + }(), '...'); + var WebSocket(:int? closeCode, :String? closeReason) = ws; + final close = + SpinifyDisconnectCode.normalize(closeCode, closeReason); _log( - const SpinifyLogLevel.warning(), - 'transport_closed_error', - 'Transport closed on different and not active transport', + const SpinifyLogLevel.transport(), + 'transport_disconnect', + 'Transport disconnected ' + '${close.reconnect ? 'temporarily' : 'permanently'} ' + 'with reason: ${close.reason}', { - 'transport': ws, + 'code': close.code, + 'reason': close.reason, + 'reconnect': close.reconnect, }, ); + _internalDisconnect( + code: close.code, + reason: close.reason, + reconnect: close.reconnect, + ); } - return true; - }(), '...'); - var WebSocket(:int? closeCode, :String? closeReason) = ws; - final close = SpinifyDisconnectCode.normalize(closeCode, closeReason); - _log( - const SpinifyLogLevel.transport(), - 'transport_disconnect', - 'Transport disconnected ' - '${close.reconnect ? 'temporarily' : 'permanently'} ' - 'with reason: ${close.reason}', - { - 'code': close.code, - 'reason': close.reason, - 'reconnect': close.reconnect, - }, - ); - _internalDisconnect( - code: close.code, - reason: close.reason, - reconnect: close.reconnect, - ); - } - _replySubscription = - ws.stream.transform(StreamTransformer.fromHandlers( - handleData: (data, sink) { - _metrics - ..bytesReceived += data.length - ..chunksReceived += 1; - for (final reply in _codec.decoder.convert(data)) { - _metrics.repliesDecoded += 1; - sink.add(reply); - } - }, - )).listen( - (reply) { - assert(() { - if (!identical(ws, _transport)) { + _replySubscription = + ws.stream.transform(StreamTransformer.fromHandlers( + handleData: (data, sink) { + _metrics + ..bytesReceived += data.length + ..chunksReceived += 1; + for (final reply in _codec.decoder.convert(data)) { + _metrics.repliesDecoded += 1; + sink.add(reply); + } + }, + )).listen( + (reply) { + assert(() { + if (!identical(ws, _transport)) { + _log( + const SpinifyLogLevel.warning(), + 'wrong_transport_error', + 'Reply received on different and not active transport', + { + 'transport': ws, + 'reply': reply, + }, + ); + } + return true; + }(), '...'); + + handleReply(reply); // Handle replies + }, + onDone: handleDone, + onError: (Object error, StackTrace stackTrace) { _log( const SpinifyLogLevel.warning(), - 'wrong_transport_error', - 'Reply received on different and not active transport', + 'reply_error', + 'Error receiving reply', { - 'transport': ws, - 'reply': reply, + 'error': error, + 'stackTrace': stackTrace, }, ); - } - return true; - }(), '...'); - - handleReply(reply); // Handle replies - }, - onDone: handleDone, - onError: (Object error, StackTrace stackTrace) { - _log( - const SpinifyLogLevel.warning(), - 'reply_error', - 'Error receiving reply', - { - 'error': error, - 'stackTrace': stackTrace, }, + cancelOnError: false, ); - }, - cancelOnError: false, - ); - await _sendCommandAsync(request); + await _sendCommandAsync(request); - checkStillConnecting(); + checkStillConnecting(); - final result = await connectResultCompleter.future; + final result = await connectResultCompleter.future; - checkStillConnecting(); + checkStillConnecting(); - if (!state.isConnecting) { - throw const SpinifyConnectionException( - message: 'Connection is not in connecting state', - ); - } else if (!identical(ws, _transport)) { - throw const SpinifyConnectionException( - message: 'Transport is not the same as created', - ); - } + if (!state.isConnecting) { + throw const SpinifyConnectionException( + message: 'Connection is not in connecting state', + ); + } else if (!identical(ws, _transport)) { + throw const SpinifyConnectionException( + message: 'Transport is not the same as created', + ); + } - _setState(SpinifyState$Connected( - url: url, - client: result.client, - version: result.version, - expires: result.expires, - ttl: result.ttl, - node: result.node, - pingInterval: result.pingInterval, - sendPong: result.sendPong, - session: result.session, - data: result.data, - )); - - _onReply(result); // Handle connect reply - handleReply = _onReply; // Switch to normal reply handler - - _setUpRefreshConnection(); // Start refresh connection timer - _setUpPingTimer(); // Start expecting ping messages - - // Notify ready. - if (readyCompleter.isCompleted) { - throw const SpinifyConnectionException( - message: 'Ready completer is already completed. Why so?', - ); - } else { - readyCompleter.complete(); - _readyCompleter = null; - } + _setState(SpinifyState$Connected( + url: url, + client: result.client, + version: result.version, + expires: result.expires, + ttl: result.ttl, + node: result.node, + pingInterval: result.pingInterval, + sendPong: result.sendPong, + session: result.session, + data: result.data, + )); - _metrics.lastConnectAt = DateTime.now(); - _metrics.connects++; + _onReply(result); // Handle connect reply + handleReply = _onReply; // Switch to normal reply handler - _log( - const SpinifyLogLevel.config(), - 'connected', - 'Connected to server with $url successfully', - { - 'url': url, - 'request': request, - 'result': result, - }, - ); - } on Object catch ($error, stackTrace) { - final SpinifyConnectionException error; - if ($error is SpinifyConnectionException) { - error = $error; - } else { - error = SpinifyConnectionException( - message: 'Error connecting to server $url', - error: $error, - ); - } - if (!readyCompleter.isCompleted) - readyCompleter.completeError(error, stackTrace); - _readyCompleter = null; - _log( - const SpinifyLogLevel.error(), - 'connect_error', - 'Error connecting to server $url', - { - 'url': url, - 'error': error, - 'stackTrace': stackTrace, - }, - ); + _setUpRefreshConnection(); // Start refresh connection timer + _setUpPingTimer(); // Start expecting ping messages - final transport = _transport; // Close transport - if (transport != null && !transport.isClosed) transport.close(); - _transport = null; + // Notify ready. + if (readyCompleter.isCompleted) { + throw const SpinifyConnectionException( + message: 'Ready completer is already completed. Why so?', + ); + } else { + readyCompleter.complete(); + _readyCompleter = null; + } - switch ($error) { - case SpinifyErrorResult result: - if (result.code == 109) { - // Token expired error. - _setUpReconnectTimer(); // Retry resubscribe - } else if (result.temporary) { - // Temporary error. - _setUpReconnectTimer(); // Retry resubscribe + _metrics.lastConnectAt = DateTime.now(); + _metrics.connects++; + + _log( + const SpinifyLogLevel.config(), + 'connected', + 'Connected to server with $url successfully', + { + 'url': url, + 'request': request, + 'result': result, + }, + ); + } on Object catch ($error, stackTrace) { + final SpinifyConnectionException error; + if ($error is SpinifyConnectionException) { + error = $error; } else { - // Disable resubscribe timer on permanent errors. - _setState(SpinifyState$Disconnected(temporary: false)); + error = SpinifyConnectionException( + message: 'Error connecting to server $url', + error: $error, + ); } - case SpinifyConnectionException _: - _setUpReconnectTimer(); // Some spinify exception - retry resubscribe - default: - _setUpReconnectTimer(); // Unknown error - retry resubscribe - } + if (!readyCompleter.isCompleted) + readyCompleter.completeError(error, stackTrace); + _readyCompleter = null; + _log( + const SpinifyLogLevel.error(), + 'connect_error', + 'Error connecting to server $url', + { + 'url': url, + 'error': error, + 'stackTrace': stackTrace, + }, + ); - Error.throwWithStackTrace(error, stackTrace); - } - } + final transport = _transport; // Close transport + if (transport != null && !transport.isClosed) transport.close(); + _transport = null; + + switch ($error) { + case SpinifyErrorResult result: + if (result.code == 109) { + // Token expired error. + _setUpReconnectTimer(); // Retry resubscribe + } else if (result.temporary) { + // Temporary error. + _setUpReconnectTimer(); // Retry resubscribe + } else { + // Disable resubscribe timer on permanent errors. + _setState(SpinifyState$Disconnected(temporary: false)); + } + case SpinifyConnectionException _: + _setUpReconnectTimer(); // Some spinify exception - resubscribe + default: + _setUpReconnectTimer(); // Unknown error - resubscribe + } + + Error.throwWithStackTrace(error, stackTrace); + } + }); // --- Disconnection --- // @@ -1046,81 +1048,97 @@ final class Spinify implements ISpinify { required int code, required String reason, required bool reconnect, - }) { - try { - _tearDownRefreshConnection(); - - // Unsuscribe from reply messages. - // To ignore last messages and done event from transport. - _replySubscription?.cancel().ignore(); - _replySubscription = null; - // Close transport. - _transport?.close(code, reason); - _transport = null; - - // Update metrics. - _metrics.lastDisconnectAt = DateTime.now(); - _metrics.disconnects++; - - // Close all pending replies with error. - const error = SpinifyReplyException( - replyCode: 0, - replyMessage: 'Disconnected', - temporary: true, - ); - const stackTrace = StackTrace.empty; - for (final completer in _replies.values) { - if (completer.isCompleted) continue; - completer.completeError(error, stackTrace); - _log( - const SpinifyLogLevel.warning(), - 'disconnected_reply_error', - 'Reply for command ' - '${completer.command.type}{id: ${completer.command.id}} ' - 'error on disconnect', - { - 'command': completer.command, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - } - _replies.clear(); - - // Complete ready completer with error, - // if we still waiting for connection. - if (_readyCompleter case Completer c when !c.isCompleted) { - c.completeError( - const SpinifyConnectionException( - message: 'Disconnected during connection', - ), - stackTrace, - ); - } + }) => + runZonedGuarded( + () { + try { + _tearDownRefreshConnection(); + + // Unsuscribe from reply messages. + // To ignore last messages and done event from transport. + _replySubscription?.cancel().ignore(); + _replySubscription = null; + // Close transport. + _transport?.close(code, reason); + _transport = null; + + // Update metrics. + _metrics.lastDisconnectAt = DateTime.now(); + _metrics.disconnects++; + + // Close all pending replies with error. + const error = SpinifyReplyException( + replyCode: 0, + replyMessage: 'Disconnected', + temporary: true, + ); + const stackTrace = StackTrace.empty; + for (final completer in _replies.values) { + if (completer.isCompleted) continue; + completer.completeError(error, stackTrace); + _log( + const SpinifyLogLevel.warning(), + 'disconnected_reply_error', + 'Reply for command ' + '${completer.command.type}{id: ${completer.command.id}} ' + 'error on disconnect', + { + 'command': completer.command, + 'error': error, + 'stackTrace': stackTrace, + }, + ); + } + _replies.clear(); + + // Complete ready completer with error, + // if we still waiting for connection. + if (_readyCompleter case Completer c when !c.isCompleted) { + c.completeError( + const SpinifyConnectionException( + message: 'Disconnected during connection', + ), + stackTrace, + ); + } - // Reconnect if [reconnect] is true and we have reconnect URL. - if (reconnect && _metrics.reconnectUrl != null) _setUpReconnectTimer(); - } on Object catch (error, stackTrace) { - _log( - const SpinifyLogLevel.warning(), - 'disconnected_error', - 'Error on disconnect', - { - 'error': error, - 'stackTrace': stackTrace, + // Reconnect if [reconnect] is true and we have reconnect URL. + if (reconnect && _metrics.reconnectUrl != null) + _setUpReconnectTimer(); + } on Object catch (error, stackTrace) { + _log( + const SpinifyLogLevel.warning(), + 'disconnected_error', + 'Error on disconnect', + { + 'error': error, + 'stackTrace': stackTrace, + }, + ); + } + _setState(SpinifyState$Disconnected(temporary: reconnect)); + _log( + const SpinifyLogLevel.config(), + 'disconnected', + 'Disconnected from server ' + '${reconnect ? 'temporarily' : 'permanent'}', + { + 'temporary': reconnect, + }, + ); + }, + (error, stackTrace) { + _log( + const SpinifyLogLevel.warning(), + 'disconnected_error', + 'Error on disconnect', + { + 'error': error, + 'stackTrace': stackTrace, + }, + ); }, ); - } - _setState(SpinifyState$Disconnected(temporary: reconnect)); - _log( - const SpinifyLogLevel.config(), - 'disconnected', - 'Disconnected from server ${reconnect ? 'temporarily' : 'permanent'}', - { - 'temporary': reconnect, - }, - ); - } // --- Close --- // diff --git a/lib/src/util/async_guarded.dart b/lib/src/util/async_guarded.dart new file mode 100644 index 0000000..238cf00 --- /dev/null +++ b/lib/src/util/async_guarded.dart @@ -0,0 +1,43 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +/// Runs the given [callback] in a zone that catches uncaught errors and +/// forwards them to the returned future. +@internal +Future asyncGuarded(Future Function() callback) { + final completer = Completer.sync(); + + var completed = false; + + void complete() { + if (completed) return; + completed = true; + completer.complete(); + } + + void completeError(Object error, StackTrace stackTrace) { + if (completed) return; + completed = true; + completer.completeError(error, stackTrace); + } + + runZonedGuarded( + () async { + try { + await callback(); + complete(); + } on Object catch (error, stackTrace) { + completeError(error, stackTrace); + } + }, + // ignore: unnecessary_lambdas + (error, stackTrace) { + // This is called when an error is thrown outside of the `try` block. + //debugger(); + completeError(error, stackTrace); + }, + ); + + return completer.future; +} diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index f7a29ff..3d1ae00 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -928,7 +928,6 @@ void main() { print('Wat?'); } }, - skip: true, ); // Retry connection after temporary error From 8c4091c5a927c14076545ec2dec72e2c197599de Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 7 Nov 2024 21:25:46 +0400 Subject: [PATCH 066/104] Refactor Spinify_test.dart to remove redundant try-catch block and improve disconnection handling --- test/unit/spinify_test.dart | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index 3d1ae00..9ee1134 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -909,24 +909,18 @@ void main() { test( 'Disconnect_during_connection', () async { - try { - final client = createFakeClient(); - unawaited( - expectLater( - client.connect(url).onError((error, stackTrace) { - Error.throwWithStackTrace(error!, stackTrace); - }), - throwsA(anything), - ), - ); - await client.disconnect(); - expect(client.state.isDisconnected, isTrue); - expect(client.state.isClosed, isFalse); - await client.close(); - expect(client.state.isClosed, isTrue); - } on Object { - print('Wat?'); - } + final client = createFakeClient(); + unawaited( + expectLater( + client.connect(url), + throwsA(anything), + ), + ); + await client.disconnect(); + expect(client.state.isDisconnected, isTrue); + expect(client.state.isClosed, isFalse); + await client.close(); + expect(client.state.isClosed, isTrue); }, ); From c95ddf08f14a7b4693b6e90abaf5bccad1761f2b Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 7 Nov 2024 21:27:31 +0400 Subject: [PATCH 067/104] Refactor asyncGuarded function to add optional error ignoring capability --- lib/src/util/async_guarded.dart | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/src/util/async_guarded.dart b/lib/src/util/async_guarded.dart index 238cf00..fa755c0 100644 --- a/lib/src/util/async_guarded.dart +++ b/lib/src/util/async_guarded.dart @@ -4,8 +4,13 @@ import 'package:meta/meta.dart'; /// Runs the given [callback] in a zone that catches uncaught errors and /// forwards them to the returned future. +/// +/// [ignore] is used to ignore the errors and not throw them. @internal -Future asyncGuarded(Future Function() callback) { +Future asyncGuarded( + Future Function() callback, { + bool ignore = false, +}) { final completer = Completer.sync(); var completed = false; @@ -19,7 +24,11 @@ Future asyncGuarded(Future Function() callback) { void completeError(Object error, StackTrace stackTrace) { if (completed) return; completed = true; - completer.completeError(error, stackTrace); + if (ignore) { + completer.complete(); + } else { + completer.completeError(error, stackTrace); + } } runZonedGuarded( From 9094a643035e254fcb19d697cb5d87096681d60e Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 7 Nov 2024 21:38:47 +0400 Subject: [PATCH 068/104] Refactor Spinify.dart to improve null safety, connection handling, and error handling --- lib/src/spinify.dart | 20 +++-------- .../util/{async_guarded.dart => guarded.dart} | 36 ++++++++++++++++++- 2 files changed, 40 insertions(+), 16 deletions(-) rename lib/src/util/{async_guarded.dart => guarded.dart} (61%) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index aed61d7..bcbef9a 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -28,8 +28,8 @@ import 'model/transport_interface.dart'; import 'protobuf/protobuf_codec.dart'; import 'spinify_interface.dart'; import 'subscription_interface.dart'; -import 'util/async_guarded.dart'; import 'util/backoff.dart'; +import 'util/guarded.dart'; import 'web_socket_stub.dart' // ignore: uri_does_not_exist if (dart.library.js_interop) 'web_socket_js.dart' @@ -567,11 +567,11 @@ final class Spinify implements ISpinify { Future _doOnReady(Future Function() action) => switch (state) { SpinifyState$Connected _ => action(), SpinifyState$Connecting _ => ready().then((_) => action()), - SpinifyState$Disconnected _ => Future.error( + SpinifyState$Disconnected _ => Future.error( const SpinifyConnectionException(message: 'Disconnected'), StackTrace.current, ), - SpinifyState$Closed _ => Future.error( + SpinifyState$Closed _ => Future.error( const SpinifyConnectionException(message: 'Closed'), StackTrace.current, ), @@ -1049,7 +1049,7 @@ final class Spinify implements ISpinify { required String reason, required bool reconnect, }) => - runZonedGuarded( + guarded( () { try { _tearDownRefreshConnection(); @@ -1127,17 +1127,7 @@ final class Spinify implements ISpinify { }, ); }, - (error, stackTrace) { - _log( - const SpinifyLogLevel.warning(), - 'disconnected_error', - 'Error on disconnect', - { - 'error': error, - 'stackTrace': stackTrace, - }, - ); - }, + ignore: true, ); // --- Close --- // diff --git a/lib/src/util/async_guarded.dart b/lib/src/util/guarded.dart similarity index 61% rename from lib/src/util/async_guarded.dart rename to lib/src/util/guarded.dart index fa755c0..c6eb36e 100644 --- a/lib/src/util/async_guarded.dart +++ b/lib/src/util/guarded.dart @@ -31,7 +31,7 @@ Future asyncGuarded( } } - runZonedGuarded( + runZonedGuarded>( () async { try { await callback(); @@ -50,3 +50,37 @@ Future asyncGuarded( return completer.future; } + +/// Runs the given [callback] in a zone that catches uncaught errors and +/// rethrows them. +/// +/// [ignore] is used to ignore the errors and not throw them. +@internal +void guarded( + void Function() callback, { + bool ignore = false, +}) { + Object? $error; + StackTrace? $stackTrace; + + runZonedGuarded( + () { + try { + callback(); + } on Object catch (error, stackTrace) { + $error = error; + $stackTrace = stackTrace; + } + }, + (error, stackTrace) { + $error = error; + $stackTrace = stackTrace; + }, + ); + + final error = $error; + final stackTrace = $stackTrace; + if (error == null) return; + if (ignore) return; + Error.throwWithStackTrace(error, stackTrace ?? StackTrace.empty); +} From 262cceae7149997d3fcbae0943b486278c945be9 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 7 Nov 2024 22:19:05 +0400 Subject: [PATCH 069/104] Refactor Spinify.dart to improve null safety, connection handling, and error handling --- lib/src/spinify.dart | 3 +++ lib/src/util/guarded.dart | 7 +++++-- test/unit/spinify_test.dart | 20 ++++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index bcbef9a..e9bbee6 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -643,6 +643,9 @@ final class Spinify implements ISpinify { Completer value when !value.isCompleted => value, _ => Completer(), }; + // We need this just to not receive any errors at zone + // if we are completeError before any future subscription. + readyCompleter.future.ignore(); try { if (!state.isDisconnected) { _log( diff --git a/lib/src/util/guarded.dart b/lib/src/util/guarded.dart index c6eb36e..d7acf9f 100644 --- a/lib/src/util/guarded.dart +++ b/lib/src/util/guarded.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:developer'; import 'package:meta/meta.dart'; @@ -42,8 +43,8 @@ Future asyncGuarded( }, // ignore: unnecessary_lambdas (error, stackTrace) { - // This is called when an error is thrown outside of the `try` block. - //debugger(); + // This should never be called. + debugger(); completeError(error, stackTrace); }, ); @@ -73,6 +74,8 @@ void guarded( } }, (error, stackTrace) { + // This should never be called. + debugger(); $error = error; $stackTrace = stackTrace; }, diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index 9ee1134..a4ada9f 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: cascade_invocations + import 'dart:async'; import 'dart:convert'; @@ -906,6 +908,24 @@ void main() { ); }); + test('Completer_without_future', () async { + var zoneHandler = 0; + runZonedGuarded(() { + try { + final completer = Completer(); + /* completer.future.ignore() */ + completer.completeError( + Exception('Fake error'), + StackTrace.empty, + ); + } on Object {/* ignore */} + }, (error, stackTrace) { + zoneHandler++; + }); + await Future.delayed(Duration.zero); + expect(zoneHandler, equals(1)); + }); + test( 'Disconnect_during_connection', () async { From 33fe5ad39fa4172128129337f9f5579a576a99ab Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 7 Nov 2024 22:22:15 +0400 Subject: [PATCH 070/104] Refactor Spinify.dart to improve error handling and connection handling --- lib/src/spinify.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index e9bbee6..3aa0749 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -645,6 +645,8 @@ final class Spinify implements ISpinify { }; // We need this just to not receive any errors at zone // if we are completeError before any future subscription. + // + // See more at [Completer.completeError] comments. readyCompleter.future.ignore(); try { if (!state.isDisconnected) { From d5018d03a44f37761272db70e3d343ba3662aad0 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 8 Nov 2024 03:17:04 +0400 Subject: [PATCH 071/104] Refactor unit tests and improve asyncGuarded error handling --- lib/src/model/codes.dart | 10 +- lib/src/util/guarded.dart | 41 ++--- test/unit/model_test.dart | 320 ++++++++++++++++++++++++++++++++++++++ test/unit_test.dart | 8 +- 4 files changed, 347 insertions(+), 32 deletions(-) create mode 100644 test/unit/model_test.dart diff --git a/lib/src/model/codes.dart b/lib/src/model/codes.dart index cb8c773..2da2991 100644 --- a/lib/src/model/codes.dart +++ b/lib/src/model/codes.dart @@ -122,6 +122,12 @@ extension type const SpinifyDisconnectCode(int code) implements int { normalize([int? code, String? reason]) => switch (code ?? 1) { // --- Client error codes --- // + < 0 => ( + code: SpinifyDisconnectCode(code!), + reason: reason ?? 'client error', + reconnect: false, + ), + /// Disconnect called explicitly by the client. 0 => ( code: const SpinifyDisconnectCode(0), @@ -524,12 +530,14 @@ extension type const SpinifyDisconnectCode(int code) implements int { reconnect: true, ), - /// Custom disconnect codes. + /// Custom disconnect codes (unreachable). + // coverage:ignore-start _ => ( code: SpinifyDisconnectCode(code ?? 0), reason: reason ?? 'transport closed', reconnect: false, ), + // coverage:ignore-end }; /// Reconnect is needed due to specific transport close code. diff --git a/lib/src/util/guarded.dart b/lib/src/util/guarded.dart index d7acf9f..6fb79a3 100644 --- a/lib/src/util/guarded.dart +++ b/lib/src/util/guarded.dart @@ -11,45 +11,31 @@ import 'package:meta/meta.dart'; Future asyncGuarded( Future Function() callback, { bool ignore = false, -}) { - final completer = Completer.sync(); - - var completed = false; - - void complete() { - if (completed) return; - completed = true; - completer.complete(); - } - - void completeError(Object error, StackTrace stackTrace) { - if (completed) return; - completed = true; - if (ignore) { - completer.complete(); - } else { - completer.completeError(error, stackTrace); - } - } +}) async { + Object? $error; + StackTrace? $stackTrace; - runZonedGuarded>( + await runZonedGuarded>( () async { try { await callback(); - complete(); } on Object catch (error, stackTrace) { - completeError(error, stackTrace); + $error = error; + $stackTrace = stackTrace; } }, - // ignore: unnecessary_lambdas (error, stackTrace) { // This should never be called. debugger(); - completeError(error, stackTrace); + $error = error; + $stackTrace = stackTrace; }, ); - return completer.future; + final error = $error; + if (error == null) return; + if (ignore) return; + Error.throwWithStackTrace(error, $stackTrace ?? StackTrace.empty); } /// Runs the given [callback] in a zone that catches uncaught errors and @@ -82,8 +68,7 @@ void guarded( ); final error = $error; - final stackTrace = $stackTrace; if (error == null) return; if (ignore) return; - Error.throwWithStackTrace(error, stackTrace ?? StackTrace.empty); + Error.throwWithStackTrace(error, $stackTrace ?? StackTrace.empty); } diff --git a/test/unit/model_test.dart b/test/unit/model_test.dart new file mode 100644 index 0000000..2c8a52d --- /dev/null +++ b/test/unit/model_test.dart @@ -0,0 +1,320 @@ +// ignore_for_file: non_const_call_to_literal_constructor + +import 'package:fixnum/fixnum.dart'; +import 'package:spinify/src/model/annotations.dart' as annotations; +import 'package:spinify/src/model/channel_event.dart' as channel_event; +import 'package:spinify/src/model/client_info.dart' as client_info; +import 'package:spinify/src/model/codes.dart' as codes; +import 'package:test/test.dart'; + +void main() { + group('Model', () { + group('Annotations', () { + test('Instances', () { + expect( + annotations.interactive, + isA(), + ); + expect( + annotations.sideEffect, + isA(), + ); + expect( + annotations.safe, + isA(), + ); + expect( + annotations.unsafe, + isA(), + ); + expect( + annotations.SpinifyAnnotation('name'), + isA(), + ); + expect( + annotations.Throws(const [Exception]), + isA(), + ); + }); + + test('Getters', () { + expect( + const annotations.Throws([Exception]), + isA() + .having( + (e) => e.name, + 'name', + equals('throws'), + ) + .having( + (e) => e.meta, + 'meta', + allOf( + isA>(), + isEmpty, + ), + ) + .having( + (e) => e.exceptions, + 'exceptions', + allOf( + isA>(), + hasLength(1), + contains(Exception), + ), + ), + ); + }); + }); + + group('Codes', () { + test('Instances', () { + expect(codes.SpinifyDisconnectCode.disconnect(), isA()); + expect(codes.SpinifyDisconnectCode.noPingFromServer(), isA()); + expect(codes.SpinifyDisconnectCode.internalServerError(), isA()); + expect(codes.SpinifyDisconnectCode.unauthorized(), isA()); + expect(codes.SpinifyDisconnectCode.unknownChannel(), isA()); + expect(codes.SpinifyDisconnectCode.permissionDenied(), isA()); + expect(codes.SpinifyDisconnectCode.methodNotFound(), isA()); + expect(codes.SpinifyDisconnectCode.alreadySubscribed(), isA()); + expect(codes.SpinifyDisconnectCode.limitExceeded(), isA()); + expect(codes.SpinifyDisconnectCode.badRequest(), isA()); + expect(codes.SpinifyDisconnectCode.notAvailable(), isA()); + expect(codes.SpinifyDisconnectCode.tokenExpired(), isA()); + expect(codes.SpinifyDisconnectCode.expired(), isA()); + expect(codes.SpinifyDisconnectCode.tooManyRequests(), isA()); + expect(codes.SpinifyDisconnectCode.unrecoverablePosition(), isA()); + expect(codes.SpinifyDisconnectCode.normalClosure(), isA()); + expect(codes.SpinifyDisconnectCode.abnormalClosure(), isA()); + }); + + test('Normalize', () { + for (var i = -1; i <= 5000; i++) { + final tuple = codes.SpinifyDisconnectCode.normalize(i); + expect( + tuple.code, + allOf(isA(), equals(i)), + ); + expect( + tuple.reason, + allOf(isA(), isNotEmpty), + ); + expect( + tuple.reconnect, + allOf(isA(), same(tuple.code.reconnect)), + reason: 'Code: $i should ' + '${tuple.code.reconnect ? '' : 'not '}' + 'reconnect', + ); + } + }); + }); + + group('Channel_event', () { + test('Variants', () { + final now = DateTime.now(); + const channel = 'channel'; + final events = [ + channel_event.SpinifyPublication( + timestamp: now, + channel: channel, + data: const [1, 2, 3], + offset: Int64(10), + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + tags: const {'key': 'value'}, + ), + channel_event.SpinifyPresence.join( + timestamp: now, + channel: channel, + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + ), + channel_event.SpinifyPresence.leave( + timestamp: now, + channel: channel, + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + ), + channel_event.SpinifyUnsubscribe( + timestamp: now, + channel: channel, + code: 1000, + reason: 'reason', + ), + channel_event.SpinifySubscribe( + timestamp: now, + channel: channel, + data: const [1, 2, 3], + positioned: true, + recoverable: true, + since: (epoch: 'epoch', offset: Int64(10)), + ), + channel_event.SpinifyMessage( + timestamp: now, + channel: channel, + data: const [1, 2, 3], + ), + channel_event.SpinifyConnect( + timestamp: now, + channel: channel, + client: 'client', + version: 'version', + data: const [1, 2, 3], + expires: true, + ttl: now.add(const Duration(seconds: 10)), + pingInterval: const Duration(seconds: 5), + sendPong: true, + session: 'session', + node: 'node', + ), + channel_event.SpinifyDisconnect( + timestamp: now, + channel: channel, + code: 1000, + reason: 'reason', + reconnect: true, + ), + channel_event.SpinifyRefresh( + timestamp: now, + channel: channel, + expires: true, + ttl: now.add(const Duration(seconds: 10)), + ), + ]; + + for (final event in events) { + expect( + event, + isA() + .having( + (e) => e.runtimeType, + 'runtimeType', + equals(event.runtimeType), + ) + .having( + (e) => e.timestamp, + 'timestamp', + same(now), + ) + .having( + (e) => e.channel, + 'channel', + same(channel), + ), + ); + + expect( + event.type, + allOf( + isA(), + isNotEmpty, + ), + ); + + expect( + event.toString(), + equals('${event.type}{channel: $channel}'), + ); + + expect( + event.mapOrNull( + connect: (e) => e, + disconnect: (e) => e, + message: (e) => e, + presence: (e) => e, + publication: (e) => e, + refresh: (e) => e, + subscribe: (e) => e, + unsubscribe: (e) => e, + ), + allOf( + isNotNull, + isA(), + same(event), + ), + ); + + expect( + event.mapOrNull(), + isNull, + ); + + expect( + event.map( + connect: (e) => e.isConnect, + disconnect: (e) => e.isDisconnect, + message: (e) => e.isMessage, + presence: (e) => e.isPresence, + publication: (e) => e.isPublication, + refresh: (e) => e.isRefresh, + subscribe: (e) => e.isSubscribe, + unsubscribe: (e) => e.isUnsubscribe, + ), + allOf( + isA(), + isTrue, + ), + ); + + expect( + [ + event.isConnect, + event.isDisconnect, + event.isMessage, + event.isPresence, + event.isPublication, + event.isRefresh, + event.isSubscribe, + event.isUnsubscribe, + ], + containsOnce(true), + ); + } + expect(events.sort, returnsNormally); + }); + + test('Presense', () { + final now = DateTime.now(); + const channel = 'channel'; + final join = channel_event.SpinifyPresence.join( + timestamp: now, + channel: channel, + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + ); + final leave = channel_event.SpinifyPresence.leave( + timestamp: now, + channel: channel, + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + ); + + expect(join.isJoin, isTrue); + expect(leave.isLeave, isTrue); + expect(join.isLeave, isFalse); + expect(leave.isJoin, isFalse); + }); + }); + }); +} diff --git a/test/unit_test.dart b/test/unit_test.dart index 94fb4c2..07b058c 100644 --- a/test/unit_test.dart +++ b/test/unit_test.dart @@ -4,16 +4,18 @@ import 'unit/codec_test.dart' as codec_test; import 'unit/config_test.dart' as config_test; import 'unit/jwt_test.dart' as jwt_test; import 'unit/logs_test.dart' as logs_test; +import 'unit/model_test.dart' as model_test; import 'unit/server_subscription_test.dart' as server_subscription_test; import 'unit/spinify_test.dart' as spinify_test; void main() { group('Unit', () { - logs_test.main(); + model_test.main(); config_test.main(); - spinify_test.main(); + logs_test.main(); codec_test.main(); - server_subscription_test.main(); jwt_test.main(); + spinify_test.main(); + server_subscription_test.main(); }); } From e5ff8d4b99a6541fb3e3c777a08b060eca28460d Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 8 Nov 2024 17:55:25 +0400 Subject: [PATCH 072/104] Add utility functions for map and list equality checks; enhance equality and hashCode implementations in model classes --- lib/src/model/channel_event.dart | 24 ++ lib/src/model/client_info.dart | 10 +- lib/src/model/command.dart | 40 ++ lib/src/model/exception.dart | 6 +- lib/src/model/history.dart | 15 + lib/src/model/presence_stats.dart | 15 + lib/src/util/list_equals.dart | 13 + lib/src/util/map_equals.dart | 13 + test/unit/model_test.dart | 598 +++++++++++++++++++++++++++++- 9 files changed, 726 insertions(+), 8 deletions(-) create mode 100644 lib/src/util/list_equals.dart create mode 100644 lib/src/util/map_equals.dart diff --git a/lib/src/model/channel_event.dart b/lib/src/model/channel_event.dart index eb07773..de39975 100644 --- a/lib/src/model/channel_event.dart +++ b/lib/src/model/channel_event.dart @@ -1,6 +1,8 @@ import 'package:fixnum/fixnum.dart' as fixnum; import 'package:meta/meta.dart'; +import '../util/list_equals.dart'; +import '../util/map_equals.dart'; import 'client_info.dart'; import 'stream_position.dart'; @@ -196,6 +198,28 @@ final class SpinifyPublication extends SpinifyChannelEvent { @override bool get isUnsubscribe => false; + + @override + int get hashCode => Object.hashAll([ + timestamp, + channel, + data, + offset, + info, + tags, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SpinifyPublication && + channel == other.channel && + timestamp == other.timestamp && + offset == other.offset && + info == other.info && + mapEquals(tags, other.tags) && + listEquals(data, other.data); + } } /// {@template channel_presence} diff --git a/lib/src/model/client_info.dart b/lib/src/model/client_info.dart index 7433238..4ec8715 100644 --- a/lib/src/model/client_info.dart +++ b/lib/src/model/client_info.dart @@ -1,5 +1,7 @@ import 'package:meta/meta.dart'; +import '../util/list_equals.dart'; + /// {@template client_info} /// Client information. /// {@endtemplate} @@ -39,13 +41,13 @@ final class SpinifyClientInfo { bool operator ==(Object other) => identical(this, other) || other is SpinifyClientInfo && - user == other.client && + user == other.user && client == other.client && - connectionInfo == other.connectionInfo && - channelInfo == other.channelInfo; + listEquals(connectionInfo, other.connectionInfo) && + listEquals(channelInfo, other.channelInfo); @override - String toString() => 'ClientInfo{' + String toString() => 'SpinifyClientInfo{' 'user: $user, ' 'client: $client' '}'; diff --git a/lib/src/model/command.dart b/lib/src/model/command.dart index eb7b122..fc52680 100644 --- a/lib/src/model/command.dart +++ b/lib/src/model/command.dart @@ -25,6 +25,9 @@ sealed class SpinifyCommand implements Comparable { /// Command type. abstract final String type; + /// Check if command has ID. + abstract final bool hasId; + /// Timestamp of command. final DateTime timestamp; @@ -34,6 +37,7 @@ sealed class SpinifyCommand implements Comparable { 0 => id.compareTo(other.id), int result => result, }; + @override int get hashCode => id ^ type.hashCode ^ timestamp.microsecondsSinceEpoch; @@ -65,6 +69,9 @@ final class SpinifyConnectRequest extends SpinifyCommand { @override String get type => 'ConnectRequest'; + @override + bool get hasId => true; + /// Token to authenticate. final String? token; @@ -101,6 +108,9 @@ final class SpinifySubscribeRequest extends SpinifyCommand { @override String get type => 'SubscribeRequest'; + @override + bool get hasId => true; + /// Channel to subscribe. final String channel; @@ -146,6 +156,9 @@ final class SpinifyUnsubscribeRequest extends SpinifyCommand { @override String get type => 'UnsubscribeRequest'; + @override + bool get hasId => true; + /// Channel to unsubscribe. final String channel; } @@ -163,6 +176,9 @@ final class SpinifyPublishRequest extends SpinifyCommand { @override String get type => 'PublishRequest'; + @override + bool get hasId => true; + /// Channel to publish. final String channel; @@ -182,6 +198,9 @@ final class SpinifyPresenceRequest extends SpinifyCommand { @override String get type => 'PresenceRequest'; + @override + bool get hasId => true; + /// Channel to get presence. final String channel; } @@ -198,6 +217,9 @@ final class SpinifyPresenceStatsRequest extends SpinifyCommand { @override String get type => 'PresenceStatsRequest'; + @override + bool get hasId => true; + /// Channel to get presence stats. final String channel; } @@ -217,6 +239,9 @@ final class SpinifyHistoryRequest extends SpinifyCommand { @override String get type => 'HistoryRequest'; + @override + bool get hasId => true; + /// Channel to get history. final String? channel; @@ -239,6 +264,9 @@ final class SpinifyPingRequest extends SpinifyCommand { @override String get type => 'PingRequest'; + + @override + bool get hasId => false; } /// {@macro command} @@ -252,6 +280,9 @@ final class SpinifySendRequest extends SpinifyCommand { @override String get type => 'SendRequest'; + @override + bool get hasId => false; + /// Data to send. final List data; } @@ -269,6 +300,9 @@ final class SpinifyRPCRequest extends SpinifyCommand { @override String get type => 'RPCRequest'; + @override + bool get hasId => true; + /// Data to send. final List data; @@ -288,6 +322,9 @@ final class SpinifyRefreshRequest extends SpinifyCommand { @override String get type => 'RefreshRequest'; + @override + bool get hasId => true; + /// Token to refresh. /// Token should not be null or empty string. final String token; @@ -306,6 +343,9 @@ final class SpinifySubRefreshRequest extends SpinifyCommand { @override String get type => 'SubRefreshRequest'; + @override + bool get hasId => true; + /// Channel to refresh. final String channel; diff --git a/lib/src/model/exception.dart b/lib/src/model/exception.dart index 227981e..3156bc2 100644 --- a/lib/src/model/exception.dart +++ b/lib/src/model/exception.dart @@ -37,7 +37,7 @@ sealed class SpinifyException implements Exception { } @override - int get hashCode => code.hashCode; + int get hashCode => Object.hash(code, message, error); @override bool operator ==(Object other) => identical(this, other); @@ -84,10 +84,10 @@ final class SpinifyReplyException extends SpinifyException { /// {@category Exception} final class SpinifyPingException extends SpinifyException { /// {@macro exception} - const SpinifyPingException([Object? error]) + const SpinifyPingException({String? message, Object? error}) : super( 'spinify_ping_exception', - 'Ping error', + message ?? 'Ping error', error, ); } diff --git a/lib/src/model/history.dart b/lib/src/model/history.dart index 791ce90..42700e6 100644 --- a/lib/src/model/history.dart +++ b/lib/src/model/history.dart @@ -1,5 +1,6 @@ import 'package:meta/meta.dart'; +import '../util/list_equals.dart'; import 'channel_event.dart'; import 'stream_position.dart'; @@ -21,6 +22,20 @@ final class SpinifyHistory { /// Offset and epoch of last publication in publications list final SpinifyStreamPosition since; + @override + int get hashCode => Object.hashAll([ + since.epoch, + since.offset, + publications, + ]); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SpinifyHistory && + since == other.since && + listEquals(publications, other.publications); + @override String toString() => 'SpinifyHistory{}'; } diff --git a/lib/src/model/presence_stats.dart b/lib/src/model/presence_stats.dart index eb3bc04..42613ec 100644 --- a/lib/src/model/presence_stats.dart +++ b/lib/src/model/presence_stats.dart @@ -22,6 +22,21 @@ final class SpinifyPresenceStats { /// Users count final int users; + @override + int get hashCode => Object.hash( + channel, + clients, + users, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SpinifyPresenceStats && + channel == other.channel && + clients == other.clients && + users == other.users; + @override String toString() => 'SpinifyPresenceStats{channel: $channel}'; } diff --git a/lib/src/util/list_equals.dart b/lib/src/util/list_equals.dart new file mode 100644 index 0000000..32e093e --- /dev/null +++ b/lib/src/util/list_equals.dart @@ -0,0 +1,13 @@ +import 'package:meta/meta.dart'; + +/// Check if two lists are equal. +@internal +bool listEquals(List? a, List? b) { + if (a == null) return b == null; + if (b == null || a.length != b.length) return false; + if (identical(a, b)) return true; + for (var index = 0; index < a.length; index += 1) { + if (a[index] != b[index]) return false; + } + return true; +} diff --git a/lib/src/util/map_equals.dart b/lib/src/util/map_equals.dart new file mode 100644 index 0000000..cb8f536 --- /dev/null +++ b/lib/src/util/map_equals.dart @@ -0,0 +1,13 @@ +import 'package:meta/meta.dart'; + +/// Check if two maps are equal. +@internal +bool mapEquals(Map? a, Map? b) { + if (a == null) return b == null; + if (b == null || a.length != b.length) return false; + if (identical(a, b)) return true; + for (final key in a.keys) { + if (!b.containsKey(key) || b[key] != a[key]) return false; + } + return true; +} diff --git a/test/unit/model_test.dart b/test/unit/model_test.dart index 2c8a52d..92449d2 100644 --- a/test/unit/model_test.dart +++ b/test/unit/model_test.dart @@ -3,8 +3,14 @@ import 'package:fixnum/fixnum.dart'; import 'package:spinify/src/model/annotations.dart' as annotations; import 'package:spinify/src/model/channel_event.dart' as channel_event; +import 'package:spinify/src/model/channel_events.dart' as channel_events; import 'package:spinify/src/model/client_info.dart' as client_info; import 'package:spinify/src/model/codes.dart' as codes; +import 'package:spinify/src/model/command.dart' as command; +import 'package:spinify/src/model/exception.dart' as exception; +import 'package:spinify/src/model/history.dart' as history; +import 'package:spinify/src/model/presence_stats.dart' as presence_stats; +import 'package:spinify/src/util/list_equals.dart'; import 'package:test/test.dart'; void main() { @@ -110,7 +116,7 @@ void main() { }); }); - group('Channel_event', () { + group('Channel_events', () { test('Variants', () { final now = DateTime.now(); const channel = 'channel'; @@ -315,6 +321,596 @@ void main() { expect(join.isLeave, isFalse); expect(leave.isJoin, isFalse); }); + + test('Streams', () { + final now = DateTime.now(); + const channel = 'channel'; + final events = [ + channel_event.SpinifyPublication( + timestamp: now, + channel: channel, + data: const [1, 2, 3], + offset: Int64(10), + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + tags: const {'key': 'value'}, + ), + channel_event.SpinifyPresence.join( + timestamp: now, + channel: channel, + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + ), + channel_event.SpinifyPresence.leave( + timestamp: now, + channel: channel, + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + ), + channel_event.SpinifyUnsubscribe( + timestamp: now, + channel: channel, + code: 1000, + reason: 'reason', + ), + channel_event.SpinifySubscribe( + timestamp: now, + channel: channel, + data: const [1, 2, 3], + positioned: true, + recoverable: true, + since: (epoch: 'epoch', offset: Int64(10)), + ), + channel_event.SpinifyMessage( + timestamp: now, + channel: channel, + data: const [1, 2, 3], + ), + channel_event.SpinifyConnect( + timestamp: now, + channel: channel, + client: 'client', + version: 'version', + data: const [1, 2, 3], + expires: true, + ttl: now.add(const Duration(seconds: 10)), + pingInterval: const Duration(seconds: 5), + sendPong: true, + session: 'session', + node: 'node', + ), + channel_event.SpinifyDisconnect( + timestamp: now, + channel: channel, + code: 1000, + reason: 'reason', + reconnect: true, + ), + channel_event.SpinifyRefresh( + timestamp: now, + channel: channel, + expires: true, + ttl: now.add(const Duration(seconds: 10)), + ), + ]; + for (var i = 0; i < events.length; i++) { + final event = events[i]; + channel_events.SpinifyChannelEvents stream() => + channel_events.SpinifyChannelEvents(Stream.value(event)); + expect( + stream(), + allOf( + isA>(), + isA(), + ), + ); + expectLater( + stream(), + emitsInOrder([ + same(event), + emitsDone, + ]), + ); + expectLater( + stream().filter(channel: 'another'), + emitsDone, + ); + expectLater( + stream().filter(channel: channel), + emitsInOrder([ + same(event), + emitsDone, + ]), + ); + expectLater( + stream().publication(channel: channel), + emitsInOrder([ + if (event.isPublication) same(event), + emitsDone, + ]), + ); + expectLater( + stream().presence(channel: channel), + emitsInOrder([ + if (event.isPresence) same(event), + emitsDone, + ]), + ); + expectLater( + stream().unsubscribe(channel: channel), + emitsInOrder([ + if (event.isUnsubscribe) same(event), + emitsDone, + ]), + ); + expectLater( + stream().message(channel: channel), + emitsInOrder([ + if (event.isMessage) same(event), + emitsDone, + ]), + ); + expectLater( + stream().subscribe(channel: channel), + emitsInOrder([ + if (event.isSubscribe) same(event), + emitsDone, + ]), + ); + expectLater( + stream().connect(channel: channel), + emitsInOrder([ + if (event.isConnect) same(event), + emitsDone, + ]), + ); + expectLater( + stream().disconnect(channel: channel), + emitsInOrder([ + if (event.isDisconnect) same(event), + emitsDone, + ]), + ); + expectLater( + stream().refresh(channel: channel), + emitsInOrder([ + if (event.isRefresh) same(event), + emitsDone, + ]), + ); + } + }); + + test('Client_info', () { + final info = client_info.SpinifyClientInfo( + client: 'client', + user: 'user', + channelInfo: const [1, 2, 3], + connectionInfo: const [4, 5, 6], + ); + expect(info, isA()); + expect( + info.toString(), + allOf( + isA(), + isNotEmpty, + startsWith('SpinifyClientInfo{'), + endsWith('}'), + ), + ); + expect(info == info, isTrue); + expect( + listEquals( + info.channelInfo, + info.channelInfo?.toList(growable: false), + ), + isTrue, + ); + expect( + listEquals( + info.connectionInfo, + info.connectionInfo?.toList(growable: false), + ), + isTrue, + ); + expect( + info == + client_info.SpinifyClientInfo( + user: info.user, + client: info.client, + connectionInfo: info.connectionInfo?.toList(growable: false), + channelInfo: info.channelInfo?.toList(growable: false), + ), + isTrue, + ); + expect( + info == + client_info.SpinifyClientInfo( + user: info.user, + client: info.client, + connectionInfo: info.connectionInfo?.toList(growable: false), + channelInfo: const [7, 8, 9], + ), + isFalse, + ); + }); + + test('Publications', () { + final publication1 = channel_event.SpinifyPublication( + timestamp: DateTime.now(), + channel: 'channel', + data: const [1, 2, 3], + offset: Int64(10), + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + tags: const {'key': 'value'}, + ); + final publication2 = channel_event.SpinifyPublication( + timestamp: publication1.timestamp, + channel: publication1.channel, + offset: publication1.offset, + info: publication1.info, + data: [...publication1.data], + tags: {...?publication1.tags}, + ); + expect(publication1, isA()); + expect(publication1.hashCode, isPositive); + expect( + publication1.toString(), + allOf( + isA(), + isNotEmpty, + startsWith('Publication{'), + endsWith('}'), + ), + ); + expect(publication1, equals(publication1)); + expect(publication1, equals(publication2)); + }); + + test('History', () { + final history1 = history.SpinifyHistory( + publications: [ + channel_event.SpinifyPublication( + timestamp: DateTime.now(), + channel: 'channel', + data: const [1, 2, 3], + offset: Int64(10), + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + tags: const {'key': 'value'}, + ), + ], + since: ( + epoch: 'epoch', + offset: Int64(10), + ), + ); + final history2 = history.SpinifyHistory( + publications: [ + ...history1.publications, + ], + since: history1.since, + ); + expect(history1, isA()); + expect(history1.hashCode, isPositive); + expect( + history1.toString(), + allOf( + isA(), + isNotEmpty, + startsWith('SpinifyHistory{'), + endsWith('}'), + ), + ); + expect( + listEquals(history1.publications, history2.publications), + isTrue, + ); + expect( + history1 == history2, + isTrue, + ); + }); + + test('PresenceStats', () { + const stats1 = presence_stats.SpinifyPresenceStats( + channel: 'channel', + clients: 5, + users: 3, + ); + final stats2 = presence_stats.SpinifyPresenceStats( + channel: stats1.channel, + clients: stats1.clients, + users: stats1.users, + ); + const stats3 = presence_stats.SpinifyPresenceStats( + channel: 'another', + clients: 6, + users: 4, + ); + expect(stats1, isA()); + expect(stats1.hashCode, isPositive); + expect( + stats1.toString(), + allOf( + isA(), + isNotEmpty, + startsWith('SpinifyPresenceStats{'), + endsWith('}'), + ), + ); + expect(stats1, equals(stats1)); + expect(stats1, equals(stats2)); + expect(stats1, isNot(equals(stats3))); + }); + }); + + group('Commands', () { + test('Instances', () { + const id = 1; + final timestamp = DateTime.now(); + const channel = 'channel'; + const token = 'token'; + final commands = [ + command.SpinifyConnectRequest( + id: id, + timestamp: timestamp, + data: const [1, 2, 3], + name: 'name', + token: token, + version: 'version', + subs: { + channel: command.SpinifySubscribeRequest( + channel: channel, + data: const [1, 2, 3], + epoch: 'epoch', + joinLeave: true, + offset: Int64(10), + positioned: true, + recover: true, + recoverable: true, + id: id, + timestamp: timestamp, + token: token, + ), + }, + ), + command.SpinifySubscribeRequest( + channel: channel, + data: const [1, 2, 3], + epoch: 'epoch', + joinLeave: true, + offset: Int64(10), + positioned: true, + recover: true, + recoverable: true, + id: id, + timestamp: timestamp, + token: token, + ), + command.SpinifyUnsubscribeRequest( + channel: channel, + id: id, + timestamp: timestamp, + ), + command.SpinifyPublishRequest( + channel: channel, + data: const [1, 2, 3], + id: id, + timestamp: timestamp, + ), + command.SpinifyPingRequest( + timestamp: timestamp, + ), + command.SpinifyPresenceRequest( + channel: channel, + id: id, + timestamp: timestamp, + ), + command.SpinifyPresenceStatsRequest( + channel: channel, + id: id, + timestamp: timestamp, + ), + command.SpinifyHistoryRequest( + channel: channel, + id: id, + timestamp: timestamp, + limit: 10, + reverse: true, + since: (epoch: token, offset: Int64(10)), + ), + command.SpinifySendRequest( + data: const [1, 2, 3], + timestamp: timestamp, + ), + command.SpinifyRPCRequest( + data: const [1, 2, 3], + id: id, + timestamp: timestamp, + method: 'method', + ), + command.SpinifyRefreshRequest( + id: id, + timestamp: timestamp, + token: token, + ), + command.SpinifySubRefreshRequest( + id: id, + timestamp: timestamp, + token: token, + channel: channel, + ), + ]; + + for (var i = 0; i < commands.length; i++) { + final c = commands[i]; + expect( + c, + isA() + .having( + (e) => e.id, + 'id', + c.hasId ? equals(id) : equals(0), + ) + .having( + (e) => e.timestamp, + 'timestamp', + same(timestamp), + ) + .having( + (e) => e.type, + 'type', + isNotEmpty, + ) + .having( + (e) => e.hashCode, + 'hashCode', + isPositive, + ) + .having( + (e) => e.toString(), + 'toString', + startsWith(c.type), + ), + ); + expect(c == c, isTrue); + for (var j = 0; j < commands.length; j++) { + final other = commands[j]; + expect( + c == other, + c.type == other.type, + ); + } + } + + expect(commands.sort, returnsNormally); + + final ping1 = command.SpinifyPingRequest( + timestamp: DateTime(2000), + ); + final ping2 = command.SpinifyPingRequest( + timestamp: DateTime(2001), + ); + expect(ping1.compareTo(ping2), lessThan(0)); + expect(ping1 == ping2, isFalse); + }); + }); + + group('Exceptions', () { + test('Instances', () { + const message = 'message'; + final error = Exception('error'); + final exceptions = [ + exception.SpinifyConnectionException( + message: message, + error: error, + ), + exception.SpinifyReplyException( + replyCode: 1000, + replyMessage: message, + temporary: true, + error: error, + ), + exception.SpinifyPingException( + message: message, + error: error, + ), + exception.SpinifySubscriptionException( + message: message, + error: error, + channel: 'channel', + ), + exception.SpinifySendException( + message: message, + error: error, + ), + exception.SpinifyRPCException( + error: error, + message: message, + ), + exception.SpinifyFetchException( + error: error, + message: message, + ), + exception.SpinifyRefreshException( + error: error, + message: message, + ), + exception.SpinifyTransportException( + error: error, + message: message, + data: const [1, 2, 3], + ), + ]; + + for (var i = 0; i < exceptions.length; i++) { + final e = exceptions[i]; + expect( + e, + isA() + .having( + (e) => e.message, + 'message', + equals(message), + ) + .having( + (e) => e.error, + 'error', + same(error), + ) + .having( + (e) => e.hashCode, + 'hashCode', + isPositive, + ) + .having( + (e) => e.toString(), + 'toString', + message, + ), + ); + + expect(e == e, isTrue); + } + }); + + test('Visitor', () { + final e = exception.SpinifyPingException( + error: exception.SpinifyPingException( + error: exception.SpinifyPingException( + error: exception.SpinifyPingException( + error: Exception('Fake'), + ), + ), + ), + ); + + final list = []; + e.visitor(list.add); + expect(list, hasLength(5)); + }); }); }); } From 59e34e958caf27c4359e6e505c7662d5504c1fb8 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 8 Nov 2024 18:16:41 +0400 Subject: [PATCH 073/104] Add hasId property to SpinifyReply subclasses to indicate ID presence; enhance unit tests for reply instances --- lib/src/model/reply.dart | 45 +++++++ test/unit/model_test.dart | 247 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 292 insertions(+) diff --git a/lib/src/model/reply.dart b/lib/src/model/reply.dart index 9880ada..7676676 100644 --- a/lib/src/model/reply.dart +++ b/lib/src/model/reply.dart @@ -25,6 +25,9 @@ sealed class SpinifyReply implements Comparable { /// For pushes it will have zero value. final int id; + /// Check if reply has ID. + abstract final bool hasId; + /// Timestamp of reply. final DateTime timestamp; @@ -76,6 +79,9 @@ final class SpinifyServerPing extends SpinifyReply { @override String get type => 'ServerPing'; + @override + bool get hasId => false; + @override bool get isResult => false; } @@ -96,6 +102,9 @@ final class SpinifyPush extends SpinifyReply { @override String get type => 'Push'; + @override + bool get hasId => false; + @override bool get isResult => false; @@ -128,6 +137,9 @@ final class SpinifyConnectResult extends SpinifyReply @override String get type => 'ConnectResult'; + @override + bool get hasId => true; + /// Unique client connection ID server issued to this connection final String client; @@ -183,6 +195,9 @@ final class SpinifySubscribeResult extends SpinifyReply @override String get type => 'SubscribeResult'; + @override + bool get hasId => true; + /* bool expires = 1; uint32 ttl = 2; @@ -236,6 +251,9 @@ final class SpinifyUnsubscribeResult extends SpinifyReply @override String get type => 'UnsubscribeResult'; + + @override + bool get hasId => true; } /// {@macro reply} @@ -249,6 +267,9 @@ final class SpinifyPublishResult extends SpinifyReply @override String get type => 'PublishResult'; + + @override + bool get hasId => true; } /// {@macro reply} @@ -264,6 +285,9 @@ final class SpinifyPresenceResult extends SpinifyReply @override String get type => 'PresenceResult'; + @override + bool get hasId => true; + /// Contains presence information - a map client IDs as keys /// and client information as values. final Map presence; @@ -283,6 +307,9 @@ final class SpinifyPresenceStatsResult extends SpinifyReply @override String get type => 'PresenceStatsResult'; + @override + bool get hasId => true; + /// Number of clients final int numClients; @@ -304,6 +331,9 @@ final class SpinifyHistoryResult extends SpinifyReply @override String get type => 'HistoryResult'; + @override + bool get hasId => true; + /// Offset final SpinifyStreamPosition since; @@ -322,6 +352,9 @@ final class SpinifyPingResult extends SpinifyReply @override String get type => 'PingResult'; + + @override + bool get hasId => true; } /// {@macro reply} @@ -337,6 +370,9 @@ final class SpinifyRPCResult extends SpinifyReply @override String get type => 'RPCResult'; + @override + bool get hasId => true; + /// Payload final List data; } @@ -359,6 +395,9 @@ final class SpinifyRefreshResult extends SpinifyReply @override String get type => 'RefreshResult'; + @override + bool get hasId => true; + /// Unique client connection ID server issued to this connection final String client; @@ -388,6 +427,9 @@ final class SpinifySubRefreshResult extends SpinifyReply @override String get type => 'SubRefreshResult'; + @override + bool get hasId => true; + /// Whether a server will expire subscription at some point final bool expires; @@ -413,6 +455,9 @@ final class SpinifyErrorResult extends SpinifyReply @override String get type => 'ErrorResult'; + @override + bool get hasId => true; + /// Error code. final int code; diff --git a/test/unit/model_test.dart b/test/unit/model_test.dart index 92449d2..9a0791f 100644 --- a/test/unit/model_test.dart +++ b/test/unit/model_test.dart @@ -10,6 +10,7 @@ import 'package:spinify/src/model/command.dart' as command; import 'package:spinify/src/model/exception.dart' as exception; import 'package:spinify/src/model/history.dart' as history; import 'package:spinify/src/model/presence_stats.dart' as presence_stats; +import 'package:spinify/src/model/reply.dart' as reply; import 'package:spinify/src/util/list_equals.dart'; import 'package:test/test.dart'; @@ -912,5 +913,251 @@ void main() { expect(list, hasLength(5)); }); }); + + group('Reply', () { + test('Instances', () { + const id = 1; + final timestamp = DateTime.now(); + const channel = 'channel'; + final replies = [ + reply.SpinifyServerPing( + timestamp: timestamp, + ), + reply.SpinifyPush( + timestamp: timestamp, + event: channel_event.SpinifyPublication( + timestamp: timestamp, + channel: channel, + data: const [1, 2, 3], + offset: Int64(10), + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + tags: const {'key': 'value'}, + ), + ), + reply.SpinifyConnectResult( + client: 'client', + version: 'version', + timestamp: timestamp, + id: id, + expires: true, + ttl: timestamp.add(const Duration(seconds: 10)), + data: const [1, 2, 3], + node: 'node', + pingInterval: const Duration(seconds: 5), + sendPong: true, + session: 'session', + subs: { + channel: reply.SpinifySubscribeResult( + data: const [1, 2, 3], + positioned: true, + recoverable: true, + id: id, + timestamp: timestamp, + expires: true, + publications: [ + channel_event.SpinifyPublication( + timestamp: timestamp, + channel: channel, + data: const [1, 2, 3], + offset: Int64(10), + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + tags: const {'key': 'value'}, + ), + ], + recovered: true, + since: (epoch: 'epoch', offset: Int64(10)), + ttl: timestamp.add(const Duration(seconds: 10)), + wasRecovering: true, + ), + }, + ), + reply.SpinifySubscribeResult( + id: id, + timestamp: timestamp, + expires: true, + ttl: timestamp.add(const Duration(seconds: 10)), + recoverable: true, + publications: [ + channel_event.SpinifyPublication( + timestamp: timestamp, + channel: channel, + data: const [1, 2, 3], + offset: Int64(10), + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + tags: const {'key': 'value'}, + ), + ], + recovered: true, + since: (epoch: 'epoch', offset: Int64(10)), + data: const [1, 2, 3], + positioned: true, + wasRecovering: true, + ), + reply.SpinifyUnsubscribeResult( + id: id, + timestamp: timestamp, + ), + reply.SpinifyPublishResult( + id: id, + timestamp: timestamp, + ), + reply.SpinifyPresenceResult( + id: id, + timestamp: timestamp, + presence: { + channel: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + user: 'user', + connectionInfo: const [4, 5, 6], + ), + }, + ), + reply.SpinifyPresenceStatsResult( + id: id, + timestamp: timestamp, + numClients: 5, + numUsers: 3, + ), + reply.SpinifyHistoryResult( + id: id, + timestamp: timestamp, + publications: [ + channel_event.SpinifyPublication( + timestamp: timestamp, + channel: channel, + data: const [1, 2, 3], + offset: Int64(10), + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + tags: const {'key': 'value'}, + ), + ], + since: (epoch: 'epoch', offset: Int64(10)), + ), + reply.SpinifyPingResult( + id: id, + timestamp: timestamp, + ), + reply.SpinifyRPCResult( + id: id, + timestamp: timestamp, + data: const [1, 2, 3], + ), + reply.SpinifyRefreshResult( + id: id, + timestamp: timestamp, + client: 'client', + version: 'version', + expires: true, + ttl: timestamp.add(const Duration(seconds: 10)), + ), + reply.SpinifySubRefreshResult( + id: id, + timestamp: timestamp, + expires: true, + ttl: timestamp.add(const Duration(seconds: 10)), + ), + reply.SpinifyErrorResult( + id: id, + timestamp: timestamp, + code: 1000, + message: 'message', + temporary: true, + ), + ]; + + for (var i = 0; i < replies.length; i++) { + final r = replies[i]; + expect( + r, + isA() + .having( + (e) => e.id, + 'id', + r.hasId ? equals(id) : equals(0), + ) + .having( + (e) => e.timestamp, + 'timestamp', + same(timestamp), + ) + .having( + (e) => e.type, + 'type', + isNotEmpty, + ) + .having( + (e) => e.hashCode, + 'hashCode', + isPositive, + ) + .having( + (e) => e.toString(), + 'toString', + startsWith(r.type), + ), + ); + + expect(r.isResult, r.hasId); + + expect( + r, + anyOf( + isNot(isA()), + isA().having( + (e) => e.channel == e.event.channel, + 'channel', + isTrue, + ), + ), + ); + + for (var j = 0; j < replies.length; j++) { + final other = replies[j]; + expect( + r, + r.type != other.type ? isNot(same(other)) : same(other), + ); + expect( + r, + r.type != other.type ? isNot(equals(other)) : equals(other), + ); + } + } + + expect(replies.sort, returnsNormally); + + final ping1 = reply.SpinifyPingResult( + timestamp: DateTime(2000), + id: 1, + ); + final ping2 = reply.SpinifyPingResult( + timestamp: DateTime(2001), + id: 1, + ); + expect(ping1.compareTo(ping2), lessThan(0)); + expect(ping1, isNot(equals(ping2))); + }); + }); }); } From 482b79167e09cd516d272c70b12711287cf544e1 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 8 Nov 2024 18:41:39 +0400 Subject: [PATCH 074/104] Implement Comparable for SpinifyStateBase and add comprehensive unit tests for state instances --- lib/src/model/state.dart | 7 +- test/unit/model_test.dart | 168 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 1 deletion(-) diff --git a/lib/src/model/state.dart b/lib/src/model/state.dart index dda2625..d0a3888 100644 --- a/lib/src/model/state.dart +++ b/lib/src/model/state.dart @@ -336,7 +336,8 @@ final class SpinifyState$Closed extends SpinifyState { typedef SpinifyStateMatch = R Function(S state); @immutable -abstract base class _$SpinifyStateBase { +abstract base class _$SpinifyStateBase + implements Comparable<_$SpinifyStateBase> { const _$SpinifyStateBase(this.timestamp); /// Represents the current state type. @@ -396,4 +397,8 @@ abstract base class _$SpinifyStateBase { connected: connected ?? (_) => null, closed: closed ?? (_) => null, ); + + @override + int compareTo(_$SpinifyStateBase other) => + timestamp.compareTo(other.timestamp); } diff --git a/test/unit/model_test.dart b/test/unit/model_test.dart index 9a0791f..f8d3ea6 100644 --- a/test/unit/model_test.dart +++ b/test/unit/model_test.dart @@ -11,6 +11,7 @@ import 'package:spinify/src/model/exception.dart' as exception; import 'package:spinify/src/model/history.dart' as history; import 'package:spinify/src/model/presence_stats.dart' as presence_stats; import 'package:spinify/src/model/reply.dart' as reply; +import 'package:spinify/src/model/state.dart' as state; import 'package:spinify/src/util/list_equals.dart'; import 'package:test/test.dart'; @@ -1159,5 +1160,172 @@ void main() { expect(ping1, isNot(equals(ping2))); }); }); + + group('States', () { + test('Instances', () { + final timestamp = DateTime.now(); + final states = [ + state.SpinifyState.disconnected( + timestamp: timestamp, + temporary: false, + ), + state.SpinifyState.connecting( + timestamp: timestamp, + url: 'url', + ), + state.SpinifyState.connected( + timestamp: timestamp, + expires: true, + ttl: timestamp.add(const Duration(seconds: 10)), + url: 'url', + client: 'client', + data: const [1, 2, 3], + node: 'node', + pingInterval: const Duration(seconds: 5), + sendPong: true, + session: 'session', + version: 'version', + ), + state.SpinifyState.closed( + timestamp: timestamp, + ), + ]; + + for (var i = 0; i < states.length; i++) { + final s = states[i]; + expect( + s, + isA() + .having( + (e) => e.timestamp, + 'timestamp', + same(timestamp), + ) + .having( + (e) => e.type, + 'type', + isNotEmpty, + ) + .having( + (e) => e.hashCode, + 'hashCode', + isPositive, + ) + .having( + (e) => e.toString(), + 'toString', + isNotEmpty, + ), + ); + + expect(s.hashCode, isPositive); + expect(s, equals(s)); + + expect( + s.mapOrNull( + connected: (e) => e, + connecting: (e) => e, + disconnected: (e) => e, + closed: (e) => e, + ), + allOf( + isNotNull, + isA(), + same(s), + ), + ); + + expect( + s.maybeMap( + orElse: () => 1, + ), + equals(1), + ); + + expect(s.mapOrNull(), isNull); + + expect( + s.map( + closed: (e) => e.isClosed, + connected: (e) => e.isConnected, + connecting: (e) => e.isConnecting, + disconnected: (e) => e.isDisconnected, + ), + isTrue, + ); + + expect(s.isDisconnected, isA()); + expect(s.isConnected, isA()); + expect(s.isConnecting, isA()); + expect(s.isClosed, isA()); + + expect( + s.url, + anyOf( + isNull, + 'url', + ), + ); + + expect( + s.mapOrNull( + connected: (e) => e.url, + connecting: (e) => e.url, + ), + anyOf( + isNull, + 'url', + ), + ); + + for (var j = 0; j < states.length; j++) { + final other = states[j]; + expect( + s, + s.type != other.type ? isNot(same(other)) : same(other), + ); + expect( + s, + s.type != other.type ? isNot(equals(other)) : equals(other), + ); + } + } + + expect(states.sort, returnsNormally); + }); + + test('Disconnected', () { + final timestamp = DateTime.now(); + final state1 = state.SpinifyState$Disconnected( + timestamp: timestamp, + temporary: false, + ); + final state2 = state.SpinifyState$Disconnected( + timestamp: timestamp, + temporary: false, + ); + final state3 = state.SpinifyState$Disconnected( + timestamp: timestamp.add(const Duration(seconds: 1)), + temporary: true, + ); + expect(state1, isA()); + expect(state1.hashCode, isPositive); + expect( + state1.toString(), + isNotEmpty, + ); + expect(state1, equals(state1)); + expect(state1, equals(state2)); + expect(state1, isNot(equals(state3))); + expect( + state1.permanent, + isNot(state1.temporary), + ); + expect( + state3.permanent, + isNot(state3.temporary), + ); + }); + }); }); } From f7e946dd11f1be32ae9db4a6fbd59772e9d47655 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 8 Nov 2024 18:58:39 +0400 Subject: [PATCH 075/104] Enhance unit tests for SpinifyState by adding comprehensive mapping checks for all state types --- test/unit/model_test.dart | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/unit/model_test.dart b/test/unit/model_test.dart index f8d3ea6..44f4893 100644 --- a/test/unit/model_test.dart +++ b/test/unit/model_test.dart @@ -1235,6 +1235,36 @@ void main() { ), ); + expect( + s.map( + connected: (e) => state.SpinifyState$Connected( + expires: e.expires, + url: e.url, + client: e.client, + data: e.data, + node: e.node, + pingInterval: e.pingInterval, + sendPong: e.sendPong, + session: e.session, + timestamp: DateTime(0), + ttl: e.ttl, + version: e.version, + ), + connecting: (e) => state.SpinifyState$Connecting( + timestamp: DateTime(0), + url: e.url, + ), + disconnected: (e) => state.SpinifyState$Disconnected( + timestamp: DateTime(0), + temporary: e.temporary, + ), + closed: (e) => state.SpinifyState$Closed( + timestamp: DateTime(0), + ), + ), + isNot(equals(s)), + ); + expect( s.maybeMap( orElse: () => 1, @@ -1271,6 +1301,8 @@ void main() { s.mapOrNull( connected: (e) => e.url, connecting: (e) => e.url, + closed: (e) => e.url, + disconnected: (e) => e.url, ), anyOf( isNull, From 7c7d9ea2d6248c2ef7d131e43f325250b997d491 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 8 Nov 2024 19:02:38 +0400 Subject: [PATCH 076/104] Add unit tests for SpinifyStatesStream to validate stream behavior for all state types --- test/unit/model_test.dart | 85 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/test/unit/model_test.dart b/test/unit/model_test.dart index 44f4893..c34841d 100644 --- a/test/unit/model_test.dart +++ b/test/unit/model_test.dart @@ -12,6 +12,7 @@ import 'package:spinify/src/model/history.dart' as history; import 'package:spinify/src/model/presence_stats.dart' as presence_stats; import 'package:spinify/src/model/reply.dart' as reply; import 'package:spinify/src/model/state.dart' as state; +import 'package:spinify/src/model/states_stream.dart' as states_stream; import 'package:spinify/src/util/list_equals.dart'; import 'package:test/test.dart'; @@ -1358,6 +1359,90 @@ void main() { isNot(state3.temporary), ); }); + + test('Stream', () { + final timestamp = DateTime.now(); + final states = [ + state.SpinifyState.disconnected( + timestamp: timestamp, + temporary: false, + ), + state.SpinifyState.connecting( + timestamp: timestamp, + url: 'url', + ), + state.SpinifyState.connected( + timestamp: timestamp, + expires: true, + ttl: timestamp.add(const Duration(seconds: 10)), + url: 'url', + client: 'client', + data: const [1, 2, 3], + node: 'node', + pingInterval: const Duration(seconds: 5), + sendPong: true, + session: 'session', + version: 'version', + ), + state.SpinifyState.closed( + timestamp: timestamp, + ), + ]; + + for (var i = 0; i < states.length; i++) { + final s = states[i]; + states_stream.SpinifyStatesStream stream() => + states_stream.SpinifyStatesStream(Stream.value(s)); + + expect( + stream(), + allOf( + isA>(), + isA(), + ), + ); + + expectLater( + stream(), + emitsInOrder([ + same(s), + emitsDone, + ]), + ); + + expectLater( + stream().closed, + emitsInOrder([ + if (s.isClosed) same(s), + emitsDone, + ]), + ); + + expectLater( + stream().connected, + emitsInOrder([ + if (s.isConnected) same(s), + emitsDone, + ]), + ); + + expectLater( + stream().connecting, + emitsInOrder([ + if (s.isConnecting) same(s), + emitsDone, + ]), + ); + + expectLater( + stream().disconnected, + emitsInOrder([ + if (s.isDisconnected) same(s), + emitsDone, + ]), + ); + } + }); }); }); } From cce7c24362350e776f8b4394b02c84e6401732b0 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 9 Nov 2024 12:52:52 +0400 Subject: [PATCH 077/104] Add metric test --- test/unit/model_test.dart | 66 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/test/unit/model_test.dart b/test/unit/model_test.dart index c34841d..822535e 100644 --- a/test/unit/model_test.dart +++ b/test/unit/model_test.dart @@ -9,6 +9,7 @@ import 'package:spinify/src/model/codes.dart' as codes; import 'package:spinify/src/model/command.dart' as command; import 'package:spinify/src/model/exception.dart' as exception; import 'package:spinify/src/model/history.dart' as history; +import 'package:spinify/src/model/metric.dart' as metric; import 'package:spinify/src/model/presence_stats.dart' as presence_stats; import 'package:spinify/src/model/reply.dart' as reply; import 'package:spinify/src/model/state.dart' as state; @@ -1444,5 +1445,70 @@ void main() { } }); }); + + group('Metric', () { + test('Freeze', () { + final mutable = metric.SpinifyMetrics$Mutable(); + expect(mutable.freeze, returnsNormally); + }); + + test('ToJson', () { + final mutable = metric.SpinifyMetrics$Mutable(); + expect(mutable.toJson, returnsNormally); + }); + + test('ToString', () { + final mutable = metric.SpinifyMetrics$Mutable(); + expect( + mutable.toString(), + allOf( + isA(), + isNotEmpty, + startsWith('SpinifyMetrics{'), + endsWith('}'), + ), + ); + }); + + test('CompareTo', () { + final list = [ + metric.SpinifyMetrics$Mutable(), + metric.SpinifyMetrics$Mutable(), + ]; + expect( + list.sort, + returnsNormally, + ); + expect( + list.map((e) => e.freeze()).toList().sort, + returnsNormally, + ); + }); + + test('Getters', () { + final metrics = metric.SpinifyMetrics$Mutable(); + expect(metrics.messagesSent, isA()); + expect(metrics.messagesReceived, isA()); + }); + + test('Channels', () { + final m = metric.SpinifyMetrics$Mutable() + ..channels.addAll({ + 'channel': metric.SpinifyMetrics$Channel$Mutable(), + }); + expect(m.channels, hasLength(1)); + expect(m.freeze, returnsNormally); + expect(m.channels['channel'], isA()); + expect( + m.channels['channel']!.toString(), + allOf( + isA(), + isNotEmpty, + startsWith(r'SpinifyMetrics$Channel{'), + endsWith('}'), + )); + expect(m.toJson, returnsNormally); + }); + }); }); } From 2cd7e8e6c0f011323da9e59bc3ef780d7c47d66b Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 9 Nov 2024 13:23:12 +0400 Subject: [PATCH 078/104] Add subscription test --- lib/src/model/subscription_state.dart | 28 +-- lib/src/model/subscription_states.dart | 13 +- test/unit/model_test.dart | 240 ++++++++++++++++++++++++- 3 files changed, 250 insertions(+), 31 deletions(-) diff --git a/lib/src/model/subscription_state.dart b/lib/src/model/subscription_state.dart index 4df305f..618f5ab 100644 --- a/lib/src/model/subscription_state.dart +++ b/lib/src/model/subscription_state.dart @@ -35,9 +35,6 @@ sealed class SpinifySubscriptionState extends _$SpinifySubscriptionStateBase { DateTime? timestamp, }) = SpinifySubscriptionState$Subscribed; - /// Converts this state to JSON. - Map toJson(); - @override String toString() => type; } @@ -80,12 +77,6 @@ final class SpinifySubscriptionState$Unsubscribed }) => unsubscribed(this); - @override - Map toJson() => { - 'type': type, - 'timestamp': timestamp.toUtc().toIso8601String(), - }; - @override int get hashCode => 0 + timestamp.microsecondsSinceEpoch * 10; @@ -134,12 +125,6 @@ final class SpinifySubscriptionState$Subscribing }) => subscribing(this); - @override - Map toJson() => { - 'type': type, - 'timestamp': timestamp.toUtc().toIso8601String(), - }; - @override int get hashCode => 1 + timestamp.microsecondsSinceEpoch * 10; @@ -192,12 +177,6 @@ final class SpinifySubscriptionState$Subscribed }) => subscribed(this); - @override - Map toJson() => { - 'type': type, - 'timestamp': timestamp.toUtc().toIso8601String(), - }; - @override int get hashCode => 2 + timestamp.microsecondsSinceEpoch * 10; @@ -214,7 +193,8 @@ typedef SpinifySubscriptionStateMatch = R Function(S state); @immutable -abstract base class _$SpinifySubscriptionStateBase { +abstract base class _$SpinifySubscriptionStateBase + implements Comparable<_$SpinifySubscriptionStateBase> { const _$SpinifySubscriptionStateBase({ required this.timestamp, }); @@ -277,4 +257,8 @@ abstract base class _$SpinifySubscriptionStateBase { subscribing: subscribing ?? (_) => null, subscribed: subscribed ?? (_) => null, ); + + @override + int compareTo(_$SpinifySubscriptionStateBase other) => + timestamp.compareTo(other.timestamp); } diff --git a/lib/src/model/subscription_states.dart b/lib/src/model/subscription_states.dart index c20ceb4..933b57f 100644 --- a/lib/src/model/subscription_states.dart +++ b/lib/src/model/subscription_states.dart @@ -9,18 +9,15 @@ import 'subscription_state.dart'; extension type SpinifySubscriptionStates( Stream _) implements Stream { /// Unsubscribed - SpinifySubscriptionStates unsubscribed( - {String? channel}) => - filter(); + SpinifySubscriptionStates + unsubscribed() => filter(); /// Subscribing - SpinifySubscriptionStates subscribing( - {String? channel}) => - filter(); + SpinifySubscriptionStates + subscribing() => filter(); /// Subscribed - SpinifySubscriptionStates subscribed( - {String? channel}) => + SpinifySubscriptionStates subscribed() => filter(); /// Filtered stream of [SpinifySubscriptionState]. diff --git a/test/unit/model_test.dart b/test/unit/model_test.dart index 822535e..aedf968 100644 --- a/test/unit/model_test.dart +++ b/test/unit/model_test.dart @@ -1,6 +1,6 @@ // ignore_for_file: non_const_call_to_literal_constructor -import 'package:fixnum/fixnum.dart'; +import 'package:spinify/spinify.dart'; import 'package:spinify/src/model/annotations.dart' as annotations; import 'package:spinify/src/model/channel_event.dart' as channel_event; import 'package:spinify/src/model/channel_events.dart' as channel_events; @@ -14,6 +14,12 @@ import 'package:spinify/src/model/presence_stats.dart' as presence_stats; import 'package:spinify/src/model/reply.dart' as reply; import 'package:spinify/src/model/state.dart' as state; import 'package:spinify/src/model/states_stream.dart' as states_stream; +import 'package:spinify/src/model/subscription_config.dart' + as subscription_config; +import 'package:spinify/src/model/subscription_state.dart' + as subscription_state; +import 'package:spinify/src/model/subscription_states.dart' + as subscription_states; import 'package:spinify/src/util/list_equals.dart'; import 'package:test/test.dart'; @@ -800,6 +806,14 @@ void main() { ), ); expect(c == c, isTrue); + final encoder = SpinifyProtobufCodec().encoder; + expect( + encoder.convert(c), + allOf( + isA>(), + isNotEmpty, + ), + ); for (var j = 0; j < commands.length; j++) { final other = commands[j]; expect( @@ -1510,5 +1524,229 @@ void main() { expect(m.toJson, returnsNormally); }); }); + + group('Subscription_state', () { + test('Instance', () { + final unsubscribed = + subscription_state.SpinifySubscriptionState$Unsubscribed(); + final subscribing = + subscription_state.SpinifySubscriptionState$Subscribing(); + final subscribed = + subscription_state.SpinifySubscriptionState$Subscribed(); + final list = [unsubscribed, subscribing, subscribed]; + for (var i = 0; i < list.length; i++) { + final s = list[i]; + subscription_states.SpinifySubscriptionStates stream() => + subscription_states.SpinifySubscriptionStates(Stream.value(s)); + expect( + s, + isA() + .having( + (e) => e.hashCode, + 'hashCode', + isPositive, + ) + .having( + (e) => e.toString(), + 'toString', + isNotEmpty, + ), + ); + expect(s, equals(s)); + expect(s, isNot(equals(list[(i + 1) % list.length]))); + + expect( + s.maybeMap( + orElse: () => 1, + ), + equals(1), + ); + + expect(s.mapOrNull(), isNull); + + expect( + s.map( + subscribed: (e) => e.isSubscribed, + subscribing: (e) => e.isSubscribing, + unsubscribed: (e) => e.isUnsubscribed, + ), + isTrue, + ); + + expect(s.isSubscribed, isA()); + expect(s.isSubscribing, isA()); + expect(s.isUnsubscribed, isA()); + + expect( + s.type, + allOf( + isNotNull, + isNotEmpty, + ), + ); + + expect( + s.mapOrNull( + subscribed: (e) => e.type, + subscribing: (e) => e.type, + unsubscribed: (e) => e.type, + ), + allOf( + isNotNull, + isNotEmpty, + ), + ); + + expect( + stream(), + allOf( + isA>(), + isA(), + ), + ); + + expectLater( + stream(), + emitsInOrder([ + same(s), + emitsDone, + ]), + ); + + for (var j = 0; j < list.length; j++) { + final other = list[j]; + expect( + s, + s.type != other.type ? isNot(same(other)) : same(other), + ); + expect( + s, + s.type != other.type ? isNot(equals(other)) : equals(other), + ); + } + } + + expect(list.sort, returnsNormally); + }); + + test('Equality', () { + final unsubscribed = + subscription_state.SpinifySubscriptionState.unsubscribed(); + final subscribing = + subscription_state.SpinifySubscriptionState.subscribing(); + final subscribed = + subscription_state.SpinifySubscriptionState.subscribed(); + + final unsubscribed2 = + subscription_state.SpinifySubscriptionState.unsubscribed( + timestamp: DateTime(0)); + final subscribing2 = + subscription_state.SpinifySubscriptionState.subscribing( + timestamp: DateTime(0)); + final subscribed2 = + subscription_state.SpinifySubscriptionState.subscribed( + timestamp: DateTime(0)); + expect(unsubscribed, isNot(equals(unsubscribed2))); + expect(subscribing, isNot(equals(subscribing2))); + expect(subscribed, isNot(equals(subscribed2))); + expect(unsubscribed, equals(unsubscribed)); + expect(subscribing, equals(subscribing)); + expect(subscribed, equals(subscribed)); + }); + + test('Stream', () { + final timestamp = DateTime.now(); + final states = [ + subscription_state.SpinifySubscriptionState.unsubscribed( + timestamp: timestamp), + subscription_state.SpinifySubscriptionState.subscribing( + timestamp: timestamp), + subscription_state.SpinifySubscriptionState.subscribed( + timestamp: timestamp), + ]; + + for (var i = 0; i < states.length; i++) { + final s = states[i]; + subscription_states.SpinifySubscriptionStates stream() => + subscription_states.SpinifySubscriptionStates(Stream.value(s)); + + expect( + stream(), + allOf( + isA>(), + isA(), + ), + ); + + expectLater( + stream(), + emitsInOrder([ + same(s), + emitsDone, + ]), + ); + + expectLater( + stream().unsubscribed(), + emitsInOrder([ + if (s.isUnsubscribed) same(s), + emitsDone, + ]), + ); + + expectLater( + stream().subscribing(), + emitsInOrder([ + if (s.isSubscribing) same(s), + emitsDone, + ]), + ); + + expectLater( + stream().subscribed(), + emitsInOrder([ + if (s.isSubscribed) same(s), + emitsDone, + ]), + ); + } + }); + }); + + group('Subscription_config', () { + test('ByDefault', () { + expect( + subscription_config.SpinifySubscriptionConfig.byDefault, + returnsNormally, + ); + }); + + test('Instance', () { + final config = subscription_config.SpinifySubscriptionConfig( + getPayload: () async => [1, 2, 3], + getToken: () async => 'token', + resubscribeInterval: (min: Duration.zero, max: Duration.zero), + since: (epoch: 'epoch', offset: Int64(10)), + timeout: Duration.zero, + joinLeave: true, + positioned: true, + recoverable: true, + ); + expect( + config, + isA() + .having( + (e) => e.hashCode, + 'hashCode', + isPositive, + ) + .having( + (e) => e.toString(), + 'toString', + isNotEmpty, + ), + ); + }); + }); }); } From 237096c18aab9f7cb7c72fa2840277cb35f50862 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 9 Nov 2024 13:51:54 +0400 Subject: [PATCH 079/104] Refactor guarded functions to remove debugger calls and add unit tests for utility functions --- lib/src/util/guarded.dart | 5 +- test/unit/util_test.dart | 138 ++++++++++++++++++++++++++++++++++++++ test/unit_test.dart | 2 + 3 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 test/unit/util_test.dart diff --git a/lib/src/util/guarded.dart b/lib/src/util/guarded.dart index 6fb79a3..2a317ea 100644 --- a/lib/src/util/guarded.dart +++ b/lib/src/util/guarded.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:developer'; import 'package:meta/meta.dart'; @@ -26,7 +25,7 @@ Future asyncGuarded( }, (error, stackTrace) { // This should never be called. - debugger(); + //debugger(); $error = error; $stackTrace = stackTrace; }, @@ -61,7 +60,7 @@ void guarded( }, (error, stackTrace) { // This should never be called. - debugger(); + //debugger(); $error = error; $stackTrace = stackTrace; }, diff --git a/test/unit/util_test.dart b/test/unit/util_test.dart new file mode 100644 index 0000000..92bcbff --- /dev/null +++ b/test/unit/util_test.dart @@ -0,0 +1,138 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:spinify/src/util/backoff.dart'; +import 'package:spinify/src/util/event_queue.dart'; +import 'package:spinify/src/util/guarded.dart'; +import 'package:spinify/src/util/list_equals.dart'; +import 'package:spinify/src/util/map_equals.dart'; +import 'package:test/test.dart'; + +void main() => group('Util', () { + test('Backoff', () { + expect(() => Backoff.nextDelay(5, 10, 50), returnsNormally); + expect(Backoff.nextDelay(5, 10, 50), isA()); + expect(Backoff.nextDelay(5, 5, 5), isA()); + }); + + test( + 'EventQueue', + () => fakeAsync( + (async) { + expect(EventQueue.new, returnsNormally); + var queue = EventQueue(); + expect(queue.isClosed, isFalse); + expectLater(queue.add(() {}), completes); + var counter = 0; + expectLater(queue.add(() => counter++), completes); + expect(counter, 0); + expectLater(queue.close(), completes); + async.elapse(Duration.zero); + expect(counter, 1); + expect(queue.isClosed, isTrue); + + queue = EventQueue(); + expectLater(queue.add(() {}), completes); + expectLater(queue.add(() {}), throwsStateError); + expectLater(queue.close(force: true), completes); + expectLater(queue.close(force: true), completes); + expectLater(queue.close(force: false), completes); + async.elapse(Duration.zero); + expect(queue.isClosed, isTrue); + expectLater(() => queue.add(() {}), throwsStateError); + }, + ), + ); + + test('Guarded', () { + expect( + () => guarded(() => 1), + returnsNormally, + ); + expect( + () => guarded(() => throw Exception(), ignore: false), + throwsException, + ); + expect( + () => guarded(() => throw Exception(), ignore: true), + returnsNormally, + ); + expect( + () => guarded( + () => Future.delayed( + Duration.zero, + () { + throw Exception(); + }, + ), + ignore: false, + ), + returnsNormally, + ); + expect( + () => guarded( + () => Future.delayed( + Duration.zero, + () { + throw Exception(); + }, + ), + ignore: true, + ), + returnsNormally, + ); + + expect( + () => guarded(() { + Completer().completeError(Exception()); + }), + returnsNormally, + ); + }); + + test('AsyncGuarded', () { + expectLater( + asyncGuarded(() async => 1), + completes, + ); + expectLater( + asyncGuarded(() async => throw Exception(), ignore: false), + throwsException, + ); + expectLater( + asyncGuarded(() async => throw Exception(), ignore: true), + completes, + ); + expectLater( + asyncGuarded(() async { + Completer().completeError(Exception()); + }), + throwsException, + ); + expectLater( + asyncGuarded(() async { + await Future.delayed(Duration.zero); + Completer().completeError(Exception()); + }), + completes, + ); + }); + + test('ListEquals', () { + expect(listEquals([1, 2, 3], [1, 2, 3]), isTrue); + expect(listEquals([1, 2, 3], [1, 2, 4]), isFalse); + expect(listEquals([1, 2, 3], [1, 2]), isFalse); + expect(listEquals(null, [1, 2, 3, 4]), isFalse); + expect(listEquals([1, 2, 3, 4], null), isFalse); + expect(listEquals(null, null), isTrue); + }); + + test('MapEquals', () { + expect(mapEquals({1: 2, 3: 4}, {1: 2, 3: 4}), isTrue); + expect(mapEquals({1: 2, 3: 4}, {1: 2, 3: 5}), isFalse); + expect(mapEquals({1: 2, 3: 4}, {1: 2}), isFalse); + expect(mapEquals(null, {1: 2, 3: 4}), isFalse); + expect(mapEquals({1: 2, 3: 4}, null), isFalse); + expect(mapEquals(null, null), isTrue); + }); + }); diff --git a/test/unit_test.dart b/test/unit_test.dart index 07b058c..7f22dae 100644 --- a/test/unit_test.dart +++ b/test/unit_test.dart @@ -7,9 +7,11 @@ import 'unit/logs_test.dart' as logs_test; import 'unit/model_test.dart' as model_test; import 'unit/server_subscription_test.dart' as server_subscription_test; import 'unit/spinify_test.dart' as spinify_test; +import 'unit/util_test.dart' as util_test; void main() { group('Unit', () { + util_test.main(); model_test.main(); config_test.main(); logs_test.main(); From c74ebd130ac4bd01941f3fdb6ec25d1b375bae02 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Mon, 11 Nov 2024 16:30:07 +0400 Subject: [PATCH 080/104] Add mutex benchmark for measuring performance of different mutex implementations --- benchmark/mutex_benchmark.dart | 157 +++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 benchmark/mutex_benchmark.dart diff --git a/benchmark/mutex_benchmark.dart b/benchmark/mutex_benchmark.dart new file mode 100644 index 0000000..f81817f --- /dev/null +++ b/benchmark/mutex_benchmark.dart @@ -0,0 +1,157 @@ +// ignore_for_file: avoid_print + +import 'dart:async'; +import 'dart:collection'; + +import 'package:benchmark_harness/benchmark_harness.dart'; + +void main() => Future(() async { + final baseUs = await _WithoutMutex().measure(); + final scores = await Stream.fromIterable( + [ + _MutexCompleterList(), + _MutexCompleterQueue(), + _MutexCompleterLinkedList(), + ], + ) + .asyncMap((benchmark) async => + (name: benchmark.name, score: await benchmark.measure() - baseUs)) + .toList(); + scores.sort((a, b) => a.score.compareTo(b.score)); + for (final score in scores) { + print('${score.name}: ${score.score} us.'); + } + }); + +class _Base extends AsyncBenchmarkBase { + _Base(super.name); + + int _counter = 0; + + @override + Future setup() async { + _counter = 0; + return super.setup(); + } + + /// Measures the score for this benchmark by executing it repeatedly until + /// time minimum has been reached. + static Future measureFor( + Future Function() f, int minimumMillis) async { + final futures = List>.filled(100, Future.value()); + final minimumMicros = minimumMillis * 1000; + final watch = Stopwatch()..start(); + var iter = 0; + var elapsed = 0; + while (elapsed < minimumMicros) { + for (var i = 0; i < 100; i++) { + futures[i] = f(); + iter++; + } + await Future.wait(futures); + elapsed = watch.elapsedMicroseconds; + } + return elapsed / iter; + } + + /// Measures the score for the benchmark and returns it. + @override + Future measure() async { + await setup(); + try { + // Warmup for at least 100ms. Discard result. + await measureFor(warmup, 100); + } finally { + await teardown(); + } + await setup(); + try { + // Run the benchmark for at least 2000ms. + return await measureFor(exercise, 2000); + } finally { + await teardown(); + } + } + + @override + Future teardown() async { + if (_counter == 0) throw StateError('Counter mismatch'); + return super.teardown(); + } +} + +class _WithoutMutex extends _Base { + _WithoutMutex() : super('Without mutex'); + + @override + Future run() => Future.delayed(Duration.zero, () { + final value = _counter; + _counter = value + 1; + }); +} + +class _MutexCompleterList extends _Base { + _MutexCompleterList() : super('Completers list'); + + final _list = >[Future.value()]; + + @override + Future run() async { + final last = _list.last; + final completer = Completer.sync(); + _list.add(completer.future); + await last; + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + //if (_counter < 50) print('$value -> $_counter'); + unawaited(_list.removeAt(0)); + completer.complete(); + } +} + +class _MutexCompleterLinkedList extends _Base { + _MutexCompleterLinkedList() : super('Completers linked list'); + + var _nodes = _Node(Future.value()); + + @override + Future run() async { + final prev = _nodes; + final completer = Completer.sync(); + final next = _nodes = _Node(completer.future)..next; + await prev.future; + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + //if (_counter < 50) print('$value -> $_counter'); + next.next = null; + completer.complete(); + } +} + +class _Node { + _Node(this.future); + final Future future; + _Node? next; +} + +class _MutexCompleterQueue extends _Base { + _MutexCompleterQueue() : super('Completers queue'); + + final _queue = Queue>()..add(Future.value()); + + @override + Future run() async { + final last = _queue.last; + final completer = Completer.sync(); + _queue.add(completer.future); + await last; + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + //if (_counter < 50) print('$value -> $_counter'); + unawaited(_queue.removeFirst()); + completer.complete(); + } +} From 5c33d7b1594d3a9263e427016b470482dfa192b8 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Mon, 11 Nov 2024 16:52:05 +0400 Subject: [PATCH 081/104] Refactor mutex benchmark to use different mutex implementations --- benchmark/mutex_benchmark.dart | 82 +++++++++++++++++++++++++++------- 1 file changed, 65 insertions(+), 17 deletions(-) diff --git a/benchmark/mutex_benchmark.dart b/benchmark/mutex_benchmark.dart index f81817f..7b4844f 100644 --- a/benchmark/mutex_benchmark.dart +++ b/benchmark/mutex_benchmark.dart @@ -1,5 +1,3 @@ -// ignore_for_file: avoid_print - import 'dart:async'; import 'dart:collection'; @@ -7,20 +5,26 @@ import 'package:benchmark_harness/benchmark_harness.dart'; void main() => Future(() async { final baseUs = await _WithoutMutex().measure(); - final scores = await Stream.fromIterable( + final results = await Stream.fromIterable( [ - _MutexCompleterList(), - _MutexCompleterQueue(), - _MutexCompleterLinkedList(), + _MutexList(), + _MutexQueue(), + _MutexLinkedList(), + _MutexLast(), + _MutexWrap(), ], ) .asyncMap((benchmark) async => (name: benchmark.name, score: await benchmark.measure() - baseUs)) .toList(); - scores.sort((a, b) => a.score.compareTo(b.score)); - for (final score in scores) { - print('${score.name}: ${score.score} us.'); + results.sort((a, b) => a.score.compareTo(b.score)); + final buffer = StringBuffer(); + for (final r in results) { + buffer.writeln('${r.name.padLeft(12)} |' + ' ${r.score.toStringAsPrecision(6).padRight(8)} us |' + ' ${(1000000 / r.score).round()} FPS'); } + print(buffer.toString()); // ignore: avoid_print }); class _Base extends AsyncBenchmarkBase { @@ -81,7 +85,7 @@ class _Base extends AsyncBenchmarkBase { } class _WithoutMutex extends _Base { - _WithoutMutex() : super('Without mutex'); + _WithoutMutex() : super('Without'); @override Future run() => Future.delayed(Duration.zero, () { @@ -90,8 +94,8 @@ class _WithoutMutex extends _Base { }); } -class _MutexCompleterList extends _Base { - _MutexCompleterList() : super('Completers list'); +class _MutexList extends _Base { + _MutexList() : super('List'); final _list = >[Future.value()]; @@ -110,8 +114,8 @@ class _MutexCompleterList extends _Base { } } -class _MutexCompleterLinkedList extends _Base { - _MutexCompleterLinkedList() : super('Completers linked list'); +class _MutexLinkedList extends _Base { + _MutexLinkedList() : super('LinkedList'); var _nodes = _Node(Future.value()); @@ -130,14 +134,14 @@ class _MutexCompleterLinkedList extends _Base { } } -class _Node { +final class _Node { _Node(this.future); final Future future; _Node? next; } -class _MutexCompleterQueue extends _Base { - _MutexCompleterQueue() : super('Completers queue'); +class _MutexQueue extends _Base { + _MutexQueue() : super('Queue'); final _queue = Queue>()..add(Future.value()); @@ -155,3 +159,47 @@ class _MutexCompleterQueue extends _Base { completer.complete(); } } + +class _MutexLast extends _Base { + _MutexLast() : super('Last'); + + Future? _last; // The last running block + + @override + Future run() async { + final prev = _last; + final completer = Completer.sync(); + final current = _last = completer.future; + await prev; + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + //if (_counter < 50) print('$value -> $_counter'); + if (identical(_last, current)) _last = null; + completer.complete(); + } +} + +class _MutexWrap extends _Base { + _MutexWrap() : super('Wrap'); + + Future? _last; // The last running block + + Future _wrap(Future Function() fn) async { + final prev = _last; + final completer = Completer.sync(); + final current = _last = completer.future; + await prev; + await fn(); + if (identical(_last, current)) _last = null; + completer.complete(); + } + + @override + Future run() => _wrap(() async { + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + //if (_counter < 50) print('$value -> $_counter'); + }); +} From 68cb067c9237691f4045dd8f5d37a7071a28155e Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Mon, 11 Nov 2024 17:06:22 +0400 Subject: [PATCH 082/104] Refactor mutex benchmark to use different mutex implementations --- benchmark/mutex_benchmark.dart | 54 ++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/benchmark/mutex_benchmark.dart b/benchmark/mutex_benchmark.dart index 7b4844f..c16ffdd 100644 --- a/benchmark/mutex_benchmark.dart +++ b/benchmark/mutex_benchmark.dart @@ -1,3 +1,10 @@ +/* + * Mutex benchmark + * https://gist.github.com/PlugFox/264d59a37d02dd06a7123ef19ee8537d + * https://dartpad.dev?id=264d59a37d02dd06a7123ef19ee8537d + * Mike Matiunin , 11 November 2024 + */ + import 'dart:async'; import 'dart:collection'; @@ -9,7 +16,8 @@ void main() => Future(() async { [ _MutexList(), _MutexQueue(), - _MutexLinkedList(), + _MutexLinked(), + _MutexLock(), _MutexLast(), _MutexWrap(), ], @@ -114,22 +122,23 @@ class _MutexList extends _Base { } } -class _MutexLinkedList extends _Base { - _MutexLinkedList() : super('LinkedList'); +class _MutexLinked extends _Base { + _MutexLinked() : super('Linked'); - var _nodes = _Node(Future.value()); + _Node? _node; @override Future run() async { - final prev = _nodes; + final prev = _node; final completer = Completer.sync(); - final next = _nodes = _Node(completer.future)..next; - await prev.future; + final current = _node = _Node(completer.future)..prev = prev; + await prev?.future; final value = _counter; await Future.delayed(Duration.zero); _counter = value + 1; //if (_counter < 50) print('$value -> $_counter'); - next.next = null; + current.prev = null; + if (identical(_node, current)) _node = null; completer.complete(); } } @@ -137,7 +146,7 @@ class _MutexLinkedList extends _Base { final class _Node { _Node(this.future); final Future future; - _Node? next; + _Node? prev; } class _MutexQueue extends _Base { @@ -180,6 +189,33 @@ class _MutexLast extends _Base { } } +class _MutexLock extends _Base { + _MutexLock() : super('Lock'); + + Future? _last; // The last running block + + Future _lock() async { + final prev = _last; + final completer = Completer.sync(); + final current = _last = completer.future; + await prev; + return () { + if (identical(_last, current)) _last = null; + completer.complete(); + }; + } + + @override + Future run() async { + final unlock = await _lock(); + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + //if (_counter < 50) print('$value -> $_counter'); + unlock(); + } +} + class _MutexWrap extends _Base { _MutexWrap() : super('Wrap'); From c1f6ac62e298af3d21c199f020b2d7ecfade6528 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Mon, 11 Nov 2024 17:41:30 +0400 Subject: [PATCH 083/104] Refactor mutex benchmark to use different mutex implementations and add a new mutex implementation called _MutexEncapsulated --- benchmark/mutex_benchmark.dart | 78 ++++++++++++++++++++++++++++++---- 1 file changed, 69 insertions(+), 9 deletions(-) diff --git a/benchmark/mutex_benchmark.dart b/benchmark/mutex_benchmark.dart index c16ffdd..67dd3bc 100644 --- a/benchmark/mutex_benchmark.dart +++ b/benchmark/mutex_benchmark.dart @@ -9,6 +9,7 @@ import 'dart:async'; import 'dart:collection'; import 'package:benchmark_harness/benchmark_harness.dart'; +import 'package:meta/meta.dart'; void main() => Future(() async { final baseUs = await _WithoutMutex().measure(); @@ -20,6 +21,7 @@ void main() => Future(() async { _MutexLock(), _MutexLast(), _MutexWrap(), + _MutexEncapsulated(), ], ) .asyncMap((benchmark) async => @@ -41,6 +43,7 @@ class _Base extends AsyncBenchmarkBase { int _counter = 0; @override + @mustCallSuper Future setup() async { _counter = 0; return super.setup(); @@ -68,6 +71,7 @@ class _Base extends AsyncBenchmarkBase { /// Measures the score for the benchmark and returns it. @override + @mustCallSuper Future measure() async { await setup(); try { @@ -86,6 +90,7 @@ class _Base extends AsyncBenchmarkBase { } @override + @mustCallSuper Future teardown() async { if (_counter == 0) throw StateError('Counter mismatch'); return super.teardown(); @@ -130,8 +135,7 @@ class _MutexLinked extends _Base { @override Future run() async { final prev = _node; - final completer = Completer.sync(); - final current = _node = _Node(completer.future)..prev = prev; + final current = _node = _Node.sync()..prev = prev; await prev?.future; final value = _counter; await Future.delayed(Duration.zero); @@ -139,16 +143,10 @@ class _MutexLinked extends _Base { //if (_counter < 50) print('$value -> $_counter'); current.prev = null; if (identical(_node, current)) _node = null; - completer.complete(); + current.release(); } } -final class _Node { - _Node(this.future); - final Future future; - _Node? prev; -} - class _MutexQueue extends _Base { _MutexQueue() : super('Queue'); @@ -239,3 +237,65 @@ class _MutexWrap extends _Base { //if (_counter < 50) print('$value -> $_counter'); }); } + +class _MutexEncapsulated extends _Base { + _MutexEncapsulated() : super('Encapsulated'); + + final _Mutex _m = _Mutex(); + + @override + Future run() async { + await _m.lock(); + try { + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + //if (_counter < 50) print('$value -> $_counter'); + } finally { + _m.release(); + } + } + + @override + @mustCallSuper + Future teardown() async { + if (_m.locks != 0) throw StateError('Lock mismatch'); + return super.teardown(); + } +} + +final class _Node { + _Node._(Completer completer) + : _completer = completer, + future = completer.future; + + factory _Node.sync() => _Node._(Completer.sync()); + + final Completer _completer; + void release() => _completer.complete(); + final Future future; + _Node? prev; +} + +class _Mutex { + _Node? _request; // The last requested block + _Node? _current; // The first and current running block + int _locks = 0; + int get locks => _locks; + + Future lock() async { + final prev = _request; + _locks++; + final current = _request = _Node.sync()..prev = prev; + await prev?.future; + _current = current..prev = null; + } + + void release() { + final current = _current; + if (current == null) return; + _locks--; + _current = null; + current.release(); + } +} From e571ee9f14533d93fb1dea250c74d0876a1e95a7 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Mon, 11 Nov 2024 17:43:39 +0400 Subject: [PATCH 084/104] Refactor mutex benchmark to include new mutex implementation and remove commented code --- benchmark/mutex_benchmark.dart | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/benchmark/mutex_benchmark.dart b/benchmark/mutex_benchmark.dart index 67dd3bc..e433f8e 100644 --- a/benchmark/mutex_benchmark.dart +++ b/benchmark/mutex_benchmark.dart @@ -12,9 +12,10 @@ import 'package:benchmark_harness/benchmark_harness.dart'; import 'package:meta/meta.dart'; void main() => Future(() async { - final baseUs = await _WithoutMutex().measure(); + //final baseUs = await _WithoutMutex().measure(); final results = await Stream.fromIterable( [ + _WithoutMutex(), _MutexList(), _MutexQueue(), _MutexLinked(), @@ -24,8 +25,10 @@ void main() => Future(() async { _MutexEncapsulated(), ], ) - .asyncMap((benchmark) async => - (name: benchmark.name, score: await benchmark.measure() - baseUs)) + .asyncMap((benchmark) async => ( + name: benchmark.name, + score: await benchmark.measure(), + )) .toList(); results.sort((a, b) => a.score.compareTo(b.score)); final buffer = StringBuffer(); From 49a1048767a8b2df9867be559f62df8b75941b4b Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Mon, 11 Nov 2024 17:59:00 +0400 Subject: [PATCH 085/104] Refactor mutex benchmark to use different mutex implementations and add new mutex implementation --- benchmark/mutex_benchmark.dart | 46 +++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/benchmark/mutex_benchmark.dart b/benchmark/mutex_benchmark.dart index e433f8e..d696897 100644 --- a/benchmark/mutex_benchmark.dart +++ b/benchmark/mutex_benchmark.dart @@ -7,35 +7,43 @@ import 'dart:async'; import 'dart:collection'; +import 'dart:isolate'; import 'package:benchmark_harness/benchmark_harness.dart'; import 'package:meta/meta.dart'; void main() => Future(() async { //final baseUs = await _WithoutMutex().measure(); - final results = await Stream.fromIterable( - [ - _WithoutMutex(), - _MutexList(), - _MutexQueue(), - _MutexLinked(), - _MutexLock(), - _MutexLast(), - _MutexWrap(), - _MutexEncapsulated(), - ], - ) - .asyncMap((benchmark) async => ( - name: benchmark.name, - score: await benchmark.measure(), - )) - .toList(); + final benchmarks = [ + _WithoutMutex.new, + _MutexList.new, + _MutexQueue.new, + _MutexLinked.new, + _MutexLock.new, + _MutexLast.new, + _MutexWrap.new, + _MutexEncapsulated.new, + ]; + final results = + await Stream.fromIterable(benchmarks) + .asyncMap( + (constructor) async => await Isolate.run( + () async { + final benchmark = constructor(); + return ( + name: benchmark.name, + score: await benchmark.measure() + ); + }, + ), + ) + .toList(); results.sort((a, b) => a.score.compareTo(b.score)); final buffer = StringBuffer(); for (final r in results) { buffer.writeln('${r.name.padLeft(12)} |' ' ${r.score.toStringAsPrecision(6).padRight(8)} us |' - ' ${(1000000 / r.score).round()} FPS'); + ' ${1000000 ~/ r.score} FPS'); } print(buffer.toString()); // ignore: avoid_print }); @@ -281,6 +289,8 @@ final class _Node { } class _Mutex { + _Mutex(); + _Node? _request; // The last requested block _Node? _current; // The first and current running block int _locks = 0; From e6ca7c9de2492c28bca3dd1b1a4b7f530c4d0b2f Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Mon, 11 Nov 2024 19:10:17 +0400 Subject: [PATCH 086/104] Refactor mutex implementation to introduce _Mutex$Request class and update locking mechanisms --- benchmark/mutex_benchmark.dart | 72 +++++++++++++++++++++++-------- lib/src/util/mutex.dart | 78 ++++++++++++++++++++++++++++++++++ test/unit/util_test.dart | 62 +++++++++++++++++++++++++++ 3 files changed, 194 insertions(+), 18 deletions(-) create mode 100644 lib/src/util/mutex.dart diff --git a/benchmark/mutex_benchmark.dart b/benchmark/mutex_benchmark.dart index d696897..639d1b9 100644 --- a/benchmark/mutex_benchmark.dart +++ b/benchmark/mutex_benchmark.dart @@ -141,12 +141,12 @@ class _MutexList extends _Base { class _MutexLinked extends _Base { _MutexLinked() : super('Linked'); - _Node? _node; + _Mutex$Request? _node; @override Future run() async { final prev = _node; - final current = _node = _Node.sync()..prev = prev; + final current = _node = _Mutex$Request.sync()..prev = prev; await prev?.future; final value = _counter; await Future.delayed(Duration.zero); @@ -263,7 +263,7 @@ class _MutexEncapsulated extends _Base { _counter = value + 1; //if (_counter < 50) print('$value -> $_counter'); } finally { - _m.release(); + _m.unlock(); } } @@ -275,36 +275,72 @@ class _MutexEncapsulated extends _Base { } } -final class _Node { - _Node._(Completer completer) +/// A request for a mutex lock. +class _Mutex$Request { + /// Creates a new mutex request. + _Mutex$Request._(Completer completer) : _completer = completer, future = completer.future; - factory _Node.sync() => _Node._(Completer.sync()); + /// Creates a new mutex request with a synchronous completer. + factory _Mutex$Request.sync() => _Mutex$Request._(Completer.sync()); - final Completer _completer; - void release() => _completer.complete(); - final Future future; - _Node? prev; + final Completer _completer; // The completer for the request. + void release() => _completer.complete(); // Releases the lock. + final Future future; // The future for the request. + _Mutex$Request? prev; // The previous request in the chain. } +/// A mutual exclusion lock. class _Mutex { + /// Creates a new mutex. _Mutex(); - _Node? _request; // The last requested block - _Node? _current; // The first and current running block - int _locks = 0; + _Mutex$Request? _last; // The last requested block + _Mutex$Request? _current; // The first and current running block + int _locks = 0; // The number of locks currently held + + /// The number of locks currently held. int get locks => _locks; + /// The list of pending locks. + List> get pending { + final pending = List>.filled(_locks, Future.value(), + growable: false); + for (var i = _locks - 1, request = _last; + i >= 0; + i--, request = request?.prev) { + final future = request?.future; + if (future != null) + pending[i] = future; + else + assert(false, 'Invalid lock state'); + } + return pending; + } + + /// Protects a callback with the mutex. + Future protect(Future Function() callback) async { + await lock(); + try { + return await callback(); + } finally { + unlock(); + } + } + + /// Locks the mutex. Future lock() async { - final prev = _request; _locks++; - final current = _request = _Node.sync()..prev = prev; - await prev?.future; - _current = current..prev = null; + final prev = _last; + final current = _last = _Mutex$Request.sync()..prev = prev; + // Wait for the previous lock to be released. + if (prev != null) await prev.future; + _current = current..prev = null; // Set the current lock. } - void release() { + /// Unlocks the mutex. + void unlock() { final current = _current; if (current == null) return; _locks--; diff --git a/lib/src/util/mutex.dart b/lib/src/util/mutex.dart new file mode 100644 index 0000000..891a3b6 --- /dev/null +++ b/lib/src/util/mutex.dart @@ -0,0 +1,78 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +/// A request for a mutex lock. +class _Mutex$Request { + /// Creates a new mutex request. + _Mutex$Request._(Completer completer) + : _completer = completer, + future = completer.future; + + /// Creates a new mutex request with a synchronous completer. + factory _Mutex$Request.sync() => _Mutex$Request._(Completer.sync()); + + final Completer _completer; // The completer for the request. + void release() => _completer.complete(); // Releases the lock. + final Future future; // The future for the request. + _Mutex$Request? prev; // The previous request in the chain. +} + +/// A mutual exclusion lock. +@internal +class Mutex { + /// Creates a new mutex. + Mutex(); + + _Mutex$Request? _last; // The last requested block + _Mutex$Request? _current; // The first and current running block + int _locks = 0; // The number of locks currently held + + /// The number of locks currently held. + int get locks => _locks; + + /// The list of pending locks. + List> get pending { + final pending = List>.filled(_locks, Future.value(), + growable: false); + for (var i = _locks - 1, request = _last; + i >= 0; + i--, request = request?.prev) { + final future = request?.future; + if (future != null) + pending[i] = future; + else + assert(false, 'Invalid lock state'); // coverage:ignore-line + } + return pending; + } + + /// Protects a callback with the mutex. + Future protect(Future Function() callback) async { + await lock(); + try { + return await callback(); + } finally { + unlock(); + } + } + + /// Locks the mutex. + Future lock() async { + _locks++; + final prev = _last; + final current = _last = _Mutex$Request.sync()..prev = prev; + // Wait for the previous lock to be released. + if (prev != null) await prev.future; + _current = current..prev = null; // Set the current lock. + } + + /// Unlocks the mutex. + void unlock() { + final current = _current; + if (current == null) return; + _locks--; + _current = null; + current.release(); + } +} diff --git a/test/unit/util_test.dart b/test/unit/util_test.dart index 92bcbff..2ba9edb 100644 --- a/test/unit/util_test.dart +++ b/test/unit/util_test.dart @@ -6,6 +6,7 @@ import 'package:spinify/src/util/event_queue.dart'; import 'package:spinify/src/util/guarded.dart'; import 'package:spinify/src/util/list_equals.dart'; import 'package:spinify/src/util/map_equals.dart'; +import 'package:spinify/src/util/mutex.dart'; import 'package:test/test.dart'; void main() => group('Util', () { @@ -135,4 +136,65 @@ void main() => group('Util', () { expect(mapEquals({1: 2, 3: 4}, null), isFalse); expect(mapEquals(null, null), isTrue); }); + + test('Mutex', () async { + fakeAsync((async) { + final m = Mutex(); + expect(m.locks, equals(0)); + expect(m.pending, isEmpty); + unawaited(expectLater(m.lock(), completes)); + expect(m.locks, equals(1)); + expect(m.pending, isNotEmpty); + unawaited(expectLater(m.lock(), completes)); + expect(m.locks, equals(2)); + expect(m.pending, hasLength(2)); + expect(m.unlock, returnsNormally); + expect(m.locks, equals(1)); + expect(m.pending, hasLength(1)); + expect(m.unlock, returnsNormally); + expect(m.locks, equals(0)); + expect(m.pending, isEmpty); + + unawaited( + expectLater( + m.protect( + () => Future.delayed(const Duration(hours: 1), () => 1), + ), + completion(equals(1)), + ), + ); + async.flushMicrotasks(); + expect(m.locks, equals(1)); + async.flushTimers(); + expect(m.locks, equals(0)); + }); + + fakeAsync((async) { + final m = Mutex(); + final list = [for (var i = 0; i < 10; i++) i]; + final result = []; + for (var i = 0; i < 10; i++) { + unawaited( + expectLater( + m.protect(() async { + final value = list[i]; + await Future.delayed(Duration(seconds: 10 - i)); + result.add(value); + }), + completes, + ), + ); + } + async.flushMicrotasks(); + expect(m.locks, equals(list.length)); + expect(m.pending, hasLength(list.length)); + async.elapse(const Duration(seconds: 10)); + expect(m.locks, equals(list.length - 1)); + expect(m.pending, hasLength(list.length - 1)); + async.flushTimers(); + expect(listEquals(result, list), isTrue); + expect(m.locks, equals(0)); + expect(m.pending, isEmpty); + }); + }); }); From 207ac312bb944f7785a2400a8dd49efee5a65348 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Mon, 11 Nov 2024 20:35:23 +0400 Subject: [PATCH 087/104] Add mutex lock for all spinify interactives methods --- lib/src/model/codes.dart | 8 +- lib/src/spinify.dart | 164 +++++++++++++++++++++--------------- lib/src/util/mutex.dart | 78 +++++++++++++++-- test/unit/model_test.dart | 35 ++++++-- test/unit/spinify_test.dart | 29 ++++--- test/unit/util_test.dart | 62 +++++++++++++- 6 files changed, 272 insertions(+), 104 deletions(-) diff --git a/lib/src/model/codes.dart b/lib/src/model/codes.dart index 2da2991..a85a9c1 100644 --- a/lib/src/model/codes.dart +++ b/lib/src/model/codes.dart @@ -116,6 +116,7 @@ extension type const SpinifyDisconnectCode(int code) implements int { @literal const SpinifyDisconnectCode.abnormalClosure() : code = 1006; + /* /// Normalize disconnect code and reason. @experimental static ({SpinifyDisconnectCode code, String reason, bool reconnect}) @@ -532,13 +533,10 @@ extension type const SpinifyDisconnectCode(int code) implements int { /// Custom disconnect codes (unreachable). // coverage:ignore-start - _ => ( - code: SpinifyDisconnectCode(code ?? 0), - reason: reason ?? 'transport closed', - reconnect: false, - ), + _ => throw ArgumentError('invalid disconnect code'), // coverage:ignore-end }; + */ /// Reconnect is needed due to specific transport close code. bool get reconnect => switch (code) { diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 3aa0749..91bbf15 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -30,6 +30,7 @@ import 'spinify_interface.dart'; import 'subscription_interface.dart'; import 'util/backoff.dart'; import 'util/guarded.dart'; +import 'util/mutex.dart'; import 'web_socket_stub.dart' // ignore: uri_does_not_exist if (dart.library.js_interop) 'web_socket_js.dart' @@ -63,7 +64,8 @@ final class Spinify implements ISpinify { @safe Spinify({SpinifyConfig? config}) : config = config ?? SpinifyConfig.byDefault(), - _codec = config?.codec ?? SpinifyProtobufCodec() { + _codec = config?.codec ?? SpinifyProtobufCodec(), + _mutex = MutexImpl() /* MutexDisabled() */ { /// Client initialization (from constructor). _init(); } @@ -80,6 +82,9 @@ final class Spinify implements ISpinify { @override final SpinifyConfig config; + /// Mutex to protect client interactive operations. + final IMutex _mutex; + @safe @override SpinifyMetrics get metrics => _metrics.freeze(); @@ -114,6 +119,7 @@ final class Spinify implements ISpinify { @override late final SpinifyChannelEvents stream = SpinifyChannelEvents(_eventController.stream); + final StreamController _eventController = StreamController.broadcast(sync: true); @@ -599,6 +605,7 @@ final class Spinify implements ISpinify { @Throws([SpinifyConnectionException]) Future connect(String url) async { try { + await _mutex.lock(); await _interactiveConnect(url); } on SpinifyConnectionException { rethrow; @@ -610,6 +617,8 @@ final class Spinify implements ISpinify { ), stackTrace, ); + } finally { + _mutex.unlock(); } } @@ -832,24 +841,27 @@ final class Spinify implements ISpinify { return true; }(), '...'); var WebSocket(:int? closeCode, :String? closeReason) = ws; - final close = - SpinifyDisconnectCode.normalize(closeCode, closeReason); + closeCode ??= 1000; + closeReason ??= 'no reason'; + final code = SpinifyDisconnectCode(closeCode); + final reason = closeReason; + final reconnect = code.reconnect; _log( const SpinifyLogLevel.transport(), 'transport_disconnect', 'Transport disconnected ' - '${close.reconnect ? 'temporarily' : 'permanently'} ' - 'with reason: ${close.reason}', + '${reconnect ? 'temporarily' : 'permanently'} ' + 'with reason: $reason', { - 'code': close.code, - 'reason': close.reason, - 'reconnect': close.reconnect, + 'code': code, + 'reason': reason, + 'reconnect': reconnect, }, ); _internalDisconnect( - code: close.code, - reason: close.reason, - reconnect: close.reconnect, + code: code, + reason: reason, + reconnect: reconnect, ); } @@ -1013,7 +1025,14 @@ final class Spinify implements ISpinify { @safe @override @nonVirtual - Future disconnect() => _interactiveDisconnect(); + Future disconnect() async { + await _mutex.lock(); + try { + await _interactiveDisconnect(); + } finally { + _mutex.unlock(); + } + } /// User initiated disconnect. @safe @@ -1142,6 +1161,7 @@ final class Spinify implements ISpinify { @nonVirtual Future close() async { if (state.isClosed) return; + await _mutex.wait(); try { _tearDownHealthCheckTimer(); _internalDisconnect( @@ -1307,6 +1327,7 @@ final class Spinify implements ISpinify { @nonVirtual @Throws([SpinifySendException]) Future send(List data) async { + await _mutex.wait(); try { await _doOnReady(() => _sendCommandAsync( SpinifySendRequest( @@ -1328,8 +1349,9 @@ final class Spinify implements ISpinify { @nonVirtual @Throws([SpinifyRPCException]) Future> rpc(String method, [List? data]) async { + await _mutex.wait(); try { - return await _doOnReady(() => _sendCommand( + final bytes = await _doOnReady(() => _sendCommand( SpinifyRPCRequest( id: _getNextCommandId(), timestamp: DateTime.now(), @@ -1337,22 +1359,13 @@ final class Spinify implements ISpinify { data: data ?? const [], ), )).then>((reply) => reply.data); + return bytes; } on SpinifyRPCException { rethrow; } on Object catch (error, stackTrace) { Error.throwWithStackTrace(SpinifyRPCException(error: error), stackTrace); } } - /* => _doOnReady( - () => _sendCommand( - SpinifyRPCRequest( - id: _getNextCommandId(), - timestamp: DateTime.now(), - method: method, - data: data ?? const [], - ), - ).then>((reply) => reply.data), - ); */ // --- Subscriptions and Channels --- // @@ -1429,7 +1442,9 @@ final class Spinify implements ISpinify { @override Future removeSubscription( - SpinifyClientSubscription subscription) async { + SpinifyClientSubscription subscription, + ) async { + await _mutex.wait(); final subFromRegistry = _clientSubscriptionRegistry.remove(subscription.channel); try { @@ -1471,15 +1486,17 @@ final class Spinify implements ISpinify { @unsafe @override - Future publish(String channel, List data) => - getSubscription(channel)?.publish(data) ?? - Future.error( - SpinifySubscriptionException( - channel: channel, - message: 'Subscription not found', - ), - StackTrace.current, - ); + Future publish(String channel, List data) async { + await _mutex.wait(); + return getSubscription(channel)?.publish(data) ?? + Future.error( + SpinifySubscriptionException( + channel: channel, + message: 'Subscription not found', + ), + StackTrace.current, + ); + } // --- Presence --- // @@ -1487,29 +1504,33 @@ final class Spinify implements ISpinify { @override @nonVirtual @Throws([SpinifyConnectionException, SpinifySubscriptionException]) - Future> presence(String channel) => - getSubscription(channel)?.presence() ?? - Future.error( - SpinifySubscriptionException( - channel: channel, - message: 'Subscription not found', - ), - StackTrace.current, - ); + Future> presence(String channel) async { + await _mutex.wait(); + return getSubscription(channel)?.presence() ?? + Future.error( + SpinifySubscriptionException( + channel: channel, + message: 'Subscription not found', + ), + StackTrace.current, + ); + } @unsafe @override @nonVirtual @Throws([SpinifyConnectionException, SpinifySubscriptionException]) - Future presenceStats(String channel) => - getSubscription(channel)?.presenceStats() ?? - Future.error( - SpinifySubscriptionException( - channel: channel, - message: 'Subscription not found', - ), - StackTrace.current, - ); + Future presenceStats(String channel) async { + await _mutex.wait(); + return getSubscription(channel)?.presenceStats() ?? + Future.error( + SpinifySubscriptionException( + channel: channel, + message: 'Subscription not found', + ), + StackTrace.current, + ); + } // --- History --- // @@ -1522,19 +1543,21 @@ final class Spinify implements ISpinify { int? limit, SpinifyStreamPosition? since, bool? reverse, - }) => - getSubscription(channel)?.history( - limit: limit, - since: since, - reverse: reverse, - ) ?? - Future.error( - SpinifySubscriptionException( - channel: channel, - message: 'Subscription not found', - ), - StackTrace.current, - ); + }) async { + await _mutex.wait(); + return getSubscription(channel)?.history( + limit: limit, + since: since, + reverse: reverse, + ) ?? + Future.error( + SpinifySubscriptionException( + channel: channel, + message: 'Subscription not found', + ), + StackTrace.current, + ); + } // --- Replies --- // @@ -1809,17 +1832,18 @@ final class Spinify implements ISpinify { class _PendingReply { _PendingReply(this.command) : _completer = Completer(); - final SpinifyCommand command; - final Completer _completer; + final SpinifyCommand command; // Command that was sent. + + final Completer _completer; // Completer for the reply. - Future get future => _completer.future; + Future get future => _completer.future; // Future for the reply. - bool get isCompleted => _completer.isCompleted; + bool get isCompleted => _completer.isCompleted; // Is reply received. - void complete(R reply) => _completer.complete(reply); + void complete(R reply) => _completer.complete(reply); // Complete reply. void completeError(SpinifyReplyException error, StackTrace stackTrace) => - _completer.completeError(error, stackTrace); + _completer.completeError(error, stackTrace); // Complete with error. } abstract base class _SpinifySubscriptionBase implements SpinifySubscription { diff --git a/lib/src/util/mutex.dart b/lib/src/util/mutex.dart index 891a3b6..d04b2aa 100644 --- a/lib/src/util/mutex.dart +++ b/lib/src/util/mutex.dart @@ -12,7 +12,12 @@ class _Mutex$Request { /// Creates a new mutex request with a synchronous completer. factory _Mutex$Request.sync() => _Mutex$Request._(Completer.sync()); + /// Creates a new mutex request with a asynchronous completer. + //factory _Mutex$Request.async() => _Mutex$Request._(Completer()); + final Completer _completer; // The completer for the request. + bool get isCompleted => _completer.isCompleted; // Is completed? + bool get isNotCompleted => !_completer.isCompleted; // Is not completed? void release() => _completer.complete(); // Releases the lock. final Future future; // The future for the request. _Mutex$Request? prev; // The previous request in the chain. @@ -20,21 +25,49 @@ class _Mutex$Request { /// A mutual exclusion lock. @internal -class Mutex { +abstract interface class IMutex { + /// The number of locks currently held. + int get locks; + + /// The list of pending locks. + List> get pending; + + /// Protects a callback with the mutex. + Future protect(Future Function() callback); + + /// Locks the mutex. + Future lock(); + + /// Unlocks the mutex. + void unlock(); + + /// Waits for the last lock at the current moment to be released. + /// This method do not add a new lock. + Future wait(); +} + +/// A mutual exclusion lock. +@internal +class MutexImpl implements IMutex { /// Creates a new mutex. - Mutex(); + MutexImpl(); _Mutex$Request? _last; // The last requested block _Mutex$Request? _current; // The first and current running block int _locks = 0; // The number of locks currently held /// The number of locks currently held. + @override int get locks => _locks; /// The list of pending locks. + @override List> get pending { - final pending = List>.filled(_locks, Future.value(), - growable: false); + final pending = List>.filled( + _locks, + Future.value(), + growable: false, + ); for (var i = _locks - 1, request = _last; i >= 0; i--, request = request?.prev) { @@ -48,6 +81,7 @@ class Mutex { } /// Protects a callback with the mutex. + @override Future protect(Future Function() callback) async { await lock(); try { @@ -58,16 +92,18 @@ class Mutex { } /// Locks the mutex. + @override Future lock() async { _locks++; final prev = _last; final current = _last = _Mutex$Request.sync()..prev = prev; // Wait for the previous lock to be released. - if (prev != null) await prev.future; + if (prev != null && prev.isNotCompleted) await prev.future; _current = current..prev = null; // Set the current lock. } /// Unlocks the mutex. + @override void unlock() { final current = _current; if (current == null) return; @@ -75,4 +111,36 @@ class Mutex { _current = null; current.release(); } + + @override + Future wait() async { + final last = _last; + if (last != null) await last.future; + } +} + +/// A fake mutex that does nothing. +@internal +class MutexDisabled implements IMutex { + MutexDisabled(); + + static final Future _future = Future.value(); + + @override + int get locks => 0; + + @override + List> get pending => const []; + + @override + Future protect(Future Function() callback) => callback(); + + @override + Future lock() => _future; + + @override + void unlock() {} + + @override + Future wait() => _future; } diff --git a/test/unit/model_test.dart b/test/unit/model_test.dart index aedf968..319ace8 100644 --- a/test/unit/model_test.dart +++ b/test/unit/model_test.dart @@ -1,6 +1,6 @@ // ignore_for_file: non_const_call_to_literal_constructor -import 'package:spinify/spinify.dart'; +import 'package:fixnum/fixnum.dart'; import 'package:spinify/src/model/annotations.dart' as annotations; import 'package:spinify/src/model/channel_event.dart' as channel_event; import 'package:spinify/src/model/channel_events.dart' as channel_events; @@ -11,6 +11,7 @@ import 'package:spinify/src/model/exception.dart' as exception; import 'package:spinify/src/model/history.dart' as history; import 'package:spinify/src/model/metric.dart' as metric; import 'package:spinify/src/model/presence_stats.dart' as presence_stats; +import 'package:spinify/src/model/pubspec.yaml.g.dart' as pubspec; import 'package:spinify/src/model/reply.dart' as reply; import 'package:spinify/src/model/state.dart' as state; import 'package:spinify/src/model/states_stream.dart' as states_stream; @@ -20,7 +21,8 @@ import 'package:spinify/src/model/subscription_state.dart' as subscription_state; import 'package:spinify/src/model/subscription_states.dart' as subscription_states; -import 'package:spinify/src/util/list_equals.dart'; +import 'package:spinify/src/protobuf/protobuf_codec.dart' as protobuf_codec; +import 'package:spinify/src/util/list_equals.dart' as list_equals; import 'package:test/test.dart'; void main() { @@ -106,7 +108,15 @@ void main() { test('Normalize', () { for (var i = -1; i <= 5000; i++) { - final tuple = codes.SpinifyDisconnectCode.normalize(i); + final code = codes.SpinifyDisconnectCode(i); + expect( + code.reconnect, + allOf(isA(), same(code.reconnect)), + reason: 'Code: $i should ' + '${code.reconnect ? '' : 'not '}' + 'reconnect', + ); + /* final tuple = codes.SpinifyDisconnectCode.normalize(i); expect( tuple.code, allOf(isA(), equals(i)), @@ -121,7 +131,7 @@ void main() { reason: 'Code: $i should ' '${tuple.code.reconnect ? '' : 'not '}' 'reconnect', - ); + ); */ } }); }); @@ -522,14 +532,14 @@ void main() { ); expect(info == info, isTrue); expect( - listEquals( + list_equals.listEquals( info.channelInfo, info.channelInfo?.toList(growable: false), ), isTrue, ); expect( - listEquals( + list_equals.listEquals( info.connectionInfo, info.connectionInfo?.toList(growable: false), ), @@ -634,7 +644,7 @@ void main() { ), ); expect( - listEquals(history1.publications, history2.publications), + list_equals.listEquals(history1.publications, history2.publications), isTrue, ); expect( @@ -806,7 +816,7 @@ void main() { ), ); expect(c == c, isTrue); - final encoder = SpinifyProtobufCodec().encoder; + final encoder = protobuf_codec.SpinifyProtobufCodec().encoder; expect( encoder.convert(c), allOf( @@ -1748,5 +1758,14 @@ void main() { ); }); }); + + group('Pubspec_yaml_g', () { + test('Instance', () { + final s = pubspec.Pubspec.source; // ignore: prefer_const_declarations + expect(s, isA>()); + final t = pubspec.Pubspec.timestamp; + expect(t, isA()); + }); + }); }); } diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index a4ada9f..5bca50e 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -61,6 +61,7 @@ void main() { logger: buffer.add, ), ); + async.flushMicrotasks(); expect(client.state, isA()); async.elapse(client.config.timeout); expect(client.state, isA()); @@ -92,6 +93,7 @@ void main() { expect(client.state.isDisconnected, isTrue); } client.close(); + async.flushMicrotasks(); expect(client.state.isClosed, isTrue); client.close(); expect(client.state.isClosed, isTrue); @@ -288,6 +290,7 @@ void main() { final ws = WebSocket$Fake(); // ignore: close_sinks final client = createFakeClient(transport: (_) async => ws..reset()) ..connect(url); + async.flushMicrotasks(); expect(client.state, isA()); async.elapse(client.config.timeout); expect(client.state, isA()); @@ -882,6 +885,7 @@ void main() { expect(client.state.isConnected, isTrue); expect(client.isClosed, isFalse); client.close(); + async.flushMicrotasks(); expect(client.state.isClosed, isTrue); expect(pings, greaterThanOrEqualTo(3 * 60 * 60 ~/ 120)); expect(refreshes, greaterThanOrEqualTo(3 * 60 * 60 ~/ 600)); @@ -928,20 +932,21 @@ void main() { test( 'Disconnect_during_connection', - () async { + () => fakeAsync((async) { final client = createFakeClient(); - unawaited( - expectLater( - client.connect(url), - throwsA(anything), - ), - ); - await client.disconnect(); - expect(client.state.isDisconnected, isTrue); - expect(client.state.isClosed, isFalse); - await client.close(); + client.connect(url); + async.flushMicrotasks(); + expect(client.state, isA()); + client.disconnect(); + async.flushMicrotasks(); + expect(client.state.isConnecting, isTrue); // Still connecting + async.elapse(client.config.timeout); // Wait for some time + expect(client.state.isDisconnected, isTrue); // Disconnected + expect(client.state.isClosed, isFalse); // Not closed + client.close(); + async.flushMicrotasks(); expect(client.state.isClosed, isTrue); - }, + }), ); // Retry connection after temporary error diff --git a/test/unit/util_test.dart b/test/unit/util_test.dart index 2ba9edb..bf8b30d 100644 --- a/test/unit/util_test.dart +++ b/test/unit/util_test.dart @@ -138,15 +138,63 @@ void main() => group('Util', () { }); test('Mutex', () async { + { + final m = MutexDisabled(); + expect(m.locks, equals(0)); + expect(m.pending, isEmpty); + unawaited( + expectLater( + m.protect(() => Future.value(1)), + completion(equals(1)), + ), + ); + expect(m.locks, equals(0)); + expect(m.pending, isEmpty); + unawaited( + expectLater( + m.lock(), + completes, + ), + ); + expect(m.locks, equals(0)); + expect(m.pending, isEmpty); + expect( + m.unlock, + returnsNormally, + ); + unawaited( + expectLater( + m.wait(), + completes, + ), + ); + } + fakeAsync((async) { - final m = Mutex(); + final m = MutexImpl(); expect(m.locks, equals(0)); expect(m.pending, isEmpty); - unawaited(expectLater(m.lock(), completes)); + unawaited( + expectLater( + m.lock(), + completes, + ), + ); expect(m.locks, equals(1)); expect(m.pending, isNotEmpty); - unawaited(expectLater(m.lock(), completes)); + unawaited( + expectLater( + m.lock(), + completes, + ), + ); expect(m.locks, equals(2)); + unawaited( + expectLater( + m.wait(), + completes, + ), + ); expect(m.pending, hasLength(2)); expect(m.unlock, returnsNormally); expect(m.locks, equals(1)); @@ -154,6 +202,12 @@ void main() => group('Util', () { expect(m.unlock, returnsNormally); expect(m.locks, equals(0)); expect(m.pending, isEmpty); + unawaited( + expectLater( + m.wait(), + completes, + ), + ); unawaited( expectLater( @@ -170,7 +224,7 @@ void main() => group('Util', () { }); fakeAsync((async) { - final m = Mutex(); + final m = MutexImpl(); final list = [for (var i = 0; i < 10; i++) i]; final result = []; for (var i = 0; i < 10; i++) { From 0dc581cd0fea57dd6a5827fe29b35e3822d8db3a Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Tue, 12 Nov 2024 00:04:55 +0400 Subject: [PATCH 088/104] Add tests for multiple connections and closure behavior in spinify client --- test/unit/spinify_test.dart | 96 +++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index 5bca50e..b973609 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -949,6 +949,102 @@ void main() { }), ); + test( + 'Few_connects_in_a_row', + () { + final client = createFakeClient(); + expectLater( + client.states, + emitsInOrder( + [ + isA().having( + (s) => s.url, + 'url', + equals('url1'), + ), + isA().having( + (s) => s.url, + 'url', + equals('url1'), + ), + isA().having( + (s) => s.temporary, + 'temporary', + isFalse, + ), + isA().having( + (s) => s.url, + 'url', + equals('url2'), + ), + isA().having( + (s) => s.url, + 'url', + equals('url2'), + ), + isA().having( + (s) => s.temporary, + 'temporary', + isFalse, + ), + isA().having( + (s) => s.url, + 'url', + equals('url3'), + ), + isA().having( + (s) => s.url, + 'url', + equals('url3'), + ), + isA().having( + (s) => s.temporary, + 'temporary', + isFalse, + ), + isA(), + emitsDone, + ], + ), + ); + return fakeAsync((async) { + client.connect('url1'); + client.connect('url2'); + client.connect('url3'); + async.elapse(const Duration(seconds: 1)); + expect( + client.state, + isA().having( + (s) => s.url, + 'url', + equals('url3'), + )); + client.close(); + async.flushMicrotasks(); + }); + }, + ); + + test('Closed_after_close', () { + final client = createFakeClient(); + expectLater( + client.states, + emitsInOrder( + [ + isA(), + isA(), + isA(), + isA(), + emitsDone, + ], + ), + ); + client.connect(url); + expect(client.isClosed, isFalse); + client.close(); + expectLater(client.states.last, completion(isA())); + }); + // Retry connection after temporary error /* test( 'Connection_error_retry', From e1136ad52bc41f6bf54ab0206b55e9756b3a5b32 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Tue, 12 Nov 2024 01:17:38 +0400 Subject: [PATCH 089/104] Refactor Spinify interface and exception handling; remove deprecated transport and enhance mutex release logic --- lib/spinify.dart | 1 - lib/src/deprecated/spinify_deprecated.dart | 1229 ------------------ lib/src/deprecated/subscription_impl.dart | 661 ---------- lib/src/deprecated/transport_fake.dart | 313 ----- lib/src/deprecated/transport_ws_pb_js.dart | 446 ------- lib/src/deprecated/transport_ws_pb_stub.dart | 28 - lib/src/deprecated/transport_ws_pb_vm.dart | 295 ----- lib/src/model/exception.dart | 72 + lib/src/spinify.dart | 273 +++- lib/src/spinify_interface.dart | 4 +- lib/src/util/mutex.dart | 9 +- test/unit/spinify_test.dart | 538 ++++++++ 12 files changed, 847 insertions(+), 3022 deletions(-) delete mode 100644 lib/src/deprecated/spinify_deprecated.dart delete mode 100644 lib/src/deprecated/subscription_impl.dart delete mode 100644 lib/src/deprecated/transport_fake.dart delete mode 100644 lib/src/deprecated/transport_ws_pb_js.dart delete mode 100644 lib/src/deprecated/transport_ws_pb_stub.dart delete mode 100644 lib/src/deprecated/transport_ws_pb_vm.dart diff --git a/lib/spinify.dart b/lib/spinify.dart index 9bd87a0..8bb4e72 100644 --- a/lib/spinify.dart +++ b/lib/spinify.dart @@ -2,7 +2,6 @@ library; export 'package:fixnum/fixnum.dart' show Int64; -export 'src/deprecated/transport_fake.dart'; export 'src/model/channel_event.dart'; export 'src/model/client_info.dart'; export 'src/model/codec.dart'; diff --git a/lib/src/deprecated/spinify_deprecated.dart b/lib/src/deprecated/spinify_deprecated.dart deleted file mode 100644 index 8d1e436..0000000 --- a/lib/src/deprecated/spinify_deprecated.dart +++ /dev/null @@ -1,1229 +0,0 @@ -@Deprecated('Use new implementation instead') - -import 'dart:async'; -import 'dart:collection'; - -import 'package:fixnum/fixnum.dart' as fixnum; -import 'package:meta/meta.dart'; - -import '../model/annotations.dart'; -import '../model/channel_event.dart'; -import '../model/channel_events.dart'; -import '../model/client_info.dart'; -import '../model/command.dart'; -import '../model/config.dart'; -import '../model/constant.dart'; -import '../model/exception.dart'; -import '../model/history.dart'; -import '../model/metric.dart'; -import '../model/presence_stats.dart'; -import '../model/reply.dart'; -import '../model/state.dart'; -import '../model/states_stream.dart'; -import '../model/stream_position.dart'; -import '../model/subscription_config.dart'; -import '../model/subscription_state.dart'; -import '../model/subscription_states.dart'; -import '../model/transport_interface.dart'; -import '../spinify_interface.dart'; -import '../subscription_interface.dart'; -import '../util/backoff.dart'; - -part 'subscription_impl.dart'; - -/// Base class for Spinify client. -abstract base class SpinifyBase implements ISpinify { - /// Create a new Spinify client. - SpinifyBase({required this.config}) { - _init(); - } - - /// Counter for command messages. - int _getNextCommandId() { - if (_metrics.commandId == kMaxInt) _metrics.commandId = 1; - return _metrics.commandId++; - } - - @override - bool get isClosed => state.isClosed; - - /// Spinify config. - @override - @nonVirtual - final SpinifyConfig config; - - late final SpinifyTransportBuilder _createTransport; - dynamic _transport; - - final SpinifyMetrics$Mutable _metrics = SpinifyMetrics$Mutable(); - - /// Client initialization (from constructor). - @mustCallSuper - void _init() { - _createTransport = config.transportBuilder! /* ?? $createWebSocketClient */; - config.logger?.call( - const SpinifyLogLevel.info(), - 'init', - 'Spinify client initialized', - { - 'config': config, - }, - ); - } - - /// On connect to the server. - @mustCallSuper - Future _onConnected() async {} - - @mustCallSuper - Future _onReply(SpinifyReply reply) async { - config.logger?.call( - const SpinifyLogLevel.debug(), - 'reply', - 'Reply ${reply.type}{id: ${reply.id}} received', - { - 'reply': reply, - }, - ); - } - - /// On disconnect from the server. - @mustCallSuper - Future _onDisconnected({required bool temporary}) async {} - - Future _doOnReady(Future Function() action) { - if (state.isConnected) return action(); - return ready().then((_) => action()); - } - - @override - Future close() async { - config.logger?.call( - const SpinifyLogLevel.info(), - 'closed', - 'Closed', - { - 'state': state, - }, - ); - } -} - -/// Base mixin for Spinify client state management. -base mixin SpinifyStateMixin on SpinifyBase { - @override - SpinifyState get state => _metrics.state; - - @override - late final SpinifyStatesStream states = - SpinifyStatesStream(_statesController.stream); - - @nonVirtual - final StreamController _statesController = - StreamController.broadcast(); - - @nonVirtual - void _setState(SpinifyState state) { - final previous = _metrics.state; - _statesController.add(_metrics.state = state); - config.logger?.call( - const SpinifyLogLevel.config(), - 'state_changed', - 'State changed from $previous to $state', - { - 'previous': previous, - 'state': state, - }, - ); - } - - @override - Future _onDisconnected({required bool temporary}) async { - await super._onDisconnected(temporary: temporary); - if (!state.isDisconnected) { - _setState(SpinifyState$Disconnected(temporary: temporary)); - config.logger?.call( - const SpinifyLogLevel.config(), - 'disconnected', - 'Disconnected from server', - {}, - ); - } - } - - @override - Future close() async { - await super.close(); - if (!state.isClosed) _setState(SpinifyState$Closed()); - await _statesController.close(); - } -} - -/// Base mixin for Spinify command sending. -base mixin SpinifyCommandMixin on SpinifyBase { - final Map completer})> - _replies = - completer})>{}; - - @override - Future send(List data) => _doOnReady( - () => _sendCommandAsync( - SpinifySendRequest( - timestamp: DateTime.now(), - data: data, - ), - ), - ); - - Future _sendCommand(SpinifyCommand command) async { - config.logger?.call( - const SpinifyLogLevel.debug(), - 'send_command_begin', - 'Command ${command.type}{id: ${command.id}} sent begin', - { - 'command': command, - }, - ); - try { - // coverage:ignore-start - assert(command.id > -1, 'Command ID should be greater or equal to 0'); - assert(_replies[command.id] == null, 'Command ID should be unique'); - assert(_transport != null, 'Transport is not connected'); - assert(!state.isClosed, 'State is closed'); - // coverage:ignore-end - final completer = Completer(); - _replies[command.id] = (command: command, completer: completer); - await _transport?.send(command); // await _sendCommandAsync(command); - final result = await completer.future.timeout(config.timeout); - config.logger?.call( - const SpinifyLogLevel.config(), - 'send_command_success', - 'Command ${command.type}{id: ${command.id}} sent successfully', - { - 'command': command, - 'result': result, - }, - ); - return result; - } on Object catch (error, stackTrace) { - final tuple = _replies.remove(command.id); - if (tuple != null && !tuple.completer.isCompleted) { - tuple.completer.completeError(error, stackTrace); - config.logger?.call( - const SpinifyLogLevel.warning(), - 'send_command_error', - 'Error sending command ${command.type}{id: ${command.id}}', - { - 'command': command, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - } - rethrow; - } - } - - Future _sendCommandAsync(SpinifyCommand command) async { - config.logger?.call( - const SpinifyLogLevel.debug(), - 'send_command_async_begin', - 'Comand ${command.type}{id: ${command.id}} sent async begin', - { - 'command': command, - }, - ); - try { - // coverage:ignore-start - assert(command.id > -1, 'Command ID should be greater or equal to 0'); - assert(_transport != null, 'Transport is not connected'); - assert(!state.isClosed, 'State is closed'); - // coverage:ignore-end - await _transport?.send(command); - config.logger?.call( - const SpinifyLogLevel.config(), - 'send_command_async_success', - 'Command sent ${command.type}{id: ${command.id}} async successfully', - { - 'command': command, - }, - ); - } on Object catch (error, stackTrace) { - config.logger?.call( - const SpinifyLogLevel.warning(), - 'send_command_async_error', - 'Error sending command ${command.type}{id: ${command.id}} async', - { - 'command': command, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - rethrow; - } - } - - @override - @sideEffect - Future _onReply(SpinifyReply reply) async { - // coverage:ignore-start - assert( - reply.id >= 0 && reply.id <= _metrics.commandId, - 'Reply ID should be greater or equal to 0 ' - 'and less or equal than command ID'); - // coverage:ignore-end - if (reply.isResult) { - if (reply.id case int id when id > 0) { - final completer = _replies.remove(id)?.completer; - // coverage:ignore-start - assert( - completer != null, - 'Reply completer not found', - ); - assert( - completer?.isCompleted == false, - 'Reply completer already completed', - ); - // coverage:ignore-end - if (reply is SpinifyErrorResult) { - completer?.completeError(SpinifyReplyException( - replyCode: reply.code, - replyMessage: reply.message, - temporary: reply.temporary, - )); - } else { - completer?.complete(reply); - } - } - } - await super._onReply(reply); - } - - @override - Future _onDisconnected({required bool temporary}) async { - late final error = StateError('Client is disconnected'); - late final stackTrace = StackTrace.current; - for (final tuple in _replies.values) { - if (tuple.completer.isCompleted) continue; - tuple.completer.completeError(error); - config.logger?.call( - const SpinifyLogLevel.warning(), - 'disconnected_reply_error', - 'Reply for command ${tuple.command.type}{id: ${tuple.command.id}} ' - 'error on disconnect', - { - 'command': tuple.command, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - } - _replies.clear(); - await super._onDisconnected(temporary: temporary); - } -} - -/// Base mixin for Spinify subscription management. -base mixin SpinifySubscriptionMixin on SpinifyBase, SpinifyCommandMixin { - final StreamController _eventController = - StreamController.broadcast(); - - @override - late final SpinifyChannelEvents stream = - SpinifyChannelEvents(_eventController.stream); - - @override - ({ - Map client, - Map server - }) get subscriptions => ( - client: UnmodifiableMapView( - _clientSubscriptionRegistry), - server: UnmodifiableMapView( - _serverSubscriptionRegistry), - ); - - /// Registry of client subscriptions. - final Map _clientSubscriptionRegistry = - {}; - - /// Registry of server subscriptions. - final Map _serverSubscriptionRegistry = - {}; - - @override - SpinifySubscription? getSubscription(String channel) => - _clientSubscriptionRegistry[channel] ?? - _serverSubscriptionRegistry[channel]; - - @override - SpinifyClientSubscription? getClientSubscription(String channel) => - _clientSubscriptionRegistry[channel]; - - @override - SpinifyServerSubscription? getServerSubscription(String channel) => - _serverSubscriptionRegistry[channel]; - - @override - SpinifyClientSubscription newSubscription( - String channel, { - SpinifySubscriptionConfig? config, - bool subscribe = false, - }) { - final sub = _clientSubscriptionRegistry[channel] ?? - _serverSubscriptionRegistry[channel]; - if (sub != null) { - this.config.logger?.call( - const SpinifyLogLevel.warning(), - 'subscription_exists_error', - 'Subscription already exists', - { - 'channel': channel, - 'subscription': sub, - }, - ); - throw SpinifySubscriptionException( - channel: channel, - message: 'Subscription already exists', - ); - } - final newSub = - _clientSubscriptionRegistry[channel] = SpinifyClientSubscriptionImpl( - client: this, - channel: channel, - config: config ?? const SpinifySubscriptionConfig.byDefault(), - ); - if (subscribe) newSub.subscribe(); - return newSub; - } - - @override - Future removeSubscription( - SpinifyClientSubscription subscription) async { - final subFromRegistry = - _clientSubscriptionRegistry.remove(subscription.channel); - try { - await subFromRegistry?.unsubscribe(); - // coverage:ignore-start - assert( - subFromRegistry != null, - 'Subscription not found in the registry', - ); - assert( - identical(subFromRegistry, subscription), - 'Subscription should be the same instance as in the registry', - ); - // coverage:ignore-end - } on Object catch (error, stackTrace) { - config.logger?.call( - const SpinifyLogLevel.warning(), - 'subscription_remove_error', - 'Error removing subscription', - { - 'channel': subscription.channel, - 'subscription': subscription, - }, - ); - Error.throwWithStackTrace( - SpinifySubscriptionException( - channel: subscription.channel, - message: 'Error while unsubscribing', - error: error, - ), - stackTrace, - ); - } finally { - subFromRegistry?.close(); - } - } - - @override - Future _onReply(SpinifyReply reply) async { - await super._onReply(reply); - if (reply is SpinifyPush) { - // Add push to the stream. - final event = reply.event; - _eventController.add(event); // Add event to the broadcast stream. - config.logger?.call( - const SpinifyLogLevel.debug(), - 'push_received', - 'Push ${event.type} received', - { - 'event': event, - }, - ); - if (event.channel.isEmpty) { - /* ignore push without channel */ - } else if (event is SpinifySubscribe) { - // Add server subscription to the registry on subscribe event. - _serverSubscriptionRegistry.putIfAbsent( - event.channel, - () => SpinifyServerSubscriptionImpl( - client: this, - channel: event.channel, - recoverable: event.recoverable, - epoch: event.since.epoch, - offset: event.since.offset, - )) - ..onEvent(event) - .._setState(SpinifySubscriptionState.subscribed(data: event.data)); - } else if (event is SpinifyUnsubscribe) { - // Remove server subscription from the registry on unsubscribe event. - _serverSubscriptionRegistry.remove(event.channel) - ?..onEvent(event) - .._setState(SpinifySubscriptionState.unsubscribed()); - // Unsubscribe client subscription on unsubscribe event. - if (_clientSubscriptionRegistry[event.channel] - case SpinifyClientSubscriptionImpl subscription) { - subscription.onEvent(event); - if (event.code < 2500) { - // Unsubscribe client subscription on unsubscribe event. - subscription - ._unsubscribe( - code: event.code, - reason: event.reason, - sendUnsubscribe: false, - ) - .ignore(); - } else { - // Resubscribe client subscription on unsubscribe event. - subscription._resubscribe().ignore(); - } - } - } else { - // Notify subscription about new event. - final sub = _serverSubscriptionRegistry[event.channel] ?? - _clientSubscriptionRegistry[event.channel]; - sub?.onEvent(event); - if (sub == null) { - // coverage:ignore-start - assert( - false, - 'Subscription not found for event ${event.channel}', - ); - // coverage:ignore-end - config.logger?.call( - const SpinifyLogLevel.warning(), - 'subscription_not_found_error', - 'Subscription ${event.channel} not found for event', - { - 'channel': event.channel, - 'event': event, - }, - ); - } else if (event is SpinifyPublication && sub.recoverable) { - // Update subscription offset on publication. - if (event.offset case fixnum.Int64 newOffset when newOffset > 0) - sub.offset = newOffset; - } - } - } else if (reply is SpinifyConnectResult) { - // Update server subscriptions. - final newServerSubs = reply.subs ?? {}; - for (final entry in newServerSubs.entries) { - final MapEntry( - key: channel, - value: value - ) = entry; - final sub = _serverSubscriptionRegistry.putIfAbsent( - channel, - () => SpinifyServerSubscriptionImpl( - client: this, - channel: channel, - recoverable: value.recoverable, - epoch: value.since.epoch, - offset: value.since.offset, - )) - .._setState(SpinifySubscriptionState.subscribed(data: value.data)); - - // Notify about new publications. - for (var publication in value.publications) { - // If publication has wrong channel, fix it. - // Thats a workaround because we do not have channel - // in the publication in this server SpinifyConnectResult reply. - if (publication.channel != channel) { - // coverage:ignore-start - assert( - publication.channel.isEmpty, - 'Publication contains wrong channel', - ); - // coverage:ignore-end - publication = publication.copyWith(channel: channel); - } - _eventController.add(publication); - sub.onEvent(publication); - // Update subscription offset on publication. - if (sub.recoverable) { - if (publication.offset case fixnum.Int64 newOffset - when newOffset > sub.offset) { - sub.offset = newOffset; - } - } - } - } - - // Remove server subscriptions that are not in the new list. - final currentServerSubs = _serverSubscriptionRegistry.keys.toSet(); - for (final key in currentServerSubs) { - if (newServerSubs.containsKey(key)) continue; - _serverSubscriptionRegistry.remove(key) - ?.._setState(SpinifySubscriptionState.unsubscribed()) - ..close(); - } - - // We should resubscribe client subscriptions here. - for (final subscription in _clientSubscriptionRegistry.values) - subscription._resubscribe().ignore(); - } - } - - @override - Future close() async { - await super.close(); - final unsubscribed = SpinifySubscriptionState.unsubscribed(); - for (final sub in _clientSubscriptionRegistry.values) - sub - .._setState(unsubscribed) - ..close(); - for (final sub in _serverSubscriptionRegistry.values) - sub - .._setState(unsubscribed) - ..close(); - _clientSubscriptionRegistry.clear(); - _serverSubscriptionRegistry.clear(); - _eventController.close().ignore(); - } -} - -/// Base mixin for Spinify client connection management (connect & disconnect). -base mixin SpinifyConnectionMixin - on - SpinifyBase, - SpinifyCommandMixin, - SpinifyStateMixin, - SpinifySubscriptionMixin { - Timer? _reconnectTimer; - Completer? _readyCompleter; - - @protected - @nonVirtual - Timer? _refreshTimer; - - @override - Future connect(String url) async { - //if (state.url == url) return; - final completer = _readyCompleter = switch (_readyCompleter) { - Completer value when !value.isCompleted => value, - _ => Completer(), - }; - try { - if (state.isConnected || state.isConnecting) await disconnect(); - } on Object {/* ignore */} - assert(!completer.isCompleted, 'Completer should not be completed'); - assert(state.isDisconnected, 'State should be disconnected'); - try { - _setState(SpinifyState$Connecting(url: _metrics.reconnectUrl = url)); - assert(state.isConnecting, 'State should be connecting'); - - // Create new transport. - _transport = await _createTransport( - url: url, - headers: config.headers, - protocols: {'centrifuge-protobuf'}, - ); - // ..onReply = _onReply - // ..onDisconnect = () => _onDisconnected().ignore(); - - // Prepare connect request. - final SpinifyConnectRequest request; - { - final token = await config.getToken?.call(); - // coverage:ignore-start - assert(token == null || token.length > 5, 'Spinify JWT is too short'); - // coverage:ignore-end - final payload = await config.getPayload?.call(); - final id = _getNextCommandId(); - final now = DateTime.now(); - request = SpinifyConnectRequest( - id: id, - timestamp: now, - token: token, - data: payload, - subs: { - for (final sub in _serverSubscriptionRegistry.values) - sub.channel: SpinifySubscribeRequest( - id: id, - timestamp: now, - channel: sub.channel, - recover: sub.recoverable, - epoch: sub.epoch, - offset: sub.offset, - token: null, - data: null, - positioned: null, - recoverable: null, - joinLeave: null, - ), - }, - name: config.client.name, - version: config.client.version, - ); - } - - final reply = await _sendCommand(request); - - if (!state.isConnecting) - throw const SpinifyConnectionException( - message: 'Connection is not in connecting state', - ); - - _setState(SpinifyState$Connected( - url: url, - client: reply.client, - version: reply.version, - expires: reply.expires, - ttl: reply.ttl, - node: reply.node, - pingInterval: reply.pingInterval, - sendPong: reply.sendPong, - session: reply.session, - data: reply.data, - )); - - _setUpRefreshConnection(); - - // Notify ready. - if (!completer.isCompleted) completer.complete(); - _readyCompleter = null; - - await _onConnected(); - - config.logger?.call( - const SpinifyLogLevel.config(), - 'connected', - 'Connected to server with $url successfully', - { - 'url': url, - 'request': request, - 'result': reply, - }, - ); - } on Object catch (error, stackTrace) { - if (!completer.isCompleted) completer.completeError(error, stackTrace); - _readyCompleter = null; - config.logger?.call( - const SpinifyLogLevel.error(), - 'connect_error', - 'Error connecting to server $url', - { - 'url': url, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - - _transport?.disconnect().ignore(); - - switch (error) { - case SpinifyErrorResult result: - if (result.code == 109) { - // Token expired error. - _setUpReconnectTimer(); // Retry resubscribe - } else if (result.temporary) { - // Temporary error. - _setUpReconnectTimer(); // Retry resubscribe - } else { - // Disable resubscribe timer - //moveToUnsubscribed(result.code, result.message, false); - _setState(SpinifyState$Disconnected(temporary: false)); - } - case SpinifyConnectionException _: - _setUpReconnectTimer(); // Some spinify exception - retry resubscribe - rethrow; - default: - _setUpReconnectTimer(); // Unknown error - retry resubscribe - } - - Error.throwWithStackTrace( - SpinifyConnectionException( - message: 'Error connecting to server $url', - error: error, - ), - stackTrace, - ); - } - } - - void _setUpRefreshConnection() { - _refreshTimer?.cancel(); - if (state - case SpinifyState$Connected( - :String url, - :bool expires, - :DateTime? ttl, - :String? node, - :Duration? pingInterval, - :bool? sendPong, - :String? session, - :List? data, - ) when expires && ttl != null) { - final duration = ttl.difference(DateTime.now()) - config.timeout; - if (duration < Duration.zero) { - config.logger?.call( - const SpinifyLogLevel.warning(), - 'refresh_connection_cancelled', - 'Spinify token TTL is too short for refresh connection', - { - 'url': url, - 'duration': duration, - 'ttl': ttl, - }, - ); - // coverage:ignore-start - assert(false, 'Token TTL is too short'); - // coverage:ignore-end - return; - } - _refreshTimer = Timer(duration, () async { - if (!state.isConnected) return; - final token = await config.getToken?.call(); - if (token == null || token.isEmpty) { - // coverage:ignore-start - assert(token == null || token.length > 5, 'Spinify JWT is too short'); - // coverage:ignore-end - config.logger?.call( - const SpinifyLogLevel.warning(), - 'refresh_connection_cancelled', - 'Spinify JWT is empty or too short for refresh connection', - { - 'url': url, - 'token': token, - }, - ); - return; - } - final request = SpinifyRefreshRequest( - id: _getNextCommandId(), - timestamp: DateTime.now(), - token: token, - ); - final SpinifyRefreshResult result; - try { - result = await _sendCommand(request); - } on Object catch (error, stackTrace) { - config.logger?.call( - const SpinifyLogLevel.error(), - 'refresh_connection_error', - 'Error refreshing connection', - { - 'url': url, - 'command': request, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - return; - } - _setState(SpinifyState$Connected( - url: url, - client: result.client, - version: result.version, - expires: result.expires, - ttl: result.ttl, - node: node, - pingInterval: pingInterval, - sendPong: sendPong, - session: session, - data: data, - )); - _setUpRefreshConnection(); - config.logger?.call( - const SpinifyLogLevel.config(), - 'refresh_connection_success', - 'Successfully refreshed connection to $url', - { - 'request': request, - 'result': result, - }, - ); - }); - } - } - - @override - Future _onConnected() async { - await super._onConnected(); - _tearDownReconnectTimer(); - _metrics.lastConnectAt = DateTime.now(); - _metrics.connects++; - } - - void _setUpReconnectTimer() { - _reconnectTimer?.cancel(); - final lastUrl = _metrics.reconnectUrl; - if (lastUrl == null) return; - final attempt = _metrics.reconnectAttempts ?? 0; - final delay = Backoff.nextDelay( - attempt, - config.connectionRetryInterval.min.inMilliseconds, - config.connectionRetryInterval.max.inMilliseconds, - ); - _metrics.reconnectAttempts = attempt + 1; - if (delay <= Duration.zero) { - if (!state.isDisconnected) return; - config.logger?.call( - const SpinifyLogLevel.config(), - 'reconnect_attempt', - 'Reconnecting to $lastUrl immediately.', - { - 'url': lastUrl, - 'delay': delay, - 'attempt': attempt, - }, - ); - Future.sync(() => connect(lastUrl)).ignore(); - return; - } - config.logger?.call( - const SpinifyLogLevel.debug(), - 'reconnect_delayed', - 'Setting up reconnect timer to $lastUrl ' - 'after ${delay.inMilliseconds} ms.', - { - 'url': lastUrl, - 'delay': delay, - 'attempt': attempt, - }, - ); - _metrics.nextReconnectAt = DateTime.now().add(delay); - _reconnectTimer = Timer( - delay, - () { - //_nextReconnectionAttempt = null; - if (!state.isDisconnected) return; - config.logger?.call( - const SpinifyLogLevel.config(), - 'reconnect_attempt', - 'Reconnecting to $lastUrl after ${delay.inMilliseconds} ms.', - { - 'url': lastUrl, - 'delay': delay, - }, - ); - Future.sync(() => connect(lastUrl)).ignore(); - }, - ); - //connect(_reconnectUrl!); - } - - void _tearDownReconnectTimer() { - _metrics - ..reconnectAttempts = null - ..nextReconnectAt = null; - _reconnectTimer?.cancel(); - _reconnectTimer = null; - } - - @override - Future ready() async { - if (state.isConnected) return; - if (state.isClosed) - throw const SpinifyConnectionException( - message: 'Connection is closed permanently', - ); - return (_readyCompleter ??= Completer()).future; - } - - @override - Future disconnect() => - _disconnect(code: 1000, reason: 'disconnected by client'); - - /// Disconnect client from the server with optional reconnect and reason. - Future _disconnect( - {int? code, String? reason, bool reconnect = false}) async { - if (!reconnect) { - // Disable reconnect because we are disconnecting manually/intentionally. - _metrics.reconnectUrl = null; - _tearDownReconnectTimer(); - } - if (state.isDisconnected) return Future.value(); - await _transport?.disconnect(code, reason); - await _onDisconnected(temporary: reconnect); - } - - @override - Future _onDisconnected({required bool temporary}) async { - _refreshTimer?.cancel(); - _transport = null; - // Reconnect if that callback called not from disconnect method. - if (_metrics.reconnectUrl != null) _setUpReconnectTimer(); - if (state.isConnected || state.isConnecting) { - _metrics.lastDisconnectAt = DateTime.now(); - _metrics.disconnects++; - } - await super._onDisconnected(temporary: temporary); - } - - @override - Future _onReply(SpinifyReply reply) async { - await super._onReply(reply); - if (reply - case SpinifyPush( - event: SpinifyDisconnect(:String reason, :bool reconnect) - )) { - if (reconnect) { - // Disconnect client temporarily. - await _transport?.disconnect(1000, reason); - await _onDisconnected(temporary: true); - } else { - // Disconnect client permanently. - await disconnect(); - } - } - } - - @override - Future close() async { - await _transport?.disconnect(1000, 'Client closing'); - await super.close(); - } -} - -/// Base mixin for Spinify client ping-pong management. -base mixin SpinifyPingPongMixin - on SpinifyBase, SpinifyStateMixin, SpinifyConnectionMixin { - @protected - @nonVirtual - Timer? _pingTimer; - - /* @override - Future ping() => _doOnReady( - () => _sendCommand( - SpinifyPingRequest(timestamp: DateTime.now()), - ), - ); */ - - /// Stop keepalive timer. - @protected - @nonVirtual - void _tearDownPingTimer() => _pingTimer?.cancel(); - - /// Start or restart keepalive timer, - /// you should restart it after each received ping message. - /// Or connection will be closed by timeout. - @protected - @nonVirtual - void _restartPingTimer() { - _tearDownPingTimer(); - // coverage:ignore-start - assert(!isClosed, 'Client is closed'); - assert(state.isConnected, 'Invalid state'); - // coverage:ignore-end - if (state case SpinifyState$Connected(:Duration? pingInterval) - when pingInterval != null && pingInterval > Duration.zero) { - _pingTimer = Timer( - pingInterval + config.serverPingDelay, - () async { - // Reconnect if no pong received. - if (state case SpinifyState$Connected(:String url)) { - config.logger?.call( - const SpinifyLogLevel.warning(), - 'no_pong_reconnect', - 'No pong from server - reconnecting', - { - 'url': url, - 'pingInterval': pingInterval, - 'serverPingDelay': config.serverPingDelay, - }, - ); - try { - await _disconnect( - code: 2, - reason: 'No ping from server', - reconnect: true, - ); - await Future.delayed(Duration.zero); - } finally { - await connect(url); - } - } - /* disconnect( - SpinifyConnectingCode.noPing, - 'No ping from server', - ); */ - }, - ); - } - } - - @override - Future _onConnected() async { - _tearDownPingTimer(); - await super._onConnected(); - _restartPingTimer(); - } - - @override - Future _onReply(SpinifyReply reply) async { - if (!reply.isResult && reply is SpinifyServerPing) { - final command = SpinifyPingRequest(timestamp: DateTime.now()); - _metrics - ..lastPingAt = command.timestamp - ..receivedPings = _metrics.receivedPings + 1; - if (state case SpinifyState$Connected(:bool sendPong) when sendPong) { - // No need to handle error in a special way - - // if pong can't be sent but connection is closed anyway. - _sendCommandAsync(command).ignore(); - } - config.logger?.call( - const SpinifyLogLevel.debug(), - 'server_ping_received', - 'Ping from server received, pong sent', - { - 'ping': reply, - 'pong': command, - }, - ); - _restartPingTimer(); - } - await super._onReply(reply); - } - - @override - Future _onDisconnected({required bool temporary}) async { - _tearDownPingTimer(); - await super._onDisconnected(temporary: temporary); - } - - @override - Future close() async { - _tearDownPingTimer(); - await super.close(); - } -} - -/// Base mixin for Spinify client publications management. -base mixin SpinifyPublicationsMixin on SpinifyBase, SpinifyCommandMixin { - @override - Future publish(String channel, List data) => - getSubscription(channel)?.publish(data) ?? - Future.error( - SpinifySubscriptionException( - channel: channel, - message: 'Subscription not found', - ), - StackTrace.current, - ); -} - -/// Base mixin for Spinify client presence management. -base mixin SpinifyPresenceMixin on SpinifyBase, SpinifyCommandMixin { - @override - Future> presence(String channel) => - getSubscription(channel)?.presence() ?? - Future.error( - SpinifySubscriptionException( - channel: channel, - message: 'Subscription not found', - ), - StackTrace.current, - ); - - @override - Future presenceStats(String channel) => - getSubscription(channel)?.presenceStats() ?? - Future.error( - SpinifySubscriptionException( - channel: channel, - message: 'Subscription not found', - ), - StackTrace.current, - ); -} - -/// Base mixin for Spinify client history management. -base mixin SpinifyHistoryMixin on SpinifyBase, SpinifyCommandMixin { - @override - Future history( - String channel, { - int? limit, - SpinifyStreamPosition? since, - bool? reverse, - }) => - getSubscription(channel)?.history( - limit: limit, - since: since, - reverse: reverse, - ) ?? - Future.error( - SpinifySubscriptionException( - channel: channel, - message: 'Subscription not found', - ), - StackTrace.current, - ); -} - -/// Base mixin for Spinify client RPC management. -base mixin SpinifyRPCMixin on SpinifyBase, SpinifyCommandMixin { - @override - Future> rpc(String method, [List? data]) => _doOnReady( - () => _sendCommand( - SpinifyRPCRequest( - id: _getNextCommandId(), - timestamp: DateTime.now(), - method: method, - data: data ?? const [], - ), - ).then>((reply) => reply.data), - ); -} - -/// Base mixin for Spinify client metrics management. -base mixin SpinifyMetricsMixin on SpinifyBase { - @override - SpinifyMetrics get metrics => _metrics.freeze(); -} - -/// {@template spinify} -/// Spinify client for Centrifuge. -/// -/// Centrifugo SDKs use WebSocket as the main data transport and send/receive -/// messages encoded according to our bidirectional protocol. -/// That protocol is built on top of the Protobuf schema -/// (both JSON and binary Protobuf formats are supported). -/// It provides asynchronous communication, sending RPC, -/// multiplexing subscriptions to channels, etc. -/// -/// Client SDK wraps the protocol and exposes a set of APIs to developers. -/// -/// Client connection has 4 states: -/// - [SpinifyState$Disconnected] -/// - [SpinifyState$Connecting] -/// - [SpinifyState$Connected] -/// - [SpinifyState$Closed] -/// -/// {@endtemplate} -/// {@category Client} -final class Spinify extends SpinifyBase - with - SpinifyStateMixin, - SpinifyCommandMixin, - SpinifySubscriptionMixin, - SpinifyConnectionMixin, - SpinifyPingPongMixin, - SpinifyPublicationsMixin, - SpinifyPresenceMixin, - SpinifyHistoryMixin, - SpinifyRPCMixin, - SpinifyMetricsMixin { - /// {@macro spinify} - Spinify({SpinifyConfig? config}) - : super(config: config ?? SpinifyConfig.byDefault()); - - /// Create client and connect. - /// - /// {@macro spinify} - factory Spinify.connect(String url, {SpinifyConfig? config}) => - Spinify(config: config)..connect(url); -} diff --git a/lib/src/deprecated/subscription_impl.dart b/lib/src/deprecated/subscription_impl.dart deleted file mode 100644 index ca9985c..0000000 --- a/lib/src/deprecated/subscription_impl.dart +++ /dev/null @@ -1,661 +0,0 @@ -part of 'spinify_deprecated.dart'; - -@internal -abstract base class SpinifySubscriptionBase implements SpinifySubscription { - SpinifySubscriptionBase({ - required SpinifySubscriptionMixin client, - required this.channel, - required this.recoverable, - required this.epoch, - required this.offset, - }) : _clientWR = WeakReference(client), - _clientConfig = client.config { - _metrics = _client._metrics.channels - .putIfAbsent(channel, SpinifyMetrics$Channel$Mutable.new); - } - - @override - final String channel; - - /// Spinify client weak reference. - final WeakReference _clientWR; - - /// Spinify client - SpinifySubscriptionMixin get _client { - final target = _clientWR.target; - // coverage:ignore-start - if (target == null) { - throw SpinifySubscriptionException( - channel: channel, - message: 'Spinify client is do not exist anymore', - ); - } - // coverage:ignore-end - return target; - } - - /// Spinify channel metrics. - late final SpinifyMetrics$Channel$Mutable _metrics; - - /// Spinify client configuration. - final SpinifyConfig _clientConfig; - - /// Spinify logger. - SpinifyLogger? get _logger => _clientConfig.logger; - - final StreamController _stateController = - StreamController.broadcast(); - - final StreamController _eventController = - StreamController.broadcast(); - - Future _sendCommand( - SpinifyCommand Function(int nextId) builder, - ) => - _client._doOnReady( - () => _client._sendCommand( - builder(_client._getNextCommandId()), - ), - ); - - @override - bool recoverable; - - @override - String epoch; - - @override - fixnum.Int64 offset; - - @override - SpinifySubscriptionState get state => _metrics.state; - - @override - SpinifySubscriptionStates get states => - SpinifySubscriptionStates(_stateController.stream); - - @override - SpinifyChannelEvents get stream => - SpinifyChannelEvents(_eventController.stream); - - @sideEffect - @mustCallSuper - void onEvent(SpinifyChannelEvent event) { - // coverage:ignore-start - assert( - event.channel == channel, - 'Subscription "$channel" received event for another channel', - ); - // coverage:ignore-end - _eventController.add(event); - _logger?.call( - const SpinifyLogLevel.debug(), - 'subscription_event_received', - 'Subscription "$channel" received ${event.type} event', - { - 'channel': channel, - 'subscription': this, - 'event': event, - if (event is SpinifyPublication) 'publication': event, - }, - ); - } - - @mustCallSuper - void _setState(SpinifySubscriptionState state) { - final previous = _metrics.state; - if (previous == state) return; - _stateController.add(_metrics.state = state); - _logger?.call( - const SpinifyLogLevel.config(), - 'subscription_state_changed', - 'Subscription "$channel" state changed to ${state.type}', - { - 'channel': channel, - 'subscription': this, - 'previous': previous, - 'state': state, - }, - ); - } - - @mustCallSuper - @interactive - void close() { - _stateController.close().ignore(); - _eventController.close().ignore(); - // coverage:ignore-start - assert(state.isUnsubscribed, - 'Subscription "$channel" is not unsubscribed before closing'); - // coverage:ignore-end - } - - @override - @interactive - Future ready() async { - if (_client.isClosed) - throw SpinifySubscriptionException( - channel: channel, - message: 'Client is closed', - ); - if (_metrics.state.isSubscribed) return; - if (_stateController.isClosed) - throw SpinifySubscriptionException( - channel: channel, - message: 'Subscription is closed permanently', - ); - final state = await _stateController.stream - .firstWhere((state) => !state.isSubscribing); - if (!state.isSubscribed) - throw SpinifySubscriptionException( - channel: channel, - message: 'Subscription failed to subscribe', - ); - } - - @override - @interactive - Future history({ - int? limit, - SpinifyStreamPosition? since, - bool? reverse, - }) => - _sendCommand( - (id) => SpinifyHistoryRequest( - id: id, - channel: channel, - timestamp: DateTime.now(), - limit: limit, - since: since, - reverse: reverse, - ), - ).then( - (reply) => SpinifyHistory( - publications: List.unmodifiable( - reply.publications.map((pub) => pub.copyWith(channel: channel))), - since: reply.since, - ), - ); - - @override - @interactive - Future> presence() => - _sendCommand( - (id) => SpinifyPresenceRequest( - id: id, - channel: channel, - timestamp: DateTime.now(), - ), - ).then>((reply) => reply.presence); - - @override - @interactive - Future presenceStats() => - _sendCommand( - (id) => SpinifyPresenceStatsRequest( - id: id, - channel: channel, - timestamp: DateTime.now(), - ), - ).then( - (reply) => SpinifyPresenceStats( - channel: channel, - clients: reply.numClients, - users: reply.numUsers, - ), - ); - - @override - @interactive - Future publish(List data) => _sendCommand( - (id) => SpinifyPublishRequest( - id: id, - channel: channel, - timestamp: DateTime.now(), - data: data, - ), - ); -} - -@internal -final class SpinifyClientSubscriptionImpl extends SpinifySubscriptionBase - implements SpinifyClientSubscription { - SpinifyClientSubscriptionImpl({ - required super.client, - required super.channel, - required this.config, - }) : super( - recoverable: config.recoverable, - epoch: config.since?.epoch ?? '', - offset: config.since?.offset ?? fixnum.Int64.ZERO, - ); - - @override - final SpinifySubscriptionConfig config; - - /// Whether the subscription should recover. - bool _recover = false; - - /// Interactively subscribes to the channel. - @override - @interactive - Future subscribe() async { - // Check if the client is connected - switch (_client.state) { - case SpinifyState$Connected _: - break; - case SpinifyState$Connecting _: - case SpinifyState$Disconnected _: - await _client.ready(); - case SpinifyState$Closed _: - throw SpinifySubscriptionException( - channel: channel, - message: 'Client is closed', - ); - } - - // Check if the subscription is already subscribed - switch (state) { - case SpinifySubscriptionState$Subscribed _: - return; - case SpinifySubscriptionState$Subscribing _: - await ready(); - case SpinifySubscriptionState$Unsubscribed _: - await _resubscribe(); - } - } - - /// Interactively unsubscribes from the channel. - @override - @interactive - Future unsubscribe([ - int code = 0, - String reason = 'unsubscribe called', - ]) => - _unsubscribe( - code: code, - reason: reason, - sendUnsubscribe: true, - ); - - /// Unsubscribes from the channel. - Future _unsubscribe({ - required int code, - required String reason, - required bool sendUnsubscribe, - }) async { - final currentState = _metrics.state; - _tearDownResubscribeTimer(); - _tearDownRefreshSubscriptionTimer(); - if (currentState.isUnsubscribed) return; - _setState(SpinifySubscriptionState$Unsubscribed()); - _metrics.lastUnsubscribeAt = DateTime.now(); - _metrics.unsubscribes++; - try { - if (sendUnsubscribe && - currentState.isSubscribed && - _client.state.isConnected) { - await _sendCommand( - (id) => SpinifyUnsubscribeRequest( - id: id, - channel: channel, - timestamp: DateTime.now(), - ), - ); - } - } on Object catch (error, stackTrace) { - _logger?.call( - const SpinifyLogLevel.error(), - 'subscription_unsubscribe_error', - 'Subscription "$channel" failed to unsubscribe', - { - 'channel': channel, - 'subscription': this, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - _client._transport?.disconnect(4, 'unsubscribe error').ignore(); - if (error is SpinifyException) rethrow; - Error.throwWithStackTrace( - SpinifySubscriptionException( - channel: channel, - message: 'Error while unsubscribing', - error: error, - ), - stackTrace, - ); - } - } - - /// `SubscriptionImpl{}._resubscribe()` from `centrifuge` package - Future _resubscribe() async { - if (!_metrics.state.isUnsubscribed) return; - try { - _setState(SpinifySubscriptionState$Subscribing()); - - final token = await config.getToken?.call(); - // Token can be null if it is not required for subscription. - if (token != null && token.length <= 5) { - throw SpinifySubscriptionException( - channel: channel, - message: 'Subscription token is empty', - ); - } - - final data = await config.getPayload?.call(); - - final recover = - _recover && offset > fixnum.Int64.ZERO && epoch.isNotEmpty; - - final result = await _sendCommand( - (id) => SpinifySubscribeRequest( - id: id, - channel: channel, - timestamp: DateTime.now(), - token: token, - recoverable: recoverable, - recover: recover, - offset: recover ? offset : null, - epoch: recover ? epoch : null, - positioned: config.positioned, - joinLeave: config.joinLeave, - data: data, - ), - ); - - if (state.isUnsubscribed) { - _logger?.call( - const SpinifyLogLevel.debug(), - 'subscription_resubscribe_skipped', - 'Subscription "$channel" resubscribe skipped, ' - 'subscription is unsubscribed.', - { - 'channel': channel, - 'subscription': this, - }, - ); - await _unsubscribe( - code: 0, - reason: 'resubscribe skipped', - sendUnsubscribe: false, - ); - } - - // If subscription is recoverable and server sends recoverable flag - // then we should update epoch and offset values. - if (result.recoverable) { - _recover = true; - epoch = result.since.epoch; - offset = result.since.offset; - } - - _setState(SpinifySubscriptionState$Subscribed(data: result.data)); - - // Set up refresh subscription timer if needed. - if (result.expires) { - if (result.ttl case DateTime ttl when ttl.isAfter(DateTime.now())) { - _setUpRefreshSubscriptionTimer(ttl: ttl); - } else { - // coverage:ignore-start - assert( - false, - 'Subscription "$channel" has invalid TTL: ${result.ttl}', - ); - // coverage:ignore-end - } - } - - // Handle received publications and update offset. - for (final pub in result.publications) { - _client._eventController.add(pub); - onEvent(pub); - if (pub.offset case fixnum.Int64 value when value > offset) { - offset = value; - } - } - - _onSubscribed(); // Successful subscription completed - - _logger?.call( - const SpinifyLogLevel.config(), - 'subscription_subscribed', - 'Subscription "$channel" subscribed', - { - 'channel': channel, - 'subscription': this, - }, - ); - } on Object catch (error, stackTrace) { - _logger?.call( - const SpinifyLogLevel.error(), - 'subscription_resubscribe_error', - 'Subscription "$channel" failed to resubscribe', - { - 'channel': channel, - 'subscription': this, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - switch (error) { - case SpinifyErrorResult result: - if (result.code == 109) { - _setUpResubscribeTimer(); // Token expired error, retry resubscribe - } else if (result.temporary) { - _setUpResubscribeTimer(); // Temporary error, retry resubscribe - } else { - // Disable resubscribe timer and unsubscribe - _unsubscribe( - code: result.code, - reason: result.message, - sendUnsubscribe: false, - ).ignore(); - } - case SpinifySubscriptionException _: - _setUpResubscribeTimer(); // Some spinify exception, retry resubscribe - rethrow; - default: - _setUpResubscribeTimer(); // Unknown error, retry resubscribe - } - Error.throwWithStackTrace( - SpinifySubscriptionException( - channel: channel, - message: 'Failed to resubscribe to "$channel"', - error: error, - ), - stackTrace, - ); - } - } - - /// Successful subscription completed. - void _onSubscribed() { - _tearDownResubscribeTimer(); - _metrics.lastSubscribeAt = DateTime.now(); - _metrics.subscribes++; - } - - /// Resubscribe timer. - Timer? _resubscribeTimer; - - /// Set up resubscribe timer. - void _setUpResubscribeTimer() { - _resubscribeTimer?.cancel(); - final attempt = _metrics.resubscribeAttempts ?? 0; - final delay = Backoff.nextDelay( - attempt, - _client.config.connectionRetryInterval.min.inMilliseconds, - _client.config.connectionRetryInterval.max.inMilliseconds, - ); - _metrics.resubscribeAttempts = attempt + 1; - if (delay <= Duration.zero) { - if (!state.isUnsubscribed) return; - _logger?.call( - const SpinifyLogLevel.config(), - 'subscription_resubscribe_attempt', - 'Resubscibing to $channel immediately.', - { - 'channel': channel, - 'delay': delay, - 'subscription': this, - 'attempts': attempt, - }, - ); - Future.sync(subscribe).ignore(); - return; - } - _logger?.call( - const SpinifyLogLevel.debug(), - 'subscription_resubscribe_delayed', - 'Setting up resubscribe timer for $channel ' - 'after ${delay.inMilliseconds} ms.', - { - 'channel': channel, - 'delay': delay, - 'subscription': this, - 'attempts': attempt, - }, - ); - _metrics.nextResubscribeAt = DateTime.now().add(delay); - _resubscribeTimer = Timer(delay, () { - if (!state.isUnsubscribed) return; - _logger?.call( - const SpinifyLogLevel.debug(), - 'subscription_resubscribe_attempt', - 'Resubscribing to $channel after ${delay.inMilliseconds} ms.', - { - 'channel': channel, - 'subscription': this, - 'attempts': attempt, - }, - ); - Future.sync(_resubscribe).ignore(); - }); - } - - /// Tear down resubscribe timer. - void _tearDownResubscribeTimer() { - _metrics - ..resubscribeAttempts = 0 - ..nextResubscribeAt = null; - _resubscribeTimer?.cancel(); - _resubscribeTimer = null; - } - - /// Refresh subscription timer. - Timer? _refreshTimer; - - /// Set up refresh subscription timer. - void _setUpRefreshSubscriptionTimer({required DateTime ttl}) { - _tearDownRefreshSubscriptionTimer(); - _metrics.ttl = ttl; - _refreshTimer = Timer(ttl.difference(DateTime.now()), _refreshToken); - } - - /// Tear down refresh subscription timer. - void _tearDownRefreshSubscriptionTimer() { - _refreshTimer?.cancel(); - _refreshTimer = null; - _metrics.ttl = null; - } - - /// Refresh subscription token. - void _refreshToken() => runZonedGuarded( - () async { - _tearDownRefreshSubscriptionTimer(); - if (!state.isSubscribed || !_client.state.isConnected) return; - final token = await config.getToken?.call(); - if (token == null || token.isEmpty) { - throw SpinifySubscriptionException( - channel: channel, - message: 'Token is empty', - ); - } - final result = await _sendCommand( - (id) => SpinifySubRefreshRequest( - id: id, - channel: channel, - timestamp: DateTime.now(), - token: token, - ), - ); - - DateTime? newTtl; - if (result.expires) { - if (result.ttl case DateTime ttl when ttl.isAfter(DateTime.now())) { - newTtl = ttl; - _setUpRefreshSubscriptionTimer(ttl: ttl); - } else { - // coverage:ignore-start - assert( - false, - 'Subscription "$channel" has invalid TTL: ${result.ttl}', - ); - // coverage:ignore-end - } - } - - _logger?.call( - const SpinifyLogLevel.debug(), - 'subscription_refresh_token', - 'Subscription "$channel" token refreshed', - { - 'channel': channel, - 'subscription': this, - if (newTtl != null) 'ttl': newTtl, - }, - ); - }, - (error, stackTrace) { - _logger?.call( - const SpinifyLogLevel.error(), - 'subscription_refresh_token_error', - 'Subscription "$channel" failed to refresh token', - { - 'channel': channel, - 'subscription': this, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - - // Calculate new TTL for refresh subscription timer - late final ttl = - DateTime.now().add(Backoff.nextDelay(0, 5 * 1000, 10 * 1000)); - switch (error) { - case SpinifyErrorResult result: - if (result.temporary) { - _setUpRefreshSubscriptionTimer(ttl: ttl); - } else { - // Disable refresh subscription timer and unsubscribe - _unsubscribe( - code: result.code, - reason: result.message, - sendUnsubscribe: true, - ).ignore(); - } - case SpinifySubscriptionException _: - _setUpRefreshSubscriptionTimer(ttl: ttl); - default: - _setUpRefreshSubscriptionTimer(ttl: ttl); - } - }, - ); -} - -@internal -final class SpinifyServerSubscriptionImpl extends SpinifySubscriptionBase - implements SpinifyServerSubscription { - SpinifyServerSubscriptionImpl({ - required super.client, - required super.channel, - required super.recoverable, - required super.epoch, - required super.offset, - }); - - @override - SpinifyChannelEvents get stream => - _client.stream.filter(channel: channel); -} diff --git a/lib/src/deprecated/transport_fake.dart b/lib/src/deprecated/transport_fake.dart deleted file mode 100644 index 819d952..0000000 --- a/lib/src/deprecated/transport_fake.dart +++ /dev/null @@ -1,313 +0,0 @@ -/* // ignore_for_file: avoid_setters_without_getters -// coverage:ignore-file - -import 'dart:async'; -import 'dart:convert'; -import 'dart:math' as math; - -import 'package:fixnum/fixnum.dart'; - -import '../model/channel_event.dart'; -import '../model/command.dart'; -import '../model/metric.dart'; -import '../model/reply.dart'; -import '../model/transport_interface.dart'; - -/// Create a fake Spinify transport. -SpinifyTransportBuilder $createFakeSpinifyTransport({ - SpinifyReply? Function(SpinifyCommand command)? overrideCommand, - void Function(dynamic transport)? out, -}) => - ({ - /// URL for the connection - required url, - - /// Spinify client configuration - required config, - - /// Metrics - required metrics, - - /// Callback for reply messages - required Future Function(SpinifyReply reply) onReply, - - /// Callback for disconnect event - required Future Function({required bool temporary}) onDisconnect, - }) async { - final transport = SpinifyTransportFake( - overrideCommand: overrideCommand, - ) - ..metrics = metrics - ..onReply = onReply - ..onDisconnect = ({required temporary}) { - out?.call(null); - return onDisconnect(temporary: temporary); - }; - await transport._connect(url); - out?.call(transport); - return transport; - }; - -/// Spinify fake transport -class SpinifyTransportFake implements ISpinifyTransport { - /// Create a fake transport. - SpinifyTransportFake({ - // Delay in milliseconds - this.delay = 10, - SpinifyReply? Function(SpinifyCommand command)? overrideCommand, - }) : _random = math.Random(), - _overrideCommand = overrideCommand; - - final SpinifyReply? Function(SpinifyCommand command)? _overrideCommand; - - /// Delay in milliseconds in the fake transport to simulate network latency. - int delay; - final math.Random _random; - - Future _sleep() => Future.delayed( - Duration(milliseconds: _random.nextInt(delay ~/ 2) + delay ~/ 2)); - - bool get _isConnected => _timer != null; - Timer? _timer; - - Future _connect(String url) async { - if (_isConnected) return; - await _sleep(); - _timer = Timer.periodic(const Duration(seconds: 25), (timer) { - if (!_isConnected) timer.cancel(); - _response((now) => SpinifyPingResult(id: 0, timestamp: now)); - }); - } - - @override - Future send(SpinifyCommand command) async { - if (!_isConnected) throw StateError('Not connected'); - metrics - ..bytesSent += 1 - ..messagesSent += 1; - await _sleep(); - if (_overrideCommand != null) { - final reply = _overrideCommand.call(command); - if (reply != null) _onReply?.call(reply).ignore(); - return; - } - switch (command) { - case SpinifyPingRequest(:int id): - _response( - (now) => SpinifyPingResult( - id: id, - timestamp: now, - ), - ); - case SpinifyConnectRequest(:int id): - _response( - (now) => SpinifyConnectResult( - id: id, - timestamp: now, - client: 'fake', - version: '0.0.1', - expires: false, - ttl: null, - data: null, - subs: { - 'notification:index': SpinifySubscribeResult( - id: id, - timestamp: now, - data: null, - expires: false, - ttl: null, - positioned: false, - publications: const [], - recoverable: false, - recovered: false, - since: (epoch: '...', offset: Int64.ZERO), - wasRecovering: false, - ), - }, - pingInterval: const Duration(seconds: 25), - sendPong: false, - session: 'fake', - node: 'fake', - ), - ); - case SpinifySubscribeRequest(:int id): - _response( - (now) => SpinifySubscribeResult( - id: id, - timestamp: now, - data: null, - expires: false, - ttl: null, - positioned: false, - publications: const [], - recoverable: false, - recovered: false, - since: (epoch: '...', offset: Int64.ZERO), - wasRecovering: false, - ), - ); - case SpinifyUnsubscribeRequest(:int id): - _response( - (now) => SpinifyUnsubscribeResult( - id: id, - timestamp: now, - ), - ); - case SpinifyPublishRequest(:int id): - _response( - (now) => SpinifyPublishResult( - id: id, - timestamp: now, - ), - ); - case SpinifyPresenceRequest(:int id): - _response( - (now) => SpinifyPresenceResult( - id: id, - timestamp: now, - presence: const {}, - ), - ); - case SpinifyPresenceStatsRequest(:int id): - _response( - (now) => SpinifyPresenceStatsResult( - id: id, - timestamp: now, - numClients: 0, - numUsers: 0, - ), - ); - case SpinifyHistoryRequest(:int id): - _response( - (now) => SpinifyHistoryResult( - id: id, - timestamp: now, - since: (epoch: '...', offset: Int64.ZERO), - publications: const [], - ), - ); - case SpinifyRPCRequest(:int id, :String method, :List data): - _response( - (now) => SpinifyRPCResult( - id: id, - timestamp: now, - data: switch (method) { - 'getCurrentYear' => - utf8.encode('{"year": ${DateTime.now().year}}'), - 'echo' => data, - _ => throw ArgumentError('Unknown method: $method'), - }, - ), - ); - case SpinifyRefreshRequest(:int id): - _response( - (now) => SpinifyRefreshResult( - id: id, - timestamp: now, - client: 'fake', - version: '0.0.1', - expires: false, - ttl: null, - ), - ); - case SpinifySubRefreshRequest(:int id): - _response( - (now) => SpinifySubRefreshResult( - id: id, - timestamp: now, - expires: false, - ttl: null, - ), - ); - case SpinifySendRequest(): - // Asynchronously send a message to the server - } - } - - void _response(SpinifyReply Function(DateTime now) reply) => Timer( - Duration(milliseconds: delay), - () { - if (!_isConnected) return; - metrics - ..bytesReceived += 1 - ..messagesReceived += 1; - _onReply?.call(reply(DateTime.now())).ignore(); - }, - ); - - /// Metrics - late SpinifyMetrics$Mutable metrics; - - /// Callback for reply messages - set onReply(Future Function(SpinifyReply reply) handler) => - _onReply = handler; - Future Function(SpinifyReply reply)? _onReply; - - /// Callback for disconnect event - set onDisconnect(Future Function({required bool temporary}) handler) => - _onDisconnect = handler; - Future Function({required bool temporary})? _onDisconnect; - - @override - Future disconnect([int? code, String? reason]) async { - if (!_isConnected) return; - await _sleep(); - int? closeCode; - String? closeReason; - var reconnect = true; - if (code case int value when value > 0) { - switch (value) { - case 1009: - // reconnect is true by default - closeCode = 3; // disconnectCodeMessageSizeLimit; - closeReason = 'message size limit exceeded'; - reconnect = true; - case < 3000: - // We expose codes defined by Centrifuge protocol, - // hiding details about transport-specific error codes. - // We may have extra optional transportCode field in the future. - // reconnect is true by default - closeCode = 1; // connectingCodeTransportClosed; - closeReason = reason; - reconnect = true; - case >= 3000 && <= 3499: - // reconnect is true by default - closeCode = value; - closeReason = reason; - reconnect = true; - case >= 3500 && <= 3999: - // application terminal codes - closeCode = value; - closeReason = reason ?? 'application terminal code'; - reconnect = false; - case >= 4000 && <= 4499: - // custom disconnect codes - // reconnect is true by default - closeCode = value; - closeReason = reason; - reconnect = true; - case >= 4500 && <= 4999: - // custom disconnect codes - // application terminal codes - closeCode = value; - closeReason = reason ?? 'application terminal code'; - reconnect = false; - case >= 5000: - // reconnect is true by default - closeCode = value; - closeReason = reason; - reconnect = true; - default: - closeCode = value; - closeReason = reason; - reconnect = false; - } - } - closeCode ??= 1; // connectingCodeTransportClosed - closeReason ??= 'transport closed'; - await _onDisconnect?.call(temporary: reconnect); - _timer?.cancel(); - _timer = null; - } -} - */ \ No newline at end of file diff --git a/lib/src/deprecated/transport_ws_pb_js.dart b/lib/src/deprecated/transport_ws_pb_js.dart deleted file mode 100644 index 764a1ec..0000000 --- a/lib/src/deprecated/transport_ws_pb_js.dart +++ /dev/null @@ -1,446 +0,0 @@ -/* -import 'dart:async'; -import 'dart:convert'; -import 'dart:js_interop' as js; -import 'dart:typed_data'; - -import 'package:meta/meta.dart'; -import 'package:protobuf/protobuf.dart' as pb; -import 'package:web/web.dart' as web; - -import '../model/channel_event.dart'; -import '../model/command.dart'; -import '../model/config.dart'; -import '../model/metric.dart'; -import '../model/reply.dart'; -import '../model/transport_interface.dart'; -import '../protobuf/client.pb.dart' as pb; -import '../protobuf/protobuf_codec.dart'; -import '../util/event_queue.dart'; - -const _BlobCodec _blobCodec = _BlobCodec(); - -@immutable -final class _BlobCodec { - const _BlobCodec(); - - @internal - web.Blob write(Object data) { - switch (data) { - case String text: - return web.Blob([Uint8List.fromList(utf8.encode(text)).toJS].toJS); - case TypedData td: - return web.Blob([ - Uint8List.view( - td.buffer, - td.offsetInBytes, - td.lengthInBytes, - ).toJS - ].toJS); - case ByteBuffer bb: - return web.Blob([bb.asUint8List().toJS].toJS); - case List bytes: - return web.Blob([Uint8List.fromList(bytes).toJS].toJS); - case web.Blob blob: - return web.Blob([blob].toJS); - default: - throw ArgumentError.value(data, 'data', 'Invalid data type.'); - } - } - - @internal - Future read(js.JSAny? data) async { - switch (data) { - case String text: - return utf8.encode(text); - case web.Blob blob: - final arrayBuffer = await blob.arrayBuffer().toDart; - return arrayBuffer.toDart.asUint8List(); - case TypedData td: - return Uint8List.view( - td.buffer, - td.offsetInBytes, - td.lengthInBytes, - ); - case ByteBuffer bb: - return bb.asUint8List(); - case List bytes: - return Uint8List.fromList(bytes); - default: - assert(false, 'Unsupported data type: $data'); - throw ArgumentError.value(data, 'data', 'Invalid data type.'); - } - } -} - -/// Create a WebSocket Protocol Buffers transport. -@internal -Future $create$WS$PB$Transport({ - /// URL for the connection - required String url, - - /// Spinify client configuration - required SpinifyConfig config, - - /// Metrics - required SpinifyMetrics$Mutable metrics, - - /// Callback for reply messages - required void Function(SpinifyReply reply) onReply, - - /// Callback for disconnect event - required void Function({required bool temporary}) onDisconnect, -}) async { - // ignore: close_sinks - final socket = web.WebSocket( - url, - {'centrifuge-protobuf'} - .map((e) => e.toJS) - .toList(growable: false) - .toJS, - ); - - SpinifyTransport$WS$PB$JS? transport; - - final eventQueue = EventQueue(); // Event queue for WebSocket events - - // ignore: cancel_subscriptions - StreamSubscription? onOpen, onError, onMessage, onClose; - try { - final completer = Completer(); - - // coverage:ignore-start - onOpen = socket.onOpen.listen((event) { - eventQueue.add(() { - if (transport != null || completer.isCompleted) return; - completer.complete(); - }); - }); - - onError = socket.onError.listen((event) { - eventQueue.add(() async { - if (transport != null && !transport.disconnected) { - await transport.disconnect(); - return; - } - if (completer.isCompleted) return; - switch (event) { - case web.ErrorEvent value - when value.error != null || value.message.isNotEmpty: - completer.completeError(Exception( - 'WebSocket connection error: ${value.error ?? value.message}')); - default: - completer.completeError( - Exception('WebSocket connection error: Unknown error')); - } - }); - }); - - /* socket.onmessage = (web.MessageEvent event) { - final data = event.data; - eventQueue.add(() async { - if (transport == null || transport.disconnected) return; - final bytes = await _blobCodec.read(data); - transport._onData(bytes); - }); - }.toJS; */ - onMessage = socket.onMessage.listen((event) { - eventQueue.add(() async { - final bytes = await _blobCodec.read(event.data); - if (transport == null || transport.disconnected) return; - transport._onData(bytes); - }); - }); - - onClose = socket.onClose.listen((event) { - final code = event.code; - final reason = event.reason; - eventQueue.add(() async { - for (final e in [onOpen, onError, onMessage, onClose]) - e?.cancel().ignore(); - - if (transport != null && !transport.disconnected) { - transport - .._closeCode = code - .._closeReason = reason - .._onDone(); - await transport.disconnect(code, reason); - } - - if (socket.readyState != 3) socket.close(code, reason); - eventQueue.close(force: true).ignore(); - - if (completer.isCompleted) return; - completer.completeError( - Exception('WebSocket connection closed: $code $reason')); - }); - }); - - await completer.future; - - // 0 CONNECTING Socket has been created. The connection is not yet open. - // 1 OPEN The connection is open and ready to communicate. - // 2 CLOSING The connection is in the process of closing. - // 3 CLOSED The connection is closed or couldn't be opened. - assert(socket.readyState == 1, 'Socket is not open'); - // coverage:ignore-end - - // ignore: join_return_with_assignment - transport = SpinifyTransport$WS$PB$JS( - socket, - config, - metrics, - onReply, - onDisconnect, - ); - - return transport; - } on Object { - for (final e in [onOpen, onError, onMessage, onClose]) e?.cancel().ignore(); - if (socket.readyState != 3) socket.close(); - eventQueue.close(force: true).ignore(); - rethrow; - // coverage:ignore-end - } -} - -/// Create a WebSocket Protocol Buffers transport. -@internal -final class SpinifyTransport$WS$PB$JS implements ISpinifyTransport { - SpinifyTransport$WS$PB$JS( - this._socket, - SpinifyConfig config, - this._metrics, - this._onReply, - this._onDisconnect, - ) : _logger = config.logger, - _encoder = switch (config.logger) { - null => const ProtobufCommandEncoder(), - _ => ProtobufCommandEncoder(config.logger), - }, - _decoder = switch (config.logger) { - null => const ProtobufReplyDecoder(), - _ => ProtobufReplyDecoder(config.logger), - }, - disconnected = false; - - final web.WebSocket _socket; - final Converter _encoder; - final Converter _decoder; - final SpinifyLogger? _logger; - - bool disconnected; - int? _closeCode; - String? _closeReason; - - /// Metrics - final SpinifyMetrics$Mutable _metrics; - - /// Callback for reply messages - final void Function(SpinifyReply reply) _onReply; - - /// Callback for disconnect event - final void Function({required bool temporary}) _onDisconnect; - - /// Fired when data is received through a WebSocket. - void _onData(Object? bytes) { - if (bytes is! Uint8List || bytes.isEmpty) { - assert(false, 'Data is not byte array'); - return; - } - - _metrics - ..bytesReceived += bytes.length - ..messagesReceived += 1; - final reader = pb.CodedBufferReader(bytes); - while (!reader.isAtEnd()) { - try { - final message = pb.Reply(); - reader.readMessage(message, pb.ExtensionRegistry.EMPTY); - final reply = _decoder.convert(message); - _onReply.call(reply); - _logger?.call( - const SpinifyLogLevel.transport(), - 'transport_on_reply', - 'Reply ${reply.type}{id: ${reply.id}} received', - { - 'protocol': 'protobuf', - 'transport': 'websocket', - 'bytes': bytes, - 'length': bytes.length, - 'reply': reply, - 'protobuf': message, - }, - ); - } on Object catch (error, stackTrace) { - _logger?.call( - const SpinifyLogLevel.error(), - 'transport_on_reply_error', - 'Error reading reply message', - { - 'protocol': 'protobuf', - 'transport': 'websocket', - 'bytes': bytes, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - assert(false, 'Error reading message: $error'); - continue; - } - } - } - - @override - Future send(SpinifyCommand command) async { - try { - final message = _encoder.convert(command); - final commandData = message.writeToBuffer(); - final length = commandData.lengthInBytes; - final writer = pb.CodedBufferWriter() - ..writeInt32NoTag(length); //..writeRawBytes(commandData); - final bytes = writer.toBuffer() + commandData; - switch (bytes) { - case Uint8List uint8List: - _socket.send(uint8List.toJS); - case TypedData td: - _socket.send(Uint8List.view( - td.buffer, - td.offsetInBytes, - td.lengthInBytes, - ).toJS); - case List bytes: - _socket.send(Uint8List.fromList(bytes).toJS); - } - _metrics - ..bytesSent += bytes.length - ..messagesSent += 1; - _logger?.call( - const SpinifyLogLevel.transport(), - 'transport_send', - 'Command ${command.type}{id: ${command.id}} sent', - { - 'protocol': 'protobuf', - 'transport': 'websocket', - 'command': command, - 'protobuf': message, - 'length': bytes.length, - 'bytes': bytes, - }, - ); - } on Object catch (error, stackTrace) { - _logger?.call( - const SpinifyLogLevel.error(), - 'transport_send_error', - 'Error sending command ${command.type}{id: ${command.id}}', - { - 'protocol': 'protobuf', - 'transport': 'websocket', - 'command': command, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - rethrow; - } - } - - void _onDone() { - final timestamp = DateTime.now(); - int? code; - String? reason; - var reconnect = true; - if (_closeCode case int closeCode when closeCode > 0) { - switch (closeCode) { - case 1009: - // reconnect is true by default - code = 3; // disconnectCodeMessageSizeLimit; - reason = 'message size limit exceeded'; - reconnect = true; - case < 3000: - // We expose codes defined by Centrifuge protocol, - // hiding details about transport-specific error codes. - // We may have extra optional transportCode field in the future. - // reconnect is true by default - code = 1; // connectingCodeTransportClosed; - reason = _closeReason; - reconnect = true; - case >= 3000 && <= 3499: - // reconnect is true by default - code = closeCode; - reason = _closeReason; - reconnect = true; - case >= 3500 && <= 3999: - // application terminal codes - code = closeCode; - reason = _closeReason ?? 'application terminal code'; - reconnect = false; - case >= 4000 && <= 4499: - // custom disconnect codes - // reconnect is true by default - code = closeCode; - reason = _closeReason; - reconnect = true; - case >= 4500 && <= 4999: - // custom disconnect codes - // application terminal codes - code = closeCode; - reason = _closeReason ?? 'application terminal code'; - reconnect = false; - case >= 5000: - // reconnect is true by default - code = closeCode; - reason = _closeReason; - reconnect = true; - default: - code = closeCode; - reason = _closeReason; - reconnect = false; - } - } - code ??= 1; // connectingCodeTransportClosed - reason ??= 'transport closed'; - _onReply.call( - SpinifyPush( - timestamp: timestamp, - event: SpinifyDisconnect( - channel: '', // empty channel - timestamp: timestamp, - code: code, - reason: reason, - reconnect: reconnect, - ), - ), - ); - _onDisconnect.call(temporary: reconnect); - _logger?.call( - const SpinifyLogLevel.transport(), - 'transport_disconnect', - 'Transport disconnected ' - '${reconnect ? 'temporarily' : 'permanently'} ' - 'with reason: $reason', - { - 'code': code, - 'reason': reason, - 'reconnect': reconnect, - }, - ); - } - - @override - Future disconnect([int? code, String? reason]) async { - disconnected = true; - _closeCode = code; - _closeReason = reason; - if (_socket.readyState == 3) - return; - else if (code != null && reason != null) - _socket.close(code, reason); - else if (code != null) - _socket.close(code); - else - _socket.close(); - //assert(_socket.readyState == 3, 'Socket is not closed'); - } -} - */ \ No newline at end of file diff --git a/lib/src/deprecated/transport_ws_pb_stub.dart b/lib/src/deprecated/transport_ws_pb_stub.dart deleted file mode 100644 index 6a4020e..0000000 --- a/lib/src/deprecated/transport_ws_pb_stub.dart +++ /dev/null @@ -1,28 +0,0 @@ -/* // coverage:ignore-file -import 'package:meta/meta.dart'; - -import '../model/config.dart'; -import '../model/metric.dart'; -import '../model/reply.dart'; -import '../model/transport_interface.dart'; - -/// Create a WebSocket Protocol Buffers transport. -@internal -Future $create$WS$PB$Transport({ - /// URL for the connection - required String url, - - /// Spinify client configuration - required SpinifyConfig config, - - /// Metrics - required SpinifyMetrics$Mutable metrics, - - /// Callback for reply messages - required void Function(SpinifyReply reply) onReply, - - /// Callback for disconnect event - required void Function({required bool temporary}) onDisconnect, -}) => - throw UnimplementedError(); - */ \ No newline at end of file diff --git a/lib/src/deprecated/transport_ws_pb_vm.dart b/lib/src/deprecated/transport_ws_pb_vm.dart deleted file mode 100644 index 61b8a88..0000000 --- a/lib/src/deprecated/transport_ws_pb_vm.dart +++ /dev/null @@ -1,295 +0,0 @@ -/* import 'dart:async'; -import 'dart:convert'; -import 'dart:io' as io; - -import 'package:meta/meta.dart'; -import 'package:protobuf/protobuf.dart' as pb; - -import '../model/channel_event.dart'; -import '../model/command.dart'; -import '../model/config.dart'; -import '../model/metric.dart'; -import '../model/reply.dart'; -import '../model/transport_interface.dart'; -import '../protobuf/client.pb.dart' as pb; -import '../protobuf/protobuf_codec.dart'; - -/// Create a WebSocket Protocol Buffers transport. -@internal -Future $create$WS$PB$Transport({ - /// URL for the connection - required String url, - - /// Spinify client configuration - required SpinifyConfig config, - - /// Metrics - required SpinifyMetrics$Mutable metrics, - - /// Callback for reply messages - required void Function(SpinifyReply reply) onReply, - - /// Callback for disconnect event - required void Function({required bool temporary}) onDisconnect, -}) async { - // ignore: close_sinks - final socket = await io.WebSocket.connect( - url, - headers: config.headers, - protocols: {'centrifuge-protobuf'}, - ); - final transport = SpinifyTransport$WS$PB$VM( - socket, - config, - metrics, - onReply, - onDisconnect, - ); - // 0 CONNECTING Socket has been created. The connection is not yet open. - // 1 OPEN The connection is open and ready to communicate. - // 2 CLOSING The connection is in the process of closing. - // 3 CLOSED The connection is closed or couldn't be opened. - assert(socket.readyState == io.WebSocket.open, 'Socket is not open'); - return transport; -} - -/// Create a WebSocket Protocol Buffers transport. -@internal -final class SpinifyTransport$WS$PB$VM implements ISpinifyTransport { - SpinifyTransport$WS$PB$VM( - this._socket, - SpinifyConfig config, - this._metrics, - this._onReply, - this._onDisconnect, - ) : _logger = config.logger, - _encoder = switch (config.logger) { - null => const ProtobufCommandEncoder(), - _ => ProtobufCommandEncoder(config.logger), - }, - _decoder = switch (config.logger) { - null => const ProtobufReplyDecoder(), - _ => ProtobufReplyDecoder(config.logger), - } { - _subscription = _socket.listen( - _onData, - cancelOnError: false, - onDone: _onDone, - ); - } - - final io.WebSocket _socket; - final Converter _encoder; - final Converter _decoder; - final SpinifyLogger? _logger; - late final StreamSubscription _subscription; - - /// Metrics - final SpinifyMetrics$Mutable _metrics; - - /// Callback for reply messages - final void Function(SpinifyReply reply) _onReply; - - /// Callback for disconnect event - final void Function({required bool temporary}) _onDisconnect; - - void _onData(Object? bytes) { - // coverage:ignore-start - if (bytes is! List || bytes.isEmpty) { - assert(false, 'Data is not byte array'); - return; - } - // coverage:ignore-end - _metrics - ..bytesReceived += bytes.length - ..messagesReceived += 1; - final reader = pb.CodedBufferReader(bytes); - while (!reader.isAtEnd()) { - try { - final message = pb.Reply(); - reader.readMessage(message, pb.ExtensionRegistry.EMPTY); - final reply = _decoder.convert(message); - _onReply.call(reply); - _logger?.call( - const SpinifyLogLevel.transport(), - 'transport_on_reply', - 'Reply ${reply.type}{id: ${reply.id}} received', - { - 'protocol': 'protobuf', - 'transport': 'websocket', - 'bytes': bytes, - 'length': bytes.length, - 'reply': reply, - 'protobuf': message, - }, - ); - } on Object catch (error, stackTrace) { - // coverage:ignore-start - _logger?.call( - const SpinifyLogLevel.error(), - 'transport_on_reply_error', - 'Error reading reply message', - { - 'protocol': 'protobuf', - 'transport': 'websocket', - 'bytes': bytes, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - assert(false, 'Error reading message: $error'); - continue; - // coverage:ignore-end - } - } - } - - @override - Future send(SpinifyCommand command) async { - try { - final message = _encoder.convert(command); - final commandData = message.writeToBuffer(); - final length = commandData.lengthInBytes; - final writer = pb.CodedBufferWriter() - ..writeInt32NoTag(length); //..writeRawBytes(commandData); - final bytes = writer.toBuffer() + commandData; - _socket.add(bytes); - _metrics - ..bytesSent += bytes.length - ..messagesSent += 1; - _logger?.call( - const SpinifyLogLevel.transport(), - 'transport_send', - 'Command ${command.type}{id: ${command.id}} sent', - { - 'protocol': 'protobuf', - 'transport': 'websocket', - 'command': command, - 'protobuf': message, - 'length': bytes.length, - 'bytes': bytes, - }, - ); - } on Object catch (error, stackTrace) { - // coverage:ignore-start - _logger?.call( - const SpinifyLogLevel.error(), - 'transport_send_error', - 'Error sending command ${command.type}{id: ${command.id}}', - { - 'protocol': 'protobuf', - 'transport': 'websocket', - 'command': command, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - rethrow; - // coverage:ignore-end - } - } - - void _onDone() { - final timestamp = DateTime.now(); - int? code; - String? reason; - var reconnect = true; - if (_socket - case io.WebSocket( - :int closeCode, - :String? closeReason, - ) when closeCode > 0) { - switch (closeCode) { - case 1009: - // reconnect is true by default - code = 3; // disconnectCodeMessageSizeLimit; - reason = 'message size limit exceeded'; - reconnect = true; - case < 3000: - // We expose codes defined by Centrifuge protocol, - // hiding details about transport-specific error codes. - // We may have extra optional transportCode field in the future. - // reconnect is true by default - code = 1; // connectingCodeTransportClosed; - reason = closeReason; - reconnect = true; - case >= 3000 && <= 3499: - // reconnect is true by default - code = closeCode; - reason = closeReason; - reconnect = true; - case >= 3500 && <= 3999: - // application terminal codes - code = closeCode; - reason = closeReason ?? 'application terminal code'; - reconnect = false; - case >= 4000 && <= 4499: - // custom disconnect codes - // reconnect is true by default - code = closeCode; - reason = closeReason; - reconnect = true; - case >= 4500 && <= 4999: - // custom disconnect codes - // application terminal codes - code = closeCode; - reason = closeReason ?? 'application terminal code'; - reconnect = false; - case >= 5000: - // reconnect is true by default - code = closeCode; - reason = closeReason; - reconnect = true; - default: - code = closeCode; - reason = closeReason; - reconnect = false; - } - } - code ??= 1; // connectingCodeTransportClosed - reason ??= 'transport closed'; - _onReply.call( - SpinifyPush( - timestamp: timestamp, - event: SpinifyDisconnect( - channel: '', // empty channel - timestamp: timestamp, - code: code, - reason: reason, - reconnect: reconnect, - ), - ), - ); - _onDisconnect.call(temporary: reconnect); - _logger?.call( - const SpinifyLogLevel.transport(), - 'transport_disconnect', - 'Transport disconnected ' - '${reconnect ? 'temporarily' : 'permanently'} ' - 'with reason: $reason', - { - 'code': code, - 'reason': reason, - 'reconnect': reconnect, - }, - ); - } - - @override - Future disconnect([int? code, String? reason]) async { - await _subscription.cancel(); - // coverage:ignore-start - if (_socket.readyState == 3) - return; - else if (code != null && reason != null) - await _socket.close(code, reason); - else if (code != null) - await _socket.close(code); - else - await _socket.close(); - // coverage:ignore-end - // Thats a bug in the dart:io, the socket is not closed immediately - //assert(_socket.readyState == io.WebSocket.closed, 'Socket is not closed'); - } -} - */ \ No newline at end of file diff --git a/lib/src/model/exception.dart b/lib/src/model/exception.dart index 3156bc2..b686dcc 100644 --- a/lib/src/model/exception.dart +++ b/lib/src/model/exception.dart @@ -124,6 +124,78 @@ final class SpinifySendException extends SpinifyException { ); } +/// {@macro exception} +/// {@category Exception} +final class SpinifyPublishException extends SpinifyException { + /// {@macro exception} + const SpinifyPublishException({ + required this.channel, + String? message, + Object? error, + }) : super( + 'spinify_publish_exception', + message ?? 'Failed to publish message to channel', + error, + ); + + /// Publish channel. + final String channel; +} + +/// {@macro exception} +/// {@category Exception} +final class SpinifyPresenceException extends SpinifyException { + /// {@macro exception} + const SpinifyPresenceException({ + required this.channel, + String? message, + Object? error, + }) : super( + 'spinify_presence_exception', + message ?? 'Failed to get presence info for channel', + error, + ); + + /// Presence channel. + final String channel; +} + +/// {@macro exception} +/// {@category Exception} +final class SpinifyPresenceStatsException extends SpinifyException { + /// {@macro exception} + const SpinifyPresenceStatsException({ + required this.channel, + String? message, + Object? error, + }) : super( + 'spinify_presence_stats_exception', + message ?? 'Failed to get presence stats for channel', + error, + ); + + /// Presence channel. + final String channel; +} + +/// {@macro exception} +/// {@category Exception} +final class SpinifyHistoryException extends SpinifyException { + /// {@macro exception} + const SpinifyHistoryException({ + required this.channel, + String? message, + Object? error, + }) : super( + 'spinify_history_exception', + message ?? 'Failed to get history for channel', + error, + ); + + /// Presence channel. + final String channel; +} + /// {@macro exception} /// {@category Exception} final class SpinifyRPCException extends SpinifyException { diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 91bbf15..f7f83f9 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -1159,10 +1159,16 @@ final class Spinify implements ISpinify { @safe @override @nonVirtual - Future close() async { + Future close({bool force = false}) async { if (state.isClosed) return; - await _mutex.wait(); + await _mutex.lock(); try { + if (!force) { + try { + await Future.wait(_replies.values.map((e) => e.future)) + .timeout(config.timeout); + } on Object {/* ignore */} + } _tearDownHealthCheckTimer(); _internalDisconnect( code: const SpinifyDisconnectCode.normalClosure(), @@ -1171,6 +1177,7 @@ final class Spinify implements ISpinify { ); _setState(SpinifyState$Closed()); } on Object {/* ignore */} finally { + _mutex.unlock(); _statesController.close().ignore(); _eventController.close().ignore(); _log( @@ -1327,9 +1334,10 @@ final class Spinify implements ISpinify { @nonVirtual @Throws([SpinifySendException]) Future send(List data) async { - await _mutex.wait(); + await _mutex.lock(); + Future result; try { - await _doOnReady(() => _sendCommandAsync( + result = _doOnReady(() => _sendCommandAsync( SpinifySendRequest( timestamp: DateTime.now(), data: data, @@ -1339,6 +1347,15 @@ final class Spinify implements ISpinify { rethrow; } on Object catch (error, stackTrace) { Error.throwWithStackTrace(SpinifySendException(error: error), stackTrace); + } finally { + _mutex.unlock(); + } + try { + return await result; + } on SpinifySendException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace(SpinifySendException(error: error), stackTrace); } } @@ -1349,17 +1366,26 @@ final class Spinify implements ISpinify { @nonVirtual @Throws([SpinifyRPCException]) Future> rpc(String method, [List? data]) async { - await _mutex.wait(); + await _mutex.lock(); + Future result; try { - final bytes = await _doOnReady(() => _sendCommand( + result = _doOnReady(() => _sendCommand( SpinifyRPCRequest( id: _getNextCommandId(), timestamp: DateTime.now(), method: method, data: data ?? const [], ), - )).then>((reply) => reply.data); - return bytes; + )); + } on SpinifyRPCException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace(SpinifyRPCException(error: error), stackTrace); + } finally { + _mutex.unlock(); + } + try { + return await result.then((value) => value.data); } on SpinifyRPCException { rethrow; } on Object catch (error, stackTrace) { @@ -1486,16 +1512,55 @@ final class Spinify implements ISpinify { @unsafe @override + @Throws([SpinifyPublishException]) Future publish(String channel, List data) async { - await _mutex.wait(); - return getSubscription(channel)?.publish(data) ?? - Future.error( - SpinifySubscriptionException( - channel: channel, - message: 'Subscription not found', - ), - StackTrace.current, + await _mutex.lock(); + Future result; + try { + final sub = getSubscription(channel); + if (sub == null) { + _log( + const SpinifyLogLevel.warning(), + 'subscription_not_found_error', + 'Subscription not found', + { + 'channel': channel, + }, + ); + throw SpinifyPublishException( + channel: channel, + message: 'Subscription not found', ); + } + result = sub.publish(data); + } on SpinifyPublishException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyPublishException( + channel: channel, + message: 'Failed to publish data', + error: error, + ), + stackTrace, + ); + } finally { + _mutex.unlock(); + } + try { + return await result; + } on SpinifyPublishException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyPublishException( + channel: channel, + message: 'Failed to publish data', + error: error, + ), + stackTrace, + ); + } } // --- Presence --- // @@ -1503,33 +1568,109 @@ final class Spinify implements ISpinify { @unsafe @override @nonVirtual - @Throws([SpinifyConnectionException, SpinifySubscriptionException]) + @Throws([SpinifyPresenceException]) Future> presence(String channel) async { - await _mutex.wait(); - return getSubscription(channel)?.presence() ?? - Future.error( - SpinifySubscriptionException( - channel: channel, - message: 'Subscription not found', - ), - StackTrace.current, + await _mutex.lock(); + Future> result; + try { + final sub = getSubscription(channel); + if (sub == null) { + _log( + const SpinifyLogLevel.warning(), + 'subscription_not_found_error', + 'Subscription not found', + { + 'channel': channel, + }, ); + throw SpinifySubscriptionException( + channel: channel, + message: 'Subscription not found', + ); + } + result = sub.presence(); + } on SpinifyPresenceException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyPresenceException( + channel: channel, + message: 'Failed to get presence data', + error: error, + ), + stackTrace, + ); + } finally { + _mutex.unlock(); + } + try { + return await result; + } on SpinifyPresenceException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyPresenceException( + channel: channel, + message: 'Failed to get presence data', + error: error, + ), + stackTrace, + ); + } } @unsafe @override @nonVirtual - @Throws([SpinifyConnectionException, SpinifySubscriptionException]) + @Throws([SpinifyPresenceStatsException]) Future presenceStats(String channel) async { - await _mutex.wait(); - return getSubscription(channel)?.presenceStats() ?? - Future.error( - SpinifySubscriptionException( - channel: channel, - message: 'Subscription not found', - ), - StackTrace.current, + await _mutex.lock(); + Future result; + try { + final sub = getSubscription(channel); + if (sub == null) { + _log( + const SpinifyLogLevel.warning(), + 'subscription_not_found_error', + 'Subscription not found', + { + 'channel': channel, + }, + ); + throw SpinifySubscriptionException( + channel: channel, + message: 'Subscription not found', ); + } + result = sub.presenceStats(); + } on SpinifyPresenceStatsException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyPresenceStatsException( + channel: channel, + message: 'Failed to get presence stats', + error: error, + ), + stackTrace, + ); + } finally { + _mutex.unlock(); + } + try { + return await result; + } on SpinifyPresenceStatsException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyPresenceStatsException( + channel: channel, + message: 'Failed to get presence stats', + error: error, + ), + stackTrace, + ); + } } // --- History --- // @@ -1537,26 +1678,64 @@ final class Spinify implements ISpinify { @unsafe @override @nonVirtual - @Throws([SpinifyConnectionException, SpinifySubscriptionException]) + @Throws([SpinifyHistoryException]) Future history( String channel, { int? limit, SpinifyStreamPosition? since, bool? reverse, }) async { - await _mutex.wait(); - return getSubscription(channel)?.history( - limit: limit, - since: since, - reverse: reverse, - ) ?? - Future.error( - SpinifySubscriptionException( - channel: channel, - message: 'Subscription not found', - ), - StackTrace.current, + await _mutex.lock(); + Future result; + try { + final sub = getSubscription(channel); + if (sub == null) { + _log( + const SpinifyLogLevel.warning(), + 'subscription_not_found_error', + 'Subscription not found', + { + 'channel': channel, + }, ); + throw SpinifySubscriptionException( + channel: channel, + message: 'Subscription not found', + ); + } + result = sub.history( + limit: limit, + since: since, + reverse: reverse, + ); + } on SpinifyHistoryException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyHistoryException( + channel: channel, + message: 'Failed to get history data', + error: error, + ), + stackTrace, + ); + } finally { + _mutex.unlock(); + } + try { + return await result; + } on SpinifyHistoryException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyHistoryException( + channel: channel, + message: 'Failed to get history data', + error: error, + ), + stackTrace, + ); + } } // --- Replies --- // diff --git a/lib/src/spinify_interface.dart b/lib/src/spinify_interface.dart index dd6069b..db2397f 100644 --- a/lib/src/spinify_interface.dart +++ b/lib/src/spinify_interface.dart @@ -47,7 +47,9 @@ abstract interface class ISpinify /// Client if not needed anymore. /// Permanent close connection to the server and /// free all allocated resources. - Future close(); + /// If [force] is true then client will be closed immediately, + /// otherwise client will wait for all operations to complete. + Future close({bool force = false}); } /// Spinify client state owner interface. diff --git a/lib/src/util/mutex.dart b/lib/src/util/mutex.dart index d04b2aa..7ff5193 100644 --- a/lib/src/util/mutex.dart +++ b/lib/src/util/mutex.dart @@ -18,7 +18,14 @@ class _Mutex$Request { final Completer _completer; // The completer for the request. bool get isCompleted => _completer.isCompleted; // Is completed? bool get isNotCompleted => !_completer.isCompleted; // Is not completed? - void release() => _completer.complete(); // Releases the lock. + + // Releases the lock. + void release() { + final completer = _completer; + if (completer.isCompleted) return; + completer.complete(); + } + final Future future; // The future for the request. _Mutex$Request? prev; // The previous request in the chain. } diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index b973609..ca4afd6 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -394,6 +394,7 @@ void main() { expect(client.state, isA()); }, ), + skip: true, ); test( @@ -1045,6 +1046,543 @@ void main() { expectLater(client.states.last, completion(isA())); }); + test('Send', () { + final client = createFakeClient(); + expectLater( + client.send([1, 2, 3]), + throwsA(isA()), + ); + client.connect(url); + expectLater( + client.send([1, 2, 3]), + completes, + ); + client.close(); + expectLater( + client.send([1, 2, 3]), + throwsA(isA()), + ); + }); + + test('Publish', () async { + final client = createFakeClient( + transport: (_) async => WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: { + 'channel': pb.SubscribeResult( + expires: false, + ttl: null, + ), + 'another': pb.SubscribeResult( + expires: false, + ttl: null, + ), + }, + ping: 600, + pong: false, + session: 'fake', + node: 'fake', + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasPublish() && + command.publish.channel == 'channel') { + final reply = pb.Reply( + id: command.id, + publish: pb.PublishResult(), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasPublish() && + command.publish.channel == 'another') { + final reply = pb.Reply( + id: command.id, + error: pb.Error( + code: 3000, + message: 'Fake publish error', + temporary: false, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } + }); + }, + ); + unawaited(expectLater( + client.publish('channel', [1, 2, 3]), + throwsA(isA()), + )); + unawaited(client.connect(url)); + unawaited(expectLater( + client.publish('channel', [1, 2, 3]), + completes, + )); + unawaited(expectLater( + client.publish('another', [1, 2, 3]), + throwsA(isA()), + )); + unawaited(expectLater( + client.publish('unknown', [1, 2, 3]), + throwsA(isA()), + )); + unawaited(expectLater( + client.close(), + completes, + )); + unawaited(expectLater( + client.publish('channel', [1, 2, 3]), + throwsA(isA()), + )); + }); + + test('Presense', () async { + final client = createFakeClient( + transport: (_) async => WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + ping: 600, + pong: false, + subs: { + 'channel': pb.SubscribeResult( + expires: false, + ttl: null, + ), + 'another': pb.SubscribeResult( + expires: false, + ttl: null, + ), + }, + session: 'fake', + node: 'fake', + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasPresence() && + command.presence.channel == 'channel') { + final reply = pb.Reply( + id: command.id, + presence: pb.PresenceResult( + presence: { + 'channel': pb.ClientInfo( + chanInfo: [1, 2, 3], + connInfo: [1, 2, 3], + client: 'fake', + user: 'fake', + ), + }, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasPresence() && + command.presence.channel == 'another') { + final reply = pb.Reply( + id: command.id, + error: pb.Error( + code: 3000, + message: 'Fake presence error', + temporary: false, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } + }); + }, + ); + unawaited(expectLater( + client.presence('channel'), + throwsA(isA()), + )); + unawaited(client.connect(url)); + unawaited(expectLater( + client.presence('channel'), + completion( + isA>().having( + (info) => info.keys, + 'keys', + contains('channel'), + ), + ), + )); + unawaited(expectLater( + client.presence('another'), + throwsA(isA()), + )); + unawaited(expectLater( + client.presence('unknown'), + throwsA(isA()), + )); + unawaited(client.close()); + unawaited(expectLater( + client.presence('channel'), + throwsA(isA()), + )); + }); + + test('PresenceStats', () async { + final client = createFakeClient( + transport: (_) async => WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + ping: 600, + pong: false, + subs: { + 'channel': pb.SubscribeResult( + expires: false, + ttl: null, + ), + 'another': pb.SubscribeResult( + expires: false, + ttl: null, + ), + }, + session: 'fake', + node: 'fake', + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasPresenceStats() && + command.presenceStats.channel == 'channel') { + final reply = pb.Reply( + id: command.id, + presenceStats: pb.PresenceStatsResult( + numClients: 3, + numUsers: 5, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasPresenceStats() && + command.presenceStats.channel == 'another') { + final reply = pb.Reply( + id: command.id, + error: pb.Error( + code: 3000, + message: 'Fake presence stats error', + temporary: false, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } + }); + }, + ); + unawaited(expectLater( + client.presenceStats('channel'), + throwsA(isA()), + )); + unawaited(client.connect(url)); + unawaited(expectLater( + client.presenceStats('channel'), + completion( + isA() + .having( + (stats) => stats.channel, + 'channel', + equals('channel'), + ) + .having( + (stats) => stats.clients, + 'clients', + equals(3), + ) + .having( + (stats) => stats.users, + 'users', + equals(5), + ), + ), + )); + unawaited(expectLater( + client.presenceStats('another'), + throwsA(isA()), + )); + unawaited(expectLater( + client.presenceStats('unknown'), + throwsA(isA()), + )); + unawaited(expectLater( + client.close(), + completes, + )); + unawaited(expectLater( + client.presenceStats('channel'), + throwsA(isA()), + )); + }); + + test('History', () async { + final client = createFakeClient( + transport: (_) async => WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + ping: 600, + pong: false, + subs: { + 'channel': pb.SubscribeResult( + expires: false, + ttl: null, + ), + 'another': pb.SubscribeResult( + expires: false, + ttl: null, + ), + }, + session: 'fake', + node: 'fake', + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasHistory() && + command.history.channel == 'channel') { + final reply = pb.Reply( + id: command.id, + history: pb.HistoryResult( + epoch: 'epoch', + offset: Int64(5), + publications: [ + pb.Publication( + offset: Int64(5), + data: [1, 2, 3], + info: pb.ClientInfo( + chanInfo: [1, 2, 3], + connInfo: [1, 2, 3], + client: 'fake', + user: 'fake', + ), + ), + pb.Publication( + offset: Int64(6), + data: [4, 5, 6], + info: pb.ClientInfo( + chanInfo: [1, 2, 3], + connInfo: [1, 2, 3], + client: 'fake', + user: 'fake', + ), + ), + pb.Publication( + offset: Int64(7), + data: [7, 8, 9], + info: pb.ClientInfo( + chanInfo: [1, 2, 3], + connInfo: [1, 2, 3], + client: 'fake', + user: 'fake', + ), + ), + ], + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasHistory() && + command.history.channel == 'another') { + final reply = pb.Reply( + id: command.id, + error: pb.Error( + code: 3000, + message: 'Fake history error', + temporary: false, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } + }); + }, + ); + + unawaited(expectLater( + client.history('channel'), + throwsA(isA()), + )); + + unawaited(client.connect(url)); + + unawaited(expectLater( + client.history( + 'channel', + limit: 3, + reverse: false, + since: (epoch: 'epoch', offset: Int64(5)), + ), + completion( + isA() + .having( + (history) => history.since, + 'since', + equals((epoch: 'epoch', offset: Int64(5))), + ) + .having( + (history) => history.publications, + 'publications', + hasLength(3), + ), + ), + )); + + unawaited(expectLater( + client.history('another'), + throwsA(isA()), + )); + + unawaited(expectLater( + client.history('unknown'), + throwsA(isA()), + )); + + unawaited(expectLater( + client.close(), + completes, + )); + + unawaited(expectLater( + client.history('channel'), + throwsA(isA()), + )); + }); + + test('Send_few_rpc', () async { + final client = createFakeClient( + transport: (_) async => WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + ping: 600, + pong: false, + subs: {}, + session: 'fake', + node: 'fake', + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasRpc() && command.rpc.method == 'echo') { + final reply = pb.Reply( + id: command.id, + rpc: pb.RPCResult( + data: command.rpc.data, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasRpc() && + command.rpc.method == 'unknown') { + final reply = pb.Reply( + id: command.id, + error: pb.Error( + code: 3000, + message: 'Fake rpc error', + temporary: false, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } + }); + }, + ); + + unawaited(expectLater( + client.rpc('echo', [1, 2, 3]), + throwsA(isA()), + )); + + unawaited(expectLater( + client.connect(url), + completes, + )); + + unawaited(expectLater( + client.rpc('echo', [1, 2, 3]), + completion( + isA>().having( + (data) => data, + 'data', + equals([1, 2, 3]), + ), + ), + )); + + unawaited(expectLater( + client.rpc('unknown', [1, 2, 3]), + throwsA(isA()), + )); + + unawaited(expectLater( + client.rpc('unknown', [1, 2, 3]), + throwsA(isA()), + )); + + unawaited(expectLater( + client.close(), + completes, + )); + + unawaited(expectLater( + client.rpc('echo', [1, 2, 3]), + throwsA(isA()), + )); + }); + // Retry connection after temporary error /* test( 'Connection_error_retry', From 7255e54f0e164ed83a479e847f1d074628dbbe0f Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Tue, 12 Nov 2024 02:11:05 +0400 Subject: [PATCH 090/104] Enhance mutex handling in Spinify close and removeSubscription methods; improve error handling and clean up test cases --- lib/src/spinify.dart | 36 +++--- test/unit/spinify_test.dart | 229 ++++++++++++++++++------------------ test/unit/util_test.dart | 17 +++ 3 files changed, 151 insertions(+), 131 deletions(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index f7f83f9..18e150f 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -1161,13 +1161,15 @@ final class Spinify implements ISpinify { @nonVirtual Future close({bool force = false}) async { if (state.isClosed) return; - await _mutex.lock(); try { if (!force) { - try { - await Future.wait(_replies.values.map((e) => e.future)) - .timeout(config.timeout); - } on Object {/* ignore */} + await _mutex.lock(); + if (_replies.isNotEmpty) { + try { + await Future.wait(_replies.values.map((e) => e.future)) + .timeout(config.timeout); + } on Object {/* ignore */} + } } _tearDownHealthCheckTimer(); _internalDisconnect( @@ -1466,11 +1468,13 @@ final class Spinify implements ISpinify { return newSub; } + @unsafe @override + @Throws([SpinifySubscriptionException]) Future removeSubscription( SpinifyClientSubscription subscription, ) async { - await _mutex.wait(); + await _mutex.lock(); final subFromRegistry = _clientSubscriptionRegistry.remove(subscription.channel); try { @@ -1495,15 +1499,19 @@ final class Spinify implements ISpinify { 'subscription': subscription, }, ); - Error.throwWithStackTrace( - SpinifySubscriptionException( - channel: subscription.channel, - message: 'Error while unsubscribing', - error: error, - ), - stackTrace, - ); + if (error is SpinifySubscriptionException) + rethrow; + else + Error.throwWithStackTrace( + SpinifySubscriptionException( + channel: subscription.channel, + message: 'Error while unsubscribing', + error: error, + ), + stackTrace, + ); } finally { + _mutex.unlock(); subFromRegistry?.close(); } } diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index ca4afd6..107dd7e 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -283,120 +283,6 @@ void main() { }, ); - test( - 'Rpc_requests', - () => fakeAsync( - (async) { - final ws = WebSocket$Fake(); // ignore: close_sinks - final client = createFakeClient(transport: (_) async => ws..reset()) - ..connect(url); - async.flushMicrotasks(); - expect(client.state, isA()); - async.elapse(client.config.timeout); - expect(client.state, isA()); - - // Intercept the onAdd callback for echo RPC - var fn = ws.onAdd; - ws.onAdd = (bytes, sink) { - final command = ProtobufCodec.decode(pb.Command(), bytes); - if (command.hasRpc()) { - expect(command.rpc.method, anyOf('echo', 'getCurrentYear')); - switch (command.rpc.method) { - case 'echo': - final data = utf8.decode(command.rpc.data); - final reply = pb.Reply( - id: command.id, - rpc: pb.RPCResult( - data: utf8.encode(data), - ), - ); - scheduleMicrotask( - () => sink.add(ProtobufCodec.encode(reply))); - default: - return fn(bytes, sink); - } - } else { - fn(bytes, sink); - } - }; - - // Send a request - expect( - client.rpc('echo', utf8.encode('Hello, World!')), - completion(isA>().having( - (data) => utf8.decode(data), - 'data', - equals('Hello, World!'), - )), - ); - async.elapse(client.config.timeout); - expect(client.state, isA()); - - // Send 1000 requests - for (var i = 0; i < 1000; i++) { - expect( - client.rpc('echo', utf8.encode(i.toString())), - completion(isA>().having( - (data) => utf8.decode(data), - 'data', - equals(i.toString()), - )), - ); - } - - async.elapse(client.config.timeout); - expect(client.state, isA()); - client.disconnect(); - async.elapse(client.config.timeout); - expect(client.state, isA()); - client.connect(url); - async.elapse(client.config.timeout); - expect(client.state, isA()); - - // Intercept the onAdd callback for getCurrentYear RPC - ws.onAdd = (bytes, sink) { - final command = ProtobufCodec.decode(pb.Command(), bytes); - if (command.hasRpc()) { - expect(command.rpc.method, anyOf('echo', 'getCurrentYear')); - switch (command.rpc.method) { - case 'getCurrentYear': - final reply = pb.Reply( - id: command.id, - rpc: pb.RPCResult( - data: utf8 - .encode(jsonEncode({'year': DateTime.now().year})), - ), - ); - scheduleMicrotask( - () => sink.add(ProtobufCodec.encode(reply))); - default: - return fn(bytes, sink); - } - } else { - fn(bytes, sink); - } - }; - - // Another request - expect( - client.rpc('getCurrentYear', []), - completion(isA>().having( - (data) => jsonDecode(utf8.decode(data))['year'], - 'year', - DateTime.now().year, - )), - ); - async.elapse(client.config.timeout); - - expect(client.state, isA()); - client.close(); - async.elapse(client.config.timeout); - expect(client.state, isA()); - }, - ), - skip: true, - ); - test( 'Server_subscriptions', () => fakeAsync( @@ -1491,7 +1377,7 @@ void main() { )); }); - test('Send_few_rpc', () async { + test('RPC', () async { final client = createFakeClient( transport: (_) async => WebSocket$Fake() ..onAdd = (bytes, sink) { @@ -1524,8 +1410,7 @@ void main() { ); final bytes = ProtobufCodec.encode(reply); sink.add(bytes); - } else if (command.hasRpc() && - command.rpc.method == 'unknown') { + } else if (command.hasRpc()) { final reply = pb.Reply( id: command.id, error: pb.Error( @@ -1583,6 +1468,116 @@ void main() { )); }); + test( + 'RPC_many_requests', + () => fakeAsync((async) { + final client = createFakeClient( + transport: (_) async => WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + ping: 600, + pong: false, + subs: {}, + session: 'fake', + node: 'fake', + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasRpc() && command.rpc.method == 'echo') { + final reply = pb.Reply( + id: command.id, + rpc: pb.RPCResult( + data: command.rpc.data, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasRpc()) { + final reply = pb.Reply( + id: command.id, + error: pb.Error( + code: 3000, + message: 'Fake rpc error', + temporary: false, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } + }); + }, + ); + + expect( + client.connect(url), + completes, + ); + + async.elapse(client.config.timeout); + + expect( + client.state, + isA(), + ); + + expect( + client.rpc('echo', utf8.encode('Hello, World!')), + completion(isA>().having( + (data) => utf8.decode(data), + 'data', + equals('Hello, World!'), + )), + ); + + async.elapse(const Duration(hours: 1)); + + // Send 1000 requests + for (var i = 0; i < 50; i++) { + expect( + client.rpc('echo', utf8.encode(i.toString())), + completion(isA>().having( + (data) => utf8.decode(data), + 'data', + equals(i.toString()), + )), + ); + } + + async.elapse(const Duration(hours: 1)); + + expect(client.state, isA()); + + expect( + client.disconnect(), + completes, + ); + + async.elapse(const Duration(hours: 1)); + + expect(client.state, isA()); + + expect( + client.close(), + completes, + ); + + async.flushTimers(); + + expect(client.state, isA()); + }), + ); + // Retry connection after temporary error /* test( 'Connection_error_retry', diff --git a/test/unit/util_test.dart b/test/unit/util_test.dart index bf8b30d..2d3e7e2 100644 --- a/test/unit/util_test.dart +++ b/test/unit/util_test.dart @@ -250,5 +250,22 @@ void main() => group('Util', () { expect(m.locks, equals(0)); expect(m.pending, isEmpty); }); + + fakeAsync((async) { + final m = MutexImpl(); + final list = [for (var i = 0; i < 10; i++) i]; + final result = []; + for (var i = 0; i < list.length; i++) { + m.lock(); + Future.delayed(Duration(seconds: list.length - i), m.unlock); + result.add(list[i]); + } + expect(m.locks, equals(list.length)); + expect(m.pending, hasLength(list.length)); + async.flushTimers(); + expect(m.locks, equals(0)); + expect(m.pending, isEmpty); + expect(listEquals(result, list), isTrue); + }); }); }); From c7bad95911d4bd09ad304bdc1dc395d860e4e453 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Tue, 12 Nov 2024 02:12:59 +0400 Subject: [PATCH 091/104] Refactor mutex test cases to improve value handling and ensure correct locking behavior --- test/unit/util_test.dart | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/unit/util_test.dart b/test/unit/util_test.dart index 2d3e7e2..a467175 100644 --- a/test/unit/util_test.dart +++ b/test/unit/util_test.dart @@ -227,12 +227,15 @@ void main() => group('Util', () { final m = MutexImpl(); final list = [for (var i = 0; i < 10; i++) i]; final result = []; - for (var i = 0; i < 10; i++) { + final copy = list.toList(); + for (var i = 0; i < list.length; i++) { unawaited( expectLater( m.protect(() async { - final value = list[i]; - await Future.delayed(Duration(seconds: 10 - i)); + final value = copy.removeAt(0); + await Future.delayed( + Duration(seconds: list.length - i), + ); result.add(value); }), completes, @@ -255,10 +258,12 @@ void main() => group('Util', () { final m = MutexImpl(); final list = [for (var i = 0; i < 10; i++) i]; final result = []; + final copy = list.toList(); for (var i = 0; i < list.length; i++) { m.lock(); + final value = copy.removeAt(0); Future.delayed(Duration(seconds: list.length - i), m.unlock); - result.add(list[i]); + result.add(value); } expect(m.locks, equals(list.length)); expect(m.pending, hasLength(list.length)); From 0da4c94b3cb5994ab2bcda5d31cc137512bcd9d9 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Tue, 12 Nov 2024 02:13:32 +0400 Subject: [PATCH 092/104] Refactor value handling in util test cases to ensure correct order of operations during locking --- test/unit/util_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/util_test.dart b/test/unit/util_test.dart index a467175..7ca6c65 100644 --- a/test/unit/util_test.dart +++ b/test/unit/util_test.dart @@ -232,10 +232,10 @@ void main() => group('Util', () { unawaited( expectLater( m.protect(() async { - final value = copy.removeAt(0); await Future.delayed( Duration(seconds: list.length - i), ); + final value = copy.removeAt(0); result.add(value); }), completes, @@ -261,8 +261,8 @@ void main() => group('Util', () { final copy = list.toList(); for (var i = 0; i < list.length; i++) { m.lock(); - final value = copy.removeAt(0); Future.delayed(Duration(seconds: list.length - i), m.unlock); + final value = copy.removeAt(0); result.add(value); } expect(m.locks, equals(list.length)); From dc981d773831ed8bcf62e9a8c0f4e25fc1de30c5 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Tue, 12 Nov 2024 02:28:28 +0400 Subject: [PATCH 093/104] Enhance disconnect method in Spinify to support forced disconnection; improve mutex handling and ensure all operations complete before disconnecting --- lib/src/spinify.dart | 22 ++++++++++++++++------ lib/src/spinify_interface.dart | 4 +++- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 18e150f..52f124b 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -1025,12 +1025,21 @@ final class Spinify implements ISpinify { @safe @override @nonVirtual - Future disconnect() async { - await _mutex.lock(); + Future disconnect({bool force = false}) async { try { + if (!force) { + await _mutex.lock(); + if (_replies.isNotEmpty) { + try { + await Future.wait( + _replies.values.map>((e) => e.future), + ).timeout(config.timeout); + } on Object {/* ignore */} + } + } await _interactiveDisconnect(); } finally { - _mutex.unlock(); + if (!force) _mutex.unlock(); } } @@ -1166,8 +1175,9 @@ final class Spinify implements ISpinify { await _mutex.lock(); if (_replies.isNotEmpty) { try { - await Future.wait(_replies.values.map((e) => e.future)) - .timeout(config.timeout); + await Future.wait( + _replies.values.map>((e) => e.future), + ).timeout(config.timeout); } on Object {/* ignore */} } } @@ -1179,7 +1189,7 @@ final class Spinify implements ISpinify { ); _setState(SpinifyState$Closed()); } on Object {/* ignore */} finally { - _mutex.unlock(); + if (!force) _mutex.unlock(); _statesController.close().ignore(); _eventController.close().ignore(); _log( diff --git a/lib/src/spinify_interface.dart b/lib/src/spinify_interface.dart index db2397f..32ebd74 100644 --- a/lib/src/spinify_interface.dart +++ b/lib/src/spinify_interface.dart @@ -42,7 +42,9 @@ abstract interface class ISpinify Future ready(); /// Disconnect from the server. - Future disconnect(); + /// If [force] is true then client will be disconnected immediately, + /// otherwise client will wait for all operations to complete. + Future disconnect({bool force = false}); /// Client if not needed anymore. /// Permanent close connection to the server and From fe404c4ae40e4a83de3fff8a128589a8efbbc699 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Tue, 12 Nov 2024 02:59:33 +0400 Subject: [PATCH 094/104] Update tests --- test/unit/spinify_test.dart | 195 +++++++++++++++++++-------------- test/unit/web_socket_fake.dart | 14 +-- 2 files changed, 121 insertions(+), 88 deletions(-) diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index 107dd7e..0cbe248 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; import 'package:fake_async/fake_async.dart'; import 'package:mockito/annotations.dart'; @@ -466,43 +467,44 @@ void main() { var serverPingCount = 0; var serverPongCount = 0; final client = createFakeClient(transport: (_) async { - Timer? pingTimer; - return WebSocket$Fake() - ..onAdd = (bytes, sink) { - final command = ProtobufCodec.decode(pb.Command(), bytes); - if (command.hasConnect()) { - final reply = pb.Reply( - id: command.id, - connect: pb.ConnectResult( - client: 'fake', - version: '0.0.1', - expires: false, - ttl: null, - data: null, - subs: {}, - ping: 600, - pong: true, - session: 'fake', - node: 'fake', - ), + final ws = WebSocket$Fake(); + ws.onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: {}, + ping: 600, + pong: true, + session: 'fake', + node: 'fake', + ), + ); + scheduleMicrotask(() { + sink.add(ProtobufCodec.encode(reply)); + Timer.periodic( + Duration(milliseconds: reply.connect.ping), + (timer) { + if (ws.isClosed) { + timer.cancel(); + return; + } + serverPingCount++; + sink.add(ProtobufCodec.encode(pb.Reply())); + }, ); - scheduleMicrotask(() { - sink.add(ProtobufCodec.encode(reply)); - pingTimer = Timer.periodic( - Duration(milliseconds: reply.connect.ping), - (_) { - serverPingCount++; - sink.add(ProtobufCodec.encode(pb.Reply())); - }, - ); - }); - } else if (command.hasPing()) { - serverPongCount++; - } + }); + } else if (command.hasPing()) { + serverPongCount++; } - ..onDone = () { - pingTimer?.cancel(); - }; + }; + return ws; }); unawaited(client.connect(url)); async.elapse(client.config.timeout); @@ -521,43 +523,44 @@ void main() { (async) { var serverPingCount = 0, serverPongCount = 0; final client = createFakeClient(transport: (_) async { - Timer? pingTimer; - return WebSocket$Fake() - ..onAdd = (bytes, sink) { - final command = ProtobufCodec.decode(pb.Command(), bytes); - if (command.hasConnect()) { - final reply = pb.Reply( - id: command.id, - connect: pb.ConnectResult( - client: 'fake', - version: '0.0.1', - expires: false, - ttl: null, - data: null, - subs: {}, - ping: 600, - pong: false, - session: 'fake', - node: 'fake', - ), + final ws = WebSocket$Fake(); + ws.onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: {}, + ping: 600, + pong: false, + session: 'fake', + node: 'fake', + ), + ); + scheduleMicrotask(() { + sink.add(ProtobufCodec.encode(reply)); + Timer.periodic( + Duration(milliseconds: reply.connect.ping), + (timer) { + if (ws.isClosed) { + timer.cancel(); + return; + } + serverPingCount++; + sink.add(ProtobufCodec.encode(pb.Reply())); + }, ); - scheduleMicrotask(() { - sink.add(ProtobufCodec.encode(reply)); - pingTimer = Timer.periodic( - Duration(milliseconds: reply.connect.ping), - (_) { - serverPingCount++; - sink.add(ProtobufCodec.encode(pb.Reply())); - }, - ); - }); - } else if (command.hasPing()) { - serverPongCount++; - } + }); + } else if (command.hasPing()) { + serverPongCount++; } - ..onDone = () { - pingTimer?.cancel(); - }; + }; + return ws; }); unawaited(client.connect(url)); async.elapse(client.config.timeout); @@ -602,8 +605,7 @@ void main() { } else if (command.hasPing()) { serverPongCount++; } - } - ..onDone = () {}; + }; webSockets.add(ws); return ws; }); @@ -713,12 +715,12 @@ void main() { }); test('Auto_refresh', () { - late Timer pingTimer; var pings = 0, refreshes = 0; final client = createFakeClient( getToken: () async => 'token', - transport: (_) async => WebSocket$Fake() - ..onAdd = (bytes, sink) { + transport: (_) async { + final ws = WebSocket$Fake(); + ws.onAdd = (bytes, sink) { final command = ProtobufCodec.decode(pb.Command(), bytes); scheduleMicrotask(() { if (command.hasConnect()) { @@ -739,9 +741,13 @@ void main() { ); final bytes = ProtobufCodec.encode(reply); sink.add(bytes); - pingTimer = Timer.periodic( + Timer.periodic( Duration(milliseconds: reply.connect.ping), - (_) { + (timer) { + if (ws.isClosed) { + timer.cancel(); + return; + } sink.add(ProtobufCodec.encode(pb.Reply())); pings++; }, @@ -761,10 +767,9 @@ void main() { refreshes++; } }); - } - ..onDone = () { - pingTimer.cancel(); - }, + }; + return ws; + }, ); return fakeAsync((async) { client.connect(url); @@ -950,6 +955,34 @@ void main() { ); }); + test('Send_and_close', () async { + final builder = BytesBuilder(); + final client = createFakeClient( + transport: (_) async { + final ws = WebSocket$Fake(); + final onAdd = ws.onAdd; + ws.onAdd = (bytes, sink) { + onAdd(bytes, sink); + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasSend()) { + builder.add(command.send.data); + } + }); + }; + return ws; + }, + ); + await client.connect(url); + await client.send([1, 2, 3]); + await client.send([4, 5, 6]); + await client.close(); + expect( + builder.takeBytes(), + equals(Uint8List.fromList([0x01, 0x02, 0x03, 0x04, 0x05, 0x06])), + ); + }); + test('Publish', () async { final client = createFakeClient( transport: (_) async => WebSocket$Fake() diff --git a/test/unit/web_socket_fake.dart b/test/unit/web_socket_fake.dart index eefde7e..a34e639 100644 --- a/test/unit/web_socket_fake.dart +++ b/test/unit/web_socket_fake.dart @@ -24,11 +24,11 @@ class WebSocket$Fake implements WebSocket { StreamTransformer, List>.fromHandlers( handleData: _dataHandler, handleError: _errorHandler, - handleDone: _doneHandler, + /* handleDone: _doneHandler, */ ), ); onAdd = _defaultOnAddCallback; - onDone = _defaultOnDoneCallback; + /* onDone = _defaultOnDoneCallback; */ } // Default callbacks to handle connects and disconnects. @@ -60,7 +60,7 @@ class WebSocket$Fake implements WebSocket { }); } - void _defaultOnDoneCallback() {} + /* void _defaultOnDoneCallback() {} */ StreamController>? _socket; @@ -87,12 +87,12 @@ class WebSocket$Fake implements WebSocket { stackTrace, ); - /// Handle socket close. + /* /// Handle socket close. void _doneHandler(EventSink> sink) { sink.close(); _isClosed = true; onDone.call(); - } + } */ @override int? get closeCode => _closeCode; @@ -115,8 +115,8 @@ class WebSocket$Fake implements WebSocket { late void Function(List bytes, Sink> sink) onAdd = _defaultOnAddCallback; - /// Add callback to handle socket close event. - late void Function() onDone = _defaultOnDoneCallback; + /* /// Add callback to handle socket close event. + late void Function() onDone = _defaultOnDoneCallback; */ /// Send asynchroniously a reply to the client. void reply(List bytes) { From 54b7ebf78c3a15ecb40e823bbbe24ab1865977da Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 14 Nov 2024 18:49:14 +0400 Subject: [PATCH 095/104] Enhance Spinify error handling during connection retries; improve test cases for temporary and permanent connection errors --- lib/src/spinify.dart | 37 +++++---- test/unit/spinify_test.dart | 151 +++++++++++++++++++++++++++++------- 2 files changed, 144 insertions(+), 44 deletions(-) diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 52f124b..93baee0 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -802,6 +802,7 @@ final class Spinify implements ISpinify { // Create handler for connect reply. final connectResultCompleter = Completer(); + SpinifyErrorResult? errorConnectResult; // ignore: omit_local_variable_types void Function(SpinifyReply reply) handleReply = (reply) { if (connectResultCompleter.isCompleted) { @@ -816,7 +817,7 @@ final class Spinify implements ISpinify { } else if (reply is SpinifyConnectResult) { connectResultCompleter.complete(reply); } else if (reply is SpinifyErrorResult) { - connectResultCompleter.completeError(reply); + connectResultCompleter.completeError(errorConnectResult = reply); } else { connectResultCompleter.completeError( const SpinifyConnectionException( @@ -843,9 +844,10 @@ final class Spinify implements ISpinify { var WebSocket(:int? closeCode, :String? closeReason) = ws; closeCode ??= 1000; closeReason ??= 'no reason'; - final code = SpinifyDisconnectCode(closeCode); - final reason = closeReason; - final reconnect = code.reconnect; + final code = + SpinifyDisconnectCode(errorConnectResult?.code ?? closeCode); + final reason = errorConnectResult?.message ?? closeReason; + final reconnect = errorConnectResult?.temporary ?? code.reconnect; _log( const SpinifyLogLevel.transport(), 'transport_disconnect', @@ -865,18 +867,19 @@ final class Spinify implements ISpinify { ); } - _replySubscription = - ws.stream.transform(StreamTransformer.fromHandlers( - handleData: (data, sink) { - _metrics - ..bytesReceived += data.length - ..chunksReceived += 1; - for (final reply in _codec.decoder.convert(data)) { - _metrics.repliesDecoded += 1; - sink.add(reply); - } - }, - )).listen( + _replySubscription = ws.stream.transform( + StreamTransformer.fromHandlers( + handleData: (data, sink) { + _metrics + ..bytesReceived += data.length + ..chunksReceived += 1; + for (final reply in _codec.decoder.convert(data)) { + _metrics.repliesDecoded += 1; + sink.add(reply); + } + }, + ), + ).listen( (reply) { assert(() { if (!identical(ws, _transport)) { @@ -944,6 +947,7 @@ final class Spinify implements ISpinify { _onReply(result); // Handle connect reply handleReply = _onReply; // Switch to normal reply handler + _tearDownReconnectTimer(); // Cancel reconnect timer _setUpRefreshConnection(); // Start refresh connection timer _setUpPingTimer(); // Start expecting ping messages @@ -1008,6 +1012,7 @@ final class Spinify implements ISpinify { _setUpReconnectTimer(); // Retry resubscribe } else { // Disable resubscribe timer on permanent errors. + _tearDownReconnectTimer(); _setState(SpinifyState$Disconnected(temporary: false)); } case SpinifyConnectionException _: diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index 0cbe248..bff2a95 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -1611,17 +1611,17 @@ void main() { }), ); - // Retry connection after temporary error - /* test( - 'Connection_error_retry', + test( + 'Connection_error_retry_temporary', () => fakeAsync( (async) { - late Timer pingTimer; - var pings = 0, retries = 0; + Timer? pingTimer; + var retries = 0; late final client = createFakeClient( getToken: () async => 'token', transport: (_) async { - late WebSocket$Fake ws; + pingTimer?.cancel(); + late WebSocket$Fake ws; // ignore: close_sinks return ws = WebSocket$Fake() ..onAdd = (bytes, sink) { final command = ProtobufCodec.decode(pb.Command(), bytes); @@ -1638,7 +1638,6 @@ void main() { ); final bytes = ProtobufCodec.encode(reply); sink.add(bytes); - retries++; } else { final reply = pb.Reply( id: command.id, @@ -1664,11 +1663,11 @@ void main() { timer.cancel(); } else { sink.add(ProtobufCodec.encode(pb.Reply())); - pings++; } }, ); } + retries++; } else if (command.hasRefresh()) { if (command.refresh.token.isEmpty) return; final reply = pb.Reply() @@ -1683,37 +1682,133 @@ void main() { sink.add(bytes); } }); - } - ..onDone = () { - pingTimer.cancel(); }; }, ); + expect(client.state.isDisconnected, isTrue); expectLater( + client.states, + emitsInOrder([ + isA(), + isA().having( + (s) => s.temporary, + 'temporary', + isTrue, + ), + isA(), + isA().having( + (s) => s.temporary, + 'temporary', + isTrue, + ), + isA(), + isA(), + isA().having( + (s) => s.temporary, + 'temporary', + isFalse, + ), + isA(), + emitsDone, + ]), + ); + + expect( client.connect(url), - throwsA(isA()), + throwsA(isA()), + ); + + async.elapse(client.config.connectionRetryInterval.max * 3); + + client.close(); + pingTimer?.cancel(); + + async.elapse(const Duration(seconds: 5)); + + expect(client.state.isClosed, isTrue); + + expect(retries, equals(3)); + expect(client.metrics.connects, 1); + expect(client.metrics.disconnects, 3); + }, + ), + ); + + test( + 'Connection_error_retry_permament', + () => fakeAsync( + (async) { + var retries = 0; + late final client = createFakeClient( + getToken: () async => 'token', + transport: (_) async { + late WebSocket$Fake + ws; // ignore: close_sinks, unused_local_variable + return ws = WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + error: pb.Error( + code: 3500, + message: 'Fake connection error', + temporary: false, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + retries++; + } else if (command.hasRefresh()) { + if (command.refresh.token.isEmpty) return; + final reply = pb.Reply() + ..id = command.id + ..refresh = pb.RefreshResult( + client: 'fake', + version: '0.0.1', + expires: true, + ttl: 600, + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } + }); + }; + }, + ); + expect(client.state.isDisconnected, isTrue); + expectLater( + client.states, + emitsInOrder([ + isA(), + isA().having( + (s) => s.temporary, + 'temporary', + isFalse, + ), + isA(), + emitsDone, + ]), ); - //client.states.forEach((s) => print(' *** State: $s')); - async.elapse(client.config.timeout); expect( - client.state, - isA().having( - (s) => s.temporary, - 'temporary', - isTrue, - )); - //async.elapse(const Duration(hours: 3)); - expect(client.state.isConnected, isTrue); - expect(client.isClosed, isFalse); + client.connect(url), + throwsA(isA()), + ); + + async.elapse(client.config.connectionRetryInterval.max * 3); + client.close(); - async.elapse(const Duration(minutes: 1)); + + async.elapse(const Duration(seconds: 5)); + expect(client.state.isClosed, isTrue); - expect(pings, greaterThanOrEqualTo(1)); - expect(retries, equals(2)); + expect(retries, equals(1)); + expect(client.metrics.connects, 0); + expect(client.metrics.disconnects, 2); }, ), - skip: true, - ); */ + ); }, ); } From e9a8a54d7d1cbc4aa3c56658fb6b0192776a0fa3 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 14 Nov 2024 19:08:03 +0400 Subject: [PATCH 096/104] Update Spinify tests to verify connection retry behavior and state transitions; adjust expected retry and disconnect metrics --- test/unit/spinify_test.dart | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index bff2a95..810c6d4 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -1778,9 +1778,16 @@ void main() { }, ); expect(client.state.isDisconnected, isTrue); + expectLater( client.states, emitsInOrder([ + isA(), + isA().having( + (s) => s.temporary, + 'temporary', + isFalse, + ), isA(), isA().having( (s) => s.temporary, @@ -1795,17 +1802,26 @@ void main() { client.connect(url), throwsA(isA()), ); + async.elapse(client.config.connectionRetryInterval.max); - async.elapse(client.config.connectionRetryInterval.max * 3); + expect(client.state.isDisconnected, isTrue); + expect( + client.connect(url), + throwsA(isA()), + ); + + async.elapse(client.config.connectionRetryInterval.max); + + expect(client.state.isDisconnected, isTrue); client.close(); async.elapse(const Duration(seconds: 5)); expect(client.state.isClosed, isTrue); - expect(retries, equals(1)); + expect(retries, equals(2)); expect(client.metrics.connects, 0); - expect(client.metrics.disconnects, 2); + expect(client.metrics.disconnects, 3); }, ), ); From b67a2f0cdc8879c1915e9db8d8ff28dacc16c3b6 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 14 Nov 2024 22:57:45 +0400 Subject: [PATCH 097/104] Refactor Spinify subscriptions: remove unused server subscription test and update test imports; enhance ping timer management in spinify_test --- lib/src/spinify.dart | 4 - test/unit/server_subscription_test.dart | 123 ------------- test/unit/spinify_test.dart | 5 +- test/unit/subscription_test.dart | 228 ++++++++++++++++++++++++ test/unit_test.dart | 4 +- 5 files changed, 234 insertions(+), 130 deletions(-) delete mode 100644 test/unit/server_subscription_test.dart create mode 100644 test/unit/subscription_test.dart diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 93baee0..35e9983 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -2256,10 +2256,6 @@ final class _SpinifyServerSubscriptionImpl extends _SpinifySubscriptionBase required super.epoch, required super.offset, }); - - @override - SpinifyChannelEvents get stream => - _client.stream.filter(channel: channel); } final class _SpinifyClientSubscriptionImpl extends _SpinifySubscriptionBase diff --git a/test/unit/server_subscription_test.dart b/test/unit/server_subscription_test.dart deleted file mode 100644 index 962503e..0000000 --- a/test/unit/server_subscription_test.dart +++ /dev/null @@ -1,123 +0,0 @@ -import 'dart:async'; - -import 'package:fake_async/fake_async.dart'; -import 'package:spinify/spinify.dart'; -import 'package:spinify/src/protobuf/client.pb.dart' as pb; -import 'package:test/test.dart'; - -import 'codecs.dart'; -import 'web_socket_fake.dart'; - -//import 'server_subscription_test.mocks.dart'; - -//@GenerateNiceMocks([MockSpec(as: #MockWebSocket)]) -void main() { - group('SpinifyServerSubscription', () { - const url = 'ws://localhost:8000/connection/websocket'; - final buffer = SpinifyLogBuffer(size: 10); - - Spinify createFakeClient([Future Function(String)? transport]) => - Spinify( - config: SpinifyConfig( - transportBuilder: ({required url, headers, protocols}) => - transport?.call(url) ?? Future.value(WebSocket$Fake()), - logger: buffer.add, - ), - ); - - test( - 'Emulate_server_subscription', - () => fakeAsync( - (async) { - final client = createFakeClient( - (_) async => WebSocket$Fake() - ..onAdd = (bytes, sink) { - final command = ProtobufCodec.decode(pb.Command(), bytes); - scheduleMicrotask(() { - if (command.hasConnect()) { - sink.add( - ProtobufCodec.encode( - pb.Reply( - id: command.id, - connect: pb.ConnectResult( - client: 'fake', - version: '0.0.1', - expires: false, - ttl: null, - data: null, - subs: { - 'notification:index': pb.SubscribeResult( - data: const [], - epoch: '...', - offset: Int64.ZERO, - expires: false, - ttl: null, - positioned: false, - publications: [ - pb.Publication( - data: const [], - info: pb.ClientInfo( - client: 'fake', - user: 'fake', - ), - tags: const { - 'type': 'notification', - }, - ), - ], - recoverable: false, - recovered: false, - wasRecovering: false, - ), - }, - ping: 600, - pong: false, - session: 'fake', - node: 'fake', - ), - ), - ), - ); - } - }); - }, - ); - - client.connect(url).ignore(); - async.elapse(client.config.timeout); - expect(client.state.isConnected, isTrue); - expect(client.subscriptions.server, isNotEmpty); - expect(client.subscriptions.server['notification:index'], isNotNull); - expect( - client.getServerSubscription('notification:index'), - same(client.subscriptions.server['notification:index']), - ); - expect( - client.getClientSubscription('notification:index'), - isNull, - ); - expect( - client.subscriptions.client['notification:index'], - isNull, - ); - expect( - client.subscriptions.server, - isA>() - .having( - (s) => s.length, - 'length', - 1, - ) - .having( - (s) => s['notification:index'], - 'notification:index', - isA(), - ), - ); - - client.close(); - }, - ), - ); - }); -} diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index 810c6d4..b79abe1 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -715,10 +715,12 @@ void main() { }); test('Auto_refresh', () { + Timer? pingTimer; var pings = 0, refreshes = 0; final client = createFakeClient( getToken: () async => 'token', transport: (_) async { + pingTimer?.cancel(); final ws = WebSocket$Fake(); ws.onAdd = (bytes, sink) { final command = ProtobufCodec.decode(pb.Command(), bytes); @@ -741,7 +743,7 @@ void main() { ); final bytes = ProtobufCodec.encode(reply); sink.add(bytes); - Timer.periodic( + pingTimer = Timer.periodic( Duration(milliseconds: reply.connect.ping), (timer) { if (ws.isClosed) { @@ -777,6 +779,7 @@ void main() { expect(client.state.isConnected, isTrue); expect(client.isClosed, isFalse); client.close(); + pingTimer?.cancel(); async.flushMicrotasks(); expect(client.state.isClosed, isTrue); expect(pings, greaterThanOrEqualTo(3 * 60 * 60 ~/ 120)); diff --git a/test/unit/subscription_test.dart b/test/unit/subscription_test.dart new file mode 100644 index 0000000..f1179b0 --- /dev/null +++ b/test/unit/subscription_test.dart @@ -0,0 +1,228 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:spinify/spinify.dart'; +import 'package:spinify/src/protobuf/client.pb.dart' as pb; +import 'package:test/test.dart'; + +import 'codecs.dart'; +import 'web_socket_fake.dart'; + +//import 'subscription_test.mocks.dart'; + +//@GenerateNiceMocks([MockSpec(as: #MockWebSocket)]) +void main() { + group('ServerSubscription', () { + const url = 'ws://localhost:8000/connection/websocket'; + final buffer = SpinifyLogBuffer(size: 10); + + Spinify createFakeClient({ + Future Function(String)? transport, + Future Function()? getToken, + }) => + Spinify( + config: SpinifyConfig( + getToken: getToken, + transportBuilder: ({required url, headers, protocols}) => + transport?.call(url) ?? Future.value(WebSocket$Fake()), + logger: buffer.add, + ), + ); + + test( + 'Emulate_server_subscription', + () => fakeAsync( + (async) { + final client = createFakeClient( + transport: (_) async => WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + sink.add( + ProtobufCodec.encode( + pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: { + 'notification:index': pb.SubscribeResult( + data: const [], + epoch: '...', + offset: Int64.ZERO, + expires: false, + ttl: null, + positioned: false, + publications: [ + pb.Publication( + data: const [], + info: pb.ClientInfo( + client: 'fake', + user: 'fake', + ), + tags: const { + 'type': 'notification', + }, + ), + ], + recoverable: false, + recovered: false, + wasRecovering: false, + ), + }, + ping: 600, + pong: false, + session: 'fake', + node: 'fake', + ), + ), + ), + ); + } + }); + }, + )..connect(url); + async.elapse(client.config.timeout); + expect(client.state.isConnected, isTrue); + expect(client.subscriptions.server, isNotEmpty); + expect(client.subscriptions.server['notification:index'], isNotNull); + expect( + client.getServerSubscription('notification:index'), + same(client.subscriptions.server['notification:index']), + ); + expect( + client.getClientSubscription('notification:index'), + isNull, + ); + expect( + client.subscriptions.client['notification:index'], + isNull, + ); + expect( + client.subscriptions.server, + isA>() + .having( + (s) => s.length, + 'length', + 1, + ) + .having( + (s) => s['notification:index'], + 'notification:index', + isA(), + ), + ); + + client.close(); + }, + ), + ); + + test( + 'Events', + () => fakeAsync((async) { + Timer? pingTimer; + final client = createFakeClient( + transport: (_) async { + pingTimer?.cancel(); + late WebSocket$Fake ws; + return ws = WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: true, + ttl: 600, + data: null, + subs: { + 'notification:index': pb.SubscribeResult( + data: const [1, 2, 3], + epoch: '...', + offset: Int64.ZERO, + expires: true, + ttl: 600, + positioned: false, + publications: [ + pb.Publication( + offset: Int64.ONE, + data: const [1, 2, 3], + info: pb.ClientInfo( + client: 'fake', + user: 'fake', + ), + tags: const { + 'type': 'notification', + }, + ), + ], + recoverable: false, + recovered: false, + wasRecovering: false, + ), + }, + ping: 600, + pong: true, + session: 'fake', + node: 'fake', + ), + ); + sink.add(ProtobufCodec.encode(reply)); + pingTimer = Timer.periodic( + Duration(milliseconds: reply.connect.ping), + (timer) { + if (ws.isClosed) { + timer.cancel(); + return; + } + sink.add(ProtobufCodec.encode(pb.Reply())); + }, + ); + } else if (command.hasRefresh()) { + if (command.refresh.token.isEmpty) return; + final reply = pb.Reply() + ..id = command.id + ..refresh = pb.RefreshResult( + client: 'fake', + version: '0.0.1', + expires: true, + ttl: 600, + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } + }); + }; + }, + )..connect(url); + expectLater( + client.states, + emitsInOrder([ + isA(), + isA(), + isA(), + isA(), + emitsDone, + ]), + ); + async.elapse(client.config.timeout); + expect(client.state.isConnected, isTrue); + async.elapse(const Duration(days: 1)); + expect(client.state.isConnected, isTrue); + expect(client.subscriptions.server, isNotEmpty); + pingTimer?.cancel(); + client.close(); + async.flushTimers(); + expect(client.state.isConnected, isFalse); + expect(client.isClosed, isTrue); + })); + }); +} diff --git a/test/unit_test.dart b/test/unit_test.dart index 7f22dae..14c1fa2 100644 --- a/test/unit_test.dart +++ b/test/unit_test.dart @@ -5,8 +5,8 @@ import 'unit/config_test.dart' as config_test; import 'unit/jwt_test.dart' as jwt_test; import 'unit/logs_test.dart' as logs_test; import 'unit/model_test.dart' as model_test; -import 'unit/server_subscription_test.dart' as server_subscription_test; import 'unit/spinify_test.dart' as spinify_test; +import 'unit/subscription_test.dart' as subscription_test; import 'unit/util_test.dart' as util_test; void main() { @@ -18,6 +18,6 @@ void main() { codec_test.main(); jwt_test.main(); spinify_test.main(); - server_subscription_test.main(); + subscription_test.main(); }); } From 1e3a2d09fe8f8d67d999b45e1318caa1b09c2867 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 14 Nov 2024 23:35:08 +0400 Subject: [PATCH 098/104] Update test --- test/unit/subscription_test.dart | 59 +++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/test/unit/subscription_test.dart b/test/unit/subscription_test.dart index f1179b0..8272620 100644 --- a/test/unit/subscription_test.dart +++ b/test/unit/subscription_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:fake_async/fake_async.dart'; import 'package:spinify/spinify.dart'; @@ -125,11 +126,12 @@ void main() { test( 'Events', () => fakeAsync((async) { - Timer? pingTimer; + Timer? pingTimer, notificationTimer; final client = createFakeClient( transport: (_) async { pingTimer?.cancel(); - late WebSocket$Fake ws; + notificationTimer?.cancel(); + late WebSocket$Fake ws; // ignore: close_sinks return ws = WebSocket$Fake() ..onAdd = (bytes, sink) { final command = ProtobufCodec.decode(pb.Command(), bytes); @@ -145,11 +147,11 @@ void main() { data: null, subs: { 'notification:index': pb.SubscribeResult( - data: const [1, 2, 3], + data: utf8.encode('notification:index'), epoch: '...', offset: Int64.ZERO, - expires: true, - ttl: 600, + expires: false, + ttl: null, positioned: false, publications: [ pb.Publication( @@ -168,6 +170,18 @@ void main() { recovered: false, wasRecovering: false, ), + 'echo:index': pb.SubscribeResult( + data: utf8.encode('echo:index'), + epoch: '...', + offset: Int64.ZERO, + expires: false, + ttl: null, + positioned: false, + publications: [], + recoverable: false, + recovered: false, + wasRecovering: false, + ), }, ping: 600, pong: true, @@ -186,6 +200,25 @@ void main() { sink.add(ProtobufCodec.encode(pb.Reply())); }, ); + notificationTimer = Timer.periodic( + const Duration(minutes: 5), + (timer) { + if (ws.isClosed) { + timer.cancel(); + return; + } + sink.add(ProtobufCodec.encode(pb.Reply( + push: pb.Push( + channel: 'notification:index', + message: pb.Message( + data: utf8.encode(DateTime.now() + .toUtc() + .toIso8601String()), + ), + ), + ))); + }, + ); } else if (command.hasRefresh()) { if (command.refresh.token.isEmpty) return; final reply = pb.Reply() @@ -214,6 +247,22 @@ void main() { ]), ); async.elapse(client.config.timeout); + expectLater( + client.subscriptions.server['notification:index']?.stream + .message(), + emitsInOrder([ + for (var i = 0; i < 10; i++) + isA().having( + (m) => m.data, + 'data', + isA>().having( + (bytes) => DateTime.parse(utf8.decode(bytes)), + 'DateTime.parse', + isA(), + ), + ), + ]), + ); expect(client.state.isConnected, isTrue); async.elapse(const Duration(days: 1)); expect(client.state.isConnected, isTrue); From 90fec0e084607a4143a49706111e85d29165c219 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 14 Nov 2024 23:52:17 +0400 Subject: [PATCH 099/104] Check server subscriptions --- test/unit/subscription_test.dart | 88 ++++++++++++++++++++++++++------ 1 file changed, 71 insertions(+), 17 deletions(-) diff --git a/test/unit/subscription_test.dart b/test/unit/subscription_test.dart index 8272620..edd1604 100644 --- a/test/unit/subscription_test.dart +++ b/test/unit/subscription_test.dart @@ -131,6 +131,7 @@ void main() { transport: (_) async { pingTimer?.cancel(); notificationTimer?.cancel(); + var offset = Int64.ZERO; late WebSocket$Fake ws; // ignore: close_sinks return ws = WebSocket$Fake() ..onAdd = (bytes, sink) { @@ -149,20 +150,32 @@ void main() { 'notification:index': pb.SubscribeResult( data: utf8.encode('notification:index'), epoch: '...', - offset: Int64.ZERO, + offset: offset, + expires: false, + ttl: null, + positioned: false, + publications: [], + recoverable: false, + recovered: false, + wasRecovering: false, + ), + 'echo:index': pb.SubscribeResult( + data: utf8.encode('echo:index'), + epoch: '...', + offset: offset, expires: false, ttl: null, positioned: false, publications: [ pb.Publication( - offset: Int64.ONE, + offset: offset, data: const [1, 2, 3], info: pb.ClientInfo( client: 'fake', user: 'fake', ), tags: const { - 'type': 'notification', + 'type': 'echo', }, ), ], @@ -170,18 +183,6 @@ void main() { recovered: false, wasRecovering: false, ), - 'echo:index': pb.SubscribeResult( - data: utf8.encode('echo:index'), - epoch: '...', - offset: Int64.ZERO, - expires: false, - ttl: null, - positioned: false, - publications: [], - recoverable: false, - recovered: false, - wasRecovering: false, - ), }, ping: 600, pong: true, @@ -231,6 +232,35 @@ void main() { ); final bytes = ProtobufCodec.encode(reply); sink.add(bytes); + } else if (command.hasPublish() && + command.publish.channel == 'echo:index') { + offset++; + final reply = pb.Reply() + ..id = command.id + ..publish = pb.PublishResult(); + final bytes = ProtobufCodec.encode(reply); + sink + ..add(bytes) + ..add( + ProtobufCodec.encode( + pb.Reply( + push: pb.Push( + channel: 'echo:index', + pub: pb.Publication( + offset: offset, + tags: const {}, + data: command.publish.data, + info: pb.ClientInfo( + client: 'fake', + chanInfo: [1, 2, 3], + connInfo: [4, 5, 6], + user: 'fake', + ), + ), + ), + ), + ), + ); } }); }; @@ -247,6 +277,7 @@ void main() { ]), ); async.elapse(client.config.timeout); + expect(client.state.isConnected, isTrue); expectLater( client.subscriptions.server['notification:index']?.stream .message(), @@ -263,13 +294,36 @@ void main() { ), ]), ); - expect(client.state.isConnected, isTrue); + final echoEvents = []; + client.subscriptions.server['echo:index']?.stream + .forEach(echoEvents.add); + for (var i = 0; i < 10; i++) { + async.elapse(const Duration(minutes: 5)); + client.publish('echo:index', utf8.encode(i.toString())); + } async.elapse(const Duration(days: 1)); expect(client.state.isConnected, isTrue); expect(client.subscriptions.server, isNotEmpty); pingTimer?.cancel(); client.close(); - async.flushTimers(); + async.elapse(client.config.timeout); + expect( + echoEvents, + equals([ + for (var i = 0; i < 10; i++) + isA() + .having( + (m) => m.data, + 'data', + equals(utf8.encode(i.toString())), + ) + .having( + (m) => m.offset, + 'offset', + equals(Int64(i + 1)), + ), + ]), + ); expect(client.state.isConnected, isFalse); expect(client.isClosed, isTrue); })); From 963da0b0786325f57ba7f6de8e3467d417a3d176 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Mon, 18 Nov 2024 18:09:54 +0400 Subject: [PATCH 100/104] Enhance SpinifyChannelEvent: add copyWith method for channel modification; update event handling in _SpinifyClientSubscriptionImpl --- lib/src/model/channel_event.dart | 260 ++++++++++++++++++++- lib/src/spinify.dart | 3 +- test/unit/model_test.dart | 58 +++++ test/unit/subscription_test.dart | 381 +++++++++++++++++++++++++++++-- 4 files changed, 675 insertions(+), 27 deletions(-) diff --git a/lib/src/model/channel_event.dart b/lib/src/model/channel_event.dart index de39975..a879d8d 100644 --- a/lib/src/model/channel_event.dart +++ b/lib/src/model/channel_event.dart @@ -121,6 +121,9 @@ sealed class SpinifyChannelEvent implements Comparable { /// Whether this is a refresh event abstract final bool isRefresh; + /// Copy this event with a new channel. + SpinifyChannelEvent copyWith({String? channel}); + @override int compareTo(SpinifyChannelEvent other) => timestamp.compareTo(other.timestamp); @@ -163,17 +166,15 @@ final class SpinifyPublication extends SpinifyChannelEvent { final Map? tags; /// Copy this publication with a new channel. - SpinifyPublication copyWith({required String channel}) => - channel == this.channel - ? this - : SpinifyPublication( - timestamp: timestamp, - channel: channel, - data: data, - offset: offset, - info: info, - tags: tags, - ); + @override + SpinifyPublication copyWith({String? channel}) => SpinifyPublication( + timestamp: timestamp, + channel: channel ?? this.channel, + data: data, + offset: offset, + info: info, + tags: tags, + ); @override bool get isConnect => false; @@ -308,11 +309,35 @@ final class SpinifyJoin extends SpinifyPresence { @override String get type => 'Join'; + /// Copy this event with a new channel. + @override + SpinifyJoin copyWith({String? channel}) => SpinifyJoin( + timestamp: timestamp, + channel: channel ?? this.channel, + info: info, + ); + @override bool get isJoin => true; @override bool get isLeave => false; + + @override + int get hashCode => Object.hashAll([ + timestamp, + channel, + info, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SpinifyJoin && + channel == other.channel && + timestamp == other.timestamp && + info == other.info; + } } /// Leave event @@ -332,11 +357,35 @@ final class SpinifyLeave extends SpinifyPresence { @override String get type => 'Leave'; + /// Copy this event with a new channel. + @override + SpinifyLeave copyWith({String? channel}) => SpinifyLeave( + timestamp: timestamp, + channel: channel ?? this.channel, + info: info, + ); + @override bool get isJoin => false; @override bool get isLeave => true; + + @override + int get hashCode => Object.hashAll([ + timestamp, + channel, + info, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SpinifyLeave && + channel == other.channel && + timestamp == other.timestamp && + info == other.info; + } } /// {@template unsubscribe} @@ -363,6 +412,15 @@ final class SpinifyUnsubscribe extends SpinifyChannelEvent { /// Reason of unsubscribe. final String reason; + /// Copy this event with a new channel. + @override + SpinifyUnsubscribe copyWith({String? channel}) => SpinifyUnsubscribe( + timestamp: timestamp, + channel: channel ?? this.channel, + code: code, + reason: reason, + ); + @override bool get isConnect => false; @@ -386,6 +444,24 @@ final class SpinifyUnsubscribe extends SpinifyChannelEvent { @override bool get isUnsubscribe => true; + + @override + int get hashCode => Object.hashAll([ + timestamp, + channel, + code, + reason, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SpinifyUnsubscribe && + channel == other.channel && + timestamp == other.timestamp && + code == other.code && + reason == other.reason; + } } /// {@template message} @@ -408,6 +484,14 @@ final class SpinifyMessage extends SpinifyChannelEvent { /// Payload of message. final List data; + /// Copy this event with a new channel. + @override + SpinifyMessage copyWith({String? channel}) => SpinifyMessage( + timestamp: timestamp, + channel: channel ?? this.channel, + data: data, + ); + @override bool get isConnect => false; @@ -431,6 +515,22 @@ final class SpinifyMessage extends SpinifyChannelEvent { @override bool get isUnsubscribe => false; + + @override + int get hashCode => Object.hashAll([ + timestamp, + channel, + data, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SpinifyMessage && + channel == other.channel && + timestamp == other.timestamp && + listEquals(data, other.data); + } } /// {@template subscribe} @@ -465,6 +565,17 @@ final class SpinifySubscribe extends SpinifyChannelEvent { /// Data attached to subscription. final List? data; + /// Copy this event with a new channel. + @override + SpinifySubscribe copyWith({String? channel}) => SpinifySubscribe( + timestamp: timestamp, + channel: channel ?? this.channel, + data: data, + positioned: positioned, + recoverable: recoverable, + since: since, + ); + @override bool get isConnect => false; @@ -488,6 +599,28 @@ final class SpinifySubscribe extends SpinifyChannelEvent { @override bool get isUnsubscribe => false; + + @override + int get hashCode => Object.hashAll([ + timestamp, + channel, + positioned, + recoverable, + since, + data, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SpinifySubscribe && + channel == other.channel && + timestamp == other.timestamp && + positioned == positioned && + recoverable == recoverable && + since == since && + listEquals(data, other.data); + } } /// {@template connect} @@ -545,6 +678,22 @@ final class SpinifyConnect extends SpinifyChannelEvent { /// Payload of connected push. final List? data; + /// Copy this event with a new channel. + @override + SpinifyConnect copyWith({String? channel}) => SpinifyConnect( + timestamp: timestamp, + channel: channel ?? this.channel, + data: data, + client: client, + version: version, + expires: expires, + ttl: ttl, + pingInterval: pingInterval, + sendPong: sendPong, + session: session, + node: node, + ); + @override bool get isConnect => true; @@ -568,6 +717,38 @@ final class SpinifyConnect extends SpinifyChannelEvent { @override bool get isUnsubscribe => false; + + @override + int get hashCode => Object.hashAll([ + timestamp, + channel, + client, + version, + expires, + ttl, + pingInterval, + sendPong, + session, + node, + data, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SpinifyConnect && + channel == other.channel && + timestamp == other.timestamp && + client == other.client && + version == other.version && + expires == other.expires && + ttl == other.ttl && + pingInterval == other.pingInterval && + sendPong == other.sendPong && + session == other.session && + node == other.node && + listEquals(data, other.data); + } } /// {@template disconnect} @@ -616,6 +797,16 @@ final class SpinifyDisconnect extends SpinifyChannelEvent { /// Reconnect flag. final bool reconnect; + /// Copy this event with a new channel. + @override + SpinifyDisconnect copyWith({String? channel}) => SpinifyDisconnect( + timestamp: timestamp, + channel: channel ?? this.channel, + code: code, + reason: reason, + reconnect: reconnect, + ); + @override bool get isConnect => false; @@ -639,6 +830,26 @@ final class SpinifyDisconnect extends SpinifyChannelEvent { @override bool get isUnsubscribe => false; + + @override + int get hashCode => Object.hashAll([ + timestamp, + channel, + code, + reason, + reconnect, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SpinifyDisconnect && + channel == other.channel && + timestamp == other.timestamp && + code == other.code && + reason == other.reason && + reconnect == other.reconnect; + } } /// {@template refresh} @@ -665,6 +876,15 @@ final class SpinifyRefresh extends SpinifyChannelEvent { /// Time when connection will be expired final DateTime? ttl; + /// Copy this event with a new channel. + @override + SpinifyRefresh copyWith({String? channel}) => SpinifyRefresh( + timestamp: timestamp, + channel: channel ?? this.channel, + expires: expires, + ttl: ttl, + ); + @override bool get isConnect => false; @@ -688,4 +908,22 @@ final class SpinifyRefresh extends SpinifyChannelEvent { @override bool get isUnsubscribe => false; + + @override + int get hashCode => Object.hashAll([ + timestamp, + channel, + expires, + ttl, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SpinifyRefresh && + channel == other.channel && + timestamp == other.timestamp && + expires == other.expires && + ttl == other.ttl; + } } diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 35e9983..fe62724 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -2447,7 +2447,8 @@ final class _SpinifyClientSubscriptionImpl extends _SpinifySubscriptionBase } // Handle received publications and update offset. - for (final pub in result.publications) { + for (var pub in result.publications) { + if (pub.channel.isEmpty) pub = pub.copyWith(channel: channel); _client._eventController.add(pub); onEvent(pub); if (pub.offset case fixnum.Int64 value when value > offset) { diff --git a/test/unit/model_test.dart b/test/unit/model_test.dart index 319ace8..d02b731 100644 --- a/test/unit/model_test.dart +++ b/test/unit/model_test.dart @@ -250,6 +250,64 @@ void main() { ), ); + expect( + event.copyWith(channel: event.channel), + allOf( + equals(event), + isNot(same(event)), + isA() + .having( + (e) => e.channel, + 'channel', + same(event.channel), + ) + .having( + (e) => e.type, + 'type', + same(event.type), + ) + .having( + (e) => e.timestamp, + 'timestamp', + same(event.timestamp), + ) + .having( + (e) => e.hashCode, + 'hashCode', + same(event.hashCode), + ), + ), + ); + + expect( + event.copyWith(channel: 'another'), + allOf( + isNot(equals(event)), + isNot(same(event)), + isA() + .having( + (e) => e.channel, + 'channel', + isNot(same(event.channel)), + ) + .having( + (e) => e.type, + 'type', + same(event.type), + ) + .having( + (e) => e.timestamp, + 'timestamp', + same(event.timestamp), + ) + .having( + (e) => e.hashCode, + 'hashCode', + isNot(same(event.hashCode)), + ), + ), + ); + expect( event.toString(), equals('${event.type}{channel: $channel}'), diff --git a/test/unit/subscription_test.dart b/test/unit/subscription_test.dart index edd1604..f7d4916 100644 --- a/test/unit/subscription_test.dart +++ b/test/unit/subscription_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:developer'; import 'package:fake_async/fake_async.dart'; import 'package:spinify/spinify.dart'; @@ -13,23 +14,23 @@ import 'web_socket_fake.dart'; //@GenerateNiceMocks([MockSpec(as: #MockWebSocket)]) void main() { - group('ServerSubscription', () { - const url = 'ws://localhost:8000/connection/websocket'; - final buffer = SpinifyLogBuffer(size: 10); + const url = 'ws://localhost:8000/connection/websocket'; + final buffer = SpinifyLogBuffer(size: 10); - Spinify createFakeClient({ - Future Function(String)? transport, - Future Function()? getToken, - }) => - Spinify( - config: SpinifyConfig( - getToken: getToken, - transportBuilder: ({required url, headers, protocols}) => - transport?.call(url) ?? Future.value(WebSocket$Fake()), - logger: buffer.add, - ), - ); + Spinify createFakeClient({ + Future Function(String)? transport, + Future Function()? getToken, + }) => + Spinify( + config: SpinifyConfig( + getToken: getToken, + transportBuilder: ({required url, headers, protocols}) => + transport?.call(url) ?? Future.value(WebSocket$Fake()), + logger: buffer.add, + ), + ); + group('ServerSubscription', () { test( 'Emulate_server_subscription', () => fakeAsync( @@ -328,4 +329,354 @@ void main() { expect(client.isClosed, isTrue); })); }); + + group('ClientSubscription', () { + test( + 'Emulate_client_subscription', + () => fakeAsync( + (async) { + final client = createFakeClient( + transport: (_) async => WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + sink.add( + ProtobufCodec.encode( + pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: {}, + ping: 600, + pong: false, + session: 'fake', + node: 'fake', + ), + ), + ), + ); + } else if (command.hasSubscribe()) { + final reply = pb.Reply( + id: command.id, + subscribe: pb.SubscribeResult( + data: const [], + epoch: '...', + offset: Int64.ZERO, + expires: false, + ttl: null, + positioned: false, + publications: [ + pb.Publication( + data: const [], + info: pb.ClientInfo( + client: 'fake', + user: 'fake', + ), + tags: const { + 'type': 'notification', + }, + ), + ], + recoverable: false, + recovered: false, + wasRecovering: false, + ), + ); + sink.add(ProtobufCodec.encode(reply)); + } else if (command.hasUnsubscribe()) { + final reply = pb.Reply( + id: command.id, + unsubscribe: pb.UnsubscribeResult(), + ); + sink.add(ProtobufCodec.encode(reply)); + } else { + debugger(); + } + }); + }, + )..connect(url); + async.elapse(client.config.timeout); + expect(client.state.isConnected, isTrue); + expect(client.subscriptions.server, isEmpty); + expect(client.subscriptions.client, isEmpty); + final notifications = client.newSubscription('notification:index'); + expect( + client.subscriptions.client['notification:index'], + allOf( + isNotNull, + isA() + .having( + (s) => s.channel, + 'channel', + 'notification:index', + ) + .having( + (s) => s.state, + 'state', + isA(), + ), + ), + ); + notifications.subscribe(); + async.elapse(client.config.timeout); + expect( + client.subscriptions.client['notification:index'], + allOf( + isNotNull, + isA().having( + (s) => s.state, + 'state', + isA(), + ), + ), + ); + expect( + client.getClientSubscription('notification:index'), + allOf( + isA(), + same(notifications), + same(client.subscriptions.client['notification:index']), + ), + ); + expect( + client.getServerSubscription('notification:index'), + isNull, + ); + expect( + client.subscriptions.client, + isA>() + .having( + (s) => s.length, + 'length', + 1, + ) + .having( + (s) => s['notification:index'], + 'notification:index', + isA(), + ), + ); + client.disconnect(); + async.elapse(client.config.timeout); + expect(client.state.isConnected, isFalse); + expect(client.isClosed, isFalse); + expect(notifications.state.isUnsubscribed, isTrue); + client.close(); + async.elapse(client.config.timeout); + expect(client.state.isConnected, isFalse); + expect(client.isClosed, isTrue); + }, + ), + ); + + test( + 'Events', + () => fakeAsync((async) { + Timer? pingTimer, notificationTimer; + final client = createFakeClient( + transport: (_) async { + pingTimer?.cancel(); + notificationTimer?.cancel(); + var offset = Int64.ZERO; + late WebSocket$Fake ws; // ignore: close_sinks + return ws = WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: true, + ttl: 600, + data: null, + subs: { + 'notification:index': pb.SubscribeResult( + data: utf8.encode('notification:index'), + epoch: '...', + offset: offset, + expires: false, + ttl: null, + positioned: false, + publications: [], + recoverable: false, + recovered: false, + wasRecovering: false, + ), + 'echo:index': pb.SubscribeResult( + data: utf8.encode('echo:index'), + epoch: '...', + offset: offset, + expires: false, + ttl: null, + positioned: false, + publications: [ + pb.Publication( + offset: offset, + data: const [1, 2, 3], + info: pb.ClientInfo( + client: 'fake', + user: 'fake', + ), + tags: const { + 'type': 'echo', + }, + ), + ], + recoverable: false, + recovered: false, + wasRecovering: false, + ), + }, + ping: 600, + pong: true, + session: 'fake', + node: 'fake', + ), + ); + sink.add(ProtobufCodec.encode(reply)); + pingTimer = Timer.periodic( + Duration(milliseconds: reply.connect.ping), + (timer) { + if (ws.isClosed) { + timer.cancel(); + return; + } + sink.add(ProtobufCodec.encode(pb.Reply())); + }, + ); + notificationTimer = Timer.periodic( + const Duration(minutes: 5), + (timer) { + if (ws.isClosed) { + timer.cancel(); + return; + } + sink.add(ProtobufCodec.encode(pb.Reply( + push: pb.Push( + channel: 'notification:index', + message: pb.Message( + data: utf8.encode(DateTime.now() + .toUtc() + .toIso8601String()), + ), + ), + ))); + }, + ); + } else if (command.hasRefresh()) { + if (command.refresh.token.isEmpty) return; + final reply = pb.Reply() + ..id = command.id + ..refresh = pb.RefreshResult( + client: 'fake', + version: '0.0.1', + expires: true, + ttl: 600, + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasPublish() && + command.publish.channel == 'echo:index') { + offset++; + final reply = pb.Reply() + ..id = command.id + ..publish = pb.PublishResult(); + final bytes = ProtobufCodec.encode(reply); + sink + ..add(bytes) + ..add( + ProtobufCodec.encode( + pb.Reply( + push: pb.Push( + channel: 'echo:index', + pub: pb.Publication( + offset: offset, + tags: const {}, + data: command.publish.data, + info: pb.ClientInfo( + client: 'fake', + chanInfo: [1, 2, 3], + connInfo: [4, 5, 6], + user: 'fake', + ), + ), + ), + ), + ), + ); + } + }); + }; + }, + )..connect(url); + expectLater( + client.states, + emitsInOrder([ + isA(), + isA(), + isA(), + isA(), + emitsDone, + ]), + ); + async.elapse(client.config.timeout); + expect(client.state.isConnected, isTrue); + expectLater( + client.subscriptions.server['notification:index']?.stream + .message(), + emitsInOrder([ + for (var i = 0; i < 10; i++) + isA().having( + (m) => m.data, + 'data', + isA>().having( + (bytes) => DateTime.parse(utf8.decode(bytes)), + 'DateTime.parse', + isA(), + ), + ), + ]), + ); + final echoEvents = []; + client.subscriptions.server['echo:index']?.stream + .forEach(echoEvents.add); + for (var i = 0; i < 10; i++) { + async.elapse(const Duration(minutes: 5)); + client.publish('echo:index', utf8.encode(i.toString())); + } + async.elapse(const Duration(days: 1)); + expect(client.state.isConnected, isTrue); + expect(client.subscriptions.server, isNotEmpty); + pingTimer?.cancel(); + client.close(); + async.elapse(client.config.timeout); + expect( + echoEvents, + equals([ + for (var i = 0; i < 10; i++) + isA() + .having( + (m) => m.data, + 'data', + equals(utf8.encode(i.toString())), + ) + .having( + (m) => m.offset, + 'offset', + equals(Int64(i + 1)), + ), + ]), + ); + expect(client.state.isConnected, isFalse); + expect(client.isClosed, isTrue); + })); + }); } From 85824d7e037b09a8099a16aecaf517bbbcd914c1 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Mon, 18 Nov 2024 18:53:34 +0400 Subject: [PATCH 101/104] Refactor Spinify subscription states: add toString methods for better debugging; enhance unsubscribe logic in Spinify and _SpinifyClientSubscriptionImpl --- lib/src/model/subscription_state.dart | 12 +- lib/src/spinify.dart | 29 +- test/unit/subscription_test.dart | 453 +++++++++++++++----------- 3 files changed, 294 insertions(+), 200 deletions(-) diff --git a/lib/src/model/subscription_state.dart b/lib/src/model/subscription_state.dart index 618f5ab..de732a4 100644 --- a/lib/src/model/subscription_state.dart +++ b/lib/src/model/subscription_state.dart @@ -34,9 +34,6 @@ sealed class SpinifySubscriptionState extends _$SpinifySubscriptionStateBase { List? data, DateTime? timestamp, }) = SpinifySubscriptionState$Subscribed; - - @override - String toString() => type; } /// Unsubscribed state @@ -85,6 +82,9 @@ final class SpinifySubscriptionState$Unsubscribed identical(this, other) || other is SpinifySubscriptionState$Unsubscribed && other.timestamp.isAtSameMomentAs(timestamp); + + @override + String toString() => r'SpinifySubscriptionState$Unsubscribed{}'; } /// Subscribing state @@ -133,6 +133,9 @@ final class SpinifySubscriptionState$Subscribing identical(this, other) || other is SpinifySubscriptionState$Subscribing && other.timestamp.isAtSameMomentAs(timestamp); + + @override + String toString() => r'SpinifySubscriptionState$Subscribing{}'; } /// Subscribed state @@ -185,6 +188,9 @@ final class SpinifySubscriptionState$Subscribed identical(this, other) || other is SpinifySubscriptionState$Subscribed && other.timestamp.isAtSameMomentAs(timestamp); + + @override + String toString() => r'SpinifySubscriptionState$Subscribed{}'; } /// Pattern matching for [SpinifySubscriptionState]. diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index fe62724..9a7c1ba 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -1096,6 +1096,7 @@ final class Spinify implements ISpinify { // To ignore last messages and done event from transport. _replySubscription?.cancel().ignore(); _replySubscription = null; + // Close transport. _transport?.close(code, reason); _transport = null; @@ -1143,6 +1144,18 @@ final class Spinify implements ISpinify { // Reconnect if [reconnect] is true and we have reconnect URL. if (reconnect && _metrics.reconnectUrl != null) _setUpReconnectTimer(); + + // Unsuscribe from all subscriptions. + for (final sub in _clientSubscriptionRegistry.values) { + // Internal unsubscribe without sending message. + sub + ._unsubscribe( + code: code, + reason: reason, + sendUnsubscribe: false, + ) + .ignore(); + } } on Object catch (error, stackTrace) { _log( const SpinifyLogLevel.warning(), @@ -1192,6 +1205,18 @@ final class Spinify implements ISpinify { reason: 'normal closure', reconnect: false, ); + final subs = _clientSubscriptionRegistry.values.toList(growable: false); + for (final sub in subs) { + _clientSubscriptionRegistry.remove(sub.channel); + sub + ._unsubscribe( + code: const SpinifyDisconnectCode.normalClosure(), + reason: 'normal closure', + sendUnsubscribe: false, + ) + .whenComplete(sub.close) + .ignore(); + } _setState(SpinifyState$Closed()); } on Object {/* ignore */} finally { if (!force) _mutex.unlock(); @@ -2319,6 +2344,8 @@ final class _SpinifyClientSubscriptionImpl extends _SpinifySubscriptionBase ); /// Unsubscribes from the channel. + @unsafe + @Throws([SpinifySubscriptionException]) Future _unsubscribe({ required int code, required String reason, @@ -2356,7 +2383,7 @@ final class _SpinifyClientSubscriptionImpl extends _SpinifySubscriptionBase }, ); _client._transport?.close(4, 'unsubscribe error'); - if (error is SpinifyException) rethrow; + if (error is SpinifySubscriptionException) rethrow; Error.throwWithStackTrace( SpinifySubscriptionException( channel: channel, diff --git a/test/unit/subscription_test.dart b/test/unit/subscription_test.dart index f7d4916..70dab04 100644 --- a/test/unit/subscription_test.dart +++ b/test/unit/subscription_test.dart @@ -475,208 +475,269 @@ void main() { ); test( - 'Events', - () => fakeAsync((async) { - Timer? pingTimer, notificationTimer; - final client = createFakeClient( - transport: (_) async { - pingTimer?.cancel(); - notificationTimer?.cancel(); - var offset = Int64.ZERO; - late WebSocket$Fake ws; // ignore: close_sinks - return ws = WebSocket$Fake() - ..onAdd = (bytes, sink) { - final command = ProtobufCodec.decode(pb.Command(), bytes); - scheduleMicrotask(() { - if (command.hasConnect()) { - final reply = pb.Reply( - id: command.id, - connect: pb.ConnectResult( - client: 'fake', - version: '0.0.1', - expires: true, - ttl: 600, - data: null, - subs: { - 'notification:index': pb.SubscribeResult( - data: utf8.encode('notification:index'), - epoch: '...', - offset: offset, - expires: false, - ttl: null, - positioned: false, - publications: [], - recoverable: false, - recovered: false, - wasRecovering: false, - ), - 'echo:index': pb.SubscribeResult( - data: utf8.encode('echo:index'), - epoch: '...', + 'Events', + () => fakeAsync( + (async) { + Timer? pingTimer, notificationTimer; + final client = createFakeClient( + transport: (_) async { + pingTimer?.cancel(); + notificationTimer?.cancel(); + var echo = false; + var offset = Int64.ZERO; + late WebSocket$Fake ws; // ignore: close_sinks + return ws = WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: true, + ttl: 600, + data: null, + subs: {}, + ping: 600, + pong: true, + session: 'fake', + node: 'fake', + ), + ); + sink.add(ProtobufCodec.encode(reply)); + pingTimer = Timer.periodic( + Duration(milliseconds: reply.connect.ping), + (timer) { + if (ws.isClosed) { + timer.cancel(); + return; + } + sink.add(ProtobufCodec.encode(pb.Reply())); + }, + ); + } else if (command.hasRefresh()) { + if (command.refresh.token.isEmpty) return; + final reply = pb.Reply() + ..id = command.id + ..refresh = pb.RefreshResult( + client: 'fake', + version: '0.0.1', + expires: true, + ttl: 600, + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasPublish() && + command.publish.channel == 'echo:index' && + echo) { + offset++; + final reply = pb.Reply() + ..id = command.id + ..publish = pb.PublishResult(); + final bytes = ProtobufCodec.encode(reply); + sink + ..add(bytes) + ..add( + ProtobufCodec.encode( + pb.Reply( + push: pb.Push( + channel: 'echo:index', + pub: pb.Publication( offset: offset, - expires: false, - ttl: null, - positioned: false, - publications: [ - pb.Publication( - offset: offset, - data: const [1, 2, 3], - info: pb.ClientInfo( - client: 'fake', - user: 'fake', - ), - tags: const { - 'type': 'echo', - }, - ), - ], - recoverable: false, - recovered: false, - wasRecovering: false, - ), - }, - ping: 600, - pong: true, - session: 'fake', - node: 'fake', - ), - ); - sink.add(ProtobufCodec.encode(reply)); - pingTimer = Timer.periodic( - Duration(milliseconds: reply.connect.ping), - (timer) { - if (ws.isClosed) { - timer.cancel(); - return; - } - sink.add(ProtobufCodec.encode(pb.Reply())); - }, - ); - notificationTimer = Timer.periodic( - const Duration(minutes: 5), - (timer) { - if (ws.isClosed) { - timer.cancel(); - return; - } - sink.add(ProtobufCodec.encode(pb.Reply( - push: pb.Push( - channel: 'notification:index', - message: pb.Message( - data: utf8.encode(DateTime.now() - .toUtc() - .toIso8601String()), - ), - ), - ))); - }, - ); - } else if (command.hasRefresh()) { - if (command.refresh.token.isEmpty) return; - final reply = pb.Reply() - ..id = command.id - ..refresh = pb.RefreshResult( - client: 'fake', - version: '0.0.1', - expires: true, - ttl: 600, - ); - final bytes = ProtobufCodec.encode(reply); - sink.add(bytes); - } else if (command.hasPublish() && - command.publish.channel == 'echo:index') { - offset++; - final reply = pb.Reply() - ..id = command.id - ..publish = pb.PublishResult(); - final bytes = ProtobufCodec.encode(reply); - sink - ..add(bytes) - ..add( - ProtobufCodec.encode( - pb.Reply( - push: pb.Push( - channel: 'echo:index', - pub: pb.Publication( - offset: offset, - tags: const {}, - data: command.publish.data, - info: pb.ClientInfo( - client: 'fake', - chanInfo: [1, 2, 3], - connInfo: [4, 5, 6], - user: 'fake', - ), - ), + tags: const {}, + data: command.publish.data, + info: pb.ClientInfo( + client: 'fake', + chanInfo: [1, 2, 3], + connInfo: [4, 5, 6], + user: 'fake', ), ), ), - ); - } - }); - }; - }, - )..connect(url); - expectLater( - client.states, - emitsInOrder([ - isA(), - isA(), - isA(), - isA(), - emitsDone, - ]), - ); - async.elapse(client.config.timeout); - expect(client.state.isConnected, isTrue); - expectLater( - client.subscriptions.server['notification:index']?.stream - .message(), - emitsInOrder([ - for (var i = 0; i < 10; i++) - isA().having( + ), + ), + ); + } else if (command.hasSubscribe() && + command.subscribe.channel == 'notification:index') { + final reply = pb.Reply( + id: command.id, + subscribe: pb.SubscribeResult( + data: utf8.encode('notification:index'), + epoch: '...', + offset: offset, + expires: false, + ttl: null, + positioned: false, + publications: [], + recoverable: false, + recovered: false, + wasRecovering: false, + ), + ); + sink.add(ProtobufCodec.encode(reply)); + notificationTimer?.cancel(); + notificationTimer = Timer.periodic( + const Duration(minutes: 5), + (timer) { + if (ws.isClosed) { + timer.cancel(); + notificationTimer?.cancel(); + return; + } + sink.add(ProtobufCodec.encode(pb.Reply( + push: pb.Push( + channel: 'notification:index', + message: pb.Message( + data: utf8.encode( + DateTime.now().toUtc().toIso8601String()), + ), + ), + ))); + }, + ); + } else if (command.hasUnsubscribe() && + command.unsubscribe.channel == 'notification:index') { + final reply = pb.Reply( + id: command.id, + unsubscribe: pb.UnsubscribeResult(), + ); + sink.add(ProtobufCodec.encode(reply)); + notificationTimer?.cancel(); + } else if (command.hasSubscribe() && + command.subscribe.channel == 'echo:index') { + final reply = pb.Reply( + id: command.id, + subscribe: pb.SubscribeResult( + data: utf8.encode('echo:index'), + epoch: '...', + offset: offset, + expires: false, + ttl: null, + positioned: false, + publications: [ + pb.Publication( + offset: offset, + data: const [1, 2, 3], + info: pb.ClientInfo( + client: 'fake', + user: 'fake', + ), + tags: const { + 'type': 'echo', + }, + ), + ], + recoverable: false, + recovered: false, + wasRecovering: false, + ), + ); + sink.add(ProtobufCodec.encode(reply)); + echo = true; + } else if (command.hasUnsubscribe() && + command.unsubscribe.channel == 'echo:index') { + final reply = pb.Reply( + id: command.id, + unsubscribe: pb.UnsubscribeResult(), + ); + sink.add(ProtobufCodec.encode(reply)); + echo = false; + } + }); + }; + }, + ) + ..connect(url) + ..newSubscription('notification:index') + ..newSubscription('echo:index') + ..subscriptions.client.values.forEach((s) => s.subscribe()); + expectLater( + client.states, + emitsInOrder([ + isA(), + isA(), + isA(), + isA(), + emitsDone, + ]), + ); + expect(client.subscriptions.server, isEmpty); + expect( + client.subscriptions.client, + allOf( + isNotEmpty, + hasLength(2), + ), + ); + for (final sub in client.subscriptions.client.values) { + expect( + sub.state, + isA(), + ); + expectLater( + sub.states, + emitsInOrder([ + isA(), + isA(), + isA(), + emitsDone, + ]), + ); + } + async.elapse(client.config.timeout); + expect(client.state.isConnected, isTrue); + expectLater( + client.subscriptions.client['notification:index']?.stream.message(), + emitsInOrder([ + for (var i = 0; i < 10; i++) + isA().having( + (m) => m.data, + 'data', + isA>().having( + (bytes) => DateTime.parse(utf8.decode(bytes)), + 'DateTime.parse', + isA(), + ), + ), + ]), + ); + final echoEvents = []; + client.subscriptions.client['echo:index']?.stream + .forEach(echoEvents.add); + for (var i = 0; i < 10; i++) { + async.elapse(const Duration(minutes: 5)); + client.publish('echo:index', utf8.encode(i.toString())); + } + async.elapse(const Duration(days: 1)); + expect(client.state.isConnected, isTrue); + expect(client.subscriptions.client, isNotEmpty); + pingTimer?.cancel(); + client.close(); + async.elapse(client.config.timeout); + expect(client.subscriptions.client, isEmpty); + expect( + echoEvents, + equals([ + for (var i = 0; i < 10; i++) + isA() + .having( (m) => m.data, 'data', - isA>().having( - (bytes) => DateTime.parse(utf8.decode(bytes)), - 'DateTime.parse', - isA(), - ), + equals(utf8.encode(i.toString())), + ) + .having( + (m) => m.offset, + 'offset', + equals(Int64(i + 1)), ), - ]), - ); - final echoEvents = []; - client.subscriptions.server['echo:index']?.stream - .forEach(echoEvents.add); - for (var i = 0; i < 10; i++) { - async.elapse(const Duration(minutes: 5)); - client.publish('echo:index', utf8.encode(i.toString())); - } - async.elapse(const Duration(days: 1)); - expect(client.state.isConnected, isTrue); - expect(client.subscriptions.server, isNotEmpty); - pingTimer?.cancel(); - client.close(); - async.elapse(client.config.timeout); - expect( - echoEvents, - equals([ - for (var i = 0; i < 10; i++) - isA() - .having( - (m) => m.data, - 'data', - equals(utf8.encode(i.toString())), - ) - .having( - (m) => m.offset, - 'offset', - equals(Int64(i + 1)), - ), - ]), - ); - expect(client.state.isConnected, isFalse); - expect(client.isClosed, isTrue); - })); + ]), + ); + expect(client.state.isConnected, isFalse); + expect(client.isClosed, isTrue); + }, + ), + ); }); } From 4d08105c8b2fc4159adccbd372ad3a9fcf2722c7 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Mon, 18 Nov 2024 19:00:00 +0400 Subject: [PATCH 102/104] Disable echo server and smoke test at CI --- .github/workflows/tests.yml | 186 ++++++++++++++++++------------------ 1 file changed, 93 insertions(+), 93 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c1887e3..0cb7512 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -47,66 +47,66 @@ concurrency: cancel-in-progress: true jobs: - build-echo: - name: "Build Echo server" - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./ - container: - image: golang:1.22 - env: - echo-cache-name: echo - timeout-minutes: 5 - steps: - - name: ๐Ÿš‚ Get latest code - id: checkout - uses: actions/checkout@v4 - with: - sparse-checkout: | - .github - tool/echo - - - name: ๐Ÿ“ค Restore Go echo server - id: cache-echo-restore - uses: actions/cache/restore@v4 - with: - path: | - ~/build/bin/echo - key: ${{ runner.os }}-spinify-${{ env.echo-cache-name }}-${{ hashFiles('tool/echo/echo.go') }} - - - name: ๐Ÿฆซ Build Go echo server - id: build-echo - if: steps.cache-echo-restore.outputs.cache-hit != 'true' - working-directory: ./tool/echo - run: | - mkdir -p ~/build/bin/ - go get - go build -o ~/build/bin/echo echo.go - - - name: ๐Ÿ“ฅ Save Go echo server - id: cache-echo-save - if: steps.cache-echo-restore.outputs.cache-hit != 'true' - uses: actions/cache/save@v4 - with: - path: | - ~/build/bin/echo - key: ${{ runner.os }}-spinify-${{ env.echo-cache-name }}-${{ hashFiles('tool/echo/echo.go') }} - - - name: ๐Ÿ’พ Upload Go echo server - id: upload-echo-server - uses: actions/upload-artifact@v4 - with: - name: echo-server - path: ~/build/bin/echo - compression-level: 6 - overwrite: true - retention-days: 1 + # build-echo: + # name: "Build Echo server" + # runs-on: ubuntu-latest + # defaults: + # run: + # working-directory: ./ + # container: + # image: golang:1.22 + # env: + # echo-cache-name: echo + # timeout-minutes: 5 + # steps: + # - name: ๐Ÿš‚ Get latest code + # id: checkout + # uses: actions/checkout@v4 + # with: + # sparse-checkout: | + # .github + # tool/echo + # + # - name: ๐Ÿ“ค Restore Go echo server + # id: cache-echo-restore + # uses: actions/cache/restore@v4 + # with: + # path: | + # ~/build/bin/echo + # key: ${{ runner.os }}-spinify-${{ env.echo-cache-name }}-${{ hashFiles('tool/echo/echo.go') }} + # + # - name: ๐Ÿฆซ Build Go echo server + # id: build-echo + # if: steps.cache-echo-restore.outputs.cache-hit != 'true' + # working-directory: ./tool/echo + # run: | + # mkdir -p ~/build/bin/ + # go get + # go build -o ~/build/bin/echo echo.go + # + # - name: ๐Ÿ“ฅ Save Go echo server + # id: cache-echo-save + # if: steps.cache-echo-restore.outputs.cache-hit != 'true' + # uses: actions/cache/save@v4 + # with: + # path: | + # ~/build/bin/echo + # key: ${{ runner.os }}-spinify-${{ env.echo-cache-name }}-${{ hashFiles('tool/echo/echo.go') }} + # + # - name: ๐Ÿ’พ Upload Go echo server + # id: upload-echo-server + # uses: actions/upload-artifact@v4 + # with: + # name: echo-server + # path: ~/build/bin/echo + # compression-level: 6 + # overwrite: true + # retention-days: 1 tests: name: "Tests" runs-on: ubuntu-latest - needs: build-echo + #needs: build-echo defaults: run: working-directory: ./ @@ -129,12 +129,12 @@ jobs: test analysis_options.yaml - - name: ๐Ÿ“‚ Download Echo server - id: download-echo-server - uses: actions/download-artifact@v4 - with: - name: echo-server - path: ~/build/bin/ + #- name: ๐Ÿ“‚ Download Echo server + # id: download-echo-server + # uses: actions/download-artifact@v4 + # with: + # name: echo-server + # path: ~/build/bin/ - name: ๐Ÿ“ค Restore Pub modules id: cache-pub-restore @@ -160,16 +160,16 @@ jobs: ${{ env.PUB_CACHE }} key: ${{ runner.os }}-spinify-${{ env.pub-cache-name }}-${{ hashFiles('pubspec.yaml') }} - - name: ๐Ÿ“ข Run Echo server - id: run-echo-server - timeout-minutes: 1 - run: | - test -f ~/build/bin/echo - chmod +x ~/build/bin/echo - nohup ~/build/bin/echo > echo.log 2>&1 & - echo $! > echo_pid.txt - env: - PORT: 8000 + #- name: ๐Ÿ“ข Run Echo server + # id: run-echo-server + # timeout-minutes: 1 + # run: | + # test -f ~/build/bin/echo + # chmod +x ~/build/bin/echo + # nohup ~/build/bin/echo > echo.log 2>&1 & + # echo $! > echo_pid.txt + # env: + # PORT: 8000 - name: ๐Ÿงช Run tests id: run-tests @@ -180,27 +180,27 @@ jobs: --platform vm --compiler=kernel --coverage=coverage \ --reporter=github --file-reporter=json:reports/tests.json \ --timeout=10m --concurrency=12 --color \ - test/unit_test.dart test/smoke_test.dart - - - name: ๐Ÿงพ Upload echo logs - id: upload-echo-logs - if: always() - uses: actions/upload-artifact@v4 - with: - name: server-logs - path: echo.log - compression-level: 9 - overwrite: true - retention-days: 1 - - - name: โœ‹ Stop echo server - id: stop-echo-server - timeout-minutes: 1 - if: always() - run: | - kill $(cat echo_pid.txt) || true - rm -f echo_pid.txt || true - rm -f echo.log || true + test/unit_test.dart + + #- name: ๐Ÿงพ Upload echo logs + # id: upload-echo-logs + # if: always() + # uses: actions/upload-artifact@v4 + # with: + # name: server-logs + # path: echo.log + # compression-level: 9 + # overwrite: true + # retention-days: 1 + + #- name: โœ‹ Stop echo server + # id: stop-echo-server + # timeout-minutes: 1 + # if: always() + # run: | + # kill $(cat echo_pid.txt) || true + # rm -f echo_pid.txt || true + # rm -f echo.log || true - name: ๐Ÿ” Check coverage id: check-coverage From 01f705c5ad441ab9c9aab3b52d17bbc2ea2551ee Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Mon, 18 Nov 2024 19:04:47 +0400 Subject: [PATCH 103/104] Update GitHub Actions workflow: modify pub-cache environment variable and improve caching logic for dependencies --- .github/workflows/tests.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0cb7512..aebd0cd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -113,8 +113,8 @@ jobs: container: image: dart:stable env: - pub-cache-name: pub threshold: 50 + pub-cache: pub PUB_CACHE: /github/home/.pub-cache timeout-minutes: 15 steps: @@ -128,6 +128,8 @@ jobs: lib test analysis_options.yaml + README.md + CHANGELOG.md #- name: ๐Ÿ“‚ Download Echo server # id: download-echo-server @@ -141,8 +143,8 @@ jobs: uses: actions/cache/restore@v4 with: path: | - ${{ env.PUB_CACHE }} - key: ${{ runner.os }}-spinify-${{ env.pub-cache-name }}-${{ hashFiles('pubspec.yaml') }} + /home/runner/.pub-cache + key: ${{ runner.os }}-pub-${{ env.pub-cache }}-${{ hashFiles('pubspec.yaml') }} - name: ๐Ÿ‘ท Install Dependencies id: install-dependencies @@ -154,11 +156,12 @@ jobs: - name: ๐Ÿ“ฅ Save Pub modules id: cache-pub-save + if: steps.cache-pub-restore.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: | - ${{ env.PUB_CACHE }} - key: ${{ runner.os }}-spinify-${{ env.pub-cache-name }}-${{ hashFiles('pubspec.yaml') }} + /home/runner/.pub-cache + key: ${{ steps.cache-pub-restore.outputs.cache-primary-key }} #- name: ๐Ÿ“ข Run Echo server # id: run-echo-server From fd306df1ae9f11c5575b76ce7b5ebe52bf43ca85 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Mon, 18 Nov 2024 20:01:59 +0400 Subject: [PATCH 104/104] Update smoke tests --- CHANGELOG.md | 5 ++ lib/src/spinify.dart | 150 +++++++++++++++++++++++++++---------- test/smoke/smoke_test.dart | 32 +------- 3 files changed, 121 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8a5516..816cc12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.1.0-pre.1 + +- Large scale refactoring +- Test coverage increased to 88% + ## 0.0.4 - Update `SpinifyState$Disconnected` state and extend with `bool get temporary` diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart index 9a7c1ba..6cd17f8 100644 --- a/lib/src/spinify.dart +++ b/lib/src/spinify.dart @@ -114,7 +114,7 @@ final class Spinify implements ISpinify { @safe final StreamController _statesController = - StreamController.broadcast(sync: true); + StreamController.broadcast(sync: false); @override late final SpinifyChannelEvents stream = @@ -422,8 +422,8 @@ final class Spinify implements ISpinify { @protected @nonVirtual void _setUpReconnectTimer() { - _tearDownReconnectTimer(); final lastUrl = _metrics.reconnectUrl; + _tearDownReconnectTimer(); if (lastUrl == null) return; final attempt = _metrics.reconnectAttempts ?? 0; final delay = Backoff.nextDelay( @@ -1012,6 +1012,7 @@ final class Spinify implements ISpinify { _setUpReconnectTimer(); // Retry resubscribe } else { // Disable resubscribe timer on permanent errors. + _metrics.reconnectUrl = null; _tearDownReconnectTimer(); _setState(SpinifyState$Disconnected(temporary: false)); } @@ -1142,8 +1143,13 @@ final class Spinify implements ISpinify { } // Reconnect if [reconnect] is true and we have reconnect URL. - if (reconnect && _metrics.reconnectUrl != null) - _setUpReconnectTimer(); + if (_metrics.reconnectUrl != null) { + if (reconnect) { + _setUpReconnectTimer(); + } else { + _metrics.reconnectUrl = null; + } + } // Unsuscribe from all subscriptions. for (final sub in _clientSubscriptionRegistry.values) { @@ -1205,18 +1211,23 @@ final class Spinify implements ISpinify { reason: 'normal closure', reconnect: false, ); - final subs = _clientSubscriptionRegistry.values.toList(growable: false); - for (final sub in subs) { + + // Close all client subscriptions. + final clientSubs = + _clientSubscriptionRegistry.values.toList(growable: false); + for (final sub in clientSubs) { + sub.close(); _clientSubscriptionRegistry.remove(sub.channel); - sub - ._unsubscribe( - code: const SpinifyDisconnectCode.normalClosure(), - reason: 'normal closure', - sendUnsubscribe: false, - ) - .whenComplete(sub.close) - .ignore(); } + + // Close all server subscriptions. + final serverSubs = + _serverSubscriptionRegistry.values.toList(growable: false); + for (final sub in serverSubs) { + sub.close(); + _serverSubscriptionRegistry.remove(sub.channel); + } + _setState(SpinifyState$Closed()); } on Object {/* ignore */} finally { if (!force) _mutex.unlock(); @@ -2095,7 +2106,7 @@ abstract base class _SpinifySubscriptionBase implements SpinifySubscription { late final SpinifyMetrics$Channel$Mutable _metrics; final StreamController _stateController = - StreamController.broadcast(sync: true); + StreamController.broadcast(sync: false); final StreamController _eventController = StreamController.broadcast(sync: true); @@ -2158,7 +2169,7 @@ abstract base class _SpinifySubscriptionBase implements SpinifySubscription { @mustCallSuper void _setState(SpinifySubscriptionState state) { final previous = _metrics.state; - if (previous == state) return; + if (previous.type == state.type) return; _stateController.add(_metrics.state = state); _client._log( const SpinifyLogLevel.config(), @@ -2176,12 +2187,9 @@ abstract base class _SpinifySubscriptionBase implements SpinifySubscription { @interactive @mustCallSuper void close() { + _setState(SpinifySubscriptionState$Unsubscribed()); _stateController.close().ignore(); _eventController.close().ignore(); - // coverage:ignore-start - assert(state.isUnsubscribed, - 'Subscription "$channel" is not unsubscribed before closing'); - // coverage:ignore-end } @unsafe @@ -2208,14 +2216,17 @@ abstract base class _SpinifySubscriptionBase implements SpinifySubscription { ); } + @unsafe @override @interactive + @Throws([SpinifyHistoryException]) Future history({ int? limit, SpinifyStreamPosition? since, bool? reverse, - }) => - _sendCommand( + }) async { + try { + final reply = await _sendCommand( (id) => SpinifyHistoryRequest( id: id, channel: channel, @@ -2224,45 +2235,95 @@ abstract base class _SpinifySubscriptionBase implements SpinifySubscription { since: since, reverse: reverse, ), - ).then( - (reply) => SpinifyHistory( - publications: List.unmodifiable( - reply.publications.map((pub) => pub.copyWith(channel: channel))), - since: reply.since, + ); + return SpinifyHistory( + publications: List.unmodifiable( + reply.publications.map( + (pub) => + pub.channel != channel ? pub.copyWith(channel: channel) : pub, + ), + ), + since: reply.since, + ); + } on SpinifyHistoryException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyHistoryException( + channel: channel, + message: 'Failed to get history data', + error: error, ), + stackTrace, ); + } + } + @unsafe @override @interactive - Future> presence() => - _sendCommand( + @Throws([SpinifyPresenceException]) + Future> presence() async { + try { + final reply = await _sendCommand( (id) => SpinifyPresenceRequest( id: id, channel: channel, timestamp: DateTime.now(), ), - ).then>((reply) => reply.presence); + ); + return reply.presence; + } on SpinifyPresenceException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyPresenceException( + channel: channel, + message: 'Failed to get presence data', + error: error, + ), + stackTrace, + ); + } + } + @unsafe @override - @interactive - Future presenceStats() => - _sendCommand( + @nonVirtual + @Throws([SpinifyPresenceStatsException]) + Future presenceStats() async { + try { + final reply = await _sendCommand( (id) => SpinifyPresenceStatsRequest( id: id, channel: channel, timestamp: DateTime.now(), ), - ).then( - (reply) => SpinifyPresenceStats( + ); + return SpinifyPresenceStats( + channel: channel, + clients: reply.numClients, + users: reply.numUsers, + ); + } on SpinifyPresenceStatsException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyPresenceStatsException( channel: channel, - clients: reply.numClients, - users: reply.numUsers, + message: 'Failed to get presence stats', + error: error, ), + stackTrace, ); + } + } @override @interactive - Future publish(List data) => _sendCommand( + Future publish(List data) async { + try { + await _sendCommand( (id) => SpinifyPublishRequest( id: id, channel: channel, @@ -2270,6 +2331,19 @@ abstract base class _SpinifySubscriptionBase implements SpinifySubscription { data: data, ), ); + } on SpinifyPresenceStatsException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyPresenceStatsException( + channel: channel, + message: 'Failed to get presence stats', + error: error, + ), + stackTrace, + ); + } + } } final class _SpinifyServerSubscriptionImpl extends _SpinifySubscriptionBase diff --git a/test/smoke/smoke_test.dart b/test/smoke/smoke_test.dart index bf06b5e..bb9d000 100644 --- a/test/smoke/smoke_test.dart +++ b/test/smoke/smoke_test.dart @@ -206,37 +206,13 @@ void main() { notification!; await expectLater( - notification.history, - throwsA( - isA() - .having( - (e) => e.replyCode, - 'replyCode', - equals(108), - ) - .having( - (e) => e.message.trim().toLowerCase(), - 'message', - equals('not available'), - ), - ), + notification.history(), + throwsA(isA()), // Not available ); await expectLater(notification.presence(), completes); await expectLater( - notification.presenceStats, - throwsA( - isA() - .having( - (e) => e.replyCode, - 'replyCode', - equals(108), - ) - .having( - (e) => e.message.trim().toLowerCase(), - 'message', - equals('not available'), - ), - ), + notification.presenceStats(), + throwsA(isA()), // Not available ); await client.close(); expect(client.state, isA());