Fix two races in the notification popup and history handling - #6735
Conversation
The popup files a replay reads are written by a serialized queue of shell jobs, and the read ran as its own process alongside it. A dismissal issued a moment earlier could still be queued when the directory was read, leaving the notification out of the replay it was the newest entry of, and a clear issued a moment earlier could still be queued too, replaying entries it was about to remove. The read now waits for the queue to go idle, so the replay shows the history as of the moment it was asked for rather than whichever jobs happened to have landed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Watching a notification for in-place updates starts the moment it is handed over, but the row those updates write to is inserted a tick later, deferred to keep a mid-incubation Repeater from being mutated underneath. A client fast enough to update inside that window found no row to write to, and a property that has already changed does not change again — so the toast and its file sat on the superseded content until something else moved. The row is now refreshed from the live notification once it exists. That reads the same object the signals would have, so an update that beat the insert is picked up and one that did not costs nothing: a refresh whose content matches the row it would write is dropped, which also collapses the several signals a single multi-property update emits into one rewrite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes notification update and history replay races.
Changes:
- Refreshes popups after deferred insertion.
- Skips redundant row/file updates.
- Delays history reads until prior file jobs complete.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
shell/plugins/notifications/Service.qml |
Coordinates popup refreshes and history reads. |
shell/plugins/notifications/NotificationLogic.js |
Adds popup-role comparison helpers. |
test/shell.d/notifications-test.sh |
Tests refresh and queue-idle behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| service.historyReadPending = false | ||
| readHistoryProc.command = ["bash", "-c", | ||
| "awk 1 \"$1\"/*.json 2>/dev/null || true", "--", historyDir] | ||
| readHistoryProc.running = true |
There was a problem hiding this comment.
Right — fixed in bb1ff2d. runNextPopupFileJob() now returns early while readHistoryProc.running, so the read is a barrier in both directions, and the queue resumes from the read's onExited rather than from its output so an empty or failed read cannot park the queue behind it.
Verified live: showHistory immediately followed by clear replays all four archived notifications and then empties the directory, and notifications sent afterwards still persist and archive normally.
— 🤖 Claude, posting on behalf of @dhh
The read waited for everything queued before it, but nothing stopped the queue from running on while it worked. A clear or an archive issued during the read could delete or move files out from under awk mid-glob, so a replay could still show a partial history — some of what a clear was in the middle of emptying. The read is a barrier in both directions now: the queue holds until it exits, and it releases on exit rather than on output, so a read that comes back empty or fails cannot park the queue behind it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (1)
shell/plugins/notifications/Service.qml:408
historyReadPendingdoes not actually mark the replay's position in the queue. If a file job is running whenshowHistoryis requested, then a laterclear/archive is appended before that job exits; this branch keeps starting queued commands until the entire queue is empty, so the later operation runs before the read (and a continuous stream can starve the read). Insert a replay sentinel/barrier intopopupFileQueuewhenshowHistoryis called, and start the read when that sentinel reaches the head so commands enqueued afterward remain behind it.
if (readHistoryProc.running) return
if (popupFileProc.running || popupFileQueue.length === 0) {
if (!popupFileProc.running) startHistoryReadWhenIdle()
return
Waiting for the queue to go idle before starting the read still let work overtake it. A clear or an archive enqueued after the replay was asked for, while the current job was running, was dequeued the moment that job exited — the read only starts once nothing is left — so the replay showed the state after those jobs, which is the race this was meant to close. Unbroken file traffic could postpone the read indefinitely for the same reason. The read is now an entry in that queue rather than a process running beside it. It takes its place in line behind the work queued before the request and ahead of everything queued after, so no later job can overtake it and no amount of traffic can push it back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (1)
shell/plugins/notifications/Service.qml:529
- Queueing only the directory read does not preserve the ordering of file jobs created by the replay itself. With a live popup,
showHistoryfollowed byclearleavesclearwaiting behind this read; whenreplayHistory()runs,clearPopups()enqueues the live popup's archive after that already-queued clear. The queue then clears history and moves the popup into it again, so the laterclearrequest finishes with non-empty history. Keep replay-generated archive work at the read's queue position (ahead of jobs submitted aftershowHistory), or avoid archiving post-request rows during replay.
enqueueHistoryRead()
Follow-ups to the notification work in ab57ad6 and cd84583, from Copilot's review of the two PRs those shipped from and a codex pass over this branch. Kept together in one PR.
The replay could read the history mid-write. Popup files are written by a serialized queue of shell jobs, and
showHistoryread the directory as its own process alongside it. A dismissal issued a moment earlier could still be queued when the read ran, leaving that notification out of the replay it was the newest entry of; aclearissued a moment earlier could still be queued too, replaying entries it was about to remove. The read is now an entry in that queue rather than a process running beside it, which is what makes it a real barrier: it takes its place in line behind the work queued before the request and ahead of everything queued after, so a clear cannot delete files out from underawkmid-glob, no later job can overtake it, and unbroken file traffic cannot postpone it. It releases the queue on process exit rather than on output, so a read that comes back empty or fails cannot park the queue behind it.An update could arrive before its popup had a row. Watching a notification for in-place updates starts when it is handed over, but the row those updates write to is inserted a tick later — deferred to keep a mid-incubation Repeater from being mutated underneath. A client fast enough to update inside that window found no row, and a property that has already changed does not change again, so the toast and its file stayed on the superseded content indefinitely. The row is now refreshed from the live notification once it exists. A refresh whose content matches the row it would write is dropped, which costs nothing in the common case and also collapses the several signals one multi-property update emits into a single rewrite.
Copilot's finding that the countdown should restart for every refreshed role, not just summary/body/image, I did not apply. The example given was urgency dropping from critical back to normal resuming a nearly-exhausted fraction, but a critical toast never ticks (
lifetimeis 0, so the timer never runs) and its remaining fraction is still a full 1.0 when it becomes normal. What is left is app name, icon, glyph and expire timeout, none of which change the text the user is reading, and resetting on those would let a client hold a toast on screen indefinitely by re-sending a hint.Verified against the running shell:
dismissAll,showHistoryandclearissued back to back replays all four archived notifications and then empties the directory, so the archives landed ahead of the read and the clear behind it; a burst of eight notifications sent immediately after a replay request does not delay or displace it; new notifications still persist and archive after a read, including after one that found no history; and an in-place update still refreshes the toast and its persisted file.— 🤖 Claude, posting on behalf of @dhh