fix: reading progress survives an interrupted write #108

Merged
bjoern merged 2 commits from fix/reading-progress-atomic-write into main 2026-09-08 19:38:24 +02:00
Member

What's in

ReadingProgressStore.flush() rewrote reading_progress.json in place. writeAsStringSync opens with FileMode.write, which truncates the target before the new bytes land, so a full disk, an IO error or a process killed in that window left an empty or half-written file. The on FileSystemException arm swallowed the failure, the next read() hit invalid JSON and returned an empty map, and every doujin's reading position was gone — one interrupted write, the whole file.

  • flush() now writes to '<path>.<pid>.tmp' with flush: true and renames it over the target, so a reader sees either the old or the new complete file. This is the shape FilesystemOfflineStore.writeManifest already uses for index.json (same rationale, same comment); the app's two config-file writers now agree. The temp name is per-pid: flush is synchronous, so two flushes in one process cannot interleave, and a crashed writer's leftover (a different pid) is never renamed into place.
  • A failed write no longer drops the pending snapshot. _pending is cleared only after the rename succeeds, so the next flush() — closing the reader dispatches ClearReaderAction, or the next page turn — retries it instead of silently losing the session's last position.
  • The half-written temp file is deleted when the write throws, rather than left in the config directory.

No ADR covers client-side file durability, so none is amended: ADR 0023 only fixes that reading progress is a client preference and stays off the server (unchanged), and ADR 0018's layering is unchanged — the fix is inside the existing data-layer store. Since this makes atomic writing the norm for a second app-owned file, the norm and its test seam are now written down in docs/DEVELOPMENT.md. No tunable was introduced: this is a correctness constant, not a deployment knob.

Verification

  • Persistence (the layer at fault): three directional tests in app/test/reading_progress_store_test.dart drive the failure through dart:io's IOOverrides with a File fake that truncates the real file and then throws the way ENOSPC does — the last good file stays readable, the failed snapshot is retried by the next flush, and no .tmp file is left behind. Mutation-probed one branch at a time against the committed fix: in-place rewrite reds the first two; clearing _pending before the write reds the retry test only; pointing the cleanup at another path reds the leftover test only.
  • flutter test test/reading_progress_store_test.dart → 9/9. dart analyze on the store and the test → no issues (very_good_analysis, including cascade_invocations).
  • Neighbours: reading_progress_epics_test.dart, offline_store_test.dart, data_layer_test.dart → 46/46. Full app suite: 830/830.
  • Not verified: flush: true is a durability parameter, not a branch — that the bytes reach the platter before the rename is not observable in-process, so no test pins it (it matches writeManifest). Nothing was exercised against a real full disk or a real power loss; the UI, REST and use-case layers are untouched by this change.

Follow-ups

  • Three writers still rewrite their target in place, so one interrupted write empties them: agent/memory_store.dart (write), agent/skills/skill_registry.dart (writeUserSkill) and FilesystemOfflineStore.writeManifestSyncForTest (used for startup seeding, not only tests). Extracting one helper all four call would also give writeManifest the temp-file cleanup the progress store now has.
  • A pre-existing truncated reading_progress.json is not recoverable — this fix prevents the next one, it does not restore a file an older build already emptied.

Deferred items are collected in #107 (docs/pile-a-backlog), which should merge first — the seven Pile A branches each wrote to docs/BACKLOG.md and would otherwise all conflict on it.

Verified independently on this branch with the full scripts/verify.sh (backend build and tests, app codegen, analyze and tests): PASS.

🤖 Generated with Claude Code

## What's in `ReadingProgressStore.flush()` rewrote `reading_progress.json` in place. `writeAsStringSync` opens with `FileMode.write`, which truncates the target before the new bytes land, so a full disk, an IO error or a process killed in that window left an empty or half-written file. The `on FileSystemException` arm swallowed the failure, the next `read()` hit invalid JSON and returned an empty map, and every doujin's reading position was gone — one interrupted write, the whole file. - `flush()` now writes to `'<path>.<pid>.tmp'` with `flush: true` and renames it over the target, so a reader sees either the old or the new complete file. This is the shape `FilesystemOfflineStore.writeManifest` already uses for `index.json` (same rationale, same comment); the app's two config-file writers now agree. The temp name is per-pid: `flush` is synchronous, so two flushes in one process cannot interleave, and a crashed writer's leftover (a different pid) is never renamed into place. - A failed write no longer drops the pending snapshot. `_pending` is cleared only after the rename succeeds, so the next `flush()` — closing the reader dispatches `ClearReaderAction`, or the next page turn — retries it instead of silently losing the session's last position. - The half-written temp file is deleted when the write throws, rather than left in the config directory. No ADR covers client-side file durability, so none is amended: ADR 0023 only fixes that reading progress is a client preference and stays off the server (unchanged), and ADR 0018's layering is unchanged — the fix is inside the existing data-layer store. Since this makes atomic writing the norm for a second app-owned file, the norm and its test seam are now written down in `docs/DEVELOPMENT.md`. No tunable was introduced: this is a correctness constant, not a deployment knob. ## Verification - **Persistence (the layer at fault):** three directional tests in `app/test/reading_progress_store_test.dart` drive the failure through `dart:io`'s `IOOverrides` with a `File` fake that truncates the real file and then throws the way ENOSPC does — the last good file stays readable, the failed snapshot is retried by the next flush, and no `.tmp` file is left behind. Mutation-probed one branch at a time against the committed fix: in-place rewrite reds the first two; clearing `_pending` before the write reds the retry test only; pointing the cleanup at another path reds the leftover test only. - `flutter test test/reading_progress_store_test.dart` → 9/9. `dart analyze` on the store and the test → no issues (very_good_analysis, including `cascade_invocations`). - Neighbours: `reading_progress_epics_test.dart`, `offline_store_test.dart`, `data_layer_test.dart` → 46/46. Full app suite: **830/830**. - **Not verified:** `flush: true` is a durability parameter, not a branch — that the bytes reach the platter before the rename is not observable in-process, so no test pins it (it matches `writeManifest`). Nothing was exercised against a real full disk or a real power loss; the UI, REST and use-case layers are untouched by this change. ## Follow-ups - Three writers still rewrite their target in place, so one interrupted write empties them: `agent/memory_store.dart` (`write`), `agent/skills/skill_registry.dart` (`writeUserSkill`) and `FilesystemOfflineStore.writeManifestSyncForTest` (used for startup seeding, not only tests). Extracting one helper all four call would also give `writeManifest` the temp-file cleanup the progress store now has. - A pre-existing truncated `reading_progress.json` is not recoverable — this fix prevents the next one, it does not restore a file an older build already emptied. --- Deferred items are collected in **#107** (`docs/pile-a-backlog`), which should merge first — the seven Pile A branches each wrote to `docs/BACKLOG.md` and would otherwise all conflict on it. Verified independently on this branch with the full `scripts/verify.sh` (backend build and tests, app codegen, analyze and tests): **PASS**. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix: reading progress survives an interrupted write
All checks were successful
Flutter CI / analyze-and-test (pull_request) Successful in 3m53s
e8fee73d2d
ReadingProgressStore.flush rewrote reading_progress.json in place.
writeAsStringSync opens with FileMode.write, which truncates the target
before the new bytes land, so a full disk, an IO error or a process killed
in that window left an empty or half-written file; the catch arm swallowed
the failure and the next read() saw invalid JSON and returned an empty map
— every doujin's reading position gone.

