Skip to content

Take the name of a file that is not there yet - #13

Open
rodgco wants to merge 8 commits into
omacom:masterfrom
rodgco:fix/open-missing-file
Open

Take the name of a file that is not there yet#13
rodgco wants to merge 8 commits into
omacom:masterfrom
rodgco:fix/open-missing-file

Conversation

@rodgco

@rodgco rodgco commented Aug 16, 2026

Copy link
Copy Markdown

Fixes #6. The analysis and the shape of this fix are @Vyrnexis's, from the issue.

Backend::open only reads. A path with nothing behind it fails the ReadOnly open, reports "Could not open", and returns before setFileUrl, so the document ends up with no name at all — and Backend::save falls back to saveAsDialog(). Starting a file the way every editor is asked to start one, omawrite new_document.md, therefore met the portal's Save As dialog asking for a name that had already been given on the command line.

open now claims the URL for a blank document when the path is not there yet, so the first save lands where the writer said it should. A path that exists but cannot be read — a directory, a file without read permission — is still an error, and still leaves the document it could not replace alone.

The status line says New file new_document.md, and nothing is written until the writer saves.

Only where the file could actually be created: a name under a directory that is not there, or one the writer cannot write into, stays on master's Could not open. Otherwise the failure moves from the open to the first save, where there is no longer a Save As dialog to land in.

The guards this removes, put back

Taking the name early gives up two things master got for free, and both are restored here rather than left as the cost of the feature.

Nothing can watch a file that is not there. watchCurrentFile() only watches a path that exists, so between the open and the first save the path is unguarded: a git pull or a sync client can put a file there, and QSaveFile::commit() would replace it without a word. On master that case went through the Save As dialog, which asks before overwriting. The first save onto a path this document has never read now asks instead of writing.

The question it asks is the one the app already asks about outside changes, so it reuses that dialog — with its own wording, since nothing changed, a file appeared, and with Keep Mine leading rather than Reload. Reloading here discards everything the writer has typed against a file they have never seen, and the recovery snapshot with it, so it must not be the button that Enter presses.

A reload that loses its file must not start a new one. reloadFromDisk() went through open(), so if the file vanished between the "File changed" dialog opening and the Reload click, Reload took the absent path as a new file: document blanked, recovery cleared. open() now takes a flag; reload passes false and gets master's Could not open back.

Both guards key on a dedicated m_pathNeverRead rather than on m_hasKnownFileContents, which answers a different question — whether there is a copy to compare a watcher event against. Overloading that one turned out to be how a first attempt at this locked the writer out of their own filename: the dialog's Keep Mine could fail to clear it, and every later save asked the same question again. m_pathNeverRead is cleared by every answer the dialog can give, including the ones that fail.

Test

startsANewFileFromAPathThatIsNotThereYet drives the real Main.qml document:

  • opening a path that is not there names the document, leaves it blank and unmodified, and writes nothing to disk;
  • typing and saving lands on that path with no Save As dialog requested (QSignalSpy on saveDialogRequested stays at 0);
  • a file that is there still opens and reads;
  • a path that cannot be read still reports Could not open, keeping the previous document and its URL.

asksBeforeAFirstSaveReplacesAFileThatAppeared, asksOnlyOnceWhenWhatAppearedCannotBeRead, keepsTheDocumentWhenReloadRacesADeletion and putsKeepMineForwardWhenAFileAppeared cover the guards: that the first save onto a file that turned up asks rather than writes, that answering it once is enough even when what turned up cannot be read at all, that a refused reload keeps the document and leaves the path guarded for when the file comes back, and that Keep Mine is the button carrying focus.

Each fails with its own fix backed out, on the assertion it is named for. The unreadable case uses a directory on the path rather than a permission bit, since permissions invert under root.

Full suite in build-tests: 17 passed, 0 failed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TyQRJCyR76uk7XaNAB8jMC

rodgco and others added 2 commits August 16, 2026 11:32
Backend::open only reads: a path with nothing behind it fails the
ReadOnly open, reports "Could not open", and returns before setFileUrl,
so the document keeps no name at all. Opening a file that has yet to be
written -- `omawrite new_document.md`, the way every editor is asked to
start one -- therefore left Ctrl+S with nothing to save to, and the
portal's Save As dialog asked for a name the writer had already given.

