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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,7 @@ jobs:
fail-fast: false
matrix:
dart: [3.6, 3.12]
package: [cli_tools, config, serverpod_logging]
exclude:
- package: serverpod_logging
include:
- package: serverpod_logging
dart: 3.10.3
package: [cli_tools, config, isolated_object, serverpod_logging]
runs-on: ubuntu-latest
defaults:
run:
Expand All @@ -40,7 +35,7 @@ jobs:
fail-fast: false
matrix:
dart: [3.6, 3.12]
package: [cli_tools, config, serverpod_logging]
package: [cli_tools, config, isolated_object, serverpod_logging]
platform: [ubuntu-latest]
include:
- package: cli_tools
Expand Down
16 changes: 16 additions & 0 deletions .github/workflows/publish-isolated_object.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: Publish isolated_object package

on:
push:
tags:
# Matches tags like isolated_object-v1.2.3 and isolated_object-v1.2.3-pre.1
- 'isolated_object-v[0-9]+.[0-9]+.[0-9]+'
- 'isolated_object-v[0-9]+.[0-9]+.[0-9]+-*'

jobs:
publish:
permissions:
id-token: write
uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1
with:
working-directory: packages/isolated_object
3 changes: 3 additions & 0 deletions packages/isolated_object/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 0.1.0

- Initial version.
28 changes: 28 additions & 0 deletions packages/isolated_object/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
BSD 3-Clause License

Copyright (c) 2024, Serverpod

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.

3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
24 changes: 24 additions & 0 deletions packages/isolated_object/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# isolated_object

Wraps an object in a dedicated isolate and forwards method calls to it, so
timer- or event-loop-driven work (e.g. progress spinners) keeps animating even
when the calling isolate's event loop is blocked by heavy synchronous work.

Dependency-free.

## Usage

```dart
import 'package:isolated_object/isolated_object.dart';

final counter = IsolatedObject<Counter>(() => Counter());

await counter.evaluate((c) => c.increment());
final value = await counter.evaluate((c) => c.value);

await counter.close();
```

The factory runs inside the child isolate; each `evaluate` forwards a closure
to that isolate and returns the result. `close()` shuts the isolate down,
failing any in-flight calls rather than letting them hang.
14 changes: 14 additions & 0 deletions packages/isolated_object/analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
include: package:serverpod_lints/cli.yaml

analyzer:
language:
strict-raw-types: false
errors:
inference_failure_on_instance_creation: ignore
inference_failure_on_function_invocation: ignore
inference_failure_on_untyped_parameter: ignore
prefer_final_parameters: ignore

linter:
rules:
prefer_relative_imports: true
4 changes: 4 additions & 0 deletions packages/isolated_object/lib/isolated_object.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/// Wraps an object in a dedicated isolate, forwarding method calls to it.
library;

export 'src/isolated_object.dart';
156 changes: 156 additions & 0 deletions packages/isolated_object/lib/src/isolated_object.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import 'dart:async';
import 'dart:isolate';

/// Synchronous or asynchronous producer of a [T]. Used by [IsolatedObject]
/// to create the wrapped object inside the child isolate.
typedef Factory<T> = FutureOr<T> Function();

typedef _Action<T> = ({int id, dynamic Function(T) function});
typedef _Response = ({int id, dynamic result});
typedef _Inflight = Map<int, Completer>;
typedef _Setup = (SendPort, ReceivePort, _Inflight);

