Skip to content

Commit 3598ef8

Browse files
Fix preview generation race condition when toggling filters rapidly
Concurrent _generateProcessedPreview() calls (fired from non-awaited timer callbacks) would clobber each other's cancel tokens and prematurely clear the isGeneratingPreview flag, causing previews to silently stop updating. Add a generation counter so only the most recent call updates shared state, use a local cancel token reference instead of the instance variable, and guard _previewProcess cleanup to avoid nulling a newer call's process. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c0eaf9d commit 3598ef8

2 files changed

Lines changed: 26 additions & 8 deletions

File tree

app/lib/services/preview_generator.dart

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ class PreviewGenerator {
213213
// We no longer double for FPSDivisor=1 because we're seeking in the source
214214
final frameNumber = (timeSeconds * _frameRate).round();
215215
final configPath = '$_tempDir/preview_config_${DateTime.now().millisecondsSinceEpoch}.json';
216+
Process? process;
216217

217218
try {
218219
// Set TFF based on field order for QTGMC
@@ -250,7 +251,7 @@ class PreviewGenerator {
250251
// Run worker in preview mode
251252
// Use local variable to avoid race conditions when another preview request cancels this one
252253
// Set workingDirectory to the worker's parent directory so relative deps paths resolve correctly
253-
final process = await Process.start(
254+
process = await Process.start(
254255
_workerPath!,
255256
[
256257
'--config', configPath,
@@ -299,7 +300,9 @@ class PreviewGenerator {
299300

300301
// Wait for process to complete (use local variable to avoid race conditions)
301302
final exitCode = await process.exitCode;
302-
_previewProcess = null;
303+
if (_previewProcess == process) {
304+
_previewProcess = null;
305+
}
303306

304307
// Log the result
305308
_previewLog.add('[${DateTime.now().toIso8601String()}] Process exited with code $exitCode, output size: ${pngBytes.length} bytes');
@@ -321,7 +324,9 @@ class PreviewGenerator {
321324
_lastError = 'Preview generation error: $e';
322325
_previewLog.add('[ERROR] $e');
323326
} finally {
324-
_previewProcess = null;
327+
if (_previewProcess == process) {
328+
_previewProcess = null;
329+
}
325330
// Clean up config file on error
326331
try {
327332
await File(configPath).delete();

app/lib/viewmodels/main_viewmodel.dart

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ class MainViewModel extends ChangeNotifier {
6161
bool _isGeneratingPreview = false;
6262
CancelToken? _previewCancelToken;
6363
Timer? _previewDebounceTimer;
64+
int _previewGeneration = 0;
6465
Timer? _zoomDebounceTimer;
6566
bool _isLoadingZoomedThumbnails = false;
6667

@@ -785,17 +786,25 @@ class MainViewModel extends ChangeNotifier {
785786
}
786787

787788
/// Generates the processed preview at the current scrubber position.
789+
///
790+
/// Uses a generation counter to handle concurrent calls safely. The timer
791+
/// callback cannot await this async method, so multiple calls can overlap
792+
/// when filters are toggled rapidly. Only the most recent generation is
793+
/// allowed to update shared state (_isGeneratingPreview, processedPreview).
788794
Future<void> _generateProcessedPreview() async {
789795
final item = selectedItem;
790796
if (item == null) return;
791797

792798
// Cancel any existing preview generation
793799
_cancelPreviewGeneration();
794800

801+
final generation = ++_previewGeneration;
802+
795803
_isGeneratingPreview = true;
796804
notifyListeners();
797805

798-
_previewCancelToken = CancelToken();
806+
final cancelToken = CancelToken();
807+
_previewCancelToken = cancelToken;
799808
final timeSeconds = item.scrubberPosition * _previewGenerator.duration;
800809

801810
try {
@@ -804,18 +813,22 @@ class MainViewModel extends ChangeNotifier {
804813
pipeline: _processingPipeline,
805814
fieldOrder: effectiveFieldOrder,
806815
encodingSettings: _encodingSettings,
807-
cancelToken: _previewCancelToken,
816+
cancelToken: cancelToken,
808817
);
809818

810-
if (!(_previewCancelToken?.isCancelled ?? true)) {
819+
if (generation == _previewGeneration && !cancelToken.isCancelled) {
811820
item.processedPreview = preview;
812821
}
813822
} catch (e) {
814823
// Ignore errors from cancelled previews
815824
}
816825

817-
_isGeneratingPreview = false;
818-
notifyListeners();
826+
// Only clear the generating flag if this is still the latest generation.
827+
// Otherwise a newer call is in flight and owns the flag.
828+
if (generation == _previewGeneration) {
829+
_isGeneratingPreview = false;
830+
notifyListeners();
831+
}
819832
}
820833

821834
/// Cancels any ongoing preview generation.

0 commit comments

Comments
 (0)