diff --git a/finlog_app/app/lib/main.dart b/finlog_app/app/lib/main.dart index 9ee6bc6..91dd702 100644 --- a/finlog_app/app/lib/main.dart +++ b/finlog_app/app/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:fluttery/fluttery.dart'; import 'package:fluttery/logger.dart'; +import 'package:fluttery/worker.dart'; Future main() async { // Ensures that the Flutter engine and widget binding @@ -22,24 +23,11 @@ class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { + App.service().info("test"); + return MaterialApp( title: 'Flutter Demo', theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), ), home: const MyHomePage(title: 'Flutter Demo Home Page'), @@ -50,15 +38,6 @@ class MyApp extends StatelessWidget { class MyHomePage extends StatefulWidget { const MyHomePage({super.key, required this.title}); - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - final String title; @override @@ -69,51 +48,27 @@ class _MyHomePageState extends State { int _counter = 0; void _incrementCounter() { + App.service().spawn("worker-$_counter", () async { + App.service().info("test"); + + await Future.delayed(const Duration(seconds: 10)); + + App.service().info("end worker"); + }); setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. _counter++; }); } @override Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. return Scaffold( appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. title: Text(widget.title), ), body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('You have pushed the button this many times:'), @@ -121,6 +76,14 @@ class _MyHomePageState extends State { '$_counter', style: Theme.of(context).textTheme.headlineMedium, ), + TextButton( + onPressed: () { + print( + "active workers: ${App.service().getActiveWorkers().length}", + ); + }, + child: Text("Print workers"), + ), ], ), ), diff --git a/finlog_app/fluttery/lib/fluttery.dart b/finlog_app/fluttery/lib/fluttery.dart index cf38d0e..59a9302 100644 --- a/finlog_app/fluttery/lib/fluttery.dart +++ b/finlog_app/fluttery/lib/fluttery.dart @@ -6,6 +6,8 @@ import 'package:fluttery/secure_storage.dart'; import 'package:fluttery/src/logger/logger_impl.dart'; import 'package:fluttery/src/preferences/preferences_impl.dart'; import 'package:fluttery/src/storage/secure/secure_storage_impl.dart'; +import 'package:fluttery/src/system/worker/worker_impl.dart'; +import 'package:fluttery/worker.dart'; import 'package:kiwi/kiwi.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -31,12 +33,12 @@ class App { /// Registers the default services required by the application. static Future registerDefaultServices() async { - registerService(() => LoggerImpl()); - final prefs = await SharedPreferences.getInstance(); - registerService(() => PreferencesImpl(instance: prefs)); + registerService(() => LoggerImpl()); + registerService(() => PreferencesImpl(instance: prefs)); registerService(() => SecureStorageImpl()); + registerService(() => WorkerImpl()); } } diff --git a/finlog_app/fluttery/lib/src/system/worker/worker_impl.dart b/finlog_app/fluttery/lib/src/system/worker/worker_impl.dart new file mode 100644 index 0000000..8ac8ab2 --- /dev/null +++ b/finlog_app/fluttery/lib/src/system/worker/worker_impl.dart @@ -0,0 +1,142 @@ +import 'dart:async'; +import 'dart:isolate'; + +import 'package:flutter/services.dart' + show ServicesBinding, RootIsolateToken, BackgroundIsolateBinaryMessenger; + +import 'package:fluttery/fluttery.dart'; +import 'package:fluttery/logger.dart'; +import 'package:fluttery/worker.dart'; + +class WorkerImpl implements Worker { + WorkerImpl({ + this.defaultTimeout, + this.maxHistory = 100, + RootIsolateToken? rootToken, // optional for tests + }) : _rootToken = + rootToken ?? ServicesBinding.rootIsolateToken; // <— static getter + + final Duration? defaultTimeout; + final int maxHistory; + + // Captured from the root isolate (may be null in some test envs) + final RootIsolateToken? _rootToken; + + int _seq = 0; + final Map _active = {}; + final List _history = []; + + @override + Future spawn( + String debugName, + FutureOr Function() task, { + void Function()? preTask, + Duration? timeout, + }) { + final id = (++_seq).toString().padLeft(6, '0'); + final started = DateTime.now(); + + _active[id] = WorkerInfo( + id: id, + name: debugName, + startedAt: started, + status: WorkerStatus.running, + ); + + Future inner() async { + final token = _rootToken; // captured into closure + + return Isolate.run(() async { + // Initialize platform channels for this background isolate. + if (token != null) { + BackgroundIsolateBinaryMessenger.ensureInitialized(token); + } + + // Now it's safe to touch plugins (e.g., SharedPreferences). + await App.registerDefaultServices(); + + preTask?.call(); + + return await Future.sync(task); + }, debugName: debugName); + } + + final effectiveTimeout = timeout ?? defaultTimeout; + final fut = effectiveTimeout == null + ? inner() + : inner().timeout(effectiveTimeout); + + fut + .then((_) { + _finish(id, status: WorkerStatus.completed); + }) + .catchError((e, st) { + _finish( + id, + status: e is TimeoutException + ? WorkerStatus.timedOut + : WorkerStatus.failed, + error: e, + stack: st, + ); + // Best-effort logging + try { + App.service().error( + 'Worker job "$debugName" ($id) failed: $e', + st, + ); + } catch (_) {} + }); + + return fut; + } + + void _finish( + String id, { + required WorkerStatus status, + Object? error, + StackTrace? stack, + }) { + final prev = _active.remove(id); + final endedAt = DateTime.now(); + + final info = WorkerInfo( + id: prev?.id ?? id, + name: prev?.name ?? 'unknown', + startedAt: prev?.startedAt ?? endedAt, + status: status, + endedAt: endedAt, + error: error, + stackTrace: stack, + ); + + _history.insert(0, info); + if (_history.length > maxHistory) { + _history.removeRange(maxHistory, _history.length); + } + } + + @override + List getActiveWorkers() => + _active.values.toList() + ..sort((a, b) => a.startedAt.compareTo(b.startedAt)); + + @override + List getAllWorkers() => [...getActiveWorkers(), ..._history]; + + @override + WorkerInfo? getWorker(String id) { + final active = _active[id]; + if (active != null) return active; + for (final w in _history) { + if (w.id == id) return w; + } + return null; + } + + @override + void purge({Duration maxAge = const Duration(minutes: 30)}) { + final cutoff = DateTime.now().subtract(maxAge); + _history.removeWhere((w) => (w.endedAt ?? w.startedAt).isBefore(cutoff)); + } +} diff --git a/finlog_app/fluttery/lib/worker.dart b/finlog_app/fluttery/lib/worker.dart new file mode 100644 index 0000000..afa2fa5 --- /dev/null +++ b/finlog_app/fluttery/lib/worker.dart @@ -0,0 +1,47 @@ +import 'dart:async'; +import 'package:fluttery/fluttery.dart'; + +abstract class Worker extends Service { + Future spawn( + String debugName, + FutureOr Function() task, { + void Function()? preTask, + Duration? timeout, // per-job override + }); + + /// Currently running jobs. + List getActiveWorkers(); + + /// All known jobs (active + completed + failed), up to a capped history. + List getAllWorkers(); + + /// Optional: get a single worker by id. + WorkerInfo? getWorker(String id); + + /// Remove completed/failed jobs older than [maxAge] from history. + void purge({Duration maxAge = const Duration(minutes: 30)}); +} + +enum WorkerStatus { running, completed, failed, timedOut } + +class WorkerInfo { + WorkerInfo({ + required this.id, + required this.name, + required this.startedAt, + required this.status, + this.endedAt, + this.error, + this.stackTrace, + }); + + final String id; + final String name; + final DateTime startedAt; + final WorkerStatus status; + final DateTime? endedAt; + final Object? error; + final StackTrace? stackTrace; + + Duration get duration => ((endedAt ?? DateTime.now()).difference(startedAt)); +} diff --git a/finlog_app/fluttery/test/system/worker/worker_impl_test.dart b/finlog_app/fluttery/test/system/worker/worker_impl_test.dart new file mode 100644 index 0000000..3b9a5ca --- /dev/null +++ b/finlog_app/fluttery/test/system/worker/worker_impl_test.dart @@ -0,0 +1,158 @@ +// test/system/worker/worker_impl_test.dart +import 'dart:async'; +import 'dart:isolate'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttery/fluttery.dart'; +import 'package:fluttery/preferences.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:fluttery/src/system/worker/worker_impl.dart'; +import 'package:fluttery/worker.dart'; + +Future pumpMicro([int times = 10]) => pumpEventQueue(times: times); + +Future waitFor( + bool Function() predicate, { + Duration timeout = const Duration(seconds: 2), + Duration step = const Duration(milliseconds: 20), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + if (predicate()) return; + await Future.delayed(step); + } + fail('Condition not met within $timeout'); +} + +void main() { + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + expect(ServicesBinding.rootIsolateToken, isNotNull); + }); + + group('worker', () { + test( + 'spawn returns value; preTask runs; active->history tracking', + () async { + final worker = WorkerImpl(); + App.service().setBool("test", false); + + var preCalled = false; + + final fut = worker.spawn( + 'ok', + () async { + await Future.delayed(const Duration(milliseconds: 20)); + return 7; + }, + // Ensure the worker isolate has the prefs mock (even if not used). + preTask: () { + SharedPreferences.setMockInitialValues({}); + preCalled = true; + }, + ); + + // Shortly after spawn there should be one active job. + await Future.delayed(const Duration(milliseconds: 10)); + expect(worker.getActiveWorkers().length, 1); + + final res = await fut; + expect(res, 7); + expect(preCalled, isTrue); + + await waitFor(() => worker.getActiveWorkers().isEmpty); + await waitFor(() => worker.getAllWorkers().isNotEmpty); + final all = worker.getAllWorkers(); + expect(all.first.status, WorkerStatus.completed); + expect(all.first.name, 'ok'); + }, + // If you still see VM callback warnings here, consider making + // Preferences lazy in your app code to avoid plugin calls on registration. + // skip: true, + ); + + test('timeout marks job as timedOut and throws TimeoutException', () async { + final worker = WorkerImpl( + defaultTimeout: const Duration(milliseconds: 50), + ); + + await expectLater( + worker.spawn( + 'timeout', + // Long task so the wrapper .timeout triggers + () async => Future.delayed(const Duration(milliseconds: 220)), + // Make sure prefs mock is available in the worker isolate even if + // App.registerDefaultServices touches SharedPreferences. + preTask: () => + SharedPreferences.setMockInitialValues({}), + ), + throwsA(isA()), + ); + + // Wait until the worker updates history in its catchError path + await waitFor(() => worker.getAllWorkers().isNotEmpty); + final all = worker.getAllWorkers(); + expect(all.first.status, WorkerStatus.timedOut); + expect(all.first.name, 'timeout'); + }); + + test('failure marks job as failed and surfaces RemoteError', () async { + final worker = WorkerImpl(); + + await expectLater( + worker.spawn( + 'fail', + () async { + await Future.delayed(const Duration(milliseconds: 10)); + throw StateError('boom'); + }, + // Ensure plugin mocks exist if defaults touch plugins + preTask: () => + SharedPreferences.setMockInitialValues({}), + ), + // Isolate.run returns a RemoteError to the caller isolate + throwsA(isA()), + ); + + await waitFor(() => worker.getAllWorkers().isNotEmpty); + final all = worker.getAllWorkers(); + expect(all.first.status, WorkerStatus.failed); + expect(all.first.name, 'fail'); + }); + + test( + 'getWorker while running, then after completion; purge removes old', + () async { + final worker = WorkerImpl(); + + final fut = worker.spawn( + 'long', + () async => Future.delayed(const Duration(milliseconds: 160)), + preTask: () => + SharedPreferences.setMockInitialValues({}), + ); + + await Future.delayed(const Duration(milliseconds: 25)); + final active = worker.getActiveWorkers(); + expect(active.length, 1); + final id = active.first.id; + expect(worker.getWorker(id)?.status, WorkerStatus.running); + + await fut; + await waitFor( + () => worker.getWorker(id)?.status == WorkerStatus.completed, + ); + + expect(worker.getAllWorkers().length, 1); + + worker.purge(maxAge: Duration.zero); + await pumpMicro(); + expect(worker.getAllWorkers(), isEmpty); + }, + // skip: true, + ); + }); +}