Claim the URL for a blank document when the path is not there, and let
the first save land on it. A path that exists but cannot be read is
still an error, as it should be: this is only about the file that is
not there yet.

Fixes omacom#6

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TyQRJCyR76uk7XaNAB8jMC
Taking any absent path meant a name under a directory that is not there was accepted too, and the failure moved from the open to the first save: `omawrite ~/notes/2026/draft.md` with no `2026/` directory opened a document titled `draft.md`, and Ctrl+S then answered with `Could not save draft.md.` in the footer and no dialog, because the URL was valid enough to skip the Save As fallback that used to catch this. Same for a name in a directory the writer cannot write, and for a trailing-slash path, which is a directory name that `fileName()` reports as `Untitled.md`.

Claim the name only where the file could actually be created. Everything else keeps master's answer: `Could not open`, and the first save still offers somewhere to put the text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@omarchybot

Copy link
Copy Markdown
Collaborator

Reviewed against master and ran the suite on a disposable VM: 13 passed, 0 failed, before and after the commit below.

Pushed 770f714 to this branch. The early return took any absent path, including one under a directory that is not there, or one the writer cannot write into — and that moves the failure from the open to the first save, where there is no longer a Save As fallback to land in. omawrite ~/notes/2026/draft.md with no 2026/ directory opens a document titled draft.md, and Ctrl+S answers with Could not save draft.md. in the footer and no dialog, because the URL is now valid enough to skip saveAsDialog(). A trailing-slash path is the same case and shows as Untitled.md, since fileName() is empty for it. The commit claims the name only where the file could actually be created, leaves everything else on master's Could not open, and adds one assertion to your test — which fails without the guard.

Two things I did not change, for the maintainer to decide:

  • Nothing watches the path between open and the first save: watchCurrentFile() can only watch a file that already exists, and no directory is watched. If another process creates that file in the meantime — a git pull, a sync client — saveTo() replaces it via QSaveFile::commit() with no external-change dialog, because m_hasKnownFileContents is false and no watcher event was ever possible. On master that case went through the Save As dialog, which asks before overwriting. Narrow, but it is a guard this removes rather than one that was never there.
  • reloadFromDisk() calls open(m_fileUrl), so if the file vanishes between the "File changed" dialog opening and the Reload click, Reload now blanks the document and clears recovery instead of reporting Could not open and leaving it alone. The dialog disables Reload when it already knows the file was deleted, so only that race reaches it.

Worth knowing before merge: #9 rewrites the same function. It routes every write of m_lastKnownFileContents/m_hasKnownFileContents through a new setKnownFileContents() that also maintains m_lastKnownFileText, the baseline its editorTextChanged compares against to clear the modified flag. This block sets those two members by hand and never touches m_lastKnownFileText, so whichever lands second, the new-file path has to call setKnownFileContents(QByteArray(), false). Left as is: open an existing file, then open a name that is not there, and the baseline stays the old file's text — typing exactly that text into the blank document clears modified, deletes the recovery snapshot, and closing the window never asks.

rodgco and others added 3 commits August 24, 2026 13:06
reloadFromDisk() calls open(), and open() now takes a path with nothing
behind it as a file the writer means to start. Reload is not that: it is
asked for a file we already read. The "File changed" dialog leaves the
Reload button enabled whenever the file was still there when the watcher
fired, so a deletion in the seconds between the dialog opening and the
click reaches reloadFromDisk() with the file gone -- and the new-file
branch answered by blanking the editor, clearing the recovery snapshot
and reporting "New file". That is the writer's only remaining copy, and
master reported "Could not open" and left it alone.

Route both callers through a private openPath() that carries whether an
absent path may become a new document. open() still says yes; reload
says no and falls through to the ReadOnly open that fails, which is
master's answer, word for word. Checking existence in reloadFromDisk()
first would leave the same race one function further along; the flag
closes it rather than narrowing it.

keepsTheDocumentWhenReloadRacesADeletion drives the real document: open
a file, type over it, remove the file, reload. Without the flag the
status reads "New file racing.md" and the typed text is gone; with it
the status is "Could not open racing.md.", the text, the URL and the
modified flag are all untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXHc91hBNDVXyoaCpqXDae
Taking the name of a file that is not there leaves the path unguarded
until the first save. watchCurrentFile() can only watch a file that
exists and no directory is watched, so nothing can report a file
appearing on that path -- and save() went straight to QSaveFile, whose
commit() replaces whatever it finds. A `git pull` or a sync client
landing that file between the open and Ctrl+S was overwritten in
silence. Before this branch the same case had no name to save to and
went through the Save As dialog, which at least asks.

