Skip to content

Commit 864e0bb

Browse files
committed
fix: handle exceptions when FlutterEngine is suspended during event emission
- Added exception handling in `AblyStreamsChannel` to prevent crashes when FlutterEngine is stopped. - Improved logging to notify dropped events due to engine inactivity. - Added iOS unit tests for regression and normal operation of event sinks. - Configured GitHub Actions to run iOS unit tests on macOS.
1 parent 5505dcd commit 864e0bb

File tree

4 files changed

+179
-21
lines changed

4 files changed

+179
-21
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
on:
2+
workflow_dispatch:
3+
pull_request:
4+
push:
5+
branches:
6+
- main
7+
8+
jobs:
9+
ios-unit-tests:
10+
runs-on: macos-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
14+
- uses: futureware-tech/simulator-action@v5
15+
id: ios-simulator
16+
17+
- uses: subosito/flutter-action@v2
18+
with:
19+
flutter-version: '3.29'
20+
cache: true
21+
22+
- name: Set up iOS test project
23+
# --skip-tests sets up the CocoaPods workspace (downloads all deps, generates
24+
# the ably_flutter-Unit-Tests scheme) without booting a simulator
25+
run: |
26+
cd ios
27+
pod lib lint ably_flutter.podspec \
28+
--allow-warnings \
29+
--skip-tests \
30+
--validation-dir=/tmp/ably_test_build \
31+
--no-clean
32+
33+
- name: Run iOS unit tests
34+
timeout-minutes: 30
35+
run: |
36+
xcodebuild test \
37+
-workspace /tmp/ably_test_build/App.xcworkspace \
38+
-scheme ably_flutter-Unit-Tests \
39+
-destination 'id=${{ steps.ios-simulator.outputs.udid }}' \
40+
CODE_SIGNING_ALLOWED=NO

ios/Classes/AblyStreamsChannel.m

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -66,26 +66,26 @@ - (void)setStreamHandlerFactory:(NSObject<FlutterStreamHandler> *(^)(id))factory
6666
[_messenger setMessageHandlerOnChannel:_name binaryMessageHandler:nil];
6767
return;
6868
}
69-
69+
7070
_streams = [NSMutableDictionary new];
7171
_listenerArguments = [NSMutableDictionary new];
7272
FlutterBinaryMessageHandler messageHandler = ^(NSData* message, FlutterBinaryReply callback) {
7373
FlutterMethodCall* call = [self->_codec decodeMethodCall:message];
7474
NSArray *methodParts = [call.method componentsSeparatedByString:@"#"];
75-
75+
7676
if (methodParts.count != 2) {
7777
callback(nil);
7878
return;
7979
}
80-
80+
8181
NSInteger keyValue = [methodParts.lastObject integerValue];
8282
if(keyValue == 0) {
8383
callback([self->_codec encodeErrorEnvelope:[FlutterError errorWithCode:@"error" message:[NSString stringWithFormat:@"Invalid method name: %@", call.method] details:nil]]);
8484
return;
8585
}
86-
86+
8787
NSNumber *key = [NSNumber numberWithInteger:keyValue];
88-
88+
8989
if ([methodParts.firstObject isEqualToString:@"listen"]) {
9090
[self listenForCall:call withKey:key usingCallback:callback andFactory:factory];
9191
} else if ([methodParts.firstObject isEqualToString:@"cancel"]) {
@@ -94,7 +94,7 @@ - (void)setStreamHandlerFactory:(NSObject<FlutterStreamHandler> *(^)(id))factory
9494
callback(nil);
9595
}
9696
};
97-
97+
9898
[_messenger setMessageHandlerOnChannel:_name binaryMessageHandler:messageHandler];
9999
}
100100

