From 6641d85f0550ca60433fc8e0d13a3dafdbb664d6 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 9 Nov 2026 15:24:01 +0100 Subject: [PATCH] crdt_sync_dart 0.12.1: serialize FileLogPersistence writes Co-Authored-By: Claude Sonnet 5.5 --- crdt_sync_dart/lib/src/io_store.dart | 20 +++++++++++--- crdt_sync_dart/pubspec.yaml | 2 +- .../test/default_client_and_io_test.dart | 27 +++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/crdt_sync_dart/lib/src/io_store.dart b/crdt_sync_dart/lib/src/io_store.dart index 89795db..6ef9acc 100644 --- a/crdt_sync_dart/lib/src/io_store.dart +++ b/crdt_sync_dart/lib/src/io_store.dart @@ -7,8 +7,11 @@ import 'store.dart'; /// Writes atomically -- to a per-process temp file, then `rename`d over the /// real path -- so a concurrent reader (e.g. a background sync isolate writing /// while the foreground app also writes) never observes a half-written file. -/// The pid in the temp name keeps two writers from colliding. This generalizes -/// diet_guard's hand-rolled `LogStorageService` write scheme. +/// The pid in the temp name keeps two processes from colliding; within one +/// instance, writes are serialized in issue order (last issued wins), so +/// overlapping calls (e.g. per-keystroke saves) never share the temp file +/// mid-flight. This generalizes diet_guard's hand-rolled `LogStorageService` +/// write scheme. /// /// Lives behind the `package:crdt_sync/crdt_sync_io.dart` entrypoint, not the /// main barrel, because it imports `dart:io` and so is unavailable on web -- @@ -20,6 +23,10 @@ class FileLogPersistence implements LogPersistence { final File _file; + /// Completes when the most recently issued write has settled. Never + /// rejects, so one failed write cannot block the ones queued after it. + Future _tail = Future.value(); + @override Future read() async { if (!_file.existsSync()) return null; @@ -31,7 +38,14 @@ class FileLogPersistence implements LogPersistence { } @override - Future write(String text) async { + Future write(String text) { + final result = _tail.then((_) => _writeNow(text)); + // The caller still sees its own error via [result]; the chain ignores it. + _tail = result.then((_) {}, onError: (Object _) {}); + return result; + } + + Future _writeNow(String text) async { await _file.parent.create(recursive: true); final tmp = File('${_file.path}.$pid.tmp'); await tmp.writeAsString(text); diff --git a/crdt_sync_dart/pubspec.yaml b/crdt_sync_dart/pubspec.yaml index b76c2a9..6abcb6a 100644 --- a/crdt_sync_dart/pubspec.yaml +++ b/crdt_sync_dart/pubspec.yaml @@ -2,7 +2,7 @@ name: crdt_sync description: >- Shared CRDT merge scheme + GitHub-Contents-API sync transport for cross-device apps. Pure Dart, no Flutter dependency. -version: 0.12.0 +version: 0.12.1 publish_to: 'none' environment: diff --git a/crdt_sync_dart/test/default_client_and_io_test.dart b/crdt_sync_dart/test/default_client_and_io_test.dart index 74d898e..2bbddaf 100644 --- a/crdt_sync_dart/test/default_client_and_io_test.dart +++ b/crdt_sync_dart/test/default_client_and_io_test.dart @@ -128,5 +128,32 @@ void main() { await store.write('{"hello":"world"}'); expect(await store.read(), '{"hello":"world"}'); }); + + test('50 overlapping writes all succeed and the last issued wins', + () async { + // Regression: every call shared one `..tmp`, so the first + // rename moved it away and a later rename threw PathNotFoundException. + final store = FileLogPersistence(File('${dir.path}/log.json')); + await Future.wait([ + for (var i = 0; i < 50; i++) store.write('{"n":$i}'), + ]); + expect(await store.read(), '{"n":49}'); + expect(dir.listSync().map((e) => e.uri.pathSegments.last), + ['log.json']); + }); + + test('a failed write rejects its caller but does not block later writes', + () async { + final target = File('${dir.path}/log.json'); + // A directory at the target path makes the rename fail. + Directory(target.path).createSync(); + final store = FileLogPersistence(target); + await expectLater( + store.write('{"n":0}'), throwsA(isA())); + + Directory(target.path).deleteSync(); + await store.write('{"n":1}'); + expect(await store.read(), '{"n":1}'); + }); }); }