The state that says so needs its own flag. m_hasKnownFileContents
answers a different question -- whether we hold a copy to compare a
watcher event against -- and reading "we have never looked at this
path" out of it does not work, because keepExternalVersion() sets it
back to false whenever the disk read fails. Keying the guard on it
therefore trapped the writer: if what turned up could not be read, a
directory or a file they may write but not read, Keep Mine reported
"Kept your version", changed nothing, and every Ctrl+S after it raised
the same dialog again, with no answer that ended it.

m_pathNeverRead is set only where this document takes a name without
having read what is on it -- the new-file open, and a recovery snapshot
naming a file that was never written -- and cleared by every outcome
that settles the question: a successful read in openPath(), a
successful write in saveTo(), a recovery snapshot whose file did read,
and keepExternalVersion() on both of its branches, the failing one
included. Failing to read is still an answer. The next save then goes
to the filesystem and the filesystem's answer ends it, which is the
only thing that can.

The guard reports the appeared file with its own signal rather than
externalChangeDetected. The two cases need different words and, more
than that, different defaults: ExternalChangeDialog focuses and
highlights Reload for an ordinary outside edit, which is right when the
file on disk is a second copy of the work, and catastrophic here, where
the writer's only copy is in the editor and reloadFromDisk() clears the
recovery snapshot on its way past. Enter on that dialog would have
taken the whole draft. Keep Mine now leads whenever the safe answer is
to keep, deleted and appeared alike, and the text says a file appeared
and that reloading discards everything typed.

The guard is in save() rather than saveTo() on purpose. saveAs()
reaches saveTo() from the portal's Save File dialog, which has already
asked about replacing whatever the writer picked; asking a second time
in a different dialog would be worse than not asking at all.

Three tests. asksBeforeAFirstSaveReplacesAFileThatAppeared: without the
guard the file on disk reads "my draft" where "arrived from elsewhere"
was. asksOnlyOnceWhenWhatAppearedCannotBeRead puts a directory on the
path; without the clear in keepExternalVersion() the dialog is raised
twice instead of once and the readable case never saves at all.
putsKeepMineForwardWhenAFileAppeared holds the button rule; under the
old one Keep Mine is not primary for an appeared file, so Reload takes
the focus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXHc91hBNDVXyoaCpqXDae
Refusing the reload keeps the writer's text, which is what that change
was for, but it leaves the document in a state nothing is looking
after: a name, no file behind it, and a watcher that dropped the path
when the file went. If the file comes back -- the pull that removed it
landing the next commit, the sync client finishing what it started --
the save had nothing to stop it, and QSaveFile replaced the returned
file unread. That is the same hazard the appeared-file guard exists to
close, one step further along the same flow.

It is the same state, so it gets the same flag. A read that fails on a
path with nothing on it, reached from reloadFromDisk() rather than
open(), sets m_pathNeverRead: this document has a name it has not
looked behind. The open() side is deliberately left out. Its failure
leaves a different document loaded under a different URL, and marking
that one unread would be a lie about a file we did read.

This is master's hazard too, not something the branch introduced, but
the reload path is one this branch rewrote and it is a line away from
the guard that closes it.

keepsTheDocumentWhenReloadRacesADeletion now runs on past the refused
reload: the file returns with different contents and the save must ask.
Without the flag no dialog is raised at all, and the returned file is
overwritten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXHc91hBNDVXyoaCpqXDae
@rodgco

rodgco commented Aug 24, 2026

Copy link
Copy Markdown
Author

The two guards you left for a decision, and what checking them turned up

Thank you for 770f714 — you were right that the early return took any absent path, and that moving the failure to the first save is worse than failing at the open, because by then there is no Save As to land in.

Both of the things you left for the maintainer are fixed here rather than accepted. Neither is a cost of the feature; both are guards master had, and taking the name early is what dropped them.

The unwatched path

Fixed in bf00e91. The first save onto a path this document has never read now asks instead of writing.

