util: write JSON atomically in WriteJson #35763

pull kevkevinpal wants to merge 1 commits into bitcoin:master from kevkevinpal:banlist-atomic-write-json-unsafe changing 7 files +75 −26
  1. kevkevinpal commented at 12:47 PM on July 21, 2026: contributor

    Summary

    Follow-up to #35384 (comment)

    • Renamed common::WriteSettings to common::WriteJson.
    • WriteJson writes to path + ".tmp" and RenameOver()s into place, so a failed or interrupted write cannot leave a truncated destination. Both settings.json and banlist.json use this helper.
    • Added a unit test for the write/read roundtrip, that the .tmp is gone after success, that rename failure is detected, and that a second failed write overwrites the same .tmp rather than creating another.
  2. DrahtBot added the label Utils/log/libs on Jul 21, 2026
  3. DrahtBot commented at 12:47 PM on July 21, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    <!--006a51241073e994b41acfe9ec718e94-->

    Code Coverage & Benchmarks

    For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/35763.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline and AI policy for information on the review process.

    Type Reviewers
    Approach ACK winterrdog
    Stale ACK Herb-ops

    If your review is incorrectly listed, please copy-paste <code>&lt;!--meta-tag:bot-skip--&gt;</code> into the comment that the bot should ignore.

    <!--174a7506f384e20aa4161008e828411d-->

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #34520 (refactor: Add [[nodiscard]] to functions returning bool+mutable ref by maflcko)

    If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

  4. winterrdog commented at 5:42 PM on July 21, 2026: contributor

    Concept ACK

    Thanks for picking these up.

    cc: @sys-dev @ryanofsky

  5. Herb-ops commented at 11:52 AM on July 28, 2026: none

    ACK 07ec5af152b5bcff1b74decb9c7b729dff6c2283, with two non-blocking observations:

    The test is useful, but it also passes when CBanDB::Write is changed back to direct writing. Negative control proved this. It therefore does not verify the rename behavior named by the test.

    The generalized WriteJsonUnsafe helper still reports “settings file” errors when writing banlist.json.tmp. “JSON file” or “file” would be accurate for both callers.

  6. winterrdog commented at 10:14 PM on July 28, 2026: contributor

    The test is useful, but it also passes when CBanDB::Write is changed back to direct writing. Negative control proved this. It therefore does not verify the rename behavior named by the test.

    The generalized WriteJsonUnsafe helper still reports “settings file” errors when writing banlist.json.tmp. “JSON file” or “file” would be accurate for both callers.

    these need attention. so, i think they are blocking

  7. winterrdog commented at 10:36 PM on July 28, 2026: contributor

    while going through this, another thought came to mind. right now WriteJsonUnsafe writes directly to whatever path it is given and relies on the caller to do the tmp-file + RenameOver() step. both current callers (args.cpp and addrdb.cpp) do this correctly, but that guarantee only lives in a comment. nothing stops a future caller from accidentally writing straight to a live file

    i was wondering if it is worth making this a bit harder to misuse. two possible approaches came to mind

    <details><summary><b>approach A: add <code>WriteJsonAtomic</code> alongside <code>WriteJsonUnsafe</code></b></summary>

    keep the low-level helper as-is, but add a wrapper that handles the tmp-file + rename step:

    // settings.h
    bool WriteJsonUnsafe(const fs::path& path,
                         const std::map<std::string, SettingsValue>& values,
                         std::vector<std::string>& errors);
    
    bool WriteJsonAtomic(const fs::path& dest_path,
                         const std::map<std::string, SettingsValue>& values,
                         std::vector<std::string>& errors);
    
    
    // settings.cpp
    bool WriteJsonAtomic(const fs::path& dest_path,
                         const std::map<std::string, SettingsValue>& values,
                         std::vector<std::string>& errors)
    {
        const fs::path path_tmp{dest_path + ".tmp"};
        if (!WriteJsonUnsafe(path_tmp, values, errors)) {
            return false;
        }
        return RenameOver(path_tmp, dest_path);
    }
    

    callers become:

    // either this
    if (!WriteJsonUnsafe(path_tmp, values, errors)) return false;
    if (!RenameOver(path_tmp, path)) return false;
    
    // or this
    if (!WriteJsonAtomic(path, values, errors)) return false;
    

    this keeps the current API intact while giving callers a safe helper for the common case. existing callers with custom tmp-file handling can keep using WriteJsonUnsafe, while new callers can just use WriteJsonAtomic. the downside is that the footgun is still there. WriteJsonUnsafe is still publicly available, so a future caller can still accidentally bypass the atomic write path. it also means carrying two helpers that are closely related

    </details>

    <details><summary><b>approach B: make the helper always write atomically</b></summary>

    this is quite similar to the idea @sys-dev suggested here.

    instead of exposing both APIs, make the helper always write to a tmp file and rename it:

    // settings.h
    bool WriteJson(const fs::path& dest_path,
                   const std::map<std::string, SettingsValue>& values,
                   std::vector<std::string>& errors);
    
    // settings.cpp
    bool WriteJson(const fs::path& dest_path,
                   const std::map<std::string, SettingsValue>& values,
                   std::vector<std::string>& errors)
    {
        SettingsValue out(SettingsValue::VOBJ);
        for (const auto& [key, value] : values) out.pushKVEnd(key, value);
    
        const fs::path path_tmp{dest_path + ".tmp"};
    
        std::ofstream file{path_tmp.std_path()};
        // ...error handling lives here...
        file << out.write(/*prettyIndent=*/4, /*indentLevel=*/1) << std::endl;
    
        return RenameOver(path_tmp, dest_path);
    }
    

    this makes the safe path the only path. every caller gets atomic writes by default, so it is no longer possible to accidentally write directly to a live file. the tradeoff is that every caller now gets .tmp + RenameOver() semantics, even if a future use case genuinely just wants "write these bytes to this exact path" (for example, a one-off dump or export). it also bakes the tmp-file naming scheme into the helper itself

    </details>


    my thinking: i lean toward approach B unless there is already a use case for exposing the raw write helper. it feels a bit simpler, and it removes the footgun instead of just documenting it. that said, i could easily be missing a future use case where writing directly to the given path is the better choice

    any thoughts ?

  8. kevkevinpal force-pushed on Aug 2, 2026
  9. kevkevinpal force-pushed on Sep 7, 2026
  10. kevkevinpal commented at 2:26 PM on September 7, 2026: contributor

    @Herb-ops @winterrdog, can you review this again?

  11. winterrdog commented at 7:18 PM on September 7, 2026: contributor

    approach ACK


    now, we can update the PR title & description to match what the current code actually does. they still mention WriteJsonUnsafe and callers doing the atomic rename, while the current implementation is self-contained and handles the "temp write + RenameOver()" itself

  12. in src/common/settings.cpp:156 in 0e67947755 outdated
     155 | +        return false;
     156 | +    }
     157 | +    if (!RenameOver(path_tmp, path)) {
     158 | +        errors.emplace_back(strprintf("Failed renaming JSON file %s to %s", fs::PathToString(path_tmp), fs::PathToString(path)));
     159 |          return false;
     160 |      }
    


    winterrdog commented at 7:21 PM on September 7, 2026:

    premise: cleaning up path_tmp in case one of the error paths is explored

    currently, WriteJson() returns false but leaves the .tmp file behind. the new test (write_uses_rename) actually has to manually remove banlist_atomic.json.tmp after exercising the error paths, so it looks like we can leave stale temp files around in cases like persistent permission problems, an antivirus file lock on Windows, or repeated rename failures, these temp files are most likely to accumulate indefinitely

    would a best-effort fs::remove(path_tmp) on the failure path make sense here, or do you prefer leaving the temp file around?

    if implemented, one of the tradeoffs i can easily think of is the loss of forensic evidence when debugging persistent failures.

    any thoughts ?


    kevkevinpal commented at 9:25 PM on September 7, 2026:

    I prefer leaving it.

    The tmp name is stable (path + ".tmp"), so a later attempt opens the same file and overwrites it. You get at most one leftover per destination, not an accumulating set. That matches the old settings.json write, which also left the .tmp on rename failure.

    The fs::remove(...tmp) in the test is just fixture cleanup.


    I can amend the commit to add a test that asserts we don't create endless tmp files



    winterrdog commented at 6:37 PM on September 8, 2026:

    The tmp name is stable (path + ".tmp"), so a later attempt opens the same file and overwrites it.

    makes sense. i concur

    let me know if this looks good to you

    yes! good, it is

  13. kevkevinpal renamed this:
    util: atomically write banlist.json and rename WriteSettings to WriteJsonUnsafe
    util: write JSON atomically in WriteJson
    on Sep 7, 2026
  14. util: atomically write banlist.json and rename WriteSettings to WriteJson 0260a681e1
  15. kevkevinpal force-pushed on Sep 7, 2026
  16. in src/common/settings.cpp:125 in 0260a681e1
     120 | @@ -119,7 +121,7 @@ bool ReadSettings(const fs::path& path, std::map<std::string, SettingsValue>& va
     121 |      return errors.empty();
     122 |  }
     123 |  
     124 | -bool WriteSettings(const fs::path& path,
     125 | +bool WriteJson(const fs::path& path,
     126 |      const std::map<std::string, SettingsValue>& values,
    


    winterrdog commented at 6:33 PM on September 8, 2026:

    :( this has dragged on for quite some time. the approach and code work fine but i spotted an API naming issue that i think is worth discussing.

    currently, WriteSettings was renamed to WriteJson, but ReadSettings kept its old name. so we now have a WriteJson / ReadSettings pair that operates on the same kind of data, but has different names (asymmetry)

    so, i see 3 potential directions:

    A. keep the ReadSettings / WriteSettings pair

    probably the simplest fix for this PR. these functions are not really generic JSON helpers anyway: they assume a flat map<string, SettingsValue> and handle the _warning_ key. the downside is that having WriteSettings write to something like banlist.json feels a little odd

    B. rename both to a ReadJson / WriteJson pair

    this fixes the asymmetry with a small change. the downside is that WriteJson sounds more generic than it actually is, because it would still have settings-specific behavior like adding the _warning_ key.

    C. extract the generic JSON functionality from the settings functionality into separate functions

    for example, have generic ReadJsonFile / WriteJsonFile helpers underneath, then keep ReadSettings / WriteSettings as thin wrappers for the settings-specific behavior.

    this seems like the cleanest long-term design if we expect more JSON-backed files to use this code, but probably more work than this PR expected.


    any thoughts on this ?


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-09-09 07:56 UTC