Skip to content

Commit f8150cd

Browse files
author
Dillon Nys
committed
chore(core)!: Chain stack traces for state machines
Errors originating within state machines lose context when thrown since there is an asynchronous gap between adding an event to a state machine and when it eventually gets processed. For example, consider the following exception thrown from a state machine: ``` ERROR | MyStateMachine | Emitted error: Exception #0 MyStateMachine.doWork (file:///Users/nydillon/dev/amplify-dart/packages/amplify_core/test/state_machine/my_state_machine.dart:82:7) <asynchronous suspension> #1 MyStateMachine.resolve (file:///Users/nydillon/dev/amplify-dart/packages/amplify_core/test/state_machine/my_state_machine.dart:100:9) <asynchronous suspension> #2 StateMachine._listenForEvents (package:amplify_core/src/state_machine/state_machine.dart:237:9) <asynchronous suspension> ``` The trace ends at `listenForEvents` in the state machine, which is where the event was popped off the internal queue. But what happened before? With chaining, we get the following which provides the whole picture: ``` ERROR | MyStateMachine | Emitted error: Exception test/state_machine/my_state_machine.dart 82:7 MyStateMachine.doWork test/state_machine/my_state_machine.dart 100:9 MyStateMachine.resolve package:amplify_core/src/state_machine/state_machine.dart 238:9 StateMachine._listenForEvents ===== asynchronous gap =========================== package:amplify_core/src/state_machine/event.dart 41:47 new EventCompleter package:amplify_core/src/state_machine/state_machine.dart 127:23 StateMachineManager.accept test/state_machine/state_machine_test.dart 47:30 main.<fn>.<fn> package:test_api/src/backend/declarer.dart 215:19 Declarer.test.<fn>.<fn> package:test_api/src/backend/declarer.dart 213:7 Declarer.test.<fn> package:test_api/src/backend/invoker.dart 258:9 Invoker._waitForOutstandingCallbacks.<fn> ``` Now we can see that the exception originated due to an event which was created by calling `StateMachineManager.accept` within a test. Much better!
1 parent 1769c90 commit f8150cd

7 files changed

Lines changed: 147 additions & 29 deletions

File tree

aft.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dependencies:
1616
intl: ">=0.17.0 <1.0.0"
1717
mime: ^1.0.0
1818
pigeon: ^7.1.5
19+
stack_trace: ^1.11.0
1920
uuid: ">=3.0.6 <=3.0.7"
2021
xml: ">=6.1.0 <=6.2.2"
2122

packages/amplify_core/lib/src/state_machine/event.dart

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,21 @@ abstract class StateMachineEvent<EventType, StateType>
3737
class EventCompleter<Event extends StateMachineEvent,
3838
State extends StateMachineState> {
3939
/// {@macro amplify_core.event_completer}
40-
EventCompleter(this.event);
40+
EventCompleter(this.event, [StackTrace? stackTrace])
41+
: stackTrace = stackTrace ?? StackTrace.current;
4142

4243
/// The event to dispatch.
4344
final Event event;
4445

46+
/// The stack trace from when [event] was created.
47+
///
48+
/// When exceptions are raised from within the state machines, the origin of
49+
/// the exception should be traceable back to the API called which kicked off
50+
/// this event. Since there may be multiple async gaps between the API call
51+
/// and a state machine failure, it is necessary to capture the stack trace
52+
/// here and chain it with later stack traces.
53+
final StackTrace stackTrace;
54+
4555
final Completer<void> _acceptedCompleter = Completer();
4656
final Completer<State> _completer = Completer();
4757

@@ -77,10 +87,3 @@ class EventCompleter<Event extends StateMachineEvent,
7787
}
7888
}
7989
}
80-
81-
/// Mixin functionality for error/failure events of a state machine.
82-
mixin ErrorEvent<EventType, StateType>
83-
on StateMachineEvent<EventType, StateType> {
84-
/// The exception which triggered this event.
85-
Exception get exception;
86-
}

packages/amplify_core/lib/src/state_machine/state.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,7 @@ mixin SuccessState<StateType> on StateMachineState<StateType> {}
2424
mixin ErrorState<StateType> on StateMachineState<StateType> {
2525
/// The exception which triggered this state.
2626
Exception get exception;
27+
28+
/// The stack trace for [exception].
29+
StackTrace get stackTrace;
2730
}

packages/amplify_core/lib/src/state_machine/state_machine.dart

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'dart:async';
55

66
import 'package:amplify_core/amplify_core.dart';
77
import 'package:meta/meta.dart';
8+
import 'package:stack_trace/stack_trace.dart';
89