Getting there took two attempts, and the first one is the interesting part. It keyed the guard on !m_hasKnownFileContents, which reads naturally as "we have never read this path" — but that flag already means something else: whether there is a copy to compare a watcher event against. keepExternalVersion() sets it back to false when the disk read fails, so if what turned up was a directory, or a file writable but not readable, "Keep Mine" reported Kept your version, changed nothing, and every later Ctrl+S raised the same dialog again. Meanwhile saveAs() to the identical path succeeded on the first ask. The save refused what the filesystem granted, with no way out through the dialog it had raised.

So there is now a separate m_pathNeverRead, cleared by every answer the dialog can give — including the branches that fail. One boolean could not carry both meanings.

The second thing the first attempt got wrong: it reused the external-change dialog as-is, and onOpened focuses Reload whenever deleted is false. In this case the writer has no copy on disk at all, and reload clears the recovery snapshot too, so Enter on that dialog destroyed everything they had written. The dialog now takes an appeared flag; keepIsSafer drives both the focus and the primary button, and the wording says a file appeared rather than that one changed, and that reloading discards everything typed.

The reload race

Fixed in f4be8dc. open() takes a mayStartNewFile flag; reloadFromDisk() passes false and gets master's Could not open back. I chose the flag over an exists() check inside reloadFromDisk() because the check only moves the race one function along.

That closed half of it. After the refused reload the path is still unwatched and the document still holds a name, so if the file comes back — a git checkout, the sync client — the save guard did not fire and the returned file was replaced silently. 98f0c3f marks the path unread in exactly that case: a read failure from reload, on a file that is genuinely absent. The open() side is deliberately excluded, since its failure leaves a different document loaded under a different URL, and calling that one unread would be a lie about a file we did read.

The close latch, filed separately as #23

Worth knowing about, since this PR makes it easier to reach. When the unsaved-changes dialog's Save leads to a save that does not happen, pendingAction stays "close", and the next successful save — any later save at all — completes the close. The window shuts on a request the writer made minutes earlier and watched fail.

It is not this PR's bug: I reproduced it on master at 8f98892 through an ordinary failed save, and the backend already clears its own latch there (backend.cpp:476) while the QML side has no equivalent. But a guard that declines to write is a second way in, so it is worth having the two land in some order rather than by accident. #23 has the reproduction.

On #9

Your note was right that it rewrites this function, and wrong about which line fixes it — through no fault of yours, since e08b2ff landed on that branch about forty seconds after you wrote.

That commit added baselineKnown to editorTextChanged(), which is false for a new-file document, so the stale m_lastKnownFileText is never consulted and the scenario you described does not fire. I checked by merging the two branches on a throwaway and running your exact steps: modified stays true.

But the merge does introduce the opposite problem — omawrite fresh.md, type, delete everything, and modified stays true, which is #4's own complaint reappearing for new files. setKnownFileContents(QByteArray(), false) does not fix that, because baselineKnown is still false. What works, verified on the trial merge at 21 passing across both suites, is two lines:

// the new-file branch: the baseline is known, and it is empty
setKnownFileContents(QByteArray(), true);
m_pathNeverRead = true;

// and in #9's status branch, so a file never written is not called "Saved"
if (m_hasKnownFileContents && !m_pathNeverRead)
    setStatus(QStringLiteral("Saved %1").arg(fileName()));

That is only safe because the save guard no longer reads m_hasKnownFileContents. Whoever merges second should apply it.

Still the maintainer's call

Four things I decided one way and would change on request: the appeared-file wording; parameterising the existing dialog rather than adding a second one; leaving Reload enabled in the appeared case, on the grounds that taking what the sync client brought is a legitimate choice even here; and the underlying decision to raise the external-change dialog rather than fall back to Save As on that first save.

17 passing, bin/build clean.

@greptile-apps

greptile-apps Bot commented Aug 24, 2026

Copy link
Copy Markdown

Greptile Summary

This PR lets an initially absent command-line path become the target of a new blank document while preserving unreadable-path errors and reload safety.

  • Tracks whether the document has ever read its target and persists that state in recovery snapshots.
  • Prompts before the first save when a file has appeared at the target.
  • Adds appeared-file dialog messaging, safer focus behavior, pending-action cleanup, and lifecycle tests.

Confidence Score: 4/5

