Repository navigation
Perf/message switch - #608
Open
valentin-morice wants to merge 8 commits into
Open
valentin-morice wants to merge 8 commits into
valentin-morice wants to merge 8 commits into
Conversation
The toolbar asks for the message it acts on about seventeen times each time the window redraws, which it does after every app event, and each ask copied the whole message, body included. With pictures in its body a message runs to megabytes: opening one such conversation spent around 200 ms copying it. reply_target now returns a reference, and the actions that keep the message clone it once.
Opening a conversation asked for each member's attachments from three places: the opened message, its conversation, and the related lookup when it answered, while the first asks were still on their way. Each answer carried the files' bytes and was copied on the main thread, so a 19 MB spreadsheet was read and handled three times. The reader now remembers which messages it has asked about since the selection, and a conversation's drawer is no longer replaced by the opened message's files alone.
An attachment held its bytes in a Vec, and the reader passes the same files to its cache, its drawer, every redraw of a conversation's drawer, popped-out windows and the gallery. Each hand-over copied them on the main thread: about 10 ms per copy of a 19 MB file, five copies or more per arrival. The bytes are now an Arc<[u8]>, so passing a file along is a reference count. Saving a file and rendering a PDF preview take the shared bytes too, rather than a copy of their own.
Attachment and GalleryItem derived Debug, which writes their bytes out number by number. relm4 logs every component input at the info level, so running with RUST_LOG=info turned each drawer or gallery update into megabytes of text and held the main loop for up to 1.6 s on attachment-heavy conversations. Both now show the file's name and size.
A conversation's drawer was handed its whole list again as each member's files came in, eight to twelve times per opening, and rebuilt its grid every time. Each rebuild started the renders of thumbnails still under way again: four pictures were decoded 32 times, and five PDFs about 25 times, one after another behind the PDF lock, so their pages trickled in for three seconds. The drawer now leaves a list it already shows alone, the reader does the same for the cards' attachment rows, and a thumbnail asked for while it renders waits for that render.
Opening a message waited for frames three times over: before handing the page to WebKit (three frames when the drawer came or went), two animation frames before the page reported ready, and two more before the still was lifted. The first two keep a black or half-placed page from being seen, which cannot happen under the still, and the last wait already makes sure the page is drawn. A page loaded under the still is now handed over at once and reports ready without waiting. Without a still nothing changes.
The backstop for a page that never reports ready fires 600 ms after every load, including the ones that did report. Taken for a second report, it patched the page again, and when another message was opened in that window and its page had not started loading yet, it could lift that message's still early. A second report for the same page is now dropped.
The worker writes the pictures a message shows into its body as base64 data: URIs, so a few photos make a body of megabytes. The reader went over each body eight times to build its page, and WebKit parsed the result twice: a conversation of two such messages made a 6 MB page and took 750 ms to open. Large pictures are now lifted out before those passes and served from memory under a hylki-inline: scheme. Save Image and Copy Image find them there, and the composer turns a pasted one back into a data: URI.
hyprlab
added a commit
that referenced
this pull request
Nov 15, 2026
The toolbar asks for the message it acts on about seventeen times each time the window redraws, which it does after every app event, and each ask copied the whole message, body included. With pictures in its body a message runs to megabytes: opening one such conversation spent around 200 ms copying it. reply_target now lends the open message, and the actions that keep it clone it once. Co-Authored-By: Valentin Morice <100000204+valentin-morice@users.noreply.github.com>
hyprlab
added a commit
that referenced
this pull request
Nov 15, 2026
Opening a conversation asked for each member's attachments from three places: the opened message, its conversation, and the related lookup when it answered, while the first asks were still on their way. Each answer carried the files' bytes and was copied on the main thread, so a 19 MB spreadsheet was read and handled three times. The reader now remembers which messages it has asked about since the selection, and a conversation's drawer is no longer replaced by the opened message's files alone. Co-Authored-By: Valentin Morice <100000204+valentin-morice@users.noreply.github.com>
hyprlab
added a commit
that referenced
this pull request
Nov 15, 2026
An attachment held its bytes in a Vec, and the reader passes the same files to its cache, its drawer, every redraw of a conversation's drawer, popped-out windows and the gallery. Each hand-over copied them on the main thread: about 10 ms per copy of a 19 MB file, five copies or more per arrival. The bytes are now an Arc<[u8]>, so passing a file along is a reference count. Saving a file and rendering a PDF preview take the shared bytes too, rather than a copy of their own. Co-Authored-By: Valentin Morice <100000204+valentin-morice@users.noreply.github.com>
hyprlab
added a commit
that referenced
this pull request
Nov 15, 2026
Attachment and GalleryItem derived Debug, which writes their bytes out number by number. relm4 logs every component input at the info level, so running with RUST_LOG=info turned each drawer or gallery update into megabytes of text and held the main loop for up to 1.6 s on attachment-heavy conversations. Both now show the file's name and size. Co-Authored-By: Valentin Morice <100000204+valentin-morice@users.noreply.github.com>
hyprlab
added a commit
that referenced
this pull request
Nov 15, 2026
A conversation's drawer was handed its whole list again as each member's files came in, eight to twelve times per opening, and rebuilt its grid every time. Each rebuild started the renders of thumbnails still under way again: four pictures were decoded 32 times, and five PDFs about 25 times, one after another behind the PDF lock, so their pages trickled in for three seconds. The drawer now leaves a list it already shows alone, the reader does the same for the cards' attachment rows, and a thumbnail asked for while it renders waits for that render. Co-Authored-By: Valentin Morice <100000204+valentin-morice@users.noreply.github.com>
hyprlab
added a commit
that referenced
this pull request
Nov 15, 2026
Opening a message waited for frames three times over: before handing the page to WebKit (three frames when the drawer came or went), two animation frames before the page reported ready, and two more before the still was lifted. The first two keep a black or half-placed page from being seen, which cannot happen under the still, and the last wait already makes sure the page is drawn. A page loaded under the still is now handed over at once and reports ready without waiting. Without a still nothing changes. Co-Authored-By: Valentin Morice <100000204+valentin-morice@users.noreply.github.com>
hyprlab
added a commit
that referenced
this pull request
Nov 15, 2026
The backstop for a page that never reports ready fires 600 ms after every load, including the ones that did report. Taken for a second report, it patched the page again, and when another message was opened in that window and its page had not started loading yet, it could lift that message's still early. A second report for the same page is now dropped. Co-Authored-By: Valentin Morice <100000204+valentin-morice@users.noreply.github.com>
hyprlab
added a commit
that referenced
this pull request
Nov 15, 2026
The worker writes the pictures a message shows into its body as base64 data: URIs, so a few photos make a body of megabytes. The reader went over each body eight times to build its page, and WebKit parsed the result twice: a conversation of two such messages made a 6 MB page and took 750 ms to open. Large pictures are now lifted out before those passes and served from memory under a hylki-inline: scheme. Save Image and Copy Image find them there, and the composer turns a pasted one back into a data: URI. Co-Authored-By: Valentin Morice <100000204+valentin-morice@users.noreply.github.com>
hyprlab
added a commit
that referenced
this pull request
Nov 15, 2026
Owner
|
Merged! thanks for the PR, this will ship in the next beta for testing |
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.
Hi! Here's another round of performance improvements. I've added a table at the bottom of this comment with the before/after measurements.
What this fixes
After #570, opening a message changes the pane in one step, but the step could still take a while, longest on messages with large attachments or pictures in their body: from about 100 ms for a plain message to 360 ms for a conversation with a 19 MB spreadsheet and 760 ms for one with 3 MB of pictures in each message. While a big message was open, every other app event slowed down too, and clicking quickly through such messages delayed the next click.
I measured before changing anything (method below). The time went to work done several times over on the main thread: copying the open message and attachment bytes, asking for the same attachments again, rebuilding the drawer and its thumbnails, building and parsing pages of megabytes, and waiting for frames the freeze from #570 already covers. Each commit takes one of these away.
How
perf(reader): lend the toolbar's target instead of copying itreply_target()returned an ownedMessage, and the window's view calls it, directly or throughacts_on_selection,toolbar_star_lit,target_in_junkandsnooze_offered, about seventeen times each time it updates, which is after every app event. With a 3 MB body that was 18 to 22 ms per event. It now returnsOption<&Message>; the actions that keep the message clone it once.perf(reader): ask for a message's attachments once per openingOpening a conversation asked for each member's attachments from the opened message, from the conversation, and again when the related lookup answered, while the first requests were still in flight. Each answer carried the bytes, so a 19 MB file was read and handled three times.
attachments_askedrecords what this opening has requested, and is cleared on the next selection. A conversation's drawer is also no longer replaced by the opened message's files alone; the single-message loader now runs for single messages only.perf(attachments): share a file's bytes instead of copying themAttachment::datais anArc<[u8]>instead of aVec<u8>(GalleryItem::datatoo), so handing a file to the cache, the drawer, a pop-out window or the gallery costs a reference count. Each arrival used to copy it three to five times on the main thread, about 10 ms per copy of 19 MB. Saving a file and rendering a PDF preview take the shared bytes as well.fix(attachments): log an attachment by its name and sizeThe derived
Debugwrote an attachment's bytes out one number at a time, and relm4 logs every component input at the info level: running withRUST_LOG=infoheld the main loop for up to 1.6 s on attachment-heavy conversations.AttachmentandGalleryItemnow show the file's name and size.perf(attachments): build the drawer once and render each thumbnail onceThe drawer was handed the whole list again as each member's files came in, eight to twelve times per opening, and rebuilt every time; each rebuild started the thumbnail renders still under way again. Four pictures were decoded 32 times, and five PDFs about 25 times one after another behind the PDF lock, so their previews trickled in for three seconds, into the next messages. The drawer now leaves a list it already shows alone (
AttachmentderivesPartialEq, which compares the shared allocation first), a thumbnail requested while it renders waits for that render (join_render/finish_render), and the reader skips an unchanged set of card attachment rows.perf(reader): skip the frame waits the still already coversAn opening waited for a GTK frame before handing the page to WebKit (three frames when the drawer came or went), then two animation frames before the page posted
ready, then two more before the still was lifted. The first two keep a black or half-placed page from being seen, which the still from #570 already prevents, and the last wait makes sure the page is drawn. When the still is up (ReaderSwap::covered()), the page is handed over at once and stampeddata-hylki-covered, which makes it postreadywithout waiting. Without a still, nothing changes.fix(reader): take a page's ready report onceThe 600 ms backstop for a page that never reports fired after every load, including those that did. Taken as a second report, it patched the page again, and if another message had been selected and its page had not started loading yet, it could lift that message's still early. A second report for the same page is now dropped.
perf(reader): serve a message's pictures apart from its pageThe worker writes pictures shown by Content-ID into the body as
data:URIs, so a few photos make a body of megabytes. Each card's document went through about eight passes over it (remote content, dark colors, the reader's sheet, the CSP, escaping intosrcdoc), and WebKit parsed the result twice: a conversation of two such messages made a 6.3 MB page.src/services/inline_images.rs(new) lifts largedata:image/…;base64URIs out of each card before those passes and serves them from memory under ahylki-inline:scheme, registered like the remote content scheme (secure, CORS-enabled, in the view's allowlist) and decoded off the main thread. The same page is now 143 KB.data:URIs.links::own_picture).hylki-inline:URL. The composer converts it to adata:URI as it lands, as it already did for pastedblob:images, so a sent message carries its own bytes.hylki-inline:counts as the message's own content, not remote content, and is allowed in the frame'simg-srcwhether remote content is allowed or not.Timing
From the selection to the first frame painted with the new message, median of three runs. The 22 newest conversations of a real IMAP inbox were opened top to bottom (first open), then bottom to top (already in memory).
Stepping through the same 22 every 250 ms, 19 to 20 now paint before the next selection (15 to 16 before), and the selection after the 6.3 MB conversation is no longer delayed (13 to 206 ms before).
Measured with temporary logging that is not part of this branch: a release build without LTO, a copy of the profile kept offline, on a hidden X display with software rendering, so absolute times on a GPU differ.
Testing
cargo test: all pass. The golden documents changed only forhylki-inline:in the frame's CSP and the sizing script'sdata-hylki-coveredcheck; the editor's script pin is updated for thepaste.jschange.Debugoutput.the_wrapper_in_a_real_engine_sizes_frames_and_refuses_injected_scriptand the newa_lifted_picture_in_a_real_engine_shows_and_fetches, which checks that a frame shows a lifted picture and that a page can fetch it, as the composer does. Each passes run alone; GTK starts on one thread per process, so the two cannot share a run.