From 9d181600468d3d246fa2cb01ad145659303a2bfc Mon Sep 17 00:00:00 2001 From: Chinmay-KB Date: Sat, 25 Jul 2026 14:14:15 +0530 Subject: [PATCH 1/3] fix(replay): keep route waits off controller queue Schedule transition deadlines outside serialized event work, cancel obsolete deadlines and captures across lifecycle/session changes, and fence late screenshots from replacement sessions. --- packages/tugboat/lib/src/controller.dart | 324 +++++++++++++++--- .../helpers/replay_coherence_harness.dart | 46 ++- ...eplay_coherence_characterization_test.dart | 263 ++++++++++++++ 3 files changed, 574 insertions(+), 59 deletions(-) diff --git a/packages/tugboat/lib/src/controller.dart b/packages/tugboat/lib/src/controller.dart index fb6a91e..7af4f58 100644 --- a/packages/tugboat/lib/src/controller.dart +++ b/packages/tugboat/lib/src/controller.dart @@ -170,6 +170,57 @@ class _VisibleRouteChange { final bool updatesRoute; } +/// One deferred route capture. Its deadline deliberately runs outside the +/// controller queue so a Navigator transition can never stall tap/scroll work. +class _RouteCaptureWork { + _RouteCaptureWork({ + required this.epoch, + required this.change, + required this.deadline, + }); + + final int epoch; + final _VisibleRouteChange change; + final Duration deadline; + final Completer completer = Completer(); + bool cancelled = false; + void Function()? _cancelDeadline; + void Function()? _cancelCapture; + + Future get done => completer.future; + + void attachDeadlineCancellation(void Function() cancelDeadline) { + if (cancelled) { + cancelDeadline(); + return; + } + _cancelDeadline = cancelDeadline; + } + + void attachCaptureCancellation(void Function() cancelCapture) { + if (cancelled) { + cancelCapture(); + return; + } + _cancelCapture = cancelCapture; + } + + void cancel() { + cancelled = true; + _cancelDeadline?.call(); + _cancelDeadline = null; + _cancelCapture?.call(); + _cancelCapture = null; + if (!completer.isCompleted) completer.complete(); + } + + void complete() { + _cancelDeadline = null; + _cancelCapture = null; + if (!completer.isCompleted) completer.complete(); + } +} + class TugboatReplayController extends ChangeNotifier { TugboatReplayController({ required this.config, @@ -189,6 +240,7 @@ class TugboatReplayController extends ChangeNotifier { final Stopwatch _clock = Stopwatch(); Future _queue = Future.value(); + int _queuedTaskCount = 0; Future? _endSessionFuture; TugboatSession? _session; @@ -216,10 +268,12 @@ class TugboatReplayController extends ChangeNotifier { bool _capturePaused = false; bool _explorationFramesSuppressed = false; bool _captureInFlight = false; + int _captureGeneration = 0; bool _capturePumpScheduled = false; bool _skipCapture = false; bool _routeCapturePending = false; int _routeEpoch = 0; + _RouteCaptureWork? _activeRouteCapture; final Map _scrollTrackers = {}; final Map _activeGestures = {}; String? _lastCapturedStateSignature; @@ -281,6 +335,11 @@ class TugboatReplayController extends ChangeNotifier { @visibleForTesting Future Function(Duration duration)? debugDelay; + /// Cancellable route-deadline scheduler used by deterministic tests. + @visibleForTesting + ({Future done, void Function() cancel}) Function(Duration duration)? + debugScheduleDelay; + /// Test-only capture executor. When set, replaces screenshot readback while /// preserving the production request/queue/pending-route control flow. @visibleForTesting @@ -348,22 +407,61 @@ class TugboatReplayController extends ChangeNotifier { return Future.delayed(effective); } + ({Future done, void Function() cancel}) _scheduleDelay( + Duration duration, + ) { + final effective = duration < Duration.zero ? Duration.zero : duration; + final override = debugScheduleDelay; + if (override != null) return override(effective); + + final completer = Completer(); + final timer = Timer(effective, completer.complete); + return ( + done: completer.future, + cancel: () { + timer.cancel(); + if (!completer.isCompleted) completer.complete(); + }, + ); + } + /// Serializes [task] on the controller queue while guaranteeing that a /// failure in one task never poisons the chain: an uncaught error in a /// plain `_queue.then(...)` would turn `_queue` into an errored future and /// silently skip every later task (tap settles, scroll ends, route /// captures) for the rest of the session. Future _enqueue(String label, Future Function() task) { - _queue = _queue.then((_) async { - try { - await task(); - } catch (error, stackTrace) { - debugPrint('[tugboat] queued $label task failed: $error\n$stackTrace'); - } - }); + _queuedTaskCount++; + _queue = _queue.then((_) => _runQueuedTask(label, task)); return _queue; } + /// Enters the serialized queue at a deadline that has already elapsed. + /// + /// Starting an idle queue immediately matters for Navigator callbacks that + /// cross a real-time transition while a widget test is inside `runAsync`. + /// The task still owns [_queue] before it runs, so later controller work + /// remains serialized behind it. + Future _enqueueReady(String label, Future Function() task) { + if (_queuedTaskCount > 0) return _enqueue(label, task); + _queuedTaskCount++; + _queue = _runQueuedTask(label, task); + return _queue; + } + + Future _runQueuedTask( + String label, + Future Function() task, + ) async { + try { + await task(); + } catch (error, stackTrace) { + debugPrint('[tugboat] queued $label task failed: $error\n$stackTrace'); + } finally { + _queuedTaskCount--; + } + } + @visibleForTesting TugboatInteractionResult debugComputeTapSettleResult({ required TugboatStateAnchor? beforeState, @@ -499,7 +597,7 @@ class TugboatReplayController extends ChangeNotifier { _semanticsHandle = null; _sinkHub = null; _session = null; - _scheduledCapture = null; + _cancelScheduledCaptureWaiters(); if (hub != null) { unawaited(ending.whenComplete(hub.dispose)); } @@ -514,6 +612,8 @@ class TugboatReplayController extends ChangeNotifier { if (active != null) return active; if (_session == null) return Future.value(); + _cancelActiveRouteCapture(); + _addEvent( TugboatEvent( id: _nextId('event'), @@ -537,6 +637,7 @@ class TugboatReplayController extends ChangeNotifier { } void start(Size viewport, String platform) { + _cancelActiveRouteCapture(); _endSessionFuture = null; _clock ..reset() @@ -636,6 +737,18 @@ class TugboatReplayController extends ChangeNotifier { required TugboatFrameTrigger trigger, bool force = false, Duration? settleDelay, + }) { + return _requestCaptureCancellable( + trigger: trigger, + force: force, + settleDelay: settleDelay, + ).done; + } + + ({Future done, void Function() cancel}) _requestCaptureCancellable({ + required TugboatFrameTrigger trigger, + bool force = false, + Duration? settleDelay, }) { if (_disposed || _capturePaused || @@ -643,7 +756,7 @@ class TugboatReplayController extends ChangeNotifier { _shouldSuppressFrameCapture) { _refreshStateAnchor(); _maybeEmitSceneInventory(); - return Future.value(_latestFrameId); + return (done: Future.value(_latestFrameId), cancel: () {}); } final delay = settleDelay ?? config.settleDelay; @@ -663,7 +776,13 @@ class TugboatReplayController extends ChangeNotifier { } _ensureCapturePumpScheduled(); - return completer.future; + return ( + done: completer.future, + cancel: () { + _scheduledCapture?.waiters.remove(completer); + if (!completer.isCompleted) completer.complete(_latestFrameId); + }, + ); } void _ensureCapturePumpScheduled() { @@ -718,10 +837,21 @@ class TugboatReplayController extends ChangeNotifier { } } + void _cancelScheduledCaptureWaiters() { + final scheduled = _scheduledCapture; + _scheduledCapture = null; + if (scheduled == null) return; + for (final waiter in scheduled.waiters) { + if (!waiter.isCompleted) waiter.complete(_latestFrameId); + } + } + Future _executeCapture({ required TugboatFrameTrigger trigger, bool force = false, }) async { + final captureGeneration = _captureGeneration; + final captureSession = _session; final captureOverride = debugExecuteCapture; if (captureOverride != null) { if (_disposed || @@ -738,6 +868,10 @@ class TugboatReplayController extends ChangeNotifier { // stale relative to real screenshot execution. _refreshStateAnchor(); final frameId = await captureOverride(trigger: trigger, force: force); + if (captureGeneration != _captureGeneration || + !identical(_session, captureSession)) { + return _latestFrameId; + } if (frameId != null) { _latestFrameId = frameId; } @@ -758,7 +892,7 @@ class TugboatReplayController extends ChangeNotifier { _captureInFlight) { return _latestFrameId; } - final session = _session; + final session = captureSession; final capturer = _capturer; if (session == null || capturer == null) return _latestFrameId; @@ -786,7 +920,13 @@ class TugboatReplayController extends ChangeNotifier { final queueWaitMicros = DateTime.now() .difference(queueStarted) .inMicroseconds; - if (_disposed || _capturePaused || _skipCapture) return _latestFrameId; + if (_disposed || + _capturePaused || + _skipCapture || + captureGeneration != _captureGeneration || + !identical(_session, session)) { + return _latestFrameId; + } _refreshStateAnchor(); final signature = _currentStateAnchor?.signature ?? ''; if (!force && @@ -803,9 +943,13 @@ class TugboatReplayController extends ChangeNotifier { force: force, waitForFrame: false, ); - if (result == null || _disposed) return _latestFrameId; - final activeSession = _session; - if (activeSession == null) return _latestFrameId; + if (result == null || + _disposed || + captureGeneration != _captureGeneration || + !identical(_session, session)) { + return _latestFrameId; + } + final activeSession = session; _screenshotBudget.record( queueWaitMicros: queueWaitMicros, @@ -1386,56 +1530,132 @@ class TugboatReplayController extends ChangeNotifier { } Future route(String type, Route? route) { + if (_disposed || _session == null || _endSessionFuture != null) { + return Future.value(); + } final transition = _parseRouteTransition(type, route); final change = _resolveVisibleRouteChange(transition); if (change == null) return Future.value(); if (change.updatesRoute) _currentRoute = change.destinationRoute; + _cancelActiveRouteCapture(); + final work = _RouteCaptureWork( + epoch: ++_routeEpoch, + change: change, + deadline: + transition.transitionDuration + + (_shouldSuppressFrameCapture ? Duration.zero : config.settleDelay), + ); + _activeRouteCapture = work; _routeCapturePending = true; _skipCapture = transition.transitionDuration > Duration.zero; - final epoch = ++_routeEpoch; - final postRouteSettle = _shouldSuppressFrameCapture - ? Duration.zero - : config.settleDelay; - return _enqueue('route_change', () async { - try { - await _delay(transition.transitionDuration + postRouteSettle); - _skipCapture = false; - if (_disposed) return; - if (epoch != _routeEpoch) return; - if (change.updatesRoute) _currentRoute = change.destinationRoute; - _refreshStateAnchor(); - final afterFrame = await _requestCapture( - trigger: TugboatFrameTrigger.route, - force: true, - ); - final previousRoute = change.previousRoute; - final destinationRoute = change.destinationRoute; - _addEvent( - TugboatEvent( - id: _nextId('event'), - atMs: atMs, - type: 'route_change', - stateAnchor: _currentStateAnchor, - afterFrame: afterFrame, - result: TugboatInteractionResult.navigated, - data: { - if (previousRoute != null) 'fromRoute': previousRoute, - if (destinationRoute != null) 'route': destinationRoute, - 'navigation': change.navigation, - }, - ), - ); - _maybeEmitSceneInventory(); - if (!_disposed) notifyListeners(); - } finally { + if (work.deadline == Duration.zero) { + // There is no wait to move out of the queue. Keeping this path queued + // preserves the observer-backed zero-duration navigation contract while + // still allowing real transition waits to run independently. + unawaited( + _enqueue( + 'route_change', + () => _finalizeRouteCapture(work), + ).then((_) => work.complete()), + ); + } else { + _startRouteDeadline(work); + } + return work.done; + } + + bool _isActiveRouteCapture(_RouteCaptureWork work) => + !_disposed && !work.cancelled && identical(_activeRouteCapture, work); + + void _cancelActiveRouteCapture() { + final active = _activeRouteCapture; + _activeRouteCapture = null; + if (active != null) _captureGeneration++; + active?.cancel(); + _routeCapturePending = false; + _skipCapture = false; + } + + void _startRouteDeadline(_RouteCaptureWork work) { + final scheduled = _scheduleDelay(work.deadline); + work.attachDeadlineCancellation(scheduled.cancel); + unawaited(_awaitRouteDeadline(work, scheduled.done)); + } + + Future _awaitRouteDeadline( + _RouteCaptureWork work, + Future deadline, + ) async { + try { + await deadline; + if (!_isActiveRouteCapture(work)) return; + _skipCapture = false; + await _enqueueReady('route_change', () => _finalizeRouteCapture(work)); + } catch (error, stackTrace) { + debugPrint('[tugboat] route deadline failed: $error\n$stackTrace'); + } finally { + work.complete(); + } + } + + Future _finalizeRouteCapture(_RouteCaptureWork work) async { + try { + if (!_isActiveRouteCapture(work)) return; + final change = work.change; + if (change.updatesRoute) _currentRoute = change.destinationRoute; + _refreshStateAnchor(); + final capture = _requestCaptureCancellable( + trigger: TugboatFrameTrigger.route, + force: true, + // The route deadline already includes the configured post-route + // settle. Scheduling it again here would delay capture twice and can + // strand widget-backed callers waiting for route completion. + settleDelay: Duration.zero, + ); + work.attachCaptureCancellation(capture.cancel); + final afterFrame = await capture.done; + if (!_isActiveRouteCapture(work)) return; + final previousRoute = change.previousRoute; + final destinationRoute = change.destinationRoute; + _addEvent( + TugboatEvent( + id: _nextId('event'), + atMs: atMs, + type: 'route_change', + stateAnchor: _currentStateAnchor, + afterFrame: afterFrame, + result: TugboatInteractionResult.navigated, + data: { + if (previousRoute != null) 'fromRoute': previousRoute, + if (destinationRoute != null) 'route': destinationRoute, + 'navigation': change.navigation, + }, + ), + ); + _maybeEmitSceneInventory(); + if (!_disposed) notifyListeners(); + } finally { + if (identical(_activeRouteCapture, work)) { + _activeRouteCapture = null; _routeCapturePending = false; + _skipCapture = false; } - }); + } } void recordAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.paused: + case AppLifecycleState.hidden: + case AppLifecycleState.detached: + _cancelActiveRouteCapture(); + break; + case AppLifecycleState.resumed: + case AppLifecycleState.inactive: + break; + } _addEvent( TugboatEvent( id: _nextId('event'), diff --git a/packages/tugboat/test/helpers/replay_coherence_harness.dart b/packages/tugboat/test/helpers/replay_coherence_harness.dart index e6e5bc7..5b8c3c7 100644 --- a/packages/tugboat/test/helpers/replay_coherence_harness.dart +++ b/packages/tugboat/test/helpers/replay_coherence_harness.dart @@ -27,6 +27,7 @@ class ControllableScheduler { final DateTime _epoch; Duration _elapsed; + int _nextOrder = 0; final List<_ScheduledDelay> _delays = <_ScheduledDelay>[]; DateTime now() => _epoch.add(_elapsed); @@ -34,18 +35,43 @@ class ControllableScheduler { Duration get elapsed => _elapsed; Future delay(Duration duration) { + return schedule(duration).done; + } + + ({Future done, void Function() cancel}) schedule(Duration duration) { if (duration <= Duration.zero) { // Mirror Future.delayed(Duration.zero): yield a turn to the event loop. final completer = Completer(); - scheduleMicrotask(completer.complete); - return completer.future; + var cancelled = false; + scheduleMicrotask(() { + if (!cancelled && !completer.isCompleted) completer.complete(); + }); + return ( + done: completer.future, + cancel: () { + cancelled = true; + if (!completer.isCompleted) completer.complete(); + }, + ); } final completer = Completer(); - _delays.add( - _ScheduledDelay(due: _elapsed + duration, completer: completer), + final scheduled = _ScheduledDelay( + due: _elapsed + duration, + order: _nextOrder++, + completer: completer, + ); + _delays.add(scheduled); + _delays.sort((a, b) { + final dueOrder = a.due.compareTo(b.due); + return dueOrder != 0 ? dueOrder : a.order.compareTo(b.order); + }); + return ( + done: completer.future, + cancel: () { + _delays.remove(scheduled); + if (!completer.isCompleted) completer.complete(); + }, ); - _delays.sort((a, b) => a.due.compareTo(b.due)); - return completer.future; } /// Advances virtual time and completes every delay that is now due. @@ -77,9 +103,14 @@ class ControllableScheduler { } class _ScheduledDelay { - _ScheduledDelay({required this.due, required this.completer}); + _ScheduledDelay({ + required this.due, + required this.order, + required this.completer, + }); final Duration due; + final int order; final Completer completer; } @@ -239,6 +270,7 @@ class ReplayCoherenceHarness { }; controller.debugNow = scheduler.now; controller.debugDelay = scheduler.delay; + controller.debugScheduleDelay = scheduler.schedule; controller.debugExecuteCapture = capturer.call; controller.debugFreezeStateAnchor = true; await controller.initialize(); diff --git a/packages/tugboat/test/replay_coherence_characterization_test.dart b/packages/tugboat/test/replay_coherence_characterization_test.dart index dcbe9ac..ff72855 100644 --- a/packages/tugboat/test/replay_coherence_characterization_test.dart +++ b/packages/tugboat/test/replay_coherence_characterization_test.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:ui' show AppLifecycleState, Size; + import 'package:flutter_test/flutter_test.dart'; import 'package:tugboat/tugboat.dart'; @@ -592,6 +595,266 @@ void main() { }, ); + test( + 'route transition delay does not block later serialized controller work', + () async { + final harness = ReplayCoherenceHarness( + settleDelay: const Duration(milliseconds: 30), + ); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.seedRouteState(route: '/', signature: 'sig-root'); + unawaited( + harness.controller.route( + 'route_push', + harness.route( + '/destination', + transitionDuration: const Duration(milliseconds: 200), + ), + ), + ); + + var laterTaskRan = false; + unawaited( + harness.controller.debugEnqueueTask('later_probe', () async { + laterTaskRan = true; + }), + ); + await harness.pumpQueueWork(); + + expect( + laterTaskRan, + isTrue, + reason: 'the transition deadline must be outside the serialized queue', + ); + }, + ); + + test( + 'ending a session cancels its pending route deadline and completes it', + () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + final pending = harness.controller.route( + 'route_push', + harness.route( + '/destination', + transitionDuration: const Duration(milliseconds: 200), + ), + ); + await harness.controller.endSession(); + await pending; + + expect(harness.controller.debugRouteCapturePending, isFalse); + expect(harness.scheduler.pendingDelayCount, 0); + await harness.flushScheduler(); + expect( + harness.controller.session!.ofType('route_change'), + isEmpty, + reason: 'a completed session must not receive a deferred route event', + ); + }, + ); + + test( + 'starting a replacement session cancels the prior route deadline', + () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + final prior = harness.controller.route( + 'route_push', + harness.route( + '/stale', + transitionDuration: const Duration(milliseconds: 200), + ), + ); + harness.controller.start(const Size(390, 844), 'test'); + await prior; + + expect(harness.controller.debugRouteCapturePending, isFalse); + expect(harness.scheduler.pendingDelayCount, 0); + await harness.flushScheduler(); + expect( + harness.controller.session!.ofType('route_change'), + isEmpty, + reason: 'a deferred callback from the old session must be inert', + ); + }, + ); + + test( + 'disposing completes a pending route waiter without advancing time', + () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + + final pending = harness.controller.route( + 'route_push', + harness.route( + '/destination', + transitionDuration: const Duration(milliseconds: 200), + ), + ); + harness.dispose(); + await pending; + + expect(harness.controller.debugRouteCapturePending, isFalse); + expect(harness.scheduler.pendingDelayCount, 0); + }, + ); + + test( + 'backgrounding cancels pending route work without a late route event', + () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + final pending = harness.controller.route( + 'route_push', + harness.route( + '/destination', + transitionDuration: const Duration(milliseconds: 200), + ), + ); + harness.controller.recordAppLifecycleState(AppLifecycleState.paused); + await pending; + + expect(harness.controller.debugRouteCapturePending, isFalse); + expect(harness.scheduler.pendingDelayCount, 0); + expect(harness.controller.session!.ofType('route_change'), isEmpty); + }, + ); + + test( + 'superseding a route during capture emits only the replacement', + () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.capturer.blockNext = true; + final stale = harness.controller.route( + 'route_push', + harness.route('/stale'), + ); + await harness.pumpQueueWork(); + expect(harness.capturer.blockedCount, 1); + + final replacement = harness.controller.route( + 'route_push', + harness.route('/replacement'), + ); + await stale; + harness.capturer.completeBlocked(); + await harness.flushScheduler(); + await replacement; + + final changes = harness.controller.session!.ofType('route_change'); + expect(changes.map((event) => event.data['route']), ['/replacement']); + expect(harness.controller.debugRouteCapturePending, isFalse); + }, + ); + + test( + 'ending a session cancels an in-flight route capture without late output', + () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + final frameCount = harness.controller.session!.frames.length; + harness.capturer.blockNext = true; + final pending = harness.controller.route( + 'route_push', + harness.route('/destination'), + ); + await harness.pumpQueueWork(); + expect(harness.capturer.blockedCount, 1); + + await harness.controller.endSession(); + await pending; + harness.capturer.completeBlocked('cancelled-route-frame'); + await harness.pumpQueueWork(); + + expect(harness.controller.session!.frames.length, frameCount); + expect(harness.controller.session!.ofType('route_change'), isEmpty); + expect(harness.controller.latestFrameId, isNot('cancelled-route-frame')); + }, + ); + + test( + 'replacement session rejects a capture from the prior route epoch', + () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.capturer.blockNext = true; + final stale = harness.controller.route( + 'route_push', + harness.route('/stale'), + ); + await harness.pumpQueueWork(); + expect(harness.capturer.blockedCount, 1); + + harness.controller.start(const Size(390, 844), 'replacement'); + await stale; + harness.capturer.completeBlocked('stale-route-frame'); + await harness.flushScheduler(); + + expect(harness.controller.session!.ofType('route_change'), isEmpty); + expect(harness.controller.latestFrameId, isNot('stale-route-frame')); + expect( + harness.controller.session!.frames + .map((frame) => frame.id) + .contains('stale-route-frame'), + isFalse, + ); + }, + ); + + test( + 'route capture failure completes the deadline and later route work', + () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.capturer.failNext = true; + final failedCapture = harness.controller.route( + 'route_push', + harness.route( + '/first', + transitionDuration: const Duration(milliseconds: 200), + ), + ); + harness.scheduler.advance(const Duration(milliseconds: 700)); + await harness.pumpQueueWork(); + await failedCapture; + + final recovery = harness.controller.route( + 'route_replace', + harness.route('/recovered'), + ); + await harness.flushScheduler(); + await recovery; + + final changes = harness.controller.session!.ofType('route_change'); + expect(changes.map((event) => event.data['route']), [ + '/first', + '/recovered', + ]); + expect(harness.controller.debugRouteCapturePending, isFalse); + expect(harness.scheduler.pendingDelayCount, 0); + }, + ); + test( 'modal push/pop and replacement share the route ordering path', () async { From f7380bf40d11019fef8ee07ee893c190dc47f94b Mon Sep 17 00:00:00 2001 From: Chinmay-KB Date: Sat, 25 Jul 2026 14:20:49 +0530 Subject: [PATCH 2/3] chore: bump tugboat for route scheduler fix --- packages/tugboat/lib/src/sdk_version.dart | 2 +- packages/tugboat/pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tugboat/lib/src/sdk_version.dart b/packages/tugboat/lib/src/sdk_version.dart index ee0531a..b6cbb25 100644 --- a/packages/tugboat/lib/src/sdk_version.dart +++ b/packages/tugboat/lib/src/sdk_version.dart @@ -1,3 +1,3 @@ // Keep this in sync with packages/tugboat/pubspec.yaml. The SDK version test // reads pubspec.yaml directly so release bumps fail fast if this drifts. -const tugboatSdkVersion = '0.4.1'; +const tugboatSdkVersion = '0.4.2'; diff --git a/packages/tugboat/pubspec.yaml b/packages/tugboat/pubspec.yaml index 80b576d..f4a0373 100644 --- a/packages/tugboat/pubspec.yaml +++ b/packages/tugboat/pubspec.yaml @@ -1,7 +1,7 @@ name: tugboat description: >- Screenshot-based session replay with compact interaction anchors for Tugboat. -version: 0.4.1 +version: 0.4.2 repository: https://github.com/blendto/tugboat-flutter issue_tracker: https://github.com/blendto/tugboat-flutter/issues homepage: https://github.com/blendto/tugboat-flutter From 5cc05f345cbf9db990da4fb3a584da35e90404d8 Mon Sep 17 00:00:00 2001 From: Chinmay-KB Date: Sat, 25 Jul 2026 21:49:14 +0530 Subject: [PATCH 3/3] fix(replay): cancel empty scheduled captures --- packages/tugboat/lib/src/controller.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/tugboat/lib/src/controller.dart b/packages/tugboat/lib/src/controller.dart index 7af4f58..6956c7f 100644 --- a/packages/tugboat/lib/src/controller.dart +++ b/packages/tugboat/lib/src/controller.dart @@ -779,7 +779,11 @@ class TugboatReplayController extends ChangeNotifier { return ( done: completer.future, cancel: () { - _scheduledCapture?.waiters.remove(completer); + final scheduled = _scheduledCapture; + scheduled?.waiters.remove(completer); + if (scheduled != null && scheduled.waiters.isEmpty) { + _scheduledCapture = null; + } if (!completer.isCompleted) completer.complete(_latestFrameId); }, ); @@ -1550,7 +1554,7 @@ class TugboatReplayController extends ChangeNotifier { _activeRouteCapture = work; _routeCapturePending = true; _skipCapture = transition.transitionDuration > Duration.zero; - if (work.deadline == Duration.zero) { + if (work.deadline <= Duration.zero) { // There is no wait to move out of the queue. Keeping this path queued // preserves the observer-backed zero-duration navigation contract while // still allowing real transition waits to run independently.