Skip to content

Give memory back after conversions and release the main window in the tray - #122

Merged
KnifeLemon merged 37 commits into
mainfrom
feature/lower-memory
Oct 18, 2026
Merged

KnifeLemon merged 37 commits into
mainfrom
feature/lower-memory

Conversation

@KnifeLemon

@KnifeLemon KnifeLemon commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

Closes #119.

After conversions Filee kept ~400 MB of private memory (and ~500 MB working set) in the tray until it quit. Now it returns to about the tray baseline 30 s after the last conversion. Closing the main window releases it instead of hiding it.

Measured

Release, self-contained, ReadyToRun build on Windows 11. Each run used a fresh FILEE_DATA_DIR. Conversions were 12 × 12 MP PNG through two watch folders (→ JPG, → WEBP). Values are working set / private, in MB.

Moment Before (3 runs) After, final non-compacting trim (2 runs)
Tray only, 90 s 101–112 / 61–62 110–111 / 61
Main window open, 20 s 186–195 / 122–125 194 / 122
Closed to tray, 45 s later 184–194 / 122 15 / 124–125
5 s after conversions (before the trim) 506–516 / 432–433 549–554 / 564–565
60 s after conversions 486–497 / 412–416 13–14 / 120–141
120 s after conversions 487–497 / 413–416 15 / 120–141
Reopened, 10 s 491–499 / 410–417 100–107 / 146–153
Closed again, 45 s later 492–499 / 411–418 13–14 / 138–145

A compacting trim (an earlier commit, measured the same day) ended 10–30 MB lower (110 MB private after conversions) but crashed Skia, see below. Conversion time and peak memory are unchanged within noise. Reopening the window takes about 60 ms longer because the window is created again. A trim takes 68–107 ms, for example Trimmed memory: private 536 MB → 124 MB (managed heap 2 MB) in 107 ms.

Why the trim doesn't compact

The first version compacted: LOH CompactOnce, GCCollectionMode.Aggressive, and a Skia cache purge. On CI the App tests then crashed in native Skia (sk_pngencoder_encode, access violation, Windows and Linux) about half a minute after a trim. Taking the purge and the LOH compaction out didn't help. With the trim off in the test host, CI passed, and with a non-compacting trim it passes too.

A compacting gen2 collection moves old objects that background collections leave in place, and native code can hold pointers into them. The trim now runs GC.Collect(MaxGeneration, Forced, blocking: true, compacting: false) twice around WaitForPendingFinalizers(). Skia's caches are left alone.

Changes

  • Idle trim (MemoryTrimmer): asked when a job finishes or the main window closes, and runs 30 s after the last request, never while JobQueue.IsBusy. It:
    • releases Filee's own engine caches (EngineCaches.Release(): rhwp/CAD font caches). Skia's caches stay: they are shared with the UI's render thread, and purging them while it drew crashed in native code on the first CI run.
    • runs a full blocking collection that doesn't compact
    • trims the working set (SetProcessWorkingSetSize(-1, -1) on Windows)
  • Main window released when it goes to the tray:
    • MainWindow.HideToTray() hides it at once and closes it on the next dispatcher turn, unless it was shown again in between.
    • WindowService.ShowMain creates a new one, restoring position, size, maximized state and page for the session (the position is remembered only while the window is on screen).
    • MainWindowViewModel is now per window and IDisposable. It drops its service subscriptions and disposes the current page.
  • FILEE_DATA_DIR (dev/testing): runs a test copy next to the user's Filee.
    • Settings, engines and logs go to that folder.
    • The single-instance mutex and pipe names include it. Without the variable they are unchanged, so a new version still hands off to a running old one.
    • System integration is skipped: Run key, context menu and Explorer menu package. --uninstall-cleanup only deletes the engine folder.
    • Documented in CONTRIBUTING.md.

Tried and dropped

GC settings made no difference after the trim (managed heap is 2–4 MB by then):

  • System.GC.Concurrent=false: 117 MB
    • ConserveMemory=7: 116 MB
    • TieredPGO=false: 114 MB
  • Server GC + DATAS: 119 MB, worse

A one-off build with Avalonia's software renderer had 20–31 MB private where this has 62–118 MB. Most of what's left is the GPU (ANGLE/D3D) renderer. Changing the renderer is out of scope here; it needs CPU and donut animation checks.

Merging with #116

Only MainWindow.axaml.cs conflicts. Keep #116's code, add || _releasing to the first condition in OnClosing, and replace its three Hide() calls with HideToTray().

