Stop a failed save from destroying the document it was saving - #561
Merged
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 43c8244d5d
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
saveSync opened the user's own document and copied into it in place, so the window between "destination truncated" and "new content written" was one where any throw left them with a mangled file and a "save failed" toast. One of the one-star reviews in the play console is exactly that: "tried to save, and got an 'Internal Server Error' that destroyed the file. Good thing it was a copy." Four things, in rising order of how often they bite: The destination was opened with the default mode "w", which providers are not required to truncate - that is what "wt" is for. Saving a document shorter than the one already there left the tail of the old file sitting behind the new content, and for a zip container like odt or docx trailing bytes after the central directory are what make it stop opening. This is the likely everyday corruptor, and it needs no failure at all to happen. A provider that rejects the mode outright falls back to "w", so this cannot make saving fail where it worked. close() was called on the output stream but not from a finally, so any throw during the copy leaked the descriptor and left flushing undefined. It is a use block now. The retranslated temp file was only deleted on the success path, so a failed save leaked it. It is deleted in a finally, and only that one: the other branch hands back the cache file of the document that is still open, which must survive. Nothing was kept, so a half-written destination could not be undone. The current content is copied into the cache before the write and put back if the write throws. If the rollback itself fails, that copy is deliberately left behind rather than deleted - at that point it is the only copy of the user's document that exists. Also post onSaveError to the main handler, which onSaveSuccess already did. The listener touches fragment state and calls requireActivity(), neither of which belongs on the loader's background thread. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E1ANEeai87KtnX5pBzgDEJ
writeTo falls back to mode "w" for a provider that rejects "wt", and "w" is the mode that may not truncate - the whole reason the fallback is a fallback. A restore that went in that way can leave the tail of the failed save sitting behind the old content and still look like it worked, and the finally block would then delete the only clean copy there was. writeTo reports whether it truncated, and restore passes that through, so a rollback that could not truncate counts as failed and the backup stays. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E1ANEeai87KtnX5pBzgDEJ
andiwand
force-pushed
the
fix/save-destroys-file
branch
from
August 2, 2026 19:18
43c8244 to
ec318c8
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
saveSyncopened the user's own document and copied into it in place. The window between "destination truncated" and "new content written" was one where any throw left them with a mangled file and a "save failed" toast.One of the 1★ reviews in the console is exactly that: "tried to save, and got an 'Internal Server Error' that destroyed the file. Good thing it was a copy." Another: "it's going to make you save a new document only to discover it saved none of your changes."
Four problems, in rising order of how often they bite
1. Mode
"w"is not required to truncate. That's what"wt"is for. Saving a document shorter than the one already there left the tail of the old file behind the new content — and for a zip container like odt or docx, trailing bytes after the central directory are what make it stop opening. This needs no failure at all to happen, which makes it the likely everyday corruptor rather than the dramatic one.A provider that rejects the mode outright falls back to
"w", so this cannot make saving fail anywhere it works today.2.
close()was not in afinally. Any throw during the copy leaked the descriptor and left flushing undefined.StreamUtil.copyexplicitly documents "Closes the input, never the output — the caller owns that one." It's auseblock now.3. The retranslated temp file was only deleted on success, so a failed save leaked it. Now deleted in a
finally— and only that one: the other branch hands back the cache file of the document that is still open, which must survive.4. Nothing was kept, so a half-written destination could not be undone. The current content is now copied into the cache before the write and put back if the write throws. If the rollback itself fails, that copy is deliberately left behind rather than deleted — at that point it is the only copy of the user's document that exists.
Also
onSaveErroris posted to the main handler, whichonSaveSuccessalready was. The listener touches fragment state and callsrequireActivity(), neither of which belongs on the loader's background thread. (SnackbarHelperdefends itself withrunOnUiThread, so this was not visibly crashing — it was just wrong.)On testing — worth being straight about
None of this is meaningfully covered, and I could not find an honest way to cover it in this repo.
FileProvider, andFileProvider.modeToModemaps"w"toMODE_TRUNCATEanyway — so a test would pass identically with and without the fix. The providers that don't truncate are third-partyDocumentsProviders (Drive, Proton, various file managers), which a test cannot stand up.ContentResolver.ACTION_CREATE_DOCUMENT, a system picker, which is why the existing instrumented tests cover entering edit mode but stop before the save.What I did verify:
spotlessCheck,assembleLiteDebug,assembleProDebug,lintProDebug,testProDebugUnitTest,testLiteDebugUnitTestall pass. The rest is reviewed by reading. Happy to add theContentResolverseam and unit-test the rollback with fakes if you'd rather have the coverage than the smaller diff.🤖 Generated with Claude Code