FB-11: serialize FileTileCache mutations and log tile fetch failures
Fixes a real race in FileTileCache where two overlapping put() calls (the persistent background map and a freshly-opened Route Planner map both fetching tiles at once) could interleave at the _saveManifest await point and silently lose a tile from the on-disk manifest. All mutating and reading operations now go through a single serialization queue. Also logs the URL and cause of tile fetch failures in CachedTileProvider before rethrowing, and adds a concurrency regression test. On-device verification was not performed (no adb/emulator access in this environment); see the ticket's Outcome section.
This commit is contained in:
@@ -91,7 +91,11 @@ class _CacheBackedImage extends ImageProvider<_CacheBackedImage> {
|
||||
await cache.put(key, bytes);
|
||||
connectivity?.reportSuccess();
|
||||
return bytes;
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
// FB-11: record the URL and the underlying HTTP status/exception so a real
|
||||
// fetch failure is visible on-device -- before this, `catch (_)` discarded the
|
||||
// cause and there was no way to tell a real failure from a rendering bug.
|
||||
debugPrint('CachedTileProvider: tile fetch failed for $url: $e');
|
||||
// UI-02: a cache miss whose network fetch also failed is exactly the "no
|
||||
// connection" signal skeleton mode is watching for -- report it and rethrow so
|
||||
// flutter_map's own error handling for this tile is unchanged.
|
||||
|
||||
@@ -30,16 +30,44 @@ abstract class TileCache {
|
||||
/// last-access time for LRU eviction. No database engine for what is, at the end of the
|
||||
/// day, a directory of small binary blobs with one number (last access) attached to each.
|
||||
class FileTileCache implements TileCache {
|
||||
FileTileCache({required Directory directory, required this.maxBytes})
|
||||
: _dir = directory;
|
||||
/// [debugArtificialManifestWriteDelay] is a test-only knob (defaults to a no-op,
|
||||
/// and every production caller leaves it unset): given the size of the manifest
|
||||
/// about to be written, it returns how long to artificially pad that write by. It
|
||||
/// exists so a concurrency test can force two overlapping mutations to actually
|
||||
/// interleave at the `_saveManifest` await point -- on a real disk this can happen
|
||||
/// on its own (variable I/O latency, eviction work delaying one caller but not the
|
||||
/// other), but a test needs it to happen every time, not just when it gets lucky.
|
||||
FileTileCache({
|
||||
required Directory directory,
|
||||
required this.maxBytes,
|
||||
Duration Function(int entryCount)? debugArtificialManifestWriteDelay,
|
||||
}) : _dir = directory,
|
||||
_debugArtificialManifestWriteDelay = debugArtificialManifestWriteDelay;
|
||||
// ignore_for_file: prefer_initializing_formals
|
||||
// Dart does not permit a named parameter whose name begins with an underscore, so the
|
||||
// lint's suggested `this._debugArtificialManifestWriteDelay` will not compile here.
|
||||
|
||||
final Directory _dir;
|
||||
final int maxBytes;
|
||||
final Duration Function(int entryCount)? _debugArtificialManifestWriteDelay;
|
||||
|
||||
final _manifest = <String, _Entry>{};
|
||||
bool _loaded = false;
|
||||
int _clock = 0;
|
||||
|
||||
/// Serializes every mutating (and manifest-reading) operation so two overlapping
|
||||
/// calls -- e.g. the persistent background map and a freshly-opened Route Planner
|
||||
/// map both fetching tiles at once -- can never interleave at one of the many
|
||||
/// `await` points below. Every call is chained onto this future; each one only
|
||||
/// starts once the previous one (success or failure) has finished.
|
||||
Future<void> _queue = Future.value();
|
||||
|
||||
Future<T> _serialized<T>(Future<T> Function() op) {
|
||||
final result = _queue.then((_) => op());
|
||||
_queue = result.then((_) {}, onError: (_) {});
|
||||
return result;
|
||||
}
|
||||
|
||||
File get _manifestFile => File('${_dir.path}/manifest.json');
|
||||
File _tileFile(TileKey key) => File('${_dir.path}/${_fileName(key)}');
|
||||
String _fileName(TileKey key) => '${key.z}_${key.x}_${key.y}.tile';
|
||||
@@ -60,15 +88,20 @@ class FileTileCache implements TileCache {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveManifest() => _manifestFile.writeAsString(
|
||||
jsonEncode({
|
||||
Future<void> _saveManifest() async {
|
||||
final encoded = jsonEncode({
|
||||
for (final e in _manifest.entries)
|
||||
e.key: {'bytes': e.value.bytes, 'lastAccess': e.value.lastAccess},
|
||||
}),
|
||||
);
|
||||
});
|
||||
final delay = _debugArtificialManifestWriteDelay?.call(_manifest.length);
|
||||
if (delay != null && delay > Duration.zero) {
|
||||
await Future<void>.delayed(delay);
|
||||
}
|
||||
await _manifestFile.writeAsString(encoded);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> put(TileKey key, Uint8List bytes) async {
|
||||
Future<void> put(TileKey key, Uint8List bytes) => _serialized(() async {
|
||||
await _ensureLoaded();
|
||||
final name = _fileName(key);
|
||||
|
||||
@@ -83,7 +116,7 @@ class FileTileCache implements TileCache {
|
||||
await _tileFile(key).writeAsBytes(bytes);
|
||||
_manifest[name] = _Entry(bytes: bytes.length, lastAccess: _clock++);
|
||||
await _saveManifest();
|
||||
}
|
||||
});
|
||||
|
||||
Future<void> _evictUntilFits(int incomingBytes) async {
|
||||
// Oldest-accessed first.
|
||||
@@ -100,7 +133,7 @@ class FileTileCache implements TileCache {
|
||||
int _totalBytes() => _manifest.values.fold(0, (sum, e) => sum + e.bytes);
|
||||
|
||||
@override
|
||||
Future<Uint8List?> get(TileKey key) async {
|
||||
Future<Uint8List?> get(TileKey key) => _serialized(() async {
|
||||
await _ensureLoaded();
|
||||
final name = _fileName(key);
|
||||
final entry = _manifest[name];
|
||||
@@ -115,16 +148,16 @@ class FileTileCache implements TileCache {
|
||||
}
|
||||
entry.lastAccess = _clock++;
|
||||
return file.readAsBytes();
|
||||
}
|
||||
});
|
||||
|
||||
@override
|
||||
Future<int> sizeBytes() async {
|
||||
Future<int> sizeBytes() => _serialized(() async {
|
||||
await _ensureLoaded();
|
||||
return _totalBytes();
|
||||
}
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
Future<void> clear() => _serialized(() async {
|
||||
await _ensureLoaded();
|
||||
for (final key in _manifest.keys.toList()) {
|
||||
final f = File('${_dir.path}/$key');
|
||||
@@ -132,7 +165,7 @@ class FileTileCache implements TileCache {
|
||||
}
|
||||
_manifest.clear();
|
||||
await _saveManifest();
|
||||
}
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
|
||||
Reference in New Issue
Block a user