Detect concurrent modification of the library file when saving - #16646
Conversation
Two JabRef instances (or any other program) writing the same .bib file could silently lose one side's changes: AtomicFileOutputStream committed whatever was written last, and on the in-place overwrite path (hard links, non-atomic file systems) two concurrent writers could even interleave into a corrupted file. AtomicFileOutputStream now snapshots the target file's existence, size, and modification time when the stream is opened and verifies it again before committing (once before creating the backup, once right before the move/overwrite). On a mismatch it throws the new FileChangedException, keeping the concurrently written file intact; SaveDatabaseAction surfaces this as a "file was modified by another program" error to the user, whose changes remain in memory. The encoding-problem retry in SaveDatabaseAction is moved out of the writer's try-with-resources: previously the outer writer's close() committed the badly encoded content after the re-encoded save, silently clobbering it (and it would now trip the conflict detection). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013pZNCbTYVPumAQAKcnHRht
…write Instead of surfacing the FileChangedException in an exception dialog, SaveDatabaseAction now shows a short notification that the library was not saved and triggers the standard "External changes detected" review flow. The change monitor is suspended during saving and thus never sees the concurrent write itself, so LibraryTab gains scanForExternalChanges() to start that scan manually. Also adds the requirement req~logic.exporter.concurrent-save-detection~1 with impl/utest traces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013pZNCbTYVPumAQAKcnHRht
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013pZNCbTYVPumAQAKcnHRht
PR Summary by QodoAbort save when the library file changes during write (concurrent-save guard)
AI Description
Diagram
High-Level Assessment
Files changed (8)
|
Code Review by Qodo
1.
|
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013pZNCbTYVPumAQAKcnHRht
… aborts The re-check after createBackup() could throw FileChangedException with the .sav file of the aborted attempt left on disk, although no save happened and the target is untouched. Backups are still kept for actual commit failures (where they have recovery value) and with keepBackup, where a backup file is expected to persist across saves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013pZNCbTYVPumAQAKcnHRht
The DatabaseChangeMonitor is created from the context's database path, which is only updated after a successful "Save as". A conflict abort in that flow would therefore scan the old library file instead of the path that actually conflicted. The scan is now only triggered for plain saves (target path equals the context's database path); a "Save as" conflict keeps just the notification. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013pZNCbTYVPumAQAKcnHRht
Integrates the approach of PR #16613: instead of unregistering the DatabaseChangeMonitor around a save and recreating it afterwards (losing every event in between), the watcher stays registered and only defers handling; one scan runs on resume. On top of that, a cheap size+mtime guard skips the full library parse when the file matches the last state known to be consistent with memory (library load, successful save, all external changes merged), so JabRef's own writes do not trigger scans. This makes the explicit post-conflict scan in SaveDatabaseAction (and its Save-As guard) redundant: resuming the monitor now performs it, against the correct (monitored) path. Co-authored-by: Siedlerchr <siedlerkiller@gmail.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013pZNCbTYVPumAQAKcnHRht
Save-path review findingsScope: current branch compared with F-01: Encoding retry can overwrite an external save
Failure sequence
Required correctionDo not commit the first, lossy write when encoding problems are found. Abort it before prompting for a different encoding, or preserve and verify a save-generation token from before the first write across the retry. Add a regression test that performs an external save while the encoding-selection dialog is open and verifies that the retry aborts rather than overwriting it. @qodo what do you think about this? |
F-01 is valid and should be treated as a blocker. The current flow reads The safest correction is to make encoding selection happen before any target-file commit: write/validate the candidate encoding to a temporary output, inspect its encoding problems, prompt if necessary, and commit only the final encoding. If that is impractical, the retry must carry forward and revalidate the original pre-save file-generation token rather than taking a fresh snapshot. A regression test should place an external replacement between the first write and the retry, then assert that the retry aborts and the external content remains intact. |
Addresses review finding F-01: the encoding-retry writer took its concurrent-change snapshot only after the encoding dialogs were closed, so an external save landing while they were open became the retry's baseline and was overwritten without detection. The retry now verifies that the file still matches the state committed by the first write and otherwise aborts through the regular concurrent-save-conflict path (notification plus external-changes review flow on monitor resume). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013pZNCbTYVPumAQAKcnHRht
The guard from the previous commit only protected the "Try different encoding" path. When the user instead clicks "Ignore" (or cancels the encoding choice) after the dialog sat open for a long time, an external save in that window had replaced the committed first write — yet the save was reported successful, marking the library clean and recording the external state as consistent: no review flow, and closing JabRef would discard the in-memory changes without any prompt. Every exit of the dialog flow now verifies the file still matches the committed write and otherwise aborts through the concurrent-save-conflict path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013pZNCbTYVPumAQAKcnHRht
… type The size+mtime baseline concept existed as three private copies (AtomicFileOutputStream, DatabaseChangeMonitor, SaveDatabaseAction). It is now a single public value type, org.jabref.logic.util.io .FileSnapshot — a version stamp for optimistic concurrency control on files — and the guard information flows through the layers instead of being re-derived: - AtomicFileOutputStream accepts an inherited expected baseline (so a follow-up writer can span a longer logical operation) and exposes the state it committed, captured immediately after the commit. - SaveDatabaseAction passes the committed state of the first write as the encoding retry's baseline (the retry writer now detects the conflict itself) and hands the final committed state to LibraryTab. - LibraryTab.resetChangedProperties(FileSnapshot) forwards it to DatabaseChangeMonitor.markConsistentWithDisk, which no longer has to re-read the file after a save — closing the window in which a concurrent write could be mistaken for the consistent state. The monitor now also correctly treats "file absent before and after" as unchanged (no scan), which the adapted tests cover. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013pZNCbTYVPumAQAKcnHRht
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013pZNCbTYVPumAQAKcnHRht
|
🤖 Generated with Claude Code TL;DR: F-01 confirmed and fixed — for the retry path and the "Ignore"/cancel paths — via the suggested save-generation token, generalized into a single guard mechanism. Problem. The retry writer took its conflict baseline after the encoding dialogs closed, so an external save landing while they were open (minutes to hours) became the baseline and was overwritten undetected. The same window also affected "Ignore"/cancel: the save reported success although the committed write had already been replaced, marking the library clean — no review flow, and closing JabRef would have discarded the in-memory changes. Fix. One concept, one implementation, enforced in one place:
Tests. Not chosen: aborting the first, lossy write before prompting — it would require holding the writer open across modal dialogs and would change the long-standing "Ignore commits the replacement characters" behavior, without adding protection beyond the token. |
The pinned intellij-format container (IDEA 2025.3.1) aligns wrapped @PARAM descriptions differently than local IDEA; this is its exact suggested diff. IDEA 2026.2.1 accepts both forms (verified locally), so this stays stable across a future formatter bump. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013pZNCbTYVPumAQAKcnHRht
The CI formatter (IDEA 2025.3.1) re-indents wrapped @PARAM continuation lines deeper on every run (IDEA-383594), so no wrapped form can ever pass: applying the format job's suggested diff made the next run demand yet deeper indentation. Unwrapped single-line descriptions are stable across IDEA 2025.3.1 and 2026.2.1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013pZNCbTYVPumAQAKcnHRht
🤖 Summary
When two JabRef instances (e.g., on two machines sharing a
.bibfile on a network share) save the same library at overlapping times, the save finishing last silently overwrote the other instance's changes — and on the in-place overwrite path introduced for hard links / non-atomic file systems, two concurrent writers could even interleave into a corrupted file.AtomicFileOutputStreamnow snapshots the target file's existence, size, and modification time when the stream is opened and verifies it again before committing; on a mismatch it aborts with the newFileChangedException, leaving the concurrently written file intact. Instead of an exception dialog, the user gets a proper flow:SaveDatabaseActionshows a "Library was not saved: the file was modified by another program." notification, and the standard "External changes detected" review flow is offered for the concurrent write. The user's changes stay in memory: they can review/merge the external changes and save again.This PR also integrates #16613 (co-authored by @Siedlerchr) and therefore closes it:
DatabaseChangeMonitornow stays registered during saves and only defers event handling, scanning once on resume — so filesystem events arriving mid-save are no longer lost (previously the monitor was unregistered and recreated, silently dropping them). On top of #16613's approach, a cheap size+modification-time guard skips the full library parse whenever the file still matches the last state known to be consistent with memory (library load, successful save, all external changes merged) — so JabRef's own writes do not cause a re-parse of a large library after every save, and the post-save scan only runs when something external actually changed the file. As a drive-by, the different-encoding retry inSaveDatabaseActionwas moved out of the writer's try-with-resources, because the outer writer'sclose()previously re-committed the badly encoded content after the re-encoded save (and would now trip the conflict detection).The guard state is modeled explicitly as optimistic concurrency control: a single public value type
org.jabref.logic.util.io.FileSnapshot(existence + size + mtime — the version stamp) is the one implementation of the baseline concept and flows through the layers instead of being re-derived.AtomicFileOutputStreamverifies the target against an expected snapshot before committing (accepting an inherited baseline, so a follow-up writer can span a longer logical operation such as the encoding-retry) and exposes the snapshot of what it committed;SaveDatabaseActionthreads that committed snapshot through the encoding dialogs into the retry writer and finally intoLibraryTab.resetChangedProperties(FileSnapshot)→DatabaseChangeMonitor.markConsistentWithDisk(...), so the monitor knows the post-save disk state without re-reading the file. The detection is best-effort (size + mtime, subject to file system timestamp resolution), which is documented onFileSnapshotand in a new requirementreq~logic.exporter.concurrent-save-detection~1.The encoding-problems dialogs are covered by the same mechanism: an external save landing while they are open (they can sit for hours) aborts the retry — and, without a retry ("Ignore"/cancel), aborts reporting success — through the regular conflict path, with regression tests for both.
jabref-contrib-policy:4.2:reviewed:okAnalogies: This PR is like honey, because a save should preserve everything the bees (both instances) collected instead of letting one hive raid the other. It is like chocolate, because atomic commits should stay whole — nobody wants two half-melted bars swirled into one. And it is like the moon, because two writers orbiting the same file must not eclipse each other's changes.
Steps to test
.bibfile externally (e.g.echo "% external" >> library.bibin a terminal — a loop appending every 100 ms makes the timing trivial).The two notifications the aborted save produces (captured from a live run with a 60k-entry library and a shell loop appending to the file during the save):
The interleaved two-writers scenario is covered by unit tests in
AtomicFileOutputStreamTest(interleavedSavesDoNotOverwriteEachOther,externalChangeOfTargetAbortsSave,externalCreationOfTargetAbortsSave,externalDeletionOfTargetAbortsSave).Related issues and pull requests
Closes #16613 — its "keep external change detection active while saving" approach is integrated here (with an added size+mtime guard that avoids re-parsing the library after every save), so that PR is superseded by this one.
Related: #16610, #7718 (the in-place save fallback for hard links / non-atomic file systems enlarged the window in which concurrent writers could corrupt the file; this PR adds the missing lost-update guard). No existing issue found for the silent lost-update itself.
AI usage
Claude Code (model claude-fable-5) — investigation, implementation, and tests were AI-generated under human direction (AIL3); reviewed and owned by the contributor.
AI CHECKLIST.md walkthrough
1. Code self-review
Nullability and control flow
== null/!= nullchecks — JSpecify annotations (@NullMarked,@Nullable,@NonNull) used instead. (The only null checks are on values declared@Nullable, consistent with the existing checks inAtomicFileOutputStream.)Objects.requireNonNull(...)— nullability expressed via JSpecify annotations.@NullMarked(org.jspecify.annotations.NullMarked).Optionalconsumed withifPresent/ifPresentOrElse/map/orElseThrow— neverorElse(unusedValue)nor anisPresent()+get()block.StringUtil.isBlank(...)used instead ofs == null || s.isBlank().Exceptions
catch (Exception e)— only specific exceptions are caught.throw new RuntimeException(...)/IllegalStateException(...)— these tear down the whole application.LOGGER.info("...", e)), not concatenated into the message string.Style and idioms
BibEntryobjects built with withers (withField, notsetField).List.of()/Map.of()/Set.of(),Path.of(),SequencedCollection/SequencedSet, text blocks. (New snapshot type is arecord;Set.of()used inSaveDatabaseAction.)Pattern.compile(...)constant, notString.matches(...).org.jabref.logic.util.BackgroundTask, notnew Thread().///) uses Markdown syntax, not JavaDoc inline tags:`code`instead of{@code},[ClassName]instead of{@link}.User-facing text
Localization.langin Java,%prefix in FXML).!; labels do not end with:."...: %0"), not string concatenation. (The new message has no variable parts.)Security
text/htmlresponse — including exception/error messages, not just the success body (XSS).Tests
org.jabref.model/org.jabref.logichave added or updated tests.assertEquals), use plain JUnit asserts (not AssertJ), have no@DisplayName, do not catch exceptions (let them propagate so JUnit reports setup/teardown failures directly), and use@TempDirinstead of manual temp directories.2. Verification commands
./gradlew :jablib:check(or./gradlew checkfor all modules)../gradlew checkstyleMain checkstyleTest checkstyleJmh../gradlew modernizer(run for the touched modulesjablibandjabgui)../gradlew --no-configuration-cache :rewriteDryRunreports no changes (run./gradlew rewriteRunto fix)../gradlew javadoc(run for the touched modulesjablibandjabgui).npx markdownlint-cli2 "docs/**/*.md" "*.md"(only if Markdown changed — run on the changedCHANGELOG.mdanddocs/requirements/save.md).rewriteRun:docker run ... intellij-format(no Docker available; formatted with local IntelliJ IDEA using.idea/codeStyles/Project.xmlinstead).3. Documentation
CHANGELOG.mdentry added if the change is visible to the user (end-user wording, no extra blank lines). Link the issue if one exists; link the PR only when no issue exists.closes/fixesfor merely-similar issues; Add "Accept external changes" button for "The library has been modified by another program" #8235/Automatic hiding of "The library has been modified by another program" when it is again the same as before #7307 concern the external-changes notification, not the lost update on save).docs/requirements/<area>.mdif the change is a new feature or significant bug fix (newdocs/requirements/save.md, traced via[impl->req~logic.exporter.concurrent-save-detection~1]and[utest->...];./gradlew traceRequirementspasses).docs/updated if behavior or architecture changed (the save strategy is documented in theAtomicFileOutputStreamJavadoc, which was updated).4. Pull request
.github/PULL_REQUEST_TEMPLATE.md, every section filled.[x],[ ], or[/].gh pr create --body-file <file>(not--body).CHANGELOG.mdused aTODOplaceholder, it was replaced with the real PR-number link after PR creation, then committed and pushed.Checklist
CHANGELOG.mdin a way that can be understood by the average user (if change is visible to the user)🤖 Generated with Claude Code