Tests

  • Main window released and recreated: state restored, old window/view model/page collected (WeakReference), reopening before the release keeps it.
  • MemoryTrimmer: runs once after quiet, not during a job, postponed by new requests.
  • JobQueue.IsBusy. CAD text measures the same after the caches are released.
  • FILEE_DATA_DIR in Core, Engines and App.
  • Core 206, Cli 21, Engines 822, App 271 passed. dotnet format is clean.

Not verified by hand: tray "Open" and the title-bar X with a real mouse, multi-monitor/DPI position restore, macOS/Linux (the working-set trim is a no-op there).

🤖 Generated with Claude Code

pleguen and others added 30 commits October 12, 2026 21:38
Co-authored-by: Junie <junie@jetbrains.com>
A Filee running as administrator (started by an installer run elevated)
ignores drops from Explorer, can't read Explorer's selection, and the
Filee that Explorer's menu starts crashed on the elevated Filee's mutex
or waited 30 s on its pipe, all without a log line.

- Read Filee's and Explorer's integrity levels at start-up. When Filee
  runs elevated and Explorer doesn't: log a warning, show a banner on
  the Home page and a one-time notice card, both with "Restart without
  administrator rights" (relaunch through Explorer's shell, after
  releasing the single-instance lock; a failed restart says so).
- A later launch that can't open the lock or is refused by the pipe
  logs it and shows one message box (once per burst of launches); the
  pipe refusal no longer waits the whole 30 s.
- Log unhandled exceptions (any thread, unobserved tasks, UI thread)
  through a logger set up before anything else, and one start-up line
  with version, path, install kind, integrity levels, OS and argument
  count.
- UI watchdog: logs a UI thread blocked for more than 5 s and when it
  answers again.
- Rate-limited diagnostics: forwarded launches received and handled,
  drag gestures that never reached the donut, shortcuts that found no
  selection (with the foreground window), the first drag over the drop
  zone.
- FFmpeg -version / -encoders time out for real (background reads, kill
  on timeout); engine statuses are checked once in the background at
  start-up; unreachable network shares on PATH are skipped after 2 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new preset option, off by default, encodes H.264 and HEVC on the GPU:
NVENC, Quick Sync and AMF on Windows, VideoToolbox on macOS (quality
mode on Apple Silicon, bitrate mode on Intel Macs), NVENC and VA-API on
Linux. Only encoders the bundled FFmpeg has are tried, best first, and
each must pass a tiny test encode with the real options (cached per
FFmpeg, encoder and bit depth) before it is used.

Quality levels map to each encoder's constant-quality control, one step
coarser than the x264/x265 CRF. HEVC keeps 10 bits, HDR colour tags and
the hvc1 tag; H.264 stays 8-bit 4:2:0 as with x264. Stream copy rules
are unchanged, and AV1, VP9 and the other codecs stay in software.

When a hardware encode fails anyway, the partial output is removed and
the file is encoded again with x264/x265; the failure goes to the log,
not to the user. Cancellation is never retried.

FFmpeg's version and encoder queries now read output asynchronously and
kill the process at their time limit, so a hung FFmpeg no longer blocks
engine detection.

The preset editor shows the option for H.264 and HEVC targets, with
English, Korean and Simplified Chinese text. ENGINES.md documents the
encoders, the probe, the quality mapping and the software retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The direct video → JPG edge and the MJPEG poster frame changed every video →
JPG conversion: rotate and flip presets failed with "can only turn images"
and resizing was ignored. Video → JPG is again one PNG still frame that the
image engines take on.