The PR should not merge until first-save conflict detection prevents a file created during the check-to-commit window from being silently replaced.

The appeared-file guard still checks target existence separately from the later save commit, so another process can create the target between those operations and have its contents overwritten without confirmation.

Files Needing Attention: src/backend.cpp

Important Files Changed

Filename Overview
src/backend.cpp Adds absent-path document initialization, first-save conflict handling, reload safeguards, and recovery persistence for never-read paths.
src/Main.qml Connects appeared-file events to the external-change dialog and clears interrupted pending actions.
src/ExternalChangeDialog.qml Adds appeared-file messaging and makes Keep Mine the primary focused action for that state.
src/backend.h Declares the appeared-file signal, guarded open helper, and never-read path state.
tests/tst_omawrite.cpp Adds coverage for absent targets, appeared files, reload deletion races, pending-action cleanup, dialog focus, and recovery.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Open local path] --> B{Path exists?}
    B -- Yes --> C[Read file and watch path]
    B -- No --> D{Parent writable?}
    D -- No --> E[Report open failure]
    D -- Yes --> F[Start blank named document]
    F --> G[First save]
    G --> H{Target now exists?}
    H -- Yes --> I[Ask Keep Mine or Reload]
    H -- No --> J[Save document]
    I -- Keep Mine --> J
    I -- Reload --> C
Loading

Reviews (2): Last reviewed commit: "Carry the never-read path through a reco..." | Re-trigger Greptile

