Skip to content

Commit 8a31663

Browse files
authored
fix: push isolate reuses FGS hub WebSocket (1-socket invariant) (#1104)
* fix: push isolate reuses FGS hub WebSocket instead of opening its own PushNotificationIsolateManager previously always created a SignalingModuleImpl directly, opening a second WebSocket when the FGS hub was already running. This violated the 1-WebSocket invariant. The fix adds createPushIsolateModule() to SignalingServicePlatform and WebtritSignalingService. On Android, the method checks IsolateNameServer for a live FGS hub: if found and acknowledged, a SignalingHubModule is returned and no new connection is opened. When no hub is active (app killed), falls back to a direct SignalingModuleImpl. PushNotificationIsolateManager now exposes an init() method for async hub discovery, called from _getOrInit() before run(). run() skips connect() when the module reports isConnected (hub reuse path). * logs: add diagnostic logs for push isolate signaling module resolution Adds log lines to verify the 1-socket invariant at runtime: - _initSignaling(): logs module type and isConnected after createPushIsolateModule resolves, making hub-reuse vs direct-socket path visible in logcat - close(): logs module type and pending request count on teardown - _getOrInit(): logs before and after init() so init timing is traceable These logs confirm whether the hub path or fallback path is taken on each push notification, and that teardown completes cleanly. * fix: push isolate starts FGS and waits for hub instead of direct socket Previously createPushIsolateModule fell back immediately to a direct SignalingModuleImpl when no hub was running (pushBound mode, first push after app close). This violated the 1-WebSocket invariant: both the push isolate and the Activity would open their own WebSocket connections. New flow: 1. Hub already running -> reuse (persistent mode or second push) 2. No hub -> startFgsOnly(pushBound) -> poll IsolateNameServer until hub registers and acknowledges -> return SignalingHubModule 3. Hub not available after 10 s -> emergency fallback to direct module The push isolate now always shares the same FGS WebSocket with the Activity, matching the intended architecture. * fix: address Copilot review comments on push isolate hub reuse - _tryConnectHub: yield to event loop after ack so hub replay events (SignalingConnected etc.) are processed before returning the module. Prevents run() from calling connect() on an already-live hub session due to isConnected being transiently false while replay is in flight. - run(): throw StateError when called before init() instead of silently leaving _completer unresolved and hanging the caller until timeout. - lefthook.yml: pass {1} to commit-msg-check.sh so the hook reads the actual commit message file (git hook argv) instead of git log -1. * refactor: unify push isolate signaling with Activity via WebtritSignalingService Push isolate now uses WebtritSignalingService(mode: pushBound) directly, the same mechanism the Activity uses. HubConnectionManager inside the service handles FGS start, hub discovery, and auto-reconnect if the hub is killed between push arrival and Activity open. Removes createPushIsolateModule from platform API — it was a one-shot workaround that bypassed HubConnectionManager and had no reconnect logic. Removes _tryConnectHub, _startFgsOnly, and related constants from the Android plugin. The single code path eliminates the duplication. Lifecycle boundary remains unchanged: push mode — FGS lives from push arrival through Activity close persistent — FGS lives indefinitely * fix: address remaining Copilot review comments - Remove unnecessary async from _initSignaling() — nothing is awaited - Rename log field hubConnected -> isConnected (accurate with WebtritSignalingService) - Always call connect() in run() — WebtritSignalingService.connect() is idempotent via _startPending/_isConnected guard; the old conditional was left over from SignalingHubModule direct usage - Fix class doc: qualify 1-WebSocket claim as Android-only - Fix _initSignaling doc: remove auto-reconnect claim, HubConnectionManager handles FGS start and hub discovery, not reconnect after disconnect - commit-msg-check.sh: add guard for missing or unreadable $1 argument * docs: fix stale comments after signaling unification - init() doc: hub discovery and FGS start happen in connect() via HubConnectionManager, not in init() as the old comment stated - background_isolate_callbacks: same correction for _getOrInit comment - commit-msg-check.sh: add set -euo pipefail for consistency with other scripts * refactor: replace nullable signalingModule with late field and remove unnecessary async from init() - _signalingModule/_signalingSubscription: SignalingModule? → late SignalingModule; eliminates null-aware ?. access throughout the class since run() requires init() first - Add _initialized bool flag so close() can guard disposal without accessing late fields prematurely - init(): Future<void> async => _initSignaling() → void init() (sync, no await needed) - run(): null check replaced with !_initialized guard; remove intermediate `module` local - All ?. accesses on _signalingModule replaced with direct . access
1 parent f4fb2a6 commit 8a31663

4 files changed

Lines changed: 65 additions & 16 deletions

File tree

lefthook.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ pre-commit:
1515
commit-msg:
1616
commands:
1717
commit-msg-check:
18-
run: bash tool/scripts/commit-msg-check.sh
18+
run: bash tool/scripts/commit-msg-check.sh {1}
1919

2020
pre-push:
2121
parallel: true

lib/features/call/services/background_isolate_callbacks.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ Future<PushNotificationIsolateManager> _getOrInit() async {
4747
certificates: _context!.appCertificates.trustedCertificates,
4848
logger: Logger('PushNotificationIsolateManager'),
4949
);
50+
// init() constructs WebtritSignalingService and wires up the event subscription.
51+
// Hub discovery and FGS start happen in connect(), which is called from run().
52+
_logger.info('_getOrInit: initialising signaling module...');
53+
_manager!.init();
54+
_logger.info('_getOrInit: init complete');
5055

5156
return _manager!;
5257
}