Saving every frame now has edges of its own (ConversionEdge.FrameSequence)
that the route planner takes only when asked for (Plan(..., frames: true)),
and then as the whole route. JobQueue and the donut's availability check ask
for it when Preset.SavesFramesOf the source: the preset saves every frame,
its target is PNG or JPG and the file is a video. The step says so
(ConversionStep.FrameSequence), so FFmpeg no longer guesses from extensions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Whether a file's frames are saved is decided once, from the format registry
(a Video source, a PNG or JPG target by format id), instead of extension
lists and guesses; the old fallback to the preset's target made intermediate
steps of other routes (video → WEBP, PDF) write folders. The editor shows
the option for "png" and "jpg" (there is no "jpeg" id) and saves it, with
its interval, only while it is shown, so a box ticked for PNG and left behind
by another target does nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without a frame rate filter the image2 muxer paced frames to a constant rate
and wrote the slow parts of a phone video several times (119 pictures for a
60-frame clip). Frames now pass through with their own timestamps
(-fps_mode passthrough; vfr with the interval's select filter), only the
first real video stream is read (-map 0:V:0, as for the still frame), and
the PNG / MJPEG encoder and image2 muxer are named.

The test records a clip at 30 then 10 fps with FFmpeg and checks there are as
many pictures as decoded frames.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The frames folder was named by hand next to a JPG/PNG path the allocator had
reserved: "Skip" and the parallel-file reservation didn't apply, and a
cancelled or failed run left a half-filled folder. It is now a folder output
like "Extract" (Allocate("", "_frames")): "clip_frames", "clip_frames (2)",
skipped or overwritten by the preset's rule. FrameSequenceWriter writes into
a hidden folder next to it and moves the frames into place only when FFmpeg
has finished; whatever happens, the hidden folder is removed.

FfmpegRunner now waits (up to 10 seconds) for a killed FFmpeg to exit, so the
partial output it still held open can be deleted on cancel. A % in the path
is escaped for FFmpeg's number pattern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…holds

Before anything is written, the frames are estimated (duration × frame rate ÷
interval). More than 100,000 pictures (an hour at 30 fps) fails with "Too
many frames … raise 'Every Nth frame'", and pictures that won't fit on the
output drive (a generous size guess per PNG or JPG) with "Not enough free
disk space", both in the user's language with the numbers in brackets. A
video that doesn't say its length or frame rate is not refused.

Engines can now refuse a file with such a reason: ConversionRefusedException
carries the error key and detail, which JobQueue shows instead of "The
engine reported an error". The editor says below the interval how many
pictures a minute of 30 fps video gives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MediaOptions.FramesPerSecond had no control in the editor and no preset set
it: removed, with FFmpeg's fps branch. The preset names are in sentence case
like the others ("Extract frames (PNG)"), the hint and description keys no
screen showed are gone, the Korean texts end in 해요 and the Chinese ones
have no fullwidth colon. The progress test expects three reports between
start and end again, as before this branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Schema 18 is taken by PDF → PPTX (#98), so "Extract frames (PNG / JPG)" come
with 19. They are inserted after "Split PDF", where a new library has them
(BuiltInData.FramePresets), instead of at the end, and stay on no donut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ENGINES.md describes the frame route and when it is taken, the folder and
file names, the interval, variable frame rate, square pixels, HDR (not
tone-mapped) and 16-bit PNG, the JPG quality mapping, the 100,000-picture
and disk space limits, and that a cancelled run leaves nothing behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…103)

PDF -> PDF presets get a compression level (Smallest 72 dpi, Balanced 150 dpi,
High quality 300 dpi), also with a page range or one file per page. Built in
with PDFsharp and Magick.NET: pictures drawn above the level's resolution are
scaled down (sizes found from the content streams, forms and annotation
appearances), photos become JPEG, graphics stay lossless, soft masks follow
their pictures, duplicate pictures are stored once, streams are deflated at
the highest level, thumbnails and private application data are dropped.
Text, fonts and drawings are untouched; a result that isn't smaller is
replaced by the original, with a note in the toast and on the command line.
Ghostscript (pdfwrite) is an opt-in second try; the smaller result is kept.

New "Compress PDF" preset on the presets list (settings schema 20; 18 and
19 are taken by other open changes), editor controls, en/ko/zh-CN texts,
ENGINES.md and README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Files of a type Filee doesn't know used to end at "This file type isn't
supported". Now:

- When the donut closes on files Filee can't read at all, a card says
  "Filee can't convert .ncm files yet" with "Request this format" (once
  per extension while Filee runs).
- A toast whose files failed for an unknown type has the same button.
- Both open the new "Format request" issue form with the title and the
  extension filled in. Only the extension goes in the link.
- The form and the card say up front that encrypted or copy-protected
  (DRM) files can't be converted, whatever the format.

Closes #117.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The variable moves settings, logs and downloaded engines to another folder,
gives the copy its own single-instance mutex and pipe, and skips the startup
entry, context menu and Explorer menu registration (and their removal in
--uninstall-cleanup). Documented in CONTRIBUTING.md as a development aid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue #119: Filee stayed at 400+ MB private after a few image conversions
and kept the main window's pages and visuals while in the tray.

- Closing the main window to the tray now closes it for real a moment
  after hiding it (MainWindow.HideToTray); WindowService.ShowMain builds a
  new one where, and on the page, the last one was. MainWindowViewModel is
  created per window and disposed with it (subscriptions on UpdateService
  and the localizer, the current page).
- MemoryTrimmer: 30 s after the last conversion finished (JobQueue.IsBusy
  must be false) and after the main window was released, drop the engines'
  caches (rhwp and CAD typefaces, Skia's glyph and resource caches), run an
  aggressive compacting collection and trim the working set on Windows.
  Logged as "Trimmed memory: private X MB -> Y MB".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release closes the window after hiding it; a hidden window may report
no position (the headless platform gave 0,0 in the full test run), which
overwrote the place taken when the user closed it. The two release tests
are one now: they share the window service, and headless tests can run
while another one pumps the dispatcher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SKGraphics.PurgeAllCaches ran on the trimmer's timer thread while the UI's
render thread could be drawing with Skia, and crashed in native code
(access violation in sk_pngencoder_encode on all three CI legs). Skia's
caches are shared with the renderer; the trim now drops only Filee's own
font caches, then collects and trims the working set as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test process still crashed while encoding a rendered frame
(sk_pngencoder_encode, access violation) after the Skia purge was
removed. LOH compaction and an Aggressive collection both move large
objects, which native code can hold pointers into on the assumption
that the large object heap doesn't move. The trim now runs a forced,
compacting collection of the small object heaps only (5-8 MB less
returned in the earlier measurements).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ost (diagnostic)

- RenderTests.Save and the other frame captures dispose the frame at the
  end, so it can't be collected while Skia encodes it.
- DIAGNOSTIC: the shared test host disposes its MemoryTrimmer, to tell
  whether the RenderTests crash on CI (sk_pngencoder_encode) comes from
  the idle trim. To be reverted or replaced once known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the trim off in the shared test host, CI passed; with it on, the
App tests crashed in Skia (sk_pngencoder_encode) about half a minute
after a trim, on Windows and Linux. A compacting gen2 collection moves
old objects that background collections leave in place, and native
code can hold pointers into them. The trim now collects without
compacting; garbage is freed and empty regions decommitted as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Generated SVGs often have "Q&A" in a <text> or "&nbsp;"/"&copy;", which
stopped Svg.Skia's XML parser ("An error occurred while parsing
EntityName"). When an SVG can't be read, Filee escapes ampersands that
don't start a reference and turns HTML entities into character
references, then reads it again. Declared DTD entities, the DOCTYPE,
comments and CDATA are left alone; valid SVGs are never changed.

Closes #120.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…to HEAD

# Conflicts:
#	src/Filee.App/App.axaml.cs
… into HEAD

# Conflicts:
#	src/Filee.Engines/Media/FfmpegConverter.cs
KnifeLemon and others added 7 commits October 19, 2026 03:40
# Conflicts:
#	src/Filee.App/ViewModels/PresetEditorViewModel.cs
#	src/Filee.Core/Conversion/IConverter.cs
#	src/Filee.Core/Conversion/RoutePlanner.cs
#	src/Filee.Core/Presets/BuiltInData.cs
#	src/Filee.Core/Presets/Preset.cs
#	src/Filee.Core/Settings/AppSettings.cs
#	src/Filee.Core/Settings/UserDataStore.cs
#	src/Filee.Engines/Media/FfmpegConverter.cs
#	tests/Filee.Core.Tests/RoutePlannerTests.cs
# Conflicts:
#	README.ko.md
#	README.md
#	README.zh-CN.md
#	docs/ENGINES.md
#	src/Filee.Core/Conversion/IConverter.cs
#	src/Filee.Core/Conversion/JobQueue.cs
#	src/Filee.Core/Presets/BuiltInData.cs
#	src/Filee.Core/Settings/AppSettings.cs
#	src/Filee.Core/Settings/UserDataStore.cs
# Conflicts:
#	src/Filee.App/Services/RadialController.cs
#	src/Filee.App/Services/WindowService.cs
#	src/Filee.App/ViewModels/JobViewModel.cs
#	src/Filee.App/Views/ToastWindow.axaml
#	tests/Filee.App.Tests/RenderTests.cs
# Conflicts:
#	src/Filee.App/App.axaml.cs
#	src/Filee.App/Services/SingleInstance.cs
#	src/Filee.App/Views/MainWindow.axaml.cs
The close-action combo (General) and the PDF compression level (preset
editor) came in after the single-selection guard: a null from the
combo now puts the previous choice back, as for every other choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@KnifeLemon
KnifeLemon merged commit 7b255e2 into main Oct 18, 2026
9 checks passed
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.

Reduce background memory footprint: ~177 MB in the tray, ~500 MB after conversions

2 participants