Summary
The Android wallet save path survives process death but not power loss. Two wallet corruptions today on an API 30 emulator, both immediately after a hard emulator death, both presenting as:
Error reading Wallet
Error: initializing wallet: File error. failed to fill whole buffer
A truncated file, unrecoverable in-app; the recovery screen's "Create New Wallet" could not proceed past it either (a second, smaller bug), and only pm clear restored the app. On a phone this failure class is a battery death or forced power-off during a save, and the wallet becomes a seed-restore.
Why the existing protection did not hold
writeEncryptedFileDurably and completePendingWrite (audit Issue P) are correct against the failure they were designed for: a crash of the process mid-write. The temp stash preserves the previous content, and the recovery verifies the target by a full decrypting read before trusting it, restoring from the temp when the read throws.
The gap is durability, not ordering. No step in the chain calls fsync: not the temp write, not the target write, and not the parent directory after creates and deletes. The observed sequence is therefore:
- The save completes in the process's view. The stream is closed, but the pages sit in the OS cache.
- The cleanup deletes the temp — also only in the cache.
- Power loss (the emulator's hard death is exactly this). The kernel's writeback never happened, or happened partially.
- On reboot the target is torn on disk and the temp is gone.
completePendingWrite finds no temp, the decrypting read fails, and the wallet is dead with nothing to restore.
The comment in the code notes iOS is safe via String.write(toFile: atomically: true); that call also fsyncs before its rename, which is the half the Android port did not inherit.
Suggested direction
- Fsync the target file after the inner write completes and BEFORE deleting the temp (Jetpack
EncryptedFile does not expose the descriptor; reopening the closed file with RandomAccessFile(file, "rw").fd.sync() durably flushes the same inode).
- Fsync the temp after the stash for the same reason, and fsync the parent directory after creates and deletes so the metadata survives too.
- Alternatively (or additionally), keep the temp until the NEXT successful launch instead of deleting it at save time:
completePendingWrite already verifies the target by decryption, so a stale temp costs one file of storage and buys a recovery window that covers the writeback gap entirely.
- Separately: the recovery screen's "Create New Wallet" should function even when the corrupt file is present; today it silently fails and strands the user.
Reproduction evidence
Emulator: Pixel 7 AVD, API 30 x86, branch feat/nym at 61b17435 (also reproduced earlier at 2b3e6d5e). Both corruptions followed a hard emulator exit (host-side kill, equivalent to power loss) during an active session. Recovery required pm clear. The wallet file existed but was truncated (the "failed to fill whole buffer" read error), and no .write.tmp was present, consistent with the post-cleanup writeback-loss window above.
Stakes: testers funding wallets on debug APKs (the #1221 flow, and the send-over-nym smoke) can lose wallet state to a crash they didn't cause; on hardware the trigger is any abrupt power event during a sync-milestone save.
Summary
The Android wallet save path survives process death but not power loss. Two wallet corruptions today on an API 30 emulator, both immediately after a hard emulator death, both presenting as:
A truncated file, unrecoverable in-app; the recovery screen's "Create New Wallet" could not proceed past it either (a second, smaller bug), and only
pm clearrestored the app. On a phone this failure class is a battery death or forced power-off during a save, and the wallet becomes a seed-restore.Why the existing protection did not hold
writeEncryptedFileDurablyandcompletePendingWrite(audit Issue P) are correct against the failure they were designed for: a crash of the process mid-write. The temp stash preserves the previous content, and the recovery verifies the target by a full decrypting read before trusting it, restoring from the temp when the read throws.The gap is durability, not ordering. No step in the chain calls
fsync: not the temp write, not the target write, and not the parent directory after creates and deletes. The observed sequence is therefore:completePendingWritefinds no temp, the decrypting read fails, and the wallet is dead with nothing to restore.The comment in the code notes iOS is safe via
String.write(toFile: atomically: true); that call also fsyncs before its rename, which is the half the Android port did not inherit.Suggested direction
EncryptedFiledoes not expose the descriptor; reopening the closed file withRandomAccessFile(file, "rw").fd.sync()durably flushes the same inode).completePendingWritealready verifies the target by decryption, so a stale temp costs one file of storage and buys a recovery window that covers the writeback gap entirely.Reproduction evidence
Emulator: Pixel 7 AVD, API 30 x86, branch
feat/nymat61b17435(also reproduced earlier at2b3e6d5e). Both corruptions followed a hard emulator exit (host-side kill, equivalent to power loss) during an active session. Recovery requiredpm clear. The wallet file existed but was truncated (the "failed to fill whole buffer" read error), and no.write.tmpwas present, consistent with the post-cleanup writeback-loss window above.Stakes: testers funding wallets on debug APKs (the #1221 flow, and the send-over-nym smoke) can lose wallet state to a crash they didn't cause; on hardware the trigger is any abrupt power event during a sync-milestone save.