/// Wraps an object of type [T] in a dedicated isolate, allowing method calls
/// to be forwarded via [evaluate].
class IsolatedObject<T> {
final Future<_Setup> _connected;

/// Spawns a dedicated isolate, constructs a [T] there via [create], and
/// returns immediately. Subsequent [evaluate] calls forward work to that
/// isolate.
///
/// If [keepIsolateAlive] is true (the default), the parent isolate stays
/// alive as long as this object is open, matching the usual service-style
/// lifecycle. Set to false for fire-and-forget isolates that shouldn't
/// block process shutdown on their own.
IsolatedObject(Factory<T> create, {bool keepIsolateAlive = true})
: _connected = _connect(create, keepIsolateAlive);

static Future<_Setup> _connect<T>(
Factory<T> create,
bool keepIsolateAlive,
) async {
final parentPort = RawReceivePort()..keepIsolateAlive = keepIsolateAlive;
final setupDone = Completer<_Setup>();

parentPort.handler = (dynamic message) async {
if (message case final RemoteError e) {
parentPort.close();
setupDone.completeError(e, e.stackTrace);
} else {
final toChild = message as SendPort;
final fromChild = ReceivePort.fromRawReceivePort(parentPort);
final inflight = _Inflight();
setupDone.complete((toChild, fromChild, inflight));
}
};

try {
await _spawn(create, parentPort.sendPort);
} catch (_) {
parentPort.close();
rethrow;
}

final result = await setupDone.future;
final (toChild, fromChild, inflight) = result;

fromChild.listen(
(dynamic message) async {
if (message case final _Response response) {
final completer = inflight.remove(response.id);
assert(completer != null, 'PROTOCOL BUG. No such ID ${response.id}');
if (completer == null) return;
switch (response.result) {
case final RemoteError e:
completer.completeError(e, e.stackTrace);
default:
completer.complete(await response.result);
}
}
},
onDone: () {
// ReceivePort closed. Fail any pending requests to avoid hangs.
for (final c in inflight.values) {
if (!c.isCompleted) {
c.completeError(StateError('IsolatedObject<$T> channel closed'));
}
}
inflight.clear();
},
);

return result;
}

static Future<Isolate> _spawn<T>(Factory<T> create, SendPort toParent) {
return Isolate.spawn((SendPort toParent) async {
final childPort = ReceivePort();
final T isolatedObject;
try {
isolatedObject = await create();
toParent.send(childPort.sendPort);
} catch (e, st) {
toParent.send(RemoteError('$e', '$st'));
return;
}

await for (final message in childPort) {
if (message == null) {
childPort.close();
break;
} else if (message case final _Action<T> action) {
try {
final result = await action.function(isolatedObject);
toParent.send((id: action.id, result: result));
} catch (e, st) {
toParent.send((id: action.id, result: RemoteError('$e', '$st')));
}
}
}
}, toParent);
}

int _nextId = 0;
bool _isClosed = false;

/// Whether [close] has been called. Once true, further [evaluate] calls
/// throw a [StateError].
bool get isClosed => _isClosed;

/// Evaluates [function] on the isolated object and returns the result.
Future<U> evaluate<U>(FutureOr<U> Function(T) function) async {
if (_isClosed) {
throw StateError('IsolatedObject<$T> is closed');
}

final (toChild, _, inflight) = await _connected;

final id = _nextId++;
final completer = Completer<U>();
inflight[id] = completer;

toChild.send((id: id, function: function));
return completer.future;
}

/// Shuts down the isolate. Idempotent. Any outstanding [evaluate] calls are
/// failed with a [StateError] rather than left to hang.
Future<void> close() async {
if (_isClosed) return;
_isClosed = true;

final (toChild, fromChild, inflight) = await _connected;

// Fail any outstanding requests.
for (final c in inflight.values) {
if (!c.isCompleted) {
c.completeError(StateError('$runtimeType is closed'));
}
}
inflight.clear();

toChild.send(null);
fromChild.close();
}
}
18 changes: 18 additions & 0 deletions packages/isolated_object/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: isolated_object
description: >-
Wraps an object in a dedicated isolate and forwards method calls to it, so
timer- or event-loop-driven work (e.g. progress spinners) keeps running even
when the calling isolate's event loop is blocked. Dependency-free.
version: 0.1.0
repository: https://github.com/serverpod/serverpod
homepage: https://serverpod.dev
issue_tracker: https://github.com/serverpod/serverpod/issues

environment:
sdk: ^3.6.0

resolution: workspace

dev_dependencies:
serverpod_lints: '>=2.7.0'
test: ^1.25.6
55 changes: 55 additions & 0 deletions packages/isolated_object/test/close_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Ported from relic's isolated_object_close_test.dart. The pending-operation
// message assertion is relaxed to `contains('closed')` so it holds for the
// canonical (serverpod) teardown, which fails in-flight calls with
// "<runtimeType> is closed" rather than relic's "channel closed".
import 'dart:async';

import 'package:isolated_object/isolated_object.dart';
import 'package:test/test.dart';

void main() {
test(
'Given an IsolatedObject, '
'when close is called multiple times, '
'then it handles it gracefully', () async {
final isolated = IsolatedObject<_Counter>(() => _Counter(0));

await isolated.close();
await isolated.close(); // Second close should not throw.

expect(isolated.isClosed, isTrue);
});

test(
'Given an IsolatedObject with pending operations, '
'when it is closed, '
'then pending operations fail with a closed error', () async {
final isolated = IsolatedObject<_Counter>(() => _Counter(0));

final pendingOperation = isolated.evaluate((counter) async {
await Future<void>.delayed(const Duration(seconds: 10));
return counter.value;
});

// Give the operation time to register as inflight.
await Future<void>.delayed(const Duration(milliseconds: 50));

await isolated.close();

await expectLater(
pendingOperation,
throwsA(
isA<StateError>().having(
(e) => e.message,
'message',
contains('closed'),
),
),
);
});
}

class _Counter {
int value;
_Counter(this.value);
}
Loading
Loading