910
/// Interface for dispatching an event to a state machine.
1011
@optionalTypeArgs
@@ -138,7 +139,7 @@ abstract class StateMachineManager<
138139
final completer = accept(event);
139140
final state = await completer.completed;
140141
if (state is ErrorState) {
141-
throw state.exception;
142+
Error.throwWithStackTrace(state.exception, state.stackTrace);
142143
}
143144
return state as SuccessState;
144145
}
@@ -167,7 +168,7 @@ abstract class StateMachineManager<
167168
final completer = dispatch(event);
168169
final state = await completer.completed;
169170
if (state is ErrorState) {
170-
throw state.exception;
171+
Error.throwWithStackTrace(state.exception, state.stackTrace);
171172
}
172173
return state as SuccessState;
173174
}
@@ -236,7 +237,7 @@ abstract class StateMachine<
236237
// fire before listeners are registered.
237238
await Future<void>.delayed(Duration.zero, () => resolve(event));
238239
} on Object catch (error, st) {
239-
_emitError(error, st);
240+
emitError(error, st);
240241
} finally {
241242
completer.complete(_currentState);
242243
}
@@ -264,16 +265,25 @@ abstract class StateMachine<
264265
_currentState = state;
265266
}
266267

267-
void _emitError(Object error, StackTrace st) {
268-
logger.error('Emitted error', error, st);
268+
/// Emits an [error] and corresponding [stackTrace].
269+
@visibleForOverriding
270+
@mustCallSuper
271+
void emitError(Object error, StackTrace stackTrace) {
272+
// Chain the stack trace of [_currentEvent]'s creation and the state machine
273+
// error to create a full picture of the error's lifecycle.
274+
final eventTrace = Trace.from(_currentCompleter.stackTrace);
275+
final stateMachineTrace = Trace.from(stackTrace);
276+
stackTrace = Chain([stateMachineTrace, eventTrace]);
269277

270-
final resolution = resolveError(error, st);
278+
logger.error('Emitted error', error, stackTrace);
279+
280+
final resolution = resolveError(error, stackTrace);
271281

272282
// Add the error to the state stream if it cannot be resolved to a new
273283
// state internally.
274284
if (resolution == null) {
275-
_currentCompleter.completeError(error, st);
276-
_stateController.addError(error, st);
285+
_currentCompleter.completeError(error, stackTrace);
286+
_stateController.addError(error, stackTrace);
277287
return;
278288
}
279289

@@ -286,11 +296,11 @@ abstract class StateMachine<
286296
final precondError = event.checkPrecondition(currentState);
287297
if (precondError != null) {
288298
logger.debug(
289-
'Precondition not met for event ($event):\n'
299+
'Precondition not met for event ($_currentEvent):\n'
290300
'${precondError.precondition}',
291301
);
292302
if (precondError.shouldEmit) {
293-
_emitError(precondError, StackTrace.current);
303+
emitError(precondError, StackTrace.current);
294304
}
295305
return false;
296306
}
@@ -334,7 +344,7 @@ abstract class StateMachine<
334344
///
335345
/// If the error cannot be resolved, return `null` and the error will be
336346
/// rethrown.
337-
State? resolveError(Object error, [StackTrace? st]);
347+
State? resolveError(Object error, StackTrace st);
338348

339349
/// Logger for the state machine.
340350
@override

packages/amplify_core/pubspec.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ dependencies:
1818
logging: ^1.0.0
1919
meta: ^1.7.0
2020
retry: ^3.1.0
21+
stack_trace: ^1.11.0
2122
uuid: ">=3.0.6 <=3.0.7"
2223

2324
dev_dependencies:

packages/amplify_core/test/state_machine/my_state_machine.dart

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ class MyState extends StateMachineState<MyType> {
5656
String get runtimeTypeName => 'MyState';
5757
}
5858

59+
class MyErrorState extends MyState with ErrorState {
60+
const MyErrorState(this.exception, this.stackTrace) : super(MyType.error);
61+
62+
@override
63+
final Exception exception;
64+
65+
@override
66+
final StackTrace stackTrace;
67+
}
68+
5969
class MyStateMachine extends StateMachine<MyEvent, MyState, StateMachineEvent,
6070
StateMachineState, MyStateMachineManager> {
6171
MyStateMachine(MyStateMachineManager manager) : super(manager, type);
@@ -96,8 +106,11 @@ class MyStateMachine extends StateMachine<MyEvent, MyState, StateMachineEvent,
96106
}
97107

98108
@override
99-
MyState? resolveError(Object error, [StackTrace? st]) {
100-
return const MyState(MyType.error);
109+
MyState? resolveError(Object error, StackTrace st) {
110+
if (error is Exception) {
111+
return MyErrorState(error, st);
112+
}
113+
return null;
101114
}
102115

103116
@override
@@ -140,6 +153,17 @@ class WorkerState extends StateMachineState<WorkType> {
140153
String get runtimeTypeName => 'WorkerState';
141154
}
142155

156+
class WorkerErrorState extends WorkerState with ErrorState {
157+
const WorkerErrorState(this.exception, this.stackTrace)
158+
: super(WorkType.error);
159+
160+
@override
161+
final Exception exception;
162+
163+
@override
164+
final StackTrace stackTrace;
165+
}
166+
143167
class WorkerMachine extends StateMachine<WorkerEvent, WorkerState,
144168
StateMachineEvent, StateMachineState, MyStateMachineManager> {
145169
WorkerMachine(MyStateMachineManager manager) : super(manager, type);
@@ -171,8 +195,11 @@ class WorkerMachine extends StateMachine<WorkerEvent, WorkerState,
171195
}
172196

173197
@override
174-
WorkerState? resolveError(Object error, [StackTrace? st]) {
175-
return const WorkerState(WorkType.error);
198+
WorkerState? resolveError(Object error, StackTrace st) {
199+
if (error is Exception) {
200+
return WorkerErrorState(error, st);
201+
}
202+
return null;
176203
}
177204

178205
@override

packages/amplify_core/test/state_machine/state_machine_test.dart

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import 'package:amplify_core/amplify_core.dart';
5+
import 'package:stack_trace/stack_trace.dart';
56
import 'package:test/test.dart';
67

78
import 'my_state_machine.dart';
@@ -36,12 +37,84 @@ void main() {
3637
);
3738
});
3839

39-
test('handles errors', () {
40-
stateMachine.accept(const MyEvent(MyType.tryWork));
41-
expect(
42-
stateMachine.stream,
43-
emitsThrough(const MyState(MyType.error)),
44-
);
40+
group('handles errors', () {
41+
/// Creates a matcher originating at StateMachineManager.[method].
42+
///
43+
/// The state machine should chain stack traces such that both the
44+
/// creation of the event and the exception are visible in the same trace.
45+
Matcher matchesChain(String method) => isA<Chain>().having(
46+
(chain) => chain.traces.map((trace) => trace.toString()),
47+
'traces',
48+
containsAllInOrder([
49+
contains('MyStateMachine.doWork'),
50+
contains('StateMachineManager.$method'),
51+
]),
52+
);
53+
54+
test('emitted from stream', () {
55+
expect(
56+
stateMachine.stream,
57+
emitsThrough(
58+
isA<MyErrorState>().having(
59+
(s) => s.stackTrace,
60+
'stackTrace',
61+
matchesChain('accept'),
62+
),
63+
),
64+
);
65+
stateMachine.accept(const MyEvent(MyType.tryWork));
66+
});
67+
68+
test('accept', () async {
69+
final completion =
70+
await stateMachine.accept(const MyEvent(MyType.tryWork)).completed;
71+
expect(
72+
completion,
73+
isA<MyErrorState>().having(
74+
(s) => s.stackTrace,
75+
'stackTrace',
76+
matchesChain('accept'),
77+
),
78+
);
79+
});
80+
81+
test('acceptAndComplete', () async {
82+
try {
83+
await stateMachine.acceptAndComplete(const MyEvent(MyType.tryWork));
84+
fail(
85+
'acceptAndComplete should rethrow the exception from the '
86+
'state machine',
87+
);
88+
} on Exception catch (_, st) {
89+
expect(st, matchesChain('acceptAndComplete'));
90+
}
91+
});
92+
93+
test('dispatch', () async {
94+
final completion = await stateMachine
95+
.dispatch(const MyEvent(MyType.tryWork))
96+
.completed;
97+
expect(
98+
completion,
99+
isA<MyErrorState>().having(
100+
(s) => s.stackTrace,
101+
'stackTrace',
102+
matchesChain('dispatch'),
103+
),
104+
);
105+
});
106+
107+
test('dispatchAndComplete', () async {
108+
try {
109+
await stateMachine.dispatchAndComplete(const MyEvent(MyType.tryWork));
110+
fail(
111+
'dispatchAndComplete should rethrow the exception from the '
112+
'state machine',
113+
);
114+
} on Exception catch (_, st) {
115+
expect(st, matchesChain('dispatchAndComplete'));
116+
}
117+
});
45118
});
46119

47120
group('subscribeTo', () {

0 commit comments

Comments
 (0)