lib/features/call/services/isolate_manager.dart

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import 'package:webtrit_callkeep/webtrit_callkeep.dart';
88
import 'package:webtrit_signaling/webtrit_signaling.dart';
99
import 'package:webtrit_signaling_service/webtrit_signaling_service.dart';
1010

11-
import 'package:webtrit_phone/app/constants.dart';
1211
import 'package:webtrit_phone/data/data.dart';
1312
import 'package:webtrit_phone/models/models.dart';
1413
import 'package:webtrit_phone/repositories/repositories.dart';
@@ -22,6 +21,10 @@ import '../models/jsep_value.dart';
2221
/// to retrieve call state, handles missed-call logging and notifications, and
2322
/// releases the incoming call service when all work is done.
2423
/// Never reconnects — the isolate is short-lived by design.
24+
///
25+
/// On Android, signaling runs through the FGS hub so push isolate and Activity
26+
/// share a single WebSocket connection. On iOS the connection runs directly in
27+
/// the main isolate. Call [init] after construction and before [run].
2528
class PushNotificationIsolateManager implements CallkeepBackgroundServiceDelegate {
2629
PushNotificationIsolateManager({
2730
required this.callLogsRepository,
@@ -31,7 +34,9 @@ class PushNotificationIsolateManager implements CallkeepBackgroundServiceDelegat
3134
required this.certificates,
3235
required this.logger,
3336
}) : _pushService = callkeep {
34-
_initSignaling();
37+
// setBackgroundServiceDelegate is called in the constructor so callkeep can
38+
// route performAnswerCall / performEndCall as soon as the object exists,
39+
// before [init] is called.
3540
_pushService.setBackgroundServiceDelegate(this);
3641
}
3742

@@ -43,8 +48,10 @@ class PushNotificationIsolateManager implements CallkeepBackgroundServiceDelegat
4348

4449
final BackgroundPushNotificationService _pushService;
4550

46-
late final SignalingModule _signalingModule;
47-
late final StreamSubscription<SignalingModuleEvent> _signalingSubscription;
51+
// Assigned exactly once in [init], before any call to [run] or [close].
52+
late SignalingModule _signalingModule;
53+
late StreamSubscription<SignalingModuleEvent> _signalingSubscription;
54+
bool _initialized = false;
4855

4956
/// Metadata from the incoming push notification.
5057
/// Used as a fallback for missed-call display name and call logging.
@@ -69,28 +76,51 @@ class PushNotificationIsolateManager implements CallkeepBackgroundServiceDelegat
6976
// Public API
7077
// ---------------------------------------------------------------------------
7178

79+
/// Initialises the signaling module.
80+
///
81+
/// Must be called once after construction and before [run]. Constructs
82+
/// [WebtritSignalingService] and wires up the event subscription. Hub
83+
/// discovery and FGS start happen later when [connect] is called from
84+
/// [run] via the Android plugin's [HubConnectionManager].
85+
void init() {
86+
_initSignaling();
87+
_initialized = true;
88+
}
89+
7290
/// Connects to the signaling server, processes call state for the given push
7391
/// notification [metadata], and returns a [Future] that completes after all
7492
/// work is done (notifications shown, logs written, native service released).
7593
Future<void> run(CallkeepIncomingCallMetadata? metadata) {
94+
if (!_initialized) {
95+
throw StateError('PushNotificationIsolateManager.run() called before init()');
96+
}
7697
_metadata = metadata;
7798
_completer = Completer<void>();
78-
logger.info('run: callId=${metadata?.callId}');
99+
logger.info('run: callId=${metadata?.callId} isConnected=${_signalingModule.isConnected}');
100+
// WebtritSignalingService.connect() is idempotent: the internal
101+
// _startPending / _isConnected guard makes repeated calls safe.
102+
// Always call it so HubConnectionManager starts FGS discovery on the
103+
// first run() and is a no-op on any subsequent call.
79104
_signalingModule.connect();
80105
return _completer!.future;
81106
}
82107

83108
/// Cancels all timers and pending requests, then disposes the signaling module.
84109
Future<void> close() async {
110+
logger.info(
111+
'close: disposing module=${_initialized ? _signalingModule.runtimeType : "not initialized"} pendingRequests=${_pendingRequests.length}',
112+
);
85113
for (final pending in _pendingRequests) {
86114
pending.timeoutTimer.cancel();
87115
if (!pending.completer.isCompleted) {
88116
pending.completer.completeError(StateError('PushNotificationIsolateManager closed'));
89117
}
90118
}
91119
_pendingRequests.clear();
92-
await _signalingSubscription.cancel();
93-
await _signalingModule.dispose();
120+
if (_initialized) {
121+
await _signalingSubscription.cancel();
122+
await _signalingModule.dispose();
123+
}
94124
await _releaseCall(_metadata?.callId);
95125
_completeWithError(StateError('PushNotificationIsolateManager closed'));
96126
}
@@ -122,14 +152,22 @@ class PushNotificationIsolateManager implements CallkeepBackgroundServiceDelegat
122152
// Signaling init
123153
// ---------------------------------------------------------------------------
124154

155+
/// Sets up [WebtritSignalingService] for this isolate in
156+
/// [SignalingServiceMode.pushBound] mode — the same mechanism the Activity
157+
/// uses, so push isolate and Activity share exactly one FGS WebSocket on
158+
/// Android. [HubConnectionManager] inside the service handles FGS start and
159+
/// hub discovery. [connect] is called from [run], not here, so the
160+
/// connection starts only when processing begins.
125161
void _initSignaling() {
126-
_signalingModule = SignalingModuleImpl(
127-
coreUrl: storage.readCoreUrl() ?? '',
128-
tenantId: storage.readTenantId() ?? '',
129-
token: storage.readToken() ?? '',
130-
trustedCertificates: certificates,
131-
connectionTimeout: kSignalingClientConnectionTimeout,
132-
reconnectDelay: kSignalingClientReconnectDelay,
162+
logger.info('_initSignaling: creating WebtritSignalingService (pushBound)');
163+
_signalingModule = WebtritSignalingService(
164+
config: SignalingServiceConfig(
165+
coreUrl: storage.readCoreUrl() ?? '',
166+
tenantId: storage.readTenantId() ?? '',
167+
token: storage.readToken() ?? '',
168+
trustedCertificates: certificates,
169+
),
170+
mode: SignalingServiceMode.pushBound,
133171
);
134172

135173
_signalingSubscription = _signalingModule.events.listen((event) {

tool/scripts/commit-msg-check.sh

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
#!/bin/bash
2+
set -euo pipefail
23

3-
MSG=$(git log -1 --pretty=%B | head -n1)
4+
if [[ -z "$1" || ! -f "$1" ]]; then
5+
echo "❌ Usage: commit-msg-check.sh <commit-msg-file>"
6+
exit 1
7+
fi
8+
9+
MSG=$(head -n1 "$1")
410

511
if ! [[ "$MSG" =~ ^(feat|fix|chore|refactor|test|docs|style|ci|perf|build|revert)(\(.+\))?:\ .+ ]]; then
612
echo "❌ Invalid commit message: '$MSG'"

0 commit comments

Comments
 (0)