Comment thread src/backend.cpp
Comment thread src/backend.cpp
Comment on lines +273 to +278
if (m_pathNeverRead && m_fileUrl.isLocalFile()
&& QFileInfo::exists(m_fileUrl.toLocalFile())) {
m_closeAfterSave = false;
emit externalFileAppeared(m_modified);
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Conflict strands pending close

When a close-triggered save encounters a file that appeared at the target, this branch returns without saveSucceeded; choosing Keep Mine only records the decision, so the pending close remains unfinished until the user manually saves again.

Knowledge Base Used:

omarchybot and others added 2 commits August 25, 2026 05:33
Closing a window with unsaved changes leaves `pendingAction` at "close" while the unsaved-changes dialog's Save runs, and `onSaveSucceeded` is what completes it. The appeared-file guard ends that save with a question rather than a write, so nothing completes it and the intent stays stored: the window the writer asked to close stays open, and then the next successful save -- an ordinary Ctrl+S minutes later, asked for on its own -- closes it on the earlier request.

The question is raised where the save would have gone, so that is where the intent is dropped. It covers both answers and Escape, which leaves the question unanswered on purpose and must not carry a close along with it.

This is issue omacom#23's shape rather than its fix. A save that fails for any other reason still leaves the close standing, and the general repair belongs there; this closes the one route into it that taking the name early opens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A snapshot names a path and holds the text; restoring it reads whatever is on that path now and takes that for the baseline. For a path this document had never read, what is there now can be a file that arrived while Omawrite was not running, and clearing m_pathNeverRead on the strength of that read told the first save it had already looked. Ctrl+S then replaced the arrived file without asking -- the same loss the guard was added for, reached over a crash instead of over a `git pull`.

So the snapshot carries the flag, and only the snapshot can answer the question. One written before this key existed reads as false, which is what it meant: before the branch, nothing could hold a name it had not written to.

The unreadable branch keeps setting it true rather than consulting the key. A path that cannot be read is unverified whatever the snapshot said about it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
@omarchybot

Copy link
Copy Markdown
Collaborator

Re-reviewed at 98f0c3f, the three new commits included. Both Greptile P1s on src/backend.cpp:273-278 hold up — one as a residual, one as a real defect — and there is a third the second reviewer turned up. Reviewed by Claude Opus 5 and by Codex at xhigh reasoning, both against the tree at 98f0c3f.

The guard does what it claims, and it does not fire on the ordinary case

omawrite fresh.md on a path that is not there still writes on the first Ctrl+S with no dialog, because the guard needs QFileInfo::exists() to be true as well as the flag. startsANewFileFromAPathThatIsNotThereYet covers exactly that and passes.

The TOCTOU is real, and not a reason to hold the PR

I checked what QSaveFile does rather than what the docs say, on a Qt 6.11.2 build: commit() on a target that appeared after open() renames over it, returns true, and the inode changes. The arrived file is gone with no error. The mechanism is as described.

What makes it a residual is the size of what it replaced. Without the guard the window is the whole span between taking the name and the first save, which can be an afternoon; with it, the window is the existence check to the rename.

It is also not closable inside QSaveFile. QSaveFile::open(WriteOnly | NewOnly) returns false on an absent target too — Qt rejects the mode outright rather than giving O_EXCL semantics. Plain QFile does honour NewOnly (false on an existing path, true on an absent one), so the race could be closed by creating that first file exclusively and writing through it, at the cost of the atomic replace for the one write where there is nothing yet to replace. renameat2(RENAME_NOREPLACE) would keep both and has no Qt API. Either is a change to the save path and a decision rather than a fix, so I left it.

The close strand is real, and worse than the bot described — fixed in b0bf1cc

Built and ran it. With pendingAction at "close", the guarded save emits externalFileAppeared and no saveSucceeded, and pendingAction stays "close" through Keep Mine, through Reload, and through Escape. The window does not close. Then the next successful save — an ordinary Ctrl+S later — fires saveSucceeded, completePendingAction() runs, and the window closes on the earlier request.

You are right that this is #23 and predates the branch: I reproduced it on origin/master at 8f98892 through a failed save, and the next successful save closed the window there too. The latch is not yours. But this branch opens a second route into it that is much easier to reach than a failed save, so b0bf1cc drops the pending action where the question is raised, in onExternalFileAppeared. It does not fix #23's general case, which still needs its own repair.

Recovery loses the never-read state — fixed in df3274c

This one is codex's. writeRecovery() stores the URL and the text; restoreRecovery() reads whatever is on the path now and clears m_pathNeverRead on the strength of that read. Start fresh.md, type, crash, let something create fresh.md while Omawrite is down, restart: the arrived file is read only as the comparison baseline, the guard never fires, and Ctrl+S replaces it. I ran it — the file ended up holding the recovered draft. df3274c puts the flag in the snapshot; a snapshot without the key reads as false, which is what it meant before this branch existed. The unreadable branch still sets it true regardless, since a path that cannot be read is unverified whatever the snapshot said.

One left for you, because the obvious fix is wrong

Choosing Reload when what appeared cannot be read — a directory, or a file you may write but not read — loops. openPath(..., false) fails; the path still exists so backend.cpp:241 does not touch the flag; nothing else clears it; every later Ctrl+S raises the same dialog. I measured three in a row.

It is the mirror of the case bf00e91 fixed for Keep Mine, but clearing the flag on a failed reload would be wrong: the writer has not said to keep their version, and an unreadable file can still be writable, so the next save would replace it in silence. Keep Mine is the way out and it works. Worth deciding rather than reflexively symmetrising.

Tests

On a disposable worker, never on the machine holding credentials. bin/test 19 passing at the pushed head (your 17 plus 2), bin/build clean, Qt 6.11.2. Both new tests were checked by mutation — reverting each fix turns its own test red and nothing else. b0bf1cc is green on its own.

Codex also noted that putsKeepMineForwardWhenAFileAppeared proves less than it reads like: it checks the buttons' primary styling but never opens the dialog, so a regression in the onOpened focus line would keep it green while Enter went back to Reload. Not worth blocking on, but it is the one new assertion that could pass for the wrong reason. Where codex agreed with conclusions already reached here its independence is not currently guaranteed — it can read this session's own working files — but the recovery finding and that observation are its own.

On #9, and on #22

Still reconciled, but the merge-order note has moved since the last pass. At this head the two no longer merge cleanly: three conflicts in backend.cpp, all mechanical — #9's setKnownFileContents(...) beside this branch's m_pathNeverRead line. Resolved that way, 20 pass across both suites and the data-loss shape does not fire: baselineKnown is false for a document with a file URL and no baseline, so emptying the editor leaves modified true.

Your two suggested lines check out. I applied them on the trial merge — setKnownFileContents(QByteArray(), true) with m_pathNeverRead = true, and && !m_pathNeverRead on #9's status branch. 20 still pass, emptying a new file goes clean with an empty status rather than Saved fresh.md, and the unknown-baseline case still stays modified. Whoever merges second wants those.

Worth knowing about #22: its autosave writes through saveToItsOwnFile()saveTo(m_fileUrl), which never enters save() and so never sees this guard. If both land, autosave would replace an appeared file on a never-read path on a timer, without anyone touching the keyboard. Whichever goes second needs the guard on that path too.

Waiting on the maintainer for the TOCTOU decision, the failed-Reload loop, and the order of #13, #9 and #22.

`remembersANeverReadPathAcrossRecovery` hand-writes the recovery JSON, so it exercises `restoreRecovery()` and nothing else. Deleting the `pathNeverRead` key from `writeRecovery()` leaves the whole suite green while the data-loss path it was added for reopens: crash on a never-read path, let something create the file while Omawrite is down, and the first save after the restart replaces it without asking, because a snapshot missing the key restores as false.

The new test drives the real writer -- open an absent path, type, wait for the recovery timer -- and reads the file back. Removing the key fails it, and so does writing a constant false; nothing else in the suite moves either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
@omarchybot

Copy link
Copy Markdown
Collaborator

Re-reviewed at df3274c. No new commits from you since the last pass, so this is one thing the second opinion found in a commit of mine, plus an answer worth stating flatly. Reviewed by Claude Opus 5 and by Codex at xhigh reasoning, both against this head.

The recovery fix was untested in the direction that matters — fixed in 6bc0092

df3274c put pathNeverRead into the recovery snapshot, and the test I added with it, remembersANeverReadPathAcrossRecovery, hand-writes that JSON. So it exercises restoreRecovery() and nothing else. Proved on a worker: delete the key from writeRecovery() (backend.cpp:595) and all 19 tests stay green, while the data-loss path the commit exists for reopens — crash on a never-read path, something creates the file while Omawrite is down, and the first save after the restart replaces it, because a snapshot without the key restores as false. My claim last time that both new tests were mutation-checked was true only for reverting a whole commit; half of that one could be reverted in silence.

6bc0092 adds writesTheNeverReadPathIntoTheSnapshot, which drives the real writer — open an absent path, type, wait for the recovery timer — and reads the file back. Removing the key fails it, writing a constant false fails it, and nothing else in the suite moves either way. That is codex's finding; it is the one thing here neither reviewer had already written down.

Nothing checks the target's existence at write time

Worth saying plainly, because it is the whole shape of the residual TOCTOU rather than a detail of it. saveTo() (backend.cpp:525-574) rejects non-local URLs, opens a QSaveFile, writes, and commits. There is no existence check and no no-replace condition anywhere in it, and commit() renames over whatever is on the path. The only existence check in the entire write path is save():273-274, which runs before the call — check-then-act by construction, so the guard is a narrowing of the window and never a closing of it.

There are two entries to saveTo() and only one is guarded. save() carries the appeared-file check; saveAs() (backend.cpp:297-298) calls saveTo() directly with no m_pathNeverRead test and no existence test, so its only protection is the file picker's overwrite prompt, which is another process and happens well before the write. That is master's behaviour rather than something this branch introduced, but it means the guard covers Ctrl+S and not Ctrl+Shift+S onto the same path — worth knowing when deciding how far to take the fix.

Greptile's two P1s, at this head

Both were raised against 98f0c3f, before the two commits I pushed on the 25th. Non-atomic appeared-file guard holds; it is the residual above, and it is a decision about the save path rather than a bug in this branch. Conflict strands pending close does not hold at this head — b0bf1cc clears pendingAction and awaitingPendingSave in onExternalFileAppeared, and dropsThePendingCloseWhenTheSaveIsRefused covers it. If you are looking at two open P1s, one of them is already answered.

Codex also read asksOnlyOnceWhenWhatAppearedCannotBeRead as proving less than its name claims. I checked and rejected that: line 336 asserts the second save reports Could not save blocked.md., which only happens through the branch the test is about.

Tests

On a disposable worker, never on the machine holding credentials. bin/test 19 passing at df3274c and 20 at 6bc0092, bin/build clean, Qt 6. Where codex agreed with conclusions already reached here its independence is not currently guaranteed — it can read this session's own working files — but the coverage gap above is its own, and it is confirmed by mutation rather than by agreement.

Still waiting on the maintainer, unchanged from the last pass: the write-time race, the failed-Reload loop, and the order of #13, #9 and #22.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Opening a non-existent file from terminal forces "Save As" dialog on first save

2 participants