fixes multiple race condition issues

This commit is contained in:
otsmr 2026-04-24 23:14:48 +02:00
parent 919aec464e
commit e8d8e8b160
5 changed files with 128 additions and 90 deletions

View file

@ -15,6 +15,7 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pbserver.dart'; import 'package:twonly/src/model/protobuf/client/generated/messages.pbserver.dart';
import 'package:twonly/src/services/api/messages.api.dart'; import 'package:twonly/src/services/api/messages.api.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/exclusive_access.utils.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
@ -158,7 +159,8 @@ Future<void> handleDownloadStatusUpdate(TaskStatusUpdate update) async {
} }
} }
Mutex protectDownload = Mutex(); Mutex _protectDownload = Mutex();
Mutex _protectDecryption = Mutex();
Future<void> startDownloadMedia(MediaFile media, bool force) async { Future<void> startDownloadMedia(MediaFile media, bool force) async {
final mediaService = MediaFileService(media); final mediaService = MediaFileService(media);
@ -175,7 +177,7 @@ Future<void> startDownloadMedia(MediaFile media, bool force) async {
return; return;
} }
final isBlocked = await protectDownload.protect<bool>(() async { final isBlocked = await _protectDownload.protect<bool>(() async {
final msg = await twonlyDB.mediaFilesDao.getMediaFileById(media.mediaId); final msg = await twonlyDB.mediaFilesDao.getMediaFileById(media.mediaId);
if (msg == null || msg.downloadState != DownloadState.pending) { if (msg == null || msg.downloadState != DownloadState.pending) {
@ -285,66 +287,86 @@ Future<void> requestMediaReupload(String mediaId) async {
} }
Future<void> handleEncryptedFile(String mediaId) async { Future<void> handleEncryptedFile(String mediaId) async {
final mediaService = await MediaFileService.fromMediaId(mediaId); await exclusiveAccess(
if (mediaService == null) { lockName: 'decryption-$mediaId',
Log.error('Media file not found in database.'); mutex: _protectDecryption,
return; action: () async {
} final mediaService = await MediaFileService.fromMediaId(mediaId);
if (mediaService == null) {
Log.error('Media file not found in database.');
return;
}
await twonlyDB.mediaFilesDao.updateMedia( if (mediaService.mediaFile.downloadState == DownloadState.ready) {
mediaId, Log.info('Decryption of $mediaId already finished.');
const MediaFilesCompanion( return;
downloadState: Value(DownloadState.downloaded), }
),
if (!mediaService.encryptedPath.existsSync()) {
Log.warn(
'Encrypted media file $mediaId does not exist anymore. Decryption probably already finished.',
);
return;
}
late Uint8List encryptedBytes;
try {
encryptedBytes = await mediaService.encryptedPath.readAsBytes();
} catch (e) {
Log.error('Could not read encrypted media file: $e');
await requestMediaReupload(mediaId);
return;
}
await twonlyDB.mediaFilesDao.updateMedia(
mediaId,
const MediaFilesCompanion(
downloadState: Value(DownloadState.downloaded),
),
);
try {
final chacha20 = FlutterChacha20.poly1305Aead();
final secretKeyData = SecretKeyData(
mediaService.mediaFile.encryptionKey!,
);
final secretBox = SecretBox(
encryptedBytes,
nonce: mediaService.mediaFile.encryptionNonce!,
mac: Mac(mediaService.mediaFile.encryptionMac!),
);
final plaintextBytes = await chacha20.decrypt(
secretBox,
secretKey: secretKeyData,
);
final rawMediaBytes = Uint8List.fromList(plaintextBytes);
await mediaService.tempPath.writeAsBytes(rawMediaBytes);
} catch (e) {
Log.error(
'Could not decrypt the media file. Requesting a new upload.',
);
await requestMediaReupload(mediaId);
return;
}
await twonlyDB.mediaFilesDao.updateMedia(
mediaId,
const MediaFilesCompanion(
downloadState: Value(DownloadState.ready),
),
);
Log.info('Decryption of $mediaId was successful');
mediaService.encryptedPath.deleteSync();
unawaited(apiService.downloadDone(mediaService.mediaFile.downloadToken!));
},
); );
late Uint8List encryptedBytes;
try {
encryptedBytes = await mediaService.encryptedPath.readAsBytes();
} catch (e) {
Log.error('Could not read encrypted media file: $e');
await requestMediaReupload(mediaId);
return;
}
try {
final chacha20 = FlutterChacha20.poly1305Aead();
final secretKeyData = SecretKeyData(mediaService.mediaFile.encryptionKey!);
final secretBox = SecretBox(
encryptedBytes,
nonce: mediaService.mediaFile.encryptionNonce!,
mac: Mac(mediaService.mediaFile.encryptionMac!),
);
final plaintextBytes = await chacha20.decrypt(
secretBox,
secretKey: secretKeyData,
);
final rawMediaBytes = Uint8List.fromList(plaintextBytes);
await mediaService.tempPath.writeAsBytes(rawMediaBytes);
} catch (e) {
Log.error(
'Could not decrypt the media file. Requesting a new upload.',
);
await requestMediaReupload(mediaId);
return;
}
await twonlyDB.mediaFilesDao.updateMedia(
mediaId,
const MediaFilesCompanion(
downloadState: Value(DownloadState.ready),
),
);
Log.info('Decryption of $mediaId was successful');
mediaService.encryptedPath.deleteSync();
unawaited(apiService.downloadDone(mediaService.mediaFile.downloadToken!));
} }
Future<void> makeMigrationToVersion91() async { Future<void> makeMigrationToVersion91() async {

View file

@ -6,7 +6,7 @@ import 'package:twonly/locator.dart';
import 'package:twonly/main.dart'; import 'package:twonly/main.dart';
import 'package:twonly/src/constants/keyvalue.keys.dart'; import 'package:twonly/src/constants/keyvalue.keys.dart';
import 'package:twonly/src/services/api/mediafiles/upload.api.dart'; import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
import 'package:twonly/src/utils/exclusive_access.dart'; import 'package:twonly/src/utils/exclusive_access.utils.dart';
import 'package:twonly/src/utils/keyvalue.dart'; import 'package:twonly/src/utils/keyvalue.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:workmanager/workmanager.dart'; import 'package:workmanager/workmanager.dart';

View file

@ -8,7 +8,7 @@ Future<T> exclusiveAccess<T>({
required Future<T> Function() action, required Future<T> Function() action,
required Mutex mutex, required Mutex mutex,
}) async { }) async {
final lockFile = File('${AppEnvironment.supportDir}/$lockName.lock'); final lockFile = File('${AppEnvironment.cacheDir}/$lockName.lock');
return mutex.protect(() async { return mutex.protect(() async {
var lockAcquired = false; var lockAcquired = false;
@ -24,8 +24,8 @@ Future<T> exclusiveAccess<T>({
try { try {
final stat = lockFile.statSync(); final stat = lockFile.statSync();
if (stat.type != FileSystemEntityType.notFound) { if (stat.type != FileSystemEntityType.notFound) {
final age = DateTime.now().difference(stat.modified).inMilliseconds; final age = DateTime.now().difference(stat.modified).inSeconds;
if (age > 1000) { if (age > 10) {
lockFile.deleteSync(); lockFile.deleteSync();
continue; continue;
} }

View file

@ -1,14 +1,27 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:mutex/mutex.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/utils/exclusive_access.utils.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
class KeyValueStore { class KeyValueStore {
static final Mutex _mutex = Mutex();
static Future<File> _getFilePath(String key) async { static Future<File> _getFilePath(String key) async {
return File('${AppEnvironment.supportDir}/keyvalue/$key.json'); return File('${AppEnvironment.supportDir}/keyvalue/$key.json');
} }
static Future<void> delete(String key) async { static Future<T> _exclusive<T>(String key, Future<T> Function() action) {
return exclusiveAccess(
lockName: 'keyvalue-$key',
mutex: _mutex,
action: action,
);
}
static Future<void> delete(String key) => _exclusive(key, () async {
try { try {
final file = await _getFilePath(key); final file = await _getFilePath(key);
if (file.existsSync()) { if (file.existsSync()) {
@ -17,30 +30,33 @@ class KeyValueStore {
} catch (e) { } catch (e) {
Log.error('Error deleting file: $e'); Log.error('Error deleting file: $e');
} }
} });
static Future<Map<String, dynamic>?> get(String key) async { static Future<Map<String, dynamic>?> get(String key) =>
try { _exclusive(key, () async {
final file = await _getFilePath(key); final file = await _getFilePath(key);
if (file.existsSync()) { try {
final contents = await file.readAsString(); if (file.existsSync()) {
return jsonDecode(contents) as Map<String, dynamic>; final contents = await file.readAsString();
} else { return jsonDecode(contents) as Map<String, dynamic>;
return null; } else {
} return null;
} catch (e) { }
Log.warn('Error reading file: $e'); } catch (e) {
return null; Log.warn('Error reading file. Deleting it.: $e');
} file.deleteSync();
} return null;
}
});
static Future<void> put(String key, Map<String, dynamic> value) async { static Future<void> put(String key, Map<String, dynamic> value) =>
try { _exclusive(key, () async {
final file = await _getFilePath(key); try {
await file.parent.create(recursive: true); final file = await _getFilePath(key);
await file.writeAsString(jsonEncode(value)); await file.parent.create(recursive: true);
} catch (e) { await file.writeAsString(jsonEncode(value));
Log.error('Error writing file: $e'); } catch (e) {
} Log.error('Error writing file: $e');
} }
});
} }

View file

@ -6,7 +6,7 @@ import 'package:logging/logging.dart';
import 'package:mutex/mutex.dart'; import 'package:mutex/mutex.dart';
import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/utils/exclusive_access.dart'; import 'package:twonly/src/utils/exclusive_access.utils.dart';
class Log { class Log {
static bool _isInitialized = false; static bool _isInitialized = false;