diff --git a/workmanager_linux/lib/src/background_runner.dart b/workmanager_linux/lib/src/background_runner.dart index 36a26323..b0302bdc 100644 --- a/workmanager_linux/lib/src/background_runner.dart +++ b/workmanager_linux/lib/src/background_runner.dart @@ -4,6 +4,8 @@ import 'dart:io'; +import 'package:workmanager_platform_interface/workmanager_platform_interface.dart'; + import '../execution.dart'; import 'payload_store.dart'; @@ -85,16 +87,43 @@ class BackgroundTaskRunner { ); return false; } + final started = DateTime.now(); + final taskInfo = TaskDebugInfo( + taskName: invocation.taskName, + inputData: inputData, + startTime: started, + ); + WorkmanagerDebug.reportStatus(taskInfo, TaskStatus.started, null); try { - return await WorkmanagerExecution.instance.runTask( + final success = await WorkmanagerExecution.instance.runTask( invocation.taskName, inputData, ); + WorkmanagerDebug.reportStatus( + taskInfo, + success ? TaskStatus.completed : TaskStatus.failed, + TaskResult( + success: success, + duration: DateTime.now().difference(started), + error: success ? null : 'handler returned false', + ), + ); + return success; } on Object catch (error, stackTrace) { stderr.writeln( 'workmanager_linux: background task "${invocation.taskName}" threw: ' '$error\n$stackTrace', ); + WorkmanagerDebug.reportStatus( + taskInfo, + TaskStatus.failed, + TaskResult( + success: false, + duration: DateTime.now().difference(started), + error: error.toString(), + ), + ); + WorkmanagerDebug.reportException(taskInfo, error, stackTrace); return false; } } diff --git a/workmanager_platform_interface/lib/src/workmanager_debug.dart b/workmanager_platform_interface/lib/src/workmanager_debug.dart new file mode 100644 index 00000000..36538146 --- /dev/null +++ b/workmanager_platform_interface/lib/src/workmanager_debug.dart @@ -0,0 +1,160 @@ +// Copyright 2024 The Flutter Workmanager Authors. All rights reserved. +// Use of this source code is governed by a MIT-style license that can be +// found in the LICENSE file. + +import 'package:workmanager_platform_interface/workmanager_platform_interface.dart' + show TaskStatus; + +/// Information about a task for debugging purposes. +/// +/// Mirrors `TaskDebugInfo` on the native (Android/iOS) debug API. +class TaskDebugInfo { + const TaskDebugInfo({ + required this.taskName, + this.uniqueName, + this.inputData, + required this.startTime, + }); + + /// The task name the work was registered with. + final String taskName; + + /// The unique name the work was registered with, when known. + final String? uniqueName; + + /// The input data the task was registered with, when known. + final Map? inputData; + + /// When the task started executing. + final DateTime startTime; +} + +/// Result information for a completed task. +/// +/// Mirrors `TaskResult` on the native (Android/iOS) debug API. +class TaskResult { + const TaskResult({ + required this.success, + required this.duration, + this.error, + }); + + /// Whether the task handler reported success. + final bool success; + + /// How long the task ran. + final Duration duration; + + /// The failure message, when the task failed. + final String? error; +} + +/// Abstract debug handler for Workmanager events. +/// +/// Mirror of the native `WorkmanagerDebug` API (Android/iOS): set a handler +/// with [WorkmanagerDebug.setCurrent] and override the callbacks you care +/// about. The default handler does nothing. +/// +/// ```dart +/// WorkmanagerDebug.setCurrent(LoggingDebugHandler()); +/// ``` +/// +/// Available on every platform: Android/iOS implementations emit natively +/// (same handler contract, minus platform context); web, linux and windows +/// emit from their Dart execution paths. +abstract class WorkmanagerDebug { + const WorkmanagerDebug(); + + static WorkmanagerDebug _current = _NoopDebugHandler(); + + /// The currently registered debug handler. + static WorkmanagerDebug get current => _current; + + /// Sets the global debug handler. + static void setCurrent(WorkmanagerDebug handler) { + _current = handler; + } + + /// Restores the default no-op handler. + static void reset() { + _current = _NoopDebugHandler(); + } + + /// Called by platform implementations when a task status changes. + static void reportStatus( + TaskDebugInfo taskInfo, + TaskStatus status, + TaskResult? result, + ) { + _current.onTaskStatusUpdate(taskInfo, status, result); + } + + /// Called by platform implementations when an exception is encountered + /// during task processing. + static void reportException( + TaskDebugInfo? taskInfo, + Object exception, + StackTrace? stackTrace, + ) { + _current.onExceptionEncountered(taskInfo, exception, stackTrace); + } + + /// Called when a task status changes. Default: do nothing. + void onTaskStatusUpdate( + TaskDebugInfo taskInfo, + TaskStatus status, + TaskResult? result, + ) {} + + /// Called when an exception occurs during task processing. Default: do + /// nothing. + void onExceptionEncountered( + TaskDebugInfo? taskInfo, + Object exception, + StackTrace? stackTrace, + ) {} +} + +class _NoopDebugHandler extends WorkmanagerDebug {} + +/// Prints debug information to the console. +class LoggingDebugHandler extends WorkmanagerDebug { + const LoggingDebugHandler(); + + @override + void onTaskStatusUpdate( + TaskDebugInfo taskInfo, + TaskStatus status, + TaskResult? result, + ) { + final buffer = StringBuffer() + ..write('[workmanager] ${taskInfo.taskName} -> $status'); + if (taskInfo.uniqueName != null) { + buffer.write(' (${taskInfo.uniqueName})'); + } + if (result != null) { + buffer.write( + ' — ${result.success ? 'OK' : 'FAILED'} in ' + '${result.duration.inMilliseconds}ms', + ); + if (result.error != null) { + buffer.write(': ${result.error}'); + } + } + // ignore: avoid_print + print(buffer.toString()); + } + + @override + void onExceptionEncountered( + TaskDebugInfo? taskInfo, + Object exception, + StackTrace? stackTrace, + ) { + // ignore: avoid_print + print( + '[workmanager] exception in ${taskInfo?.taskName ?? 'unknown task'}: ' + '$exception', + ); + } +} diff --git a/workmanager_platform_interface/lib/workmanager_platform_interface.dart b/workmanager_platform_interface/lib/workmanager_platform_interface.dart index be3deadb..386489b0 100644 --- a/workmanager_platform_interface/lib/workmanager_platform_interface.dart +++ b/workmanager_platform_interface/lib/workmanager_platform_interface.dart @@ -3,4 +3,5 @@ library workmanager_platform_interface; export 'src/workmanager_platform_interface.dart'; export 'src/work_info.dart'; export 'src/pigeon/workmanager_api.g.dart'; +export 'src/workmanager_debug.dart'; export 'src/stop_reason.dart'; diff --git a/workmanager_platform_interface/test/workmanager_debug_test.dart b/workmanager_platform_interface/test/workmanager_debug_test.dart new file mode 100644 index 00000000..4ed431df --- /dev/null +++ b/workmanager_platform_interface/test/workmanager_debug_test.dart @@ -0,0 +1,112 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:workmanager_platform_interface/workmanager_platform_interface.dart'; + +void main() { + tearDown(WorkmanagerDebug.reset); + + test('default handler is a no-op', () { + expect(WorkmanagerDebug.current, isA()); + // Must not throw. + WorkmanagerDebug.reportStatus( + _taskInfo(), + TaskStatus.started, + null, + ); + WorkmanagerDebug.reportException(_taskInfo(), StateError('boom'), null); + }); + + test('setCurrent routes status updates to the handler', () { + final events = <(TaskDebugInfo, TaskStatus, TaskResult?)>[]; + WorkmanagerDebug.setCurrent(_RecordingHandler(events)); + + final taskInfo = _taskInfo(); + WorkmanagerDebug.reportStatus(taskInfo, TaskStatus.started, null); + WorkmanagerDebug.reportStatus( + taskInfo, + TaskStatus.completed, + TaskResult(success: true, duration: const Duration(milliseconds: 42)), + ); + + expect(events, hasLength(2)); + expect(events[0].$1.taskName, 'my-task'); + expect(events[0].$2, TaskStatus.started); + expect(events[1].$2, TaskStatus.completed); + expect(events[1].$3?.success, isTrue); + expect(events[1].$3?.duration, const Duration(milliseconds: 42)); + }); + + test('setCurrent routes exceptions to the handler', () { + final exceptions = []; + WorkmanagerDebug.setCurrent( + _RecordingHandler(<(TaskDebugInfo, TaskStatus, TaskResult?)>[], + exceptions: exceptions), + ); + + final error = StateError('boom'); + WorkmanagerDebug.reportException(_taskInfo(), error, null); + + expect(exceptions, [error]); + }); + + test('reset restores the no-op handler', () { + WorkmanagerDebug.setCurrent( + _RecordingHandler(<(TaskDebugInfo, TaskStatus, TaskResult?)>[]), + ); + WorkmanagerDebug.reset(); + expect(WorkmanagerDebug.current, isA()); + // Must not throw with a cleared handler. + WorkmanagerDebug.reportStatus(_taskInfo(), TaskStatus.failed, null); + }); + + test('LoggingDebugHandler does not throw on either callback', () { + final handler = LoggingDebugHandler(); + handler.onTaskStatusUpdate( + _taskInfo(), + TaskStatus.failed, + TaskResult( + success: false, + duration: const Duration(milliseconds: 500), + error: 'nope', + ), + ); + handler.onExceptionEncountered( + _taskInfo(), + StateError('boom'), + null, + ); + expect(handler, isA()); + }); +} + +TaskDebugInfo _taskInfo() => TaskDebugInfo( + taskName: 'my-task', + uniqueName: 'my-unique', + inputData: {'key': 'value'}, + startTime: DateTime(2026, 1, 1), + ); + +class _RecordingHandler extends WorkmanagerDebug { + _RecordingHandler(this.events, {List? exceptions}) + : exceptions = exceptions ?? []; + + final List<(TaskDebugInfo, TaskStatus, TaskResult?)> events; + final List exceptions; + + @override + void onTaskStatusUpdate( + TaskDebugInfo taskInfo, + TaskStatus status, + TaskResult? result, + ) { + events.add((taskInfo, status, result)); + } + + @override + void onExceptionEncountered( + TaskDebugInfo? taskInfo, + Object exception, + StackTrace? stackTrace, + ) { + exceptions.add(exception); + } +} diff --git a/workmanager_web/lib/workmanager_web.dart b/workmanager_web/lib/workmanager_web.dart index 18fc3491..9b56fe74 100644 --- a/workmanager_web/lib/workmanager_web.dart +++ b/workmanager_web/lib/workmanager_web.dart @@ -601,6 +601,15 @@ class WorkmanagerWeb extends WorkmanagerPlatform { _tasks.remove(uniqueName); } final started = DateTime.now(); + final taskInfo = TaskDebugInfo( + taskName: taskName, + uniqueName: uniqueName, + inputData: rawInputData is Map + ? Map.from(rawInputData) + : null, + startTime: started, + ); + WorkmanagerDebug.reportStatus(taskInfo, TaskStatus.started, null); Object? result; String executedIn; String? errorMessage; @@ -629,6 +638,14 @@ class WorkmanagerWeb extends WorkmanagerPlatform { } if (errorMessage == null) { final elapsed = DateTime.now().difference(started).inMilliseconds; + WorkmanagerDebug.reportStatus( + taskInfo, + TaskStatus.completed, + TaskResult( + success: true, + duration: Duration(milliseconds: elapsed), + ), + ); _emit( 'executed', 'Task "$taskName" executed in ${elapsed}ms via $executedIn.', @@ -637,6 +654,22 @@ class WorkmanagerWeb extends WorkmanagerPlatform { taskName: taskName, result: result, ); + } else { + final elapsed = DateTime.now().difference(started).inMilliseconds; + WorkmanagerDebug.reportStatus( + taskInfo, + TaskStatus.failed, + TaskResult( + success: false, + duration: Duration(milliseconds: elapsed), + error: errorMessage, + ), + ); + WorkmanagerDebug.reportException( + taskInfo, + Exception(errorMessage), + null, + ); } await _notifyServiceWorkerExecuted( uniqueName,