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:
2026-08-25 11:36:21 -05:00
parent 4051416add
commit dfd3e24062
4 changed files with 340 additions and 15 deletions

View File

@@ -90,6 +90,45 @@ void main() {
expect(await reopened.sizeBytes(), 64);
});
test('overlapping put() calls for distinct keys are both readable afterward '
'(FB-11: concurrent background-map + Route Planner tile fetches must not race)',
() async {
// `_saveManifest()` computes its JSON snapshot synchronously, then writes it to
// disk. Two overlapping `put()` calls can interleave so that the call that
// captured the *older*, smaller snapshot (fewer entries) is also the one whose
// disk write finishes last -- silently overwriting the newer, complete manifest
// with a stale one that is missing the other call's tile. On a real device this
// depends on incidental I/O timing (which is exactly why it was so hard to catch
// and produced a rider-visible blank map only sometimes); this artificial delay
// makes that interleaving happen every single time instead of by chance, so the
// test is deterministic rather than flaky. It has no effect on production
// callers, which never pass it.
cache = FileTileCache(
directory: tempDir,
maxBytes: 1024 * 1024,
debugArtificialManifestWriteDelay: (entryCount) =>
entryCount < 2 ? const Duration(milliseconds: 50) : Duration.zero,
);
const a = TileKey(9, 1, 0);
const b = TileKey(9, 2, 0);
// Started without awaiting the first before starting the second, so both calls
// are in flight and racing across the same `await` points (`_ensureLoaded`,
// `writeAsBytes`, `_saveManifest`) at once.
final futureA = cache.put(a, bytesOfSize(1024));
final futureB = cache.put(b, bytesOfSize(1024));
await Future.wait([futureA, futureB]);
// Reopen over the same directory: this reads the manifest back from disk, which
// is exactly the file the two overlapping writes above raced to overwrite. An
// in-memory-only check wouldn't catch this -- the shared `_manifest` map itself
// is never corrupted (Dart is single-threaded), only what ends up on disk.
final reopened = FileTileCache(directory: tempDir, maxBytes: 1024 * 1024);
expect(await reopened.get(a), isNotNull, reason: 'tile a must survive the race');
expect(await reopened.get(b), isNotNull, reason: 'tile b must survive the race');
expect(await reopened.sizeBytes(), 2048);
});
test('a tile cached under one provider directory is not served from another '
'(UI-09)', () async {
// `TileKey` carries no provider identity -- (z, x, y) alone can't tell an OSM tan