@@ -110,22 +110,25 @@ - (void) reset{
110110
- (void)listenForCall:(FlutterMethodCall*)call withKey:(NSNumber*)key usingCallback:(FlutterBinaryReply)callback andFactory:(NSObject<FlutterStreamHandler> *(^)(id))factory {
111111
AblyStreamsChannelStream *stream = [AblyStreamsChannelStream new];
112112
stream.sink = ^(id event) {
113-
NSString *name = [NSString stringWithFormat:@"%@#%@", self->_name, key];
114-
115-
if (event == FlutterEndOfEventStream) {
116-
[self->_messenger sendOnChannel:name message:nil];
117-
} else if ([event isKindOfClass:[FlutterError class]]) {
118-
[self->_messenger sendOnChannel:name
119-
message:[self->_codec encodeErrorEnvelope:(FlutterError*)event]];
120-
} else {
121-
[self->_messenger sendOnChannel:name message:[self->_codec encodeSuccessEnvelope:event]];
113+
@try {
114+
NSString *name = [NSString stringWithFormat:@"%@#%@", self->_name, key];
115+
if (event == FlutterEndOfEventStream) {
116+
[self->_messenger sendOnChannel:name message:nil];
117+
} else if ([event isKindOfClass:[FlutterError class]]) {
118+
[self->_messenger sendOnChannel:name
119+
message:[self->_codec encodeErrorEnvelope:(FlutterError*)event]];
120+
} else {
121+
[self->_messenger sendOnChannel:name message:[self->_codec encodeSuccessEnvelope:event]];
122+
}
123+
} @catch (NSException *exception) {
124+
NSLog(@"AblyFlutter: Dropped event, engine not running: %@", exception.reason);
122125
}
123126
};
124127
stream.handler = factory(call.arguments);
125-
128+
126129
[_streams setObject:stream forKey:key];
127130
[_listenerArguments setObject:call.arguments forKey:key];
128-
131+
129132
[self triggerCallback:callback
130133
error:[stream.handler onListenWithArguments:call.arguments eventSink:stream.sink]];
131134
}
@@ -136,10 +139,10 @@ - (void)cancelForCall:(FlutterMethodCall*)call withKey:(NSNumber*)key usingCallb
136139
callback([_codec encodeErrorEnvelope:[FlutterError errorWithCode:@"error" message:@"No active stream to cancel" details:nil]]);
137140
return;
138141
}
139-
142+
140143
[_streams removeObjectForKey:key];
141144
[_listenerArguments removeObjectForKey:key];
142-
145+
143146
[self triggerCallback:callback error:[stream.handler onCancelWithArguments:call.arguments]];
144147
}
145148

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
#import <XCTest/XCTest.h>
2+
#import <Flutter/Flutter.h>
3+
#import "AblyStreamsChannel.h"
4+
5+
// Mock binary messenger: captures the binaryMessageHandler registered by AblyStreamsChannel,
6+
// and optionally throws NSInternalInconsistencyException on sendOnChannel:message: to simulate
7+
// a suspended/detached FlutterEngine.
8+
@interface MockThrowingMessenger : NSObject <FlutterBinaryMessenger>
9+
@property(nonatomic, copy) FlutterBinaryMessageHandler capturedHandler;
10+
@property(nonatomic, assign) BOOL shouldThrow;
11+
@property(nonatomic, assign) NSInteger sendCallCount;
12+
@end
13+
14+
@implementation MockThrowingMessenger
15+
16+
- (void)sendOnChannel:(NSString*)channel message:(NSData*)message {
17+
_sendCallCount++;
18+
if (_shouldThrow) {
19+
[NSException raise:NSInternalInconsistencyException
20+
format:@"Sending a message before the FlutterEngine has been run."];
21+
}
22+
}
23+
24+
- (void)sendOnChannel:(NSString*)channel message:(NSData*)message binaryReply:(FlutterBinaryReply)callback {}
25+
26+
- (FlutterBinaryMessengerConnection)setMessageHandlerOnChannel:(NSString*)channel
27+
binaryMessageHandler:(FlutterBinaryMessageHandler _Nullable)handler {
28+
_capturedHandler = handler;
29+
return 0;
30+
}
31+
32+
- (FlutterBinaryMessengerConnection)setMessageHandlerOnChannel:(NSString*)channel
33+
binaryMessageHandler:(FlutterBinaryMessageHandler _Nullable)handler
34+
taskQueue:(NSObject<FlutterTaskQueue>* _Nullable)taskQueue {
35+
_capturedHandler = handler;
36+
return 0;
37+
}
38+
39+
- (void)cleanUpConnection:(FlutterBinaryMessengerConnection)connection {}
40+
41+
- (NSObject<FlutterTaskQueue>*)makeBackgroundTaskQueue { return nil; }
42+
43+
@end
44+
45+
// Helper stream handler that captures the eventSink passed to it during stream setup.
46+
@interface CapturingSinkHandler : NSObject <FlutterStreamHandler>
47+
@property(nonatomic, copy) FlutterEventSink capturedSink;
48+
@end
49+
50+
@implementation CapturingSinkHandler
51+
52+
- (FlutterError*)onListenWithArguments:(id)arguments eventSink:(FlutterEventSink)events {
53+
_capturedSink = events;
54+
return nil;
55+
}
56+
57+
- (FlutterError*)onCancelWithArguments:(id)arguments { return nil; }
58+
59+
@end
60+
61+
@interface AblyStreamsChannelTests : XCTestCase
62+
@end
63+
64+
@implementation AblyStreamsChannelTests
65+
66+
// Sets up an AblyStreamsChannel with the given messenger and triggers a "listen#1" call,
67+
// which causes the channel to create a sink and pass it to the handler's onListenWithArguments:.
68+
// Returns the handler so tests can invoke the captured sink.
69+
- (CapturingSinkHandler*)setupChannelWithMessenger:(MockThrowingMessenger*)messenger {
70+
AblyStreamsChannel *channel = [AblyStreamsChannel
71+
streamsChannelWithName:@"test.channel"
72+
binaryMessenger:messenger
73+
codec:[FlutterStandardMethodCodec sharedInstance]];
74+
75+
CapturingSinkHandler *handler = [CapturingSinkHandler new];
76+
[channel setStreamHandlerFactory:^NSObject<FlutterStreamHandler>*(id args) { return handler; }];
77+
78+
// Simulate Flutter sending a "listen#1" message to open a stream.
79+
// Arguments must be non-nil: AblyStreamsChannel stores them in an NSMutableDictionary
80+
// which rejects nil values.
81+
NSData *listenMsg = [[FlutterStandardMethodCodec sharedInstance]
82+
encodeMethodCall:[FlutterMethodCall methodCallWithMethodName:@"listen#1" arguments:@{}]];
83+
messenger.capturedHandler(listenMsg, ^(NSData* reply){});
84+
85+
return handler;
86+
}
87+
88+
// Regression test for: NSInternalInconsistencyException crash when the FlutterEngine is
89+
// suspended after the app is backgrounded.
90+
// The sink block in AblyStreamsChannel must catch the exception instead of propagating it.
91+
- (void)testSinkDoesNotCrashWhenEngineNotRunning {
92+
MockThrowingMessenger *messenger = [MockThrowingMessenger new];
93+
messenger.shouldThrow = YES;
94+
CapturingSinkHandler *handler = [self setupChannelWithMessenger:messenger];
95+
96+
XCTAssertNoThrow(handler.capturedSink(@"state-change-event"),
97+
@"Sink should swallow NSInternalInconsistencyException when engine is stopped");
98+
}
99+
100+
// Verify the normal (foreground) code path still works after the fix.
101+
- (void)testSinkEmitsNormallyWhenEngineIsRunning {
102+
MockThrowingMessenger *messenger = [MockThrowingMessenger new];
103+
messenger.shouldThrow = NO;
104+
CapturingSinkHandler *handler = [self setupChannelWithMessenger:messenger];
105+
106+
XCTAssertNoThrow(handler.capturedSink(@"state-change-event"));
107+
XCTAssertEqual(messenger.sendCallCount, 1,
108+
@"Messenger should be called once for a normal event emission");
109+
}
110+
111+
@end

ios/ably_flutter.podspec

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,16 @@ Pod::Spec.new do |s|
2222
s.platform = :ios
2323
s.ios.deployment_target = '10.0'
2424

25-
# Flutter.framework does not contain a i386 slice. Only x86_64 simulators are supported.
25+
# Flutter 3.x ships both arm64 and x86_64 simulator slices; no arch restriction needed.
2626
s.pod_target_xcconfig = {
2727
'DEFINES_MODULE' => 'YES',
28-
'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64',
2928
'GCC_PREPROCESSOR_DEFINITIONS' => "FLUTTER_PACKAGE_PLUGIN_VERSION=\\@\\\"#{flutter_package_plugin_version}\\\""
3029
}
3130
s.resource_bundles = {'ably_flutter' => ['Resources/PrivacyInfo.xcprivacy']}
3231
s.swift_version = '5.0'
32+
33+
s.test_spec 'Tests' do |ts|
34+
ts.source_files = 'Tests/**/*.{h,m}'
35+
ts.framework = 'XCTest'
36+
end
3337
end

0 commit comments

Comments
 (0)