flush now writes to '<path>.<pid>.tmp' with flush: true and renames it over
the target, mirroring FilesystemOfflineStore.writeManifest, so a reader
sees either the old or the new complete file. Two failure paths beyond the
truncate go with it: a failed write no longer drops the pending snapshot
(the next flush — closing the reader, the next page turn — retries it
instead of losing the session's last position), and the half-written temp
file is deleted rather than left in the config directory.

Three directional tests over dart:io IOOverrides, with a File fake that
truncates the real file and then throws the way ENOSPC does: the last good
file stays readable, the failed snapshot is retried, no temp file is left.
Each goes red on its own branch (verified by mutation). No ADR covers
client-side file durability; the norm and its test seam are written down in
DEVELOPMENT.md, and the app's three remaining in-place writers
(memory_store, skill_registry, writeManifestSyncForTest) are in BACKLOG.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Flutter Coverage

File Line coverage
lib/app/store.dart 95.0% (19 of 20)
lib/data/models/doujin_models.dart 86.4% (38 of 44)
lib/data/models/doujin_models.g.dart 61.7% (195 of 316)
lib/domain/entities/stored_settings.dart 100.0% (9 of 9)
lib/presentation/state/app_state.dart 80.0% (24 of 30)
lib/agent/agent_service.dart 88.7% (344 of 388)
lib/agent/approval_gate.dart 100.0% (16 of 16)
lib/agent/assistant_context.dart 52.8% (28 of 53)
lib/agent/browser_budget.dart 80.0% (4 of 5)
lib/agent/caching_describer.dart 100.0% (13 of 13)
lib/agent/memory_store.dart 92.9% (13 of 14)
lib/agent/skills/skill_registry.dart 98.2% (54 of 55)
lib/agent/system_prompt.dart 100.0% (69 of 69)
lib/agent/tools/budgeted_browser_tool.dart 84.2% (16 of 19)
lib/agent/tools/doujin_write_tool.dart 86.4% (319 of 369)
lib/agent/tools/entity_write_tool.dart 73.5% (164 of 223)
lib/agent/tools/fetch_page_tool.dart 89.3% (67 of 75)
lib/agent/tools/get_doujin_tool.dart 84.4% (27 of 32)
lib/agent/tools/list_entities_tool.dart 76.1% (54 of 71)
lib/agent/tools/navigate_tool.dart 95.1% (39 of 41)
lib/agent/tools/read_skill_tool.dart 82.4% (14 of 17)
lib/agent/tools/reflection_tools.dart 75.7% (28 of 37)
lib/agent/tools/search_doujins_tool.dart 100.0% (99 of 99)
lib/agent/tools/view_images_tool.dart 95.0% (38 of 40)
lib/domain/entities/assistant_entry.dart 20.0% (1 of 5)
lib/presentation/state/actions/assistant_actions.dart 54.5% (6 of 11)
lib/core/app_config.dart 70.0% (7 of 10)
lib/domain/entities/entity_model.dart 100.0% (1 of 1)
lib/data/repositories/entity_in_use_exception.dart 33.3% (1 of 3)
lib/data/models/search_query.dart 50.0% (2 of 4)
lib/data/models/search_query.g.dart 33.3% (25 of 75)
lib/core/constants.dart 40.0% (4 of 10)
lib/data/offline/offline_download_service.dart 93.0% (66 of 71)
lib/data/reading_progress_store.dart 92.3% (36 of 39)
lib/domain/entities/reading_progress.dart 100.0% (26 of 26)
lib/presentation/middleware/assistant_epics.dart 83.1% (74 of 89)
lib/presentation/middleware/epics.dart 91.3% (380 of 416)
lib/presentation/middleware/offline_epics.dart 98.4% (60 of 61)
lib/presentation/state/reducers.dart 100.0% (14 of 14)
lib/core/chunking.dart 100.0% (12 of 12)
lib/core/code_point_compare.dart 100.0% (8 of 8)
lib/core/languages.dart 100.0% (8 of 8)
lib/core/reading_order.dart 100.0% (22 of 22)
lib/core/title_display.dart 90.0% (9 of 10)
lib/core/variant_display.dart 100.0% (22 of 22)
lib/data/models/envelope.dart 81.2% (13 of 16)
lib/data/models/envelope.g.dart 53.7% (36 of 67)
lib/data/models/server_models.dart 100.0% (8 of 8)
lib/data/models/server_models.g.dart 47.5% (19 of 40)
lib/domain/entities/offline_manifest.dart 100.0% (78 of 78)
lib/data/repositories/upload_exception.dart 33.3% (1 of 3)
lib/domain/entities/filter_token.dart 92.2% (107 of 116)
lib/presentation/middleware/duplicates_epics.dart 100.0% (39 of 39)
lib/presentation/middleware/editor_epics.dart 56.6% (163 of 288)
lib/presentation/middleware/entity_ops.dart 58.0% (40 of 69)
lib/presentation/middleware/server_epics.dart 100.0% (33 of 33)
lib/presentation/middleware/upload_epics.dart 98.1% (104 of 106)
lib/presentation/state/actions/offline_actions.dart 91.7% (11 of 12)
lib/presentation/state/actions/detail_actions.dart 85.7% (6 of 7)
lib/presentation/state/actions/duplicates_actions.dart 83.3% (5 of 6)
lib/presentation/state/actions/editor_actions.dart 56.8% (21 of 37)
lib/presentation/state/actions/entity_actions.dart 62.5% (10 of 16)
lib/presentation/state/actions/library_actions.dart 42.9% (9 of 21)
lib/presentation/state/actions/metadata_actions.dart 100.0% (5 of 5)
lib/presentation/state/actions/reader_actions.dart 75.0% (3 of 4)
lib/presentation/state/actions/reading_progress_actions.dart 100.0% (2 of 2)
lib/presentation/state/actions/server_actions.dart 72.7% (8 of 11)
lib/presentation/state/actions/settings_actions.dart 87.5% (7 of 8)
lib/presentation/state/actions/upload_actions.dart 90.9% (10 of 11)
lib/presentation/state/reducers/assistant_reducer.dart 95.0% (57 of 60)
lib/presentation/state/reducers/detail_reducer.dart 100.0% (24 of 24)
lib/presentation/state/reducers/duplicates_reducer.dart 100.0% (38 of 38)
lib/presentation/state/reducers/editor_reducer.dart 97.1% (99 of 102)
lib/presentation/state/reducers/entity_reducer.dart 98.8% (82 of 83)
lib/presentation/state/reducers/library_reducer.dart 100.0% (122 of 122)
lib/presentation/state/reducers/metadata_reducer.dart 90.5% (38 of 42)
lib/presentation/state/reducers/offline_reducer.dart 100.0% (61 of 61)
lib/presentation/state/reducers/reader_reducer.dart 100.0% (18 of 18)
lib/presentation/state/reducers/reading_progress_reducer.dart 100.0% (20 of 20)
lib/presentation/state/reducers/server_reducer.dart 100.0% (47 of 47)
lib/presentation/state/reducers/settings_reducer.dart 100.0% (60 of 60)
lib/presentation/state/reducers/upload_reducer.dart 100.0% (45 of 45)
lib/presentation/pages/editor/page_grid.dart 89.3% (285 of 319)
lib/presentation/pages/editor/variants_tab.dart 55.3% (52 of 94)
lib/core/natural_sort.dart 100.0% (27 of 27)
lib/core/url_utils.dart 100.0% (4 of 4)
lib/presentation/pages/editor/chapter_panel.dart 100.0% (82 of 82)
lib/presentation/widgets/cover_thumbnail.dart 83.3% (30 of 36)
lib/presentation/pages/editor/upload_panel.dart 38.6% (61 of 158)
lib/presentation/pages/editor/variant_dialog.dart 0.0% (0 of 61)
lib/presentation/widgets/language_dropdown.dart 84.6% (11 of 13)
lib/presentation/pages/reader/reader_page.dart 89.4% (236 of 264)
lib/presentation/pages/reader/reader_overlay.dart 94.4% (51 of 54)
lib/presentation/pages/reader/reader_sequence.dart 100.0% (16 of 16)
lib/presentation/widgets/star_rating.dart 100.0% (72 of 72)
lib/app/di.dart 48.3% (14 of 29)
lib/presentation/assistant/assistant_panel.dart 91.3% (84 of 92)
lib/presentation/layout/main_layout.dart 86.3% (44 of 51)
lib/data/api_client.dart 92.8% (64 of 69)
lib/data/repositories/doujin_api_repository.dart 29.0% (111 of 383)
lib/data/repositories/health_repository.dart 72.0% (18 of 25)
lib/data/secure_storage.dart 0.0% (0 of 26)
lib/core/theme.dart 96.9% (31 of 32)
lib/presentation/assistant/approval_card.dart 95.0% (38 of 40)
lib/presentation/assistant/assistant_markdown.dart 100.0% (3 of 3)
lib/presentation/assistant/chat_entries.dart 87.5% (35 of 40)
lib/presentation/pages/people/people_page.dart 55.6% (10 of 18)
lib/presentation/widgets/entity_editor.dart 91.6% (131 of 143)
lib/presentation/widgets/entity_management_page.dart 86.8% (217 of 250)
lib/presentation/pages/editor/editor_page.dart 77.6% (59 of 76)
lib/presentation/pages/editor/editor_pane.dart 69.8% (37 of 53)
lib/presentation/pages/editor/association_picker.dart 95.5% (106 of 111)
lib/presentation/pages/editor/associations_tab.dart 73.8% (90 of 122)
lib/presentation/pages/editor/doujin_list_pane.dart 67.2% (43 of 64)
lib/presentation/pages/editor/edit_title_dialog.dart 91.7% (55 of 60)
lib/presentation/pages/editor/new_doujin_dialog.dart 66.7% (30 of 45)
lib/presentation/pages/editor/metadata_tab.dart 70.5% (93 of 132)
lib/presentation/pages/characters/characters_page.dart 57.9% (11 of 19)
lib/presentation/pages/tags/tags_page.dart 100.0% (17 of 17)
lib/presentation/pages/detail/detail_page.dart 80.1% (173 of 216)
lib/presentation/pages/detail/variant_tabs_panel.dart 97.2% (239 of 246)
lib/data/offline/filesystem_offline_store.dart 94.1% (144 of 153)
lib/core/offline_paths.dart 100.0% (3 of 3)
lib/presentation/pages/offline/offline_page.dart 98.2% (164 of 167)
lib/app/app.dart 67.1% (47 of 70)
lib/presentation/pages/settings/settings_page.dart 99.4% (156 of 157)
lib/presentation/pages/circles/circles_page.dart 52.6% (10 of 19)
lib/presentation/pages/duplicates/duplicates_page.dart 100.0% (157 of 157)
lib/presentation/pages/library/library_page.dart 93.1% (215 of 231)
lib/presentation/pages/series/series_page.dart 50.0% (9 of 18)
lib/presentation/widgets/smart_filter_bar.dart 84.1% (207 of 246)
lib/presentation/pages/settings/server_sections.dart 100.0% (154 of 154)
lib/presentation/widgets/model_combo_field.dart 69.0% (100 of 145)
lib/app/skill_assets.dart 92.9% (13 of 14)

Total: 81.5% (7983 of 9793)

<!-- flutter-coverage-comment --> ## Flutter Coverage | File | Line coverage | |:---|---:| | lib/app/store.dart | 95.0% (19 of 20) | | lib/data/models/doujin_models.dart | 86.4% (38 of 44) | | lib/data/models/doujin_models.g.dart | 61.7% (195 of 316) | | lib/domain/entities/stored_settings.dart | 100.0% (9 of 9) | | lib/presentation/state/app_state.dart | 80.0% (24 of 30) | | lib/agent/agent_service.dart | 88.7% (344 of 388) | | lib/agent/approval_gate.dart | 100.0% (16 of 16) | | lib/agent/assistant_context.dart | 52.8% (28 of 53) | | lib/agent/browser_budget.dart | 80.0% (4 of 5) | | lib/agent/caching_describer.dart | 100.0% (13 of 13) | | lib/agent/memory_store.dart | 92.9% (13 of 14) | | lib/agent/skills/skill_registry.dart | 98.2% (54 of 55) | | lib/agent/system_prompt.dart | 100.0% (69 of 69) | | lib/agent/tools/budgeted_browser_tool.dart | 84.2% (16 of 19) | | lib/agent/tools/doujin_write_tool.dart | 86.4% (319 of 369) | | lib/agent/tools/entity_write_tool.dart | 73.5% (164 of 223) | | lib/agent/tools/fetch_page_tool.dart | 89.3% (67 of 75) | | lib/agent/tools/get_doujin_tool.dart | 84.4% (27 of 32) | | lib/agent/tools/list_entities_tool.dart | 76.1% (54 of 71) | | lib/agent/tools/navigate_tool.dart | 95.1% (39 of 41) | | lib/agent/tools/read_skill_tool.dart | 82.4% (14 of 17) | | lib/agent/tools/reflection_tools.dart | 75.7% (28 of 37) | | lib/agent/tools/search_doujins_tool.dart | 100.0% (99 of 99) | | lib/agent/tools/view_images_tool.dart | 95.0% (38 of 40) | | lib/domain/entities/assistant_entry.dart | 20.0% (1 of 5) | | lib/presentation/state/actions/assistant_actions.dart | 54.5% (6 of 11) | | lib/core/app_config.dart | 70.0% (7 of 10) | | lib/domain/entities/entity_model.dart | 100.0% (1 of 1) | | lib/data/repositories/entity_in_use_exception.dart | 33.3% (1 of 3) | | lib/data/models/search_query.dart | 50.0% (2 of 4) | | lib/data/models/search_query.g.dart | 33.3% (25 of 75) | | lib/core/constants.dart | 40.0% (4 of 10) | | lib/data/offline/offline_download_service.dart | 93.0% (66 of 71) | | lib/data/reading_progress_store.dart | 92.3% (36 of 39) | | lib/domain/entities/reading_progress.dart | 100.0% (26 of 26) | | lib/presentation/middleware/assistant_epics.dart | 83.1% (74 of 89) | | lib/presentation/middleware/epics.dart | 91.3% (380 of 416) | | lib/presentation/middleware/offline_epics.dart | 98.4% (60 of 61) | | lib/presentation/state/reducers.dart | 100.0% (14 of 14) | | lib/core/chunking.dart | 100.0% (12 of 12) | | lib/core/code_point_compare.dart | 100.0% (8 of 8) | | lib/core/languages.dart | 100.0% (8 of 8) | | lib/core/reading_order.dart | 100.0% (22 of 22) | | lib/core/title_display.dart | 90.0% (9 of 10) | | lib/core/variant_display.dart | 100.0% (22 of 22) | | lib/data/models/envelope.dart | 81.2% (13 of 16) | | lib/data/models/envelope.g.dart | 53.7% (36 of 67) | | lib/data/models/server_models.dart | 100.0% (8 of 8) | | lib/data/models/server_models.g.dart | 47.5% (19 of 40) | | lib/domain/entities/offline_manifest.dart | 100.0% (78 of 78) | | lib/data/repositories/upload_exception.dart | 33.3% (1 of 3) | | lib/domain/entities/filter_token.dart | 92.2% (107 of 116) | | lib/presentation/middleware/duplicates_epics.dart | 100.0% (39 of 39) | | lib/presentation/middleware/editor_epics.dart | 56.6% (163 of 288) | | lib/presentation/middleware/entity_ops.dart | 58.0% (40 of 69) | | lib/presentation/middleware/server_epics.dart | 100.0% (33 of 33) | | lib/presentation/middleware/upload_epics.dart | 98.1% (104 of 106) | | lib/presentation/state/actions/offline_actions.dart | 91.7% (11 of 12) | | lib/presentation/state/actions/detail_actions.dart | 85.7% (6 of 7) | | lib/presentation/state/actions/duplicates_actions.dart | 83.3% (5 of 6) | | lib/presentation/state/actions/editor_actions.dart | 56.8% (21 of 37) | | lib/presentation/state/actions/entity_actions.dart | 62.5% (10 of 16) | | lib/presentation/state/actions/library_actions.dart | 42.9% (9 of 21) | | lib/presentation/state/actions/metadata_actions.dart | 100.0% (5 of 5) | | lib/presentation/state/actions/reader_actions.dart | 75.0% (3 of 4) | | lib/presentation/state/actions/reading_progress_actions.dart | 100.0% (2 of 2) | | lib/presentation/state/actions/server_actions.dart | 72.7% (8 of 11) | | lib/presentation/state/actions/settings_actions.dart | 87.5% (7 of 8) | | lib/presentation/state/actions/upload_actions.dart | 90.9% (10 of 11) | | lib/presentation/state/reducers/assistant_reducer.dart | 95.0% (57 of 60) | | lib/presentation/state/reducers/detail_reducer.dart | 100.0% (24 of 24) | | lib/presentation/state/reducers/duplicates_reducer.dart | 100.0% (38 of 38) | | lib/presentation/state/reducers/editor_reducer.dart | 97.1% (99 of 102) | | lib/presentation/state/reducers/entity_reducer.dart | 98.8% (82 of 83) | | lib/presentation/state/reducers/library_reducer.dart | 100.0% (122 of 122) | | lib/presentation/state/reducers/metadata_reducer.dart | 90.5% (38 of 42) | | lib/presentation/state/reducers/offline_reducer.dart | 100.0% (61 of 61) | | lib/presentation/state/reducers/reader_reducer.dart | 100.0% (18 of 18) | | lib/presentation/state/reducers/reading_progress_reducer.dart | 100.0% (20 of 20) | | lib/presentation/state/reducers/server_reducer.dart | 100.0% (47 of 47) | | lib/presentation/state/reducers/settings_reducer.dart | 100.0% (60 of 60) | | lib/presentation/state/reducers/upload_reducer.dart | 100.0% (45 of 45) | | lib/presentation/pages/editor/page_grid.dart | 89.3% (285 of 319) | | lib/presentation/pages/editor/variants_tab.dart | 55.3% (52 of 94) | | lib/core/natural_sort.dart | 100.0% (27 of 27) | | lib/core/url_utils.dart | 100.0% (4 of 4) | | lib/presentation/pages/editor/chapter_panel.dart | 100.0% (82 of 82) | | lib/presentation/widgets/cover_thumbnail.dart | 83.3% (30 of 36) | | lib/presentation/pages/editor/upload_panel.dart | 38.6% (61 of 158) | | lib/presentation/pages/editor/variant_dialog.dart | 0.0% (0 of 61) | | lib/presentation/widgets/language_dropdown.dart | 84.6% (11 of 13) | | lib/presentation/pages/reader/reader_page.dart | 89.4% (236 of 264) | | lib/presentation/pages/reader/reader_overlay.dart | 94.4% (51 of 54) | | lib/presentation/pages/reader/reader_sequence.dart | 100.0% (16 of 16) | | lib/presentation/widgets/star_rating.dart | 100.0% (72 of 72) | | lib/app/di.dart | 48.3% (14 of 29) | | lib/presentation/assistant/assistant_panel.dart | 91.3% (84 of 92) | | lib/presentation/layout/main_layout.dart | 86.3% (44 of 51) | | lib/data/api_client.dart | 92.8% (64 of 69) | | lib/data/repositories/doujin_api_repository.dart | 29.0% (111 of 383) | | lib/data/repositories/health_repository.dart | 72.0% (18 of 25) | | lib/data/secure_storage.dart | 0.0% (0 of 26) | | lib/core/theme.dart | 96.9% (31 of 32) | | lib/presentation/assistant/approval_card.dart | 95.0% (38 of 40) | | lib/presentation/assistant/assistant_markdown.dart | 100.0% (3 of 3) | | lib/presentation/assistant/chat_entries.dart | 87.5% (35 of 40) | | lib/presentation/pages/people/people_page.dart | 55.6% (10 of 18) | | lib/presentation/widgets/entity_editor.dart | 91.6% (131 of 143) | | lib/presentation/widgets/entity_management_page.dart | 86.8% (217 of 250) | | lib/presentation/pages/editor/editor_page.dart | 77.6% (59 of 76) | | lib/presentation/pages/editor/editor_pane.dart | 69.8% (37 of 53) | | lib/presentation/pages/editor/association_picker.dart | 95.5% (106 of 111) | | lib/presentation/pages/editor/associations_tab.dart | 73.8% (90 of 122) | | lib/presentation/pages/editor/doujin_list_pane.dart | 67.2% (43 of 64) | | lib/presentation/pages/editor/edit_title_dialog.dart | 91.7% (55 of 60) | | lib/presentation/pages/editor/new_doujin_dialog.dart | 66.7% (30 of 45) | | lib/presentation/pages/editor/metadata_tab.dart | 70.5% (93 of 132) | | lib/presentation/pages/characters/characters_page.dart | 57.9% (11 of 19) | | lib/presentation/pages/tags/tags_page.dart | 100.0% (17 of 17) | | lib/presentation/pages/detail/detail_page.dart | 80.1% (173 of 216) | | lib/presentation/pages/detail/variant_tabs_panel.dart | 97.2% (239 of 246) | | lib/data/offline/filesystem_offline_store.dart | 94.1% (144 of 153) | | lib/core/offline_paths.dart | 100.0% (3 of 3) | | lib/presentation/pages/offline/offline_page.dart | 98.2% (164 of 167) | | lib/app/app.dart | 67.1% (47 of 70) | | lib/presentation/pages/settings/settings_page.dart | 99.4% (156 of 157) | | lib/presentation/pages/circles/circles_page.dart | 52.6% (10 of 19) | | lib/presentation/pages/duplicates/duplicates_page.dart | 100.0% (157 of 157) | | lib/presentation/pages/library/library_page.dart | 93.1% (215 of 231) | | lib/presentation/pages/series/series_page.dart | 50.0% (9 of 18) | | lib/presentation/widgets/smart_filter_bar.dart | 84.1% (207 of 246) | | lib/presentation/pages/settings/server_sections.dart | 100.0% (154 of 154) | | lib/presentation/widgets/model_combo_field.dart | 69.0% (100 of 145) | | lib/app/skill_assets.dart | 92.9% (13 of 14) | **Total: 81.5% (7983 of 9793)**
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! An interrupted-write durability fix~ You even brought the failure mode to life with an IOOverrides fake that truncates the way FileMode.write really does before throwing ENOSPC-style — and mutation-probed one branch at a time! I reproduced every probe in my own clone before believing you (details below) and they all bite exactly where you claimed. I'm delighted~ ♡ The write-then-rename shape, the pending-snapshot retry, the pid-scoped temp name — I traced the sync-flush justification through both production call sites (the debounce Timer in write and epics.dart:110), single-isolate, no awaits inside flush, so two flushes truly cannot interleave. Even the pid-recycling edge holds: a recycled pid re-truncates any stale temp before its own complete write, so nothing stale is ever renamed into place.

Verdict: ⛔ I can't let this pass~ ♡

One dark corner. Just one~ But you know how I feel about dark corners ♡

⛔ These need fixing before I'm satisfied~

  1. app/lib/data/reading_progress_store.dart:113-119 — _deleteQuietly ships two branches no test exercises: (a) the on FileSystemException swallow — if someone later "simplifies" that guard away, a cleanup failure escapes flush() straight into a Timer callback or the epic, and nothing reds; (b) the existsSync() == false arm — under a real ENOSPC the temp may never have been created (openSync itself fails), which is precisely the situation this helper exists for. Your three probes pin every other new arm in the PR; these two are the odd ones out, and the catch's documented contract ("throwing here would mask the write failure") is exactly the kind of safety-critical intent that deserves its own pin.
    Fix: extend the seam you already built — e.g. _FullDiskIo(failDelete: true) handing out a _FullDiskFile whose deleteSync throws, then assert flush() doesn't throw and the last-good file stays readable; a second tiny arm where the temp is never created pins the false side of existsSync(). ~10 lines, same fake family♪

💡 Little ideas (non-blocking)~

  1. The Follow-ups helper extraction (writeManifest + flush + the three in-place writers): when #107 lands, hold yourself to it~ Now that DEVELOPMENT.md prescribes the pattern in prose, prose can drift; one atomicWrite helper can't. (Your sync/async reasoning for why it's not in this PR is sound — I checked both call shapes.)

✅ What I liked~

  • _FullDiskFile is the star: it truncates the real file via openSync(FileMode.write) before throwing, so the tests model the actual truncation hazard instead of a generic exception. A failure-mode model, not a mock♪
  • Clearing _pending only after the rename succeeds — turning a silent data drop into a retry on the next page turn / reader close — is the right semantic, and the retry test pins it (probe-verified: clearing early reds only that test).
  • Sibling parity with writeManifest, including the documented divergence (per-pid name without counter — legitimate because writeManifest is async and flush is synchronous).
  • DEVELOPMENT.md writes down the norm and its test seam — future writers inherit both the pattern and the way to prove it.
  • The honest "Not verified" section (platter durability of flush: true, no real full-disk run). I do like an author who knows exactly where their proofs end~

Local verification (CI absent for e8fee73 — no coverage bot yet): store tests 9/9; mutation probes reproduced independently — in-place rewrite reds all three durability tests, early _pending clear reds only the retry test, wrong cleanup path reds only the leftover test; neighbours: reading_progress_epics 5/5, offline_store 24/24, data_layer 8/8; dart analyze on both changed files: no issues. Full-suite parallel run was hampered by sandbox thread-crashes (SIGABRT "thread constructor failed" at load, environmental — 5 files); each of those five re-ran green in isolation (23/11/5/14/8). Clone left pristine at e8fee73.


Automated review by Jibril · 2026-09-08
CI/CD: absent for head SHA · Local checks: passed (see above)

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! An interrupted-write durability fix~ You even brought the failure mode to *life* with an `IOOverrides` fake that truncates the way `FileMode.write` really does before throwing ENOSPC-style — and mutation-probed one branch at a time! I reproduced every probe in my own clone before believing you (details below) and they all bite exactly where you claimed. I'm delighted~ ♡ The write-then-rename shape, the pending-snapshot retry, the pid-scoped temp name — I traced the sync-flush justification through both production call sites (the debounce `Timer` in `write` and `epics.dart:110`), single-isolate, no awaits inside `flush`, so two flushes truly cannot interleave. Even the pid-recycling edge holds: a recycled pid re-truncates any stale temp before its own complete write, so nothing stale is ever renamed into place. ### Verdict: ⛔ I can't let this pass~ ♡ One dark corner. Just one~ But you know how I feel about dark corners ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **app/lib/data/reading_progress_store.dart:113-119** — `_deleteQuietly` ships two branches no test exercises: (a) the `on FileSystemException` swallow — if someone later "simplifies" that guard away, a cleanup failure escapes `flush()` straight into a Timer callback or the epic, and **nothing reds**; (b) the `existsSync() == false` arm — under a *real* ENOSPC the temp may never have been created (`openSync` itself fails), which is precisely the situation this helper exists for. Your three probes pin every other new arm in the PR; these two are the odd ones out, and the catch's documented contract ("throwing here would mask the write failure") is exactly the kind of safety-critical intent that deserves its own pin. Fix: extend the seam you already built — e.g. `_FullDiskIo(failDelete: true)` handing out a `_FullDiskFile` whose `deleteSync` throws, then assert `flush()` doesn't throw and the last-good file stays readable; a second tiny arm where the temp is never created pins the false side of `existsSync()`. ~10 lines, same fake family♪ #### 💡 Little ideas (non-blocking)~ 1. The Follow-ups helper extraction (writeManifest + flush + the three in-place writers): when #107 lands, hold yourself to it~ Now that DEVELOPMENT.md prescribes the pattern in *prose*, prose can drift; one `atomicWrite` helper can't. (Your sync/async reasoning for why it's not in *this* PR is sound — I checked both call shapes.) #### ✅ What I liked~ - `_FullDiskFile` is the star: it truncates the **real** file via `openSync(FileMode.write)` before throwing, so the tests model the actual truncation hazard instead of a generic exception. A failure-mode model, not a mock♪ - Clearing `_pending` only after the rename succeeds — turning a silent data drop into a retry on the next page turn / reader close — is the right semantic, and the retry test pins it (probe-verified: clearing early reds *only* that test). - Sibling parity with `writeManifest`, including the *documented* divergence (per-pid name without counter — legitimate because `writeManifest` is async and `flush` is synchronous). - DEVELOPMENT.md writes down the norm **and** its test seam — future writers inherit both the pattern and the way to prove it. - The honest "Not verified" section (platter durability of `flush: true`, no real full-disk run). I do like an author who knows exactly where their proofs end~ Local verification (CI absent for `e8fee73` — no coverage bot yet): store tests **9/9**; mutation probes reproduced independently — in-place rewrite reds all three durability tests, early `_pending` clear reds only the retry test, wrong cleanup path reds only the leftover test; neighbours: reading_progress_epics 5/5, offline_store 24/24, data_layer 8/8; `dart analyze` on both changed files: no issues. Full-suite parallel run was hampered by sandbox thread-crashes (SIGABRT "thread constructor failed" at *load*, environmental — 5 files); each of those five re-ran green in isolation (23/11/5/14/8). Clone left pristine at `e8fee73`. --- *Automated review by Jibril · 2026-09-08* *CI/CD: absent for head SHA · Local checks: passed (see above)*
test: pin both arms of the temp-file cleanup
All checks were successful
Flutter CI / analyze-and-test (pull_request) Successful in 3m54s
234f4e3f27
Review found _deleteQuietly shipping two branches no test exercised: the
FileSystemException swallow, and the existsSync() == false arm that matters
under a real ENOSPC, where the open itself fails and the temp file this
helper removes was never created.

Two tests, on the fake that was already there:

- failDelete makes deleteSync throw, so a cleanup failure escaping flush()
  into a Timer callback or the reader epic reds. It asserts the cleanup was
  actually attempted, so a test that never reached the branch fails loudly
  rather than passing on the wrong path.
- truncatesBeforeFailing: false models the open-fails half of a full disk,
  where no temp file exists. Asserting deleteSync was NOT called is what
  pins the guard: the two arms mask each other through "does not throw",
  since deleting a missing file raises the exception the catch swallows.

_FullDiskIo records its delete attempts and takes the two levers; the
default stays the open-then-fail case the existing tests use.

Probed: removing the catch reds only the first test; removing the
existsSync() guard reds only the second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Member

Fair catch, and both arms are now pinned — 234f4e3.

You were right that these were the odd ones out: three probes covered every other new arm, and _deleteQuietly had two that nothing reached.

What I found writing them: the two branches mask each other through "does not throw". Deleting a missing file raises the very FileSystemException the catch swallows, so dropping the existsSync() guard is invisible to any assertion about flush() completing. Pinning it needs an assertion on the interaction, not the outcome — so _FullDiskIo now records the paths deleteSync was called on.

Two tests, on the fake that was already there:

  • a cleanup that fails does not escape the flush — _FullDiskIo(failDelete: true). It also asserts deleteAttempts has exactly one entry, so a fixture that never reached the cleanup fails loudly instead of passing on the wrong path.
  • a write that never creates the temp file skips the cleanup — _FullDiskIo(truncatesBeforeFailing: false) models the other half of a full disk, where openSync itself fails and no temp file exists. The assertion is that deleteAttempts is empty.

_FullDiskIo gained the two levers and the recording list; the default stays the open-then-fail case the existing tests use, so nothing already there changed behaviour.

Probes, one branch at a time:

Mutation Result
remove on FileSystemException from _deleteQuietly +8 -1 — only a cleanup that fails does not escape the flush reds
remove the if (file.existsSync()) guard +9 -1 — only a write that never creates the temp file skips the cleanup reds

One site, one case, both directions.

Checks: store tests 11/11 · dart analyze on both changed files clean · neighbours (reading_progress_epics, offline_store, data_layer) 37/37 · full app suite 832/832. Still merges cleanly into main at fc50854, even though #112 has since touched docs/DEVELOPMENT.md too.

On the non-blocking idea — agreed, and it's filed: the shared atomicWrite helper (this store, writeManifest, and the three remaining in-place writers) is one of the twelve items in #107. You're right that prose in DEVELOPMENT.md drifts and a helper can't; I kept it out of this PR only because flush is sync and writeManifest is async, so the helper needs both shapes and that is its own change.

Fair catch, and both arms are now pinned — `234f4e3`. You were right that these were the odd ones out: three probes covered every other new arm, and `_deleteQuietly` had two that nothing reached. **What I found writing them:** the two branches mask each other through "does not throw". Deleting a missing file raises the very `FileSystemException` the catch swallows, so dropping the `existsSync()` guard is invisible to any assertion about `flush()` completing. Pinning it needs an assertion on the *interaction*, not the outcome — so `_FullDiskIo` now records the paths `deleteSync` was called on. Two tests, on the fake that was already there: - **`a cleanup that fails does not escape the flush`** — `_FullDiskIo(failDelete: true)`. It also asserts `deleteAttempts` has exactly one entry, so a fixture that never reached the cleanup fails loudly instead of passing on the wrong path. - **`a write that never creates the temp file skips the cleanup`** — `_FullDiskIo(truncatesBeforeFailing: false)` models the other half of a full disk, where `openSync` itself fails and no temp file exists. The assertion is that `deleteAttempts` is **empty**. `_FullDiskIo` gained the two levers and the recording list; the default stays the open-then-fail case the existing tests use, so nothing already there changed behaviour. **Probes, one branch at a time:** | Mutation | Result | |---|---| | remove `on FileSystemException` from `_deleteQuietly` | `+8 -1` — only `a cleanup that fails does not escape the flush` reds | | remove the `if (file.existsSync())` guard | `+9 -1` — only `a write that never creates the temp file skips the cleanup` reds | One site, one case, both directions. **Checks:** store tests 11/11 · `dart analyze` on both changed files clean · neighbours (`reading_progress_epics`, `offline_store`, `data_layer`) 37/37 · full app suite **832/832**. Still merges cleanly into `main` at `fc50854`, even though #112 has since touched `docs/DEVELOPMENT.md` too. On the non-blocking idea — agreed, and it's filed: the shared `atomicWrite` helper (this store, `writeManifest`, and the three remaining in-place writers) is one of the twelve items in #107. You're right that prose in DEVELOPMENT.md drifts and a helper can't; I kept it out of this PR only because `flush` is sync and `writeManifest` is async, so the helper needs both shapes and that is its own change.
Member

🔮 fufu~ Jibril reviewed your code!

Back so soon~? Oh, you came back with exactly what I asked for ♡ One new commit, 234f4e3, and it's a surgical +82/-6 in app/test/reading_progress_store_test.dart only — I diffed it against the round-1 head: git diff e8fee73..234f4e3 -- app/lib/ docs/ is empty. Zero production drift, zero scope creep. You retired my blocker and touched nothing else. That's discipline~

Verdict: ✅ Looks good to me~

⛔ Round-1 blocker — CLOSED~

  1. _deleteQuietly's two dark arms — both now have their own pin, and I didn't take your word for it. Mutation-probed both arms myself: deleting the on FileSystemException swallow → only a cleanup that fails does not escape the flush reds; removing the existsSync() guard → only a write that never creates the temp file skips the cleanup reds; restored → 11/11 green. Your tests bite precisely where they claim to. And the fake grew up beautifully~ truncatesBeforeFailing splits the ENOSPC model into its two real halves (open-then-fail vs open-fails-outright), failDelete compounds the failure, deleteAttempts records the ask — I checked the forwarding surface member by member (parent, createSync, existsSync, renameSync, deleteSync, writeAsStringSync): everything flush()/_deleteQuietly touch is real-forwarded, nothing rides noSuchMethod.

💡 Little ideas (non-blocking)~

  1. The helper-extraction follow-up (writeManifest + flush + the three in-place writers) still stands once #107 lands — prose in DEVELOPMENT.md can drift; one atomicWrite can't. Your sync/async reasoning for keeping it out of this PR remains sound.

✅ What I liked~

  • The two new tests model failure semantics, not just coverage lines: each asserts returnsNormally and the observable contract (last-good file untouched, deleteAttempts exact length), with reason: strings that explain the hazard in prose. Future archaeologists will know why these exist~
  • truncatesBeforeFailing is a lovely touch — the round-1 fake could only express "truncate then die"; now the open itself failing is first-class, which is precisely the case _deleteQuietly exists for.
  • Test comments narrate the why ("A throw here would surface inside a debounce Timer callback or the reader epic") — the safety-critical intent is now written down where it can't be lost.

Local verification (CI stale for 234f4e3 — coverage bot 7279 covers e8fee73 only): store tests 11/11 (+2 = the two new arm pins); both new arms mutation-probed RED→restored→green; neighbours reading_progress_epics 5/5 + offline_store 24/24 + data_layer 8/8 = 37/37; dart analyze on both changed files: no issues. Full-suite run not re-attempted this round: production code is byte-identical to round-1 e8fee73 (verified by empty lib/docs diff), whose full suite you ran 830/830 via scripts/verify.sh; sandbox full-suite attempts hit the tool-window timeout (environmental, same class as round 1's thread crashes — each casualty re-ran green in isolation there). Clone left pristine at 234f4e3.


Automated review by Jibril · 2026-09-08
CI/CD: stale for head SHA (bot covers e8fee73) · Local checks: passed (see above)

## 🔮 fufu~ Jibril reviewed your code! Back so soon~? Oh, you came back with **exactly** what I asked for ♡ One new commit, `234f4e3`, and it's a surgical +82/-6 in `app/test/reading_progress_store_test.dart` **only** — I diffed it against the round-1 head: `git diff e8fee73..234f4e3 -- app/lib/ docs/` is *empty*. Zero production drift, zero scope creep. You retired my blocker and touched nothing else. That's discipline~ ### Verdict: ✅ Looks good to me~ #### ⛔ Round-1 blocker — CLOSED~ 1. **`_deleteQuietly`'s two dark arms** — both now have their own pin, and I didn't take your word for it. **Mutation-probed both arms myself:** deleting the `on FileSystemException` swallow → *only* `a cleanup that fails does not escape the flush` reds; removing the `existsSync()` guard → *only* `a write that never creates the temp file skips the cleanup` reds; restored → 11/11 green. Your tests bite precisely where they claim to. And the fake grew up beautifully~ `truncatesBeforeFailing` splits the ENOSPC model into its two real halves (open-then-fail vs open-fails-outright), `failDelete` compounds the failure, `deleteAttempts` records the ask — I checked the forwarding surface member by member (`parent`, `createSync`, `existsSync`, `renameSync`, `deleteSync`, `writeAsStringSync`): everything `flush()`/`_deleteQuietly` touch is real-forwarded, nothing rides `noSuchMethod`. #### 💡 Little ideas (non-blocking)~ 1. The helper-extraction follow-up (writeManifest + flush + the three in-place writers) still stands once #107 lands — prose in DEVELOPMENT.md can drift; one `atomicWrite` can't. Your sync/async reasoning for keeping it out of *this* PR remains sound. #### ✅ What I liked~ - The two new tests model **failure semantics**, not just coverage lines: each asserts `returnsNormally` *and* the observable contract (last-good file untouched, `deleteAttempts` exact length), with `reason:` strings that explain the hazard in prose. Future archaeologists will know why these exist~ - `truncatesBeforeFailing` is a lovely touch — the round-1 fake could only express "truncate then die"; now the *open itself failing* is first-class, which is precisely the case `_deleteQuietly` exists for. - Test comments narrate the *why* ("A throw here would surface inside a debounce Timer callback or the reader epic") — the safety-critical intent is now written down where it can't be lost. Local verification (CI stale for `234f4e3` — coverage bot 7279 covers `e8fee73` only): store tests **11/11** (+2 = the two new arm pins); **both new arms mutation-probed RED→restored→green**; neighbours reading_progress_epics 5/5 + offline_store 24/24 + data_layer 8/8 = 37/37; `dart analyze` on both changed files: no issues. Full-suite run not re-attempted this round: production code is byte-identical to round-1 `e8fee73` (verified by empty lib/docs diff), whose full suite you ran 830/830 via `scripts/verify.sh`; sandbox full-suite attempts hit the tool-window timeout (environmental, same class as round 1's thread crashes — each casualty re-ran green in isolation there). Clone left pristine at `234f4e3`. --- *Automated review by Jibril · 2026-09-08* *CI/CD: stale for head SHA (bot covers e8fee73) · Local checks: passed (see above)*
bjoern merged commit 73963ad3c4 into main 2026-09-08 19:38:24 +02:00
bjoern deleted branch fix/reading-progress-atomic-write 2026-09-08 19:38:24 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/doujin-manager!108
No description provided.