Files
firmware/src
Clive BlackledgeandClaude Opus 5 0271be9369 fix(SafeFile): remove a stale .tmp before opening it for write (#11428)
* fix(SafeFile): remove a stale .tmp before opening it for write

SafeFile writes to <filename>.tmp, verifies it by readback, then renames it over
the real file. openFile() never removed a pre-existing .tmp - an unfinished
FIXME - and FILE_O_WRITE appends rather than truncates on Adafruit_LittleFS
(nRF52) and STM32 LittleFS.

So a .tmp left behind by a reset in the window between close() and renameFile()
is appended to on the next save. The readback hash covers only the bytes just
written, so it mismatches, close() returns false, and the tmp is left behind
again - the failure latches and every subsequent save of that file fails. Today
saveProto() discards close()'s result, so this is silent and permanent.

Guard the remove with exists(): a bare remove() of a missing file logs on
Portduino. The same guarded pattern is already used for this exact append trap
in xmodem.cpp.

Note the FIXME's commented-out body named the wrong path - it removed
'filename', the real file, not 'filenameTmp' - so it would have destroyed the
good copy had it ever been enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(SafeFile): cover the stale-.tmp path, and trim the fix comment

Adds test_safefile, the first coverage of SafeFile's write-tmp / verify-by-readback /
rename-over path that every saveProto() caller goes through. Five cases pin the contract
the fix restores: whatever the backend does on open, a completed save leaves the real file
holding exactly the bytes written and nothing else, on both the fullAtomic and the
!fullAtomic construction, with no .tmp left behind.

These tests cannot go red on the native host, and that gap cannot be closed here. FILE_O_WRITE is an append-then-seek-to-end open only on
Adafruit_LittleFS (nRF52) and on the in-repo STM32 port (STM32_LittleFS_File.cpp: LFS_O_RDWR
| LFS_O_CREAT followed by lfs_file_seek to LFS_SEEK_END). On Portduino FILE_O_WRITE is the
string "w" (FSCommon.h:13), which reaches fopen() and truncates. Reverting the source fix
and re-running leaves all five green, verified rather than assumed. test_write_open_truncates
_on_this_host asserts that premise out loud, so if the host ever gains the append behaviour
the suite starts discriminating instead of quietly agreeing.

Why the original FIXME stayed commented out, since that is the real history here. It read
"if (fullAtomic) FSCom.remove(filename)" and named the real file, not the tmp. Running it
would delete the last good copy before the replacement had been written and verified, which
is precisely the guarantee fullAtomic exists to provide. Disabling it was correct. The fix
under test removes filenameTmp instead, which is the file that actually carries the stale
bytes, and is safe to drop at any point because nothing has been promised about it yet.

Scoping the remove to fullAtomic would be wrong for the same reason. Both paths open the
same filenameTmp with the same FILE_O_WRITE; fullAtomic only decides whether the real file
is nuked up front to free space. The !fullAtomic path is the space-constrained one, so it is
if anything the more likely to be interrupted mid-write and inherit a stale tmp. Test 2
pins that.

On the cost of the added exists(). Every saveProto() already ends in SafeFile::close(), which
calls testReadback(): it reopens the tmp and reads the whole proto back one byte at a time
through f2.read() to XOR a verification hash, then renames. So the per-save cost is already an
open, a full write, a close, a full byte-wise reread, and a rename. One exists() is a single
path lookup with no erase, no program and no data read, and on the common path there is no
remove() at all. Next to the readback loop it is noise. Happy to put a number on it if wanted.

Scoping it to fullAtomic would also not do what it looks like it does. SafeFile's constructor
defaults fullAtomic to false (SafeFile.h:28), and of the saveProto call sites only
saveDeviceStateToDisk passes true. Config, moduleconfig, channels, nodedatabase and backup all
take the default, so scoping would leave the stale tmp live on almost every save path,
including the space-constrained one most likely to be interrupted mid-write.

Also trims the fix's comment to two lines per AGENTS.md, and drops the stale-tmp removal log
from LOG_WARN to LOG_DEBUG: an interrupted write is recoverable and self-healing, so it does
not warrant a warning on every boot after one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(SafeFile): fail the write when a stale tmp cannot be removed

openFile() ignored the result of FSCom.remove(). If the removal failed on an
append-on-write backend, the open that follows appended to the stale bytes, and
the readback hash is an 8 bit XOR over the whole tmp, so polluted content has a
real chance of verifying and being renamed over the good file.

It now logs and returns an invalid File. SafeFile::write() already no-ops on
!f and close() already returns false, so the caller sees the save fail rather
than silently getting a corrupt one. This is the only checked FSCom.remove() in
the tree; the other call sites are all best-effort cleanups where failure does
not compromise anything.

Also gates test_write_open_truncates_on_this_host to ARCH_PORTDUINO. It asserts
that this host truncates on FILE_O_WRITE, which is false by design on the
Adafruit_LittleFS and STM32 backends the fix exists for, so running the suite
there would fail on a premise that is only meant to describe the test host.

Both reported by CodeRabbit on #11428.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 08:23:23 +00:00
..
2026-08-20 12:28:57 +00:00
2026-08-20 12:28:57 +00:00
2026-08-20 12:28:57 +00:00
2026-01-29 05:56:19 -06:00
2026-08-20 12:28:57 +00:00
2026-02-19 07:16:33 -06:00
2026-05-11 11:46:13 -05:00
2026-08-20 12:28:57 +00:00
2026-07-01 19:01:27 -05:00
2026-08-20 12:28:57 +00:00
2026-07-16 18:57:50 -05:00
2026-08-20 12:28:57 +00:00