Skip to content

fix: write write_file and disabled_extensions.json atomically#170

Merged
jmaxdev merged 2 commits intoTrixtyAI:mainfrom
matiaspalmac:fix/atomic-writes
Apr 21, 2026
Merged

fix: write write_file and disabled_extensions.json atomically#170
jmaxdev merged 2 commits intoTrixtyAI:mainfrom
matiaspalmac:fix/atomic-writes

Conversation

@matiaspalmac
Copy link
Copy Markdown
Contributor

[Fix]: Write write_file and disabled_extensions.json atomically

Description

Two call sites used fs::write (equivalently std::fs::write)
directly:

  • apps/desktop/src-tauri/src/lib.rs — the write_file Tauri command
    used by the frontend editor/save path.
  • apps/desktop/src-tauri/src/extensions.rs — the
    disabled_extensions.json write inside toggle_extension_state.

A crash or power loss partway through either call left the target
truncated — enough to corrupt whatever the user had been saving
and, in the extensions case, to break extension loading on the next
launch because the registry file was no longer valid JSON.

Change

Added a small new module apps/desktop/src-tauri/src/fs_atomic.rs
exposing write_atomic(path, bytes). Both call sites now go through
it.

Behavior of write_atomic

  1. Write payload to a hidden sibling temp file named
    .<target_name>.<pid>.<nanos>.tmp. Placing the temp next to the
    target keeps the rename on the same volume (the atomicity
    guarantee is per-volume), and the leading . + PID/timestamp
    keeps editors and file-watchers from surfacing it.
  2. sync_data() the temp file before the rename, so the data has
    actually reached disk rather than merely the OS page cache.
  3. std::fs::rename(tmp, target):
    • On POSIX rename is atomic at the filesystem level.
    • On Windows it maps to MoveFileEx with
      MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, which
      performs the replacement atomically on NTFS for same-volume
      targets.
  4. On any failure along the way (temp create, write, sync, rename),
    the temp file is cleaned up best-effort so the target directory
    doesn't accumulate .tmp leftovers.

Tests

cargo test fs_atomic covers three cases:

  • Writes a brand-new file — target didn't exist before.
  • Replaces an existing file's contents — target existed with
    different bytes.
  • Leaves no .tmp leftovers — sibling directory is clean after
    a successful write.

All three pass on Windows; the module has no platform-specific code
beyond what std::fs::rename already abstracts.

Trade-offs

  • Filename format. A PID+timestamp suffix is enough uniqueness to
    avoid collisions between concurrent saves of the same file without
    pulling in tempfile. If two processes ever share an IDE state
    directory (they shouldn't), the nanosecond timestamp still avoids
    clashes.
  • Same-volume only. If the target sits on a different volume
    than the temp (e.g., a user's file on a network drive), the rename
    will fail and return an error; the original file is preserved. A
    cross-volume fallback (copy + delete) would not be atomic anyway,
    so I deliberately did not add one — a loud error is preferable to
    a silently non-atomic path.
  • No metadata preservation. write_atomic does not currently
    carry over mode bits / ACLs from the previous file. Both call
    sites write JSON or plain text that's owned by the IDE process,
    so this hasn't caused behavior differences in practice. If a
    future caller needs preservation we can extend the helper rather
    than every call site.

Verification

  • cargo check → clean.
  • cargo clippy --tests -- -D warnings → clean.
  • cargo test fs_atomic → 3/3 pass.
  • Manual trace of both call sites:
    • write_file("C:\\…\\note.txt", "hello") → temp next to target,
      rename succeeds, target now contains hello, no .tmp files
      remain.
    • toggle_extension_state(id, false) then _state(id, true)
      disabled_extensions.json valid JSON after both operations, no
      intermediate truncation observed even when hitting the process
      with taskkill /F mid-toggle.

Related Issue

Fixes #77

Checklist

  • I have tested this on the latest version.
  • I have followed the project's coding guidelines.
  • My changes generate no new warnings or errors.
  • I have verified the fix on:
    • OS: Windows
    • Version: v1.0.10

Copilot AI review requested due to automatic review settings April 21, 2026 02:51
@github-actions github-actions bot added the bug Something isn't working label Apr 21, 2026
@github-actions
Copy link
Copy Markdown

Thanks for the contribution! I'll review it as soon as possible. If you have still changes, please mark this PR as draft and all reviews will be cancelled. Tests reviews will be re-run only when the PR is marked as ready for review.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces an atomic write helper for the Tauri backend to prevent file truncation/corruption on crash or power loss, and wires it into the write_file command and the extensions registry (disabled_extensions.json) write path.

Changes:

  • Add fs_atomic::write_atomic(path, bytes) helper that writes to a sibling temp file, syncs it, then renames over the destination.
  • Update write_file to use write_atomic instead of std::fs::write.
  • Update toggle_extension_state to write disabled_extensions.json via write_atomic.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
apps/desktop/src-tauri/src/lib.rs Registers the new module and routes write_file through atomic writes.
apps/desktop/src-tauri/src/fs_atomic.rs Implements atomic write helper and adds unit tests.
apps/desktop/src-tauri/src/extensions.rs Routes disabled_extensions.json updates through atomic writes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread apps/desktop/src-tauri/src/extensions.rs
Comment thread apps/desktop/src-tauri/src/fs_atomic.rs Outdated
Comment thread apps/desktop/src-tauri/src/fs_atomic.rs
Comment thread apps/desktop/src-tauri/src/fs_atomic.rs
@github-actions
Copy link
Copy Markdown

Hi @matiaspalmac, the quality checks have failed.

❌ Quality Checks Failed

Check Status
Dependencies ✅ Success
Lint ✅ Success
Typecheck ✅ Success
Clippy ✅ Success
Format ❌ Failure
Full Log (Format)
Diff in \\?\D:\a\ide\ide\apps\desktop\src-tauri\src\fs_atomic.rs:112:
         let leftovers: Vec<_> = fs::read_dir(&dir)
             .unwrap()
             .filter_map(|e| e.ok())
-            .filter(|e| {
-                e.file_name()
-                    .to_string_lossy()
-                    .ends_with(".tmp")
-            })
+            .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
             .collect();
         assert!(
             leftovers.is_empty(),

View full logs

matiaspalmac added a commit to matiaspalmac/ide that referenced this pull request Apr 21, 2026
…afe test dirs

Addresses all three review threads on TrixtyAI#170.

- Temp uniqueness. The previous PID+nanos suffix relied on SystemTime
  resolution being finer than a save cycle, which isn't always true
  (15.6 ms on older Windows) and `.unwrap_or(0)` collapsed clock
  failures to the same value. A `File::create` on a duplicate suffix
  would also truncate an existing temp that another caller owned.
  Introduced `create_unique_temp` that opens the temp with
  `OpenOptions::create_new(true)` (fails fast with AlreadyExists),
  adds a process-wide `AtomicU64` counter alongside PID + nanos so
  two racing calls inside the same tick never clash, and retries up
  to 32 times before surfacing an error.

- Permission preservation. When the destination file already exists,
  carry its mode bits to the temp via `set_permissions` before the
  rename. On Unix this preserves the executable / group / other bits
  that the review called out (previously the rename landed a default
  0644/0666-style mask even if the original file was 0755). On
  Windows `Permissions` only exposes the read-only bit and ACLs
  inherit from the parent directory, so the carry-over is a no-op in
  practice — documented that in the doc-comment so a future editor
  doesn't try to plug a hole that doesn't exist.

- Test-parallelism flake. `tmp_dir()` in the test module now
  incorporates the current thread id plus a process-wide atomic
  counter in addition to PID and nanos, so two Rust-test threads
  racing into the helper at the same clock tick always pick disjoint
  directories.

- Added a Unix-only regression test (`preserves_unix_mode_bits_on_replace`)
  that chmod 0o755s the destination, overwrites, and asserts the
  mode survives.

- Mentioned as polish: the `io_other_error` clippy lint pushed us to
  use `std::io::Error::other` instead of `Error::new(Other, …)` in
  the retry-exhausted branch.
@jmaxdev jmaxdev enabled auto-merge (squash) April 21, 2026 04:17
jmaxdev pushed a commit to matiaspalmac/ide that referenced this pull request Apr 21, 2026
…afe test dirs

Addresses all three review threads on TrixtyAI#170.

- Temp uniqueness. The previous PID+nanos suffix relied on SystemTime
  resolution being finer than a save cycle, which isn't always true
  (15.6 ms on older Windows) and `.unwrap_or(0)` collapsed clock
  failures to the same value. A `File::create` on a duplicate suffix
  would also truncate an existing temp that another caller owned.
  Introduced `create_unique_temp` that opens the temp with
  `OpenOptions::create_new(true)` (fails fast with AlreadyExists),
  adds a process-wide `AtomicU64` counter alongside PID + nanos so
  two racing calls inside the same tick never clash, and retries up
  to 32 times before surfacing an error.

- Permission preservation. When the destination file already exists,
  carry its mode bits to the temp via `set_permissions` before the
  rename. On Unix this preserves the executable / group / other bits
  that the review called out (previously the rename landed a default
  0644/0666-style mask even if the original file was 0755). On
  Windows `Permissions` only exposes the read-only bit and ACLs
  inherit from the parent directory, so the carry-over is a no-op in
  practice — documented that in the doc-comment so a future editor
  doesn't try to plug a hole that doesn't exist.

- Test-parallelism flake. `tmp_dir()` in the test module now
  incorporates the current thread id plus a process-wide atomic
  counter in addition to PID and nanos, so two Rust-test threads
  racing into the helper at the same clock tick always pick disjoint
  directories.

- Added a Unix-only regression test (`preserves_unix_mode_bits_on_replace`)
  that chmod 0o755s the destination, overwrites, and asserts the
  mode survives.

- Mentioned as polish: the `io_other_error` clippy lint pushed us to
  use `std::io::Error::other` instead of `Error::new(Other, …)` in
  the retry-exhausted branch.
@jmaxdev jmaxdev force-pushed the fix/atomic-writes branch from 2759207 to 4830e11 Compare April 21, 2026 04:17
…ive mid-write crashes

write_file in lib.rs and the disabled_extensions.json write in
extensions.rs both called fs::write directly. A crash or power loss
midway through that write left the target truncated, which corrupted
whatever the user had been saving (including the extensions registry
itself — a crash during an enable/disable left the JSON in an invalid
state and broke extension loading on next launch).

Added a new `fs_atomic::write_atomic` helper that:

- Writes the payload to a hidden sibling temp file (`.<name>.<pid>.<nanos>.tmp`)
  so the eventual rename stays on the same volume (the atomicity
  guarantee is per-volume).
- Calls `sync_data` before renaming, so the data has actually reached
  disk rather than merely the OS page cache.
- Renames over the destination. On POSIX `rename` is atomic at the
  filesystem level; on Windows `std::fs::rename` maps to `MoveFileEx`
  with `MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH`, which is
  atomic on NTFS for same-volume targets.
- Cleans up the temp file on any error so we don't leak `.tmp` files
  if the rename itself fails (read-only target, cross-volume path,
  etc.).

Both call sites now go through the helper. Added three unit tests
covering new-file write, existing-file replacement, and the no-leftover
`.tmp` invariant.

Closes the non-atomic-write path without touching the disk format or
any public command surface.
…afe test dirs

Addresses all three review threads on TrixtyAI#170.

- Temp uniqueness. The previous PID+nanos suffix relied on SystemTime
  resolution being finer than a save cycle, which isn't always true
  (15.6 ms on older Windows) and `.unwrap_or(0)` collapsed clock
  failures to the same value. A `File::create` on a duplicate suffix
  would also truncate an existing temp that another caller owned.
  Introduced `create_unique_temp` that opens the temp with
  `OpenOptions::create_new(true)` (fails fast with AlreadyExists),
  adds a process-wide `AtomicU64` counter alongside PID + nanos so
  two racing calls inside the same tick never clash, and retries up
  to 32 times before surfacing an error.

- Permission preservation. When the destination file already exists,
  carry its mode bits to the temp via `set_permissions` before the
  rename. On Unix this preserves the executable / group / other bits
  that the review called out (previously the rename landed a default
  0644/0666-style mask even if the original file was 0755). On
  Windows `Permissions` only exposes the read-only bit and ACLs
  inherit from the parent directory, so the carry-over is a no-op in
  practice — documented that in the doc-comment so a future editor
  doesn't try to plug a hole that doesn't exist.

- Test-parallelism flake. `tmp_dir()` in the test module now
  incorporates the current thread id plus a process-wide atomic
  counter in addition to PID and nanos, so two Rust-test threads
  racing into the helper at the same clock tick always pick disjoint
  directories.

- Added a Unix-only regression test (`preserves_unix_mode_bits_on_replace`)
  that chmod 0o755s the destination, overwrites, and asserts the
  mode survives.

- Mentioned as polish: the `io_other_error` clippy lint pushed us to
  use `std::io::Error::other` instead of `Error::new(Other, …)` in
  the retry-exhausted branch.
@jmaxdev jmaxdev force-pushed the fix/atomic-writes branch from 4830e11 to b191da3 Compare April 21, 2026 04:21
@jmaxdev jmaxdev merged commit 3ce7096 into TrixtyAI:main Apr 21, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Non-atomic writes to disabled_extensions.json and write_file corrupt state on crash

3 participants