Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,10 @@ class _SecondaryPerformanceControls extends StatelessWidget {
const SizedBox(width: denseSpacing),
OpenSaveButtonGroup(
screenId: ScreenMetaData.performance.id,
onSave: (_) => controller.exportData(),
onSave: (_) async {
await controller.fetchFullDataBeforeExport();
controller.exportData();
},
),
if (isFlutterApp) ...[
const SizedBox(width: denseSpacing),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ class TimelineEventsController extends PerformanceFeatureController

int _nextPollStartMicros = 0;

/// The most recent pull of the VM timeline, if any.
Future<void>? _latestPull;

/// The number of requests per second that we allow for pulling the VM
/// timeline.
///
Expand Down Expand Up @@ -170,7 +173,26 @@ class TimelineEventsController extends PerformanceFeatureController
});
}

Future<void> _pullPerfettoVmTimeline({bool isInitialPull = false}) async {
/// Pulls the VM timeline events that have not been received yet.
///
/// Pulls can be started by polling, [forceRefresh], and
/// [pullLatestTimelineEvents], so each pull waits for the previous one to
/// finish. Otherwise two pulls could request the same time range and add
/// the same events to the trace twice.
Future<void> _pullPerfettoVmTimeline({bool isInitialPull = false}) {
final previousPull = _latestPull;
return _latestPull = () async {
if (previousPull != null) {
// Errors from the previous pull are handled by its own caller.
await previousPull.catchError((_) {});
}
await _pullPerfettoVmTimelineHelper(isInitialPull: isInitialPull);
}();
}

Future<void> _pullPerfettoVmTimelineHelper({
bool isInitialPull = false,
}) async {
final service = serviceConnection.serviceManager.service;
if (service == null) return;
final currentVmTime = await service.getVMTimelineMicros();
Expand Down Expand Up @@ -342,6 +364,18 @@ class TimelineEventsController extends PerformanceFeatureController
await loadPerfettoTrace();
}

/// Pulls any timeline events from the VM that have not been received yet.
///
/// Unlike [forceRefresh], this does not reload the Perfetto trace viewer.
Future<void> pullLatestTimelineEvents() async {
try {
await _pullPerfettoVmTimeline();
} catch (e, st) {
// Don't block the export of the data we already have.
_log.warning('Failed to pull the latest timeline events.', e, st);
}
}
Comment on lines +370 to +377

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[MUST-FIX] Potential Race Condition / Duplicate Timeline Events

Calling _pullPerfettoVmTimeline() concurrently can lead to a race condition where duplicate timeline events are fetched and added to traceRingBuffer.

Why this happens:

In _pullPerfettoVmTimeline(), _nextPollStartMicros is read at the beginning of the method but is only updated at the very end (after several asynchronous await points like getVMTimelineMicros and getPerfettoVMTimeline).
If pullLatestTimelineEvents() is called while a periodic poll (via _timelinePollingRateLimiter) or a manual refresh (via forceRefresh()) is already in progress, both executions will use the same _nextPollStartMicros value, resulting in overlapping time ranges and duplicate trace packets being appended to traceRingBuffer.

Suggested Fix:

To prevent concurrent executions of _pullPerfettoVmTimeline(), we should track the active pull Future and return it if a pull is already in progress.

Since _pullPerfettoVmTimeline is outside the current diff hunk, you can declare a Future<void>? _activePull field in TimelineEventsController and update _pullPerfettoVmTimeline as follows:

Future<void>? _activePull;

Future<void> _pullPerfettoVmTimeline({bool isInitialPull = false}) async {
  if (_activePull != null) {
    return _activePull;
  }
  final completer = Completer<void>();
  _activePull = completer.future;
  try {
    final service = serviceConnection.serviceManager.service;
    if (service == null) return;
    // ... existing implementation ...
    completer.complete();
  } catch (e, st) {
    completer.completeError(e, st);
    rethrow;
  } finally {
    _activePull = null;
  }
}
References
  1. Prefix every comment with a severity: [MUST-FIX] for logical bugs. (link)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks! fixed


void processTrackEvents() {
if (!_isFlutterAppHelper()) {
debugTraceCallback(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,20 @@ class PerformanceController extends DevToolsScreenController
).toJson(),
);

/// Pulls the latest timeline events so that they are included in the export.
///
/// Timeline events are only polled periodically, so the most recent events
/// would otherwise be missing from the export.
Future<void> fetchFullDataBeforeExport() async {
final serviceManager = serviceConnection.serviceManager;
if (offlineDataController.showingOfflineData.value ||
!serviceManager.connectedState.value.connected ||
(serviceManager.connectedApp?.isDartWebAppNow ?? false)) {
return;
}
await timelineEventsController.pullLatestTimelineEvents();
}

@override
FutureOr<void> releaseMemory({bool partial = false}) async {
await clearData(partial: partial, clearVmTimeline: !partial);
Expand Down
4 changes: 4 additions & 0 deletions packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ TODO: Remove this section if there are not any updates.
available for this frame" when a frame is selected before its timeline events
have been fetched from the VM.
[#10050](https://github.com/flutter/devtools/pull/10050)
* Fixed a bug where data exported from the Performance screen could be
missing the most recent timeline events. -
[#10055](https://github.com/flutter/devtools/pull/10055),
[#9731](https://github.com/flutter/devtools/issues/9731)

## CPU profiler updates

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,17 @@ void main() {
expect(controller.flutterFramesController.isActiveFeature, isTrue);
expect(controller.timelineEventsController.isActiveFeature, isTrue);
});

test('fetchFullDataBeforeExport does not pull timeline events when showing '
'offline data', () async {
final mockServiceManager =
mockServiceConnection.serviceManager as MockServiceManager;
clearInteractions(mockServiceManager);

await controller.fetchFullDataBeforeExport();

verifyNever(mockServiceManager.service);
expect(controller.timelineEventsController.fullPerfettoTrace, isEmpty);
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'package:devtools_app_shared/utils.dart';
import 'package:devtools_test/devtools_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:vm_service/vm_service.dart';

import '../../../test_infra/test_data/performance/sample_performance_data.dart';

Expand Down Expand Up @@ -82,6 +83,64 @@ void main() {
);
});

group('pullLatestTimelineEvents', () {
setUp(() {
setGlobal(PreferencesController, PreferencesController());
});

test('adds the latest VM timeline to the trace', () async {
expect(eventsController.fullPerfettoTrace, isEmpty);

await eventsController.pullLatestTimelineEvents();

expect(eventsController.fullPerfettoTrace, isNotEmpty);
});

test('completes without throwing when the pull fails', () async {
// This fake service throws when the VM timeline is requested because
// it was not provided any timeline data.
setGlobal(ServiceConnectionManager, FakeServiceConnectionManager());

await expectLater(
eventsController.pullLatestTimelineEvents(),
completes,
);
expect(eventsController.fullPerfettoTrace, isEmpty);
});

test('does not request the same time range twice', () async {
final mockService = MockVmServiceWrapper();
var currentVmTimeMicros = 0;
when(mockService.getVMTimelineMicros()).thenAnswer((_) async {
currentVmTimeMicros += 100;
return Timestamp(timestamp: currentVmTimeMicros);
});
final requestedTimeOrigins = <int?>[];
when(
mockService.getPerfettoVMTimeline(
timeOriginMicros: anyNamed('timeOriginMicros'),
timeExtentMicros: anyNamed('timeExtentMicros'),
),
).thenAnswer((invocation) async {
requestedTimeOrigins.add(
invocation.namedArguments[#timeOriginMicros] as int?,
);
return perfettoVmTimeline;
});
final serviceConnectionWithMock = FakeServiceConnectionManager();
serviceConnectionWithMock.serviceManager.service = mockService;
setGlobal(ServiceConnectionManager, serviceConnectionWithMock);

// Start the second pull before the first one has finished.
await [
eventsController.pullLatestTimelineEvents(),
eventsController.pullLatestTimelineEvents(),
].wait;

expect(requestedTimeOrigins, equals([0, 101]));
});
});

group('can clearData', () {
setUp(() async {
// Set some data in the controller. It does not matter if it is connected
Expand Down
Loading