Skip to content

Bugfixes and performance - #21

Merged
TorjeAmundsen merged 12 commits into
mainfrom
bugfixes-and-performance
Jun 24, 2026
Merged

Bugfixes and performance#21
TorjeAmundsen merged 12 commits into
mainfrom
bugfixes-and-performance

Conversation

@TorjeAmundsen

Copy link
Copy Markdown
Owner

No description provided.

  UI thread wrote refPixels, refMask, required, crop as separate
  fields; capture loop read them unsynchronized and could observe
  a new refPixels paired with an old refMask of different size,
  causing index-out-of-range or wrong similarity scores.

  - Pack the four fields into an immutable CaptureState record and
    swap it via Interlocked.CompareExchange so the pair is always
    consistent.
  - _highest max-update via CAS; resets via Interlocked.Exchange.
  - _active read/written with Volatile to ensure capture loop sees
    source swaps.
  - Length-mismatch guard before L2NormComparer.Compare as a
    belt-and-suspenders against any state slipping through.
  Two splits referencing different external files with the same
  filename overwrote each other in the target folder, and an
  external copy could clobber an internal mask of the same name
  when processed first. Pre-pass reserves filenames already locked
  in by inside-folder splits; ReserveUniqueName disambiguates
  copies with ' (n)' suffixes.
  ApplyScaledAlphaChannel looked up alphaPath in the supplied cache
  but never wrote back, so every call re-decoded the PNG. Cache
  populated on miss under a lock so concurrent Task.Run callers
  can't double-decode and leak.

  PresetEditor's _previewMaskCache started empty and was the main
  victim — every threshold/preview tweak hit the disk. MainWindow's
  pre-populated cache continues to hit immediately; on-demand misses
  now cache too.
  WriteAllTextAsync truncates the destination before writing, so a
  crash mid-write left preset.json empty or partial — next load
  deserialized to null and the preset silently vanished from the UI.
  Write to a sibling .tmp and rename via File.Move(overwrite: true);
  NTFS makes the rename atomic on the same volume.

  Stale-savestate cleanup also wrapped in try/catch per file: the
  json is already committed at that point, and a locked leftover
  file shouldn't fail the whole save.
  Process 8 pixels per iteration: 8 mask bytes broadcast 4× with
  PSHUFB, |diff| via paired SubtractSaturate+OR, PMADDWD to square
  and pair-sum into int32, widened to int64 each chunk to avoid
  overflow at the 9600 chunks of a 320×240 frame.

  Alpha bytes explicitly zeroed via a constant mask so the BGR-only
  sum matches the scalar formula even where ref/live alphas diverge
  in masked-out regions.

  Scalar fallback retained for non-AVX2 hosts and the n%8 tail.
  Per-frame byte[] allocation produced ~18 MB/s of Gen0 churn at
  60 fps (320×240×4). Two pre-allocated buffers cover the single-
  in-flight invariant enforced by _uiPostPending: capture writes to
  _frameBuffers[_writeIndex], flips on successful post so the UI
  handler keeps exclusive access until it clears _uiPostPending,
  and a dropped post leaves _writeIndex unchanged so the same
  buffer is reused next iteration.
  Three SKColor[] round-trips per call (input.Pixels, scaled.Pixels,
  output.Pixels assignment) allocated ~24 MB of managed arrays for
  a 1080p mask. Replaced with an unsafe byte loop over GetPixels()
  IntPtrs and a native blit only if the decoder picked a non-BGRA
  color type. Hard alpha threshold (==255) preserved exactly so the
  edge behavior of the linear-resized mask matches the previous
  SKColor comparison.
  - MainWindow: AppContext.BaseDirectory replaces Process.MainModule
    (AOT-safe under self-contained NativeAOT publish), drop redundant
    Dispatcher.Post wrap in CheckSavePossible (all callers UI-thread),
    add OnClosed override that disposes input/thumbnail/mask caches
    + _maskedImage + _previewBitmap, narrow temp-cleanup catches to
    IOException/UnauthorizedAccessException.
  - TestOutputWindow: LoadPrefs/SavePrefs go async + Encoding.UTF8 +
    typed catches; sync File I/O on the UI thread is gone.
  - PresetEditor: rename prompt no longer overwrites an explicit
    OK/Enter result with the Closed handler's null (Commit() guard).
    Empty/whitespace split names rejected in ValidateSplitName and
    UpdateSaveButtonState so Save can't write a malformed preset.
  - ImportSplitsDialog: thumbnail load catches IOException/
    UnauthorizedAccessException/ArgumentException only, logs via
    Utils.LogError instead of swallowing silently.
  - WebcamCapture: ErrorReported event surfaces OpenCV throws to UI
    instead of breaking out of the read loop with no signal.
  - PresetService: explicit Encoding.UTF8 on every File.*Async call.
  - MessageBox: ShowDialog Task observed via ContinueWith; a
    synchronous throw now faults tcs.Task instead of leaving callers
    awaiting a never-completing TCS.
  - AppJsonContext: register Split, List<Split>, List<PremadeSplit>
    explicitly so trimming/AOT can't drop reflection metadata that
    source-gen relies on.
  - Program: AppDomain.UnhandledException + TaskScheduler
    .UnobservedTaskException write a timestamped crash log to
    %LOCALAPPDATA%\AutoMask\crashes (XDG equivalent on Linux). Self-
    contained AOT publishes have no console and no debugger, so
    fatal exceptions previously produced a silent exit.
  - Cut 'what' / banner comments where the code already reads itself
    (cache-dispose loop, 'Commit the switch', 'Reference inputs
    captured from MainWindow', BitBltCaptureBase plumbing summary).
    Keep WHY comments — race rationales, AOT contracts, SIMD
    algorithm steps, Win32 quirks, Avalonia binding priority etc.
  - Rename short identifiers in tight loops: _cap → _videoCapture,
    dst/s/d → targetPixels/sourceRow/targetRow, sw → stopwatch, cur
    → currentSimilarity, high → highestSimilarity, src → source.
  - Narrow two bare catches: WebcamCapture.StopAsync to
    ObjectDisposedException; PresetEditor.UpdateOutputPreview to
    IOException/UnauthorizedAccessException/ArgumentException with
    Utils.LogError instead of swallowing.
  - BitBltCaptureBase.TryGrabFrame releases srcDc via try/finally so
    a throw between Acquire and Release (EnsureBuffers GDI exhaustion,
    BitBlt fault, OOM during SKBitmap alloc) can't strand the DC.
    EnsureBuffers returns bool with explicit rollback of partial GDI
    allocations on each early-out path. A GetDIBits == 0 now disposes
    the staging _frame so a transient failure doesn't leave a half-
    populated buffer that a size-matched call would skip re-allocating.
  - App.OnFrameworkInitializationCompleted registers
    Dispatcher.UIThread.UnhandledException: logs to disk, shows a
    MessageBox on the main window, sets Handled = true. Catches every
    async-void event handler's faulted continuation since they all run
    on the UI dispatcher, so an exception in a button click no longer
    bubbles to AppDomain.UnhandledException and terminates the process.
  - Crash-log writer hoisted from Program.cs to Utils.LogCrashToDisk
    so all three unhandled-exception hooks (AppDomain, TaskScheduler,
    Dispatcher) share one path.
  - CaptureController.SetSourceAsync: extracted ShutdownSourceAsync
    with a 5s Task.WaitAsync timeout per call so a hung StopAsync
    (OpenCV native deadlock during webcam release, GDI driver stall)
    cannot block the swap forever and prevent the user from switching
    to a working source. Errors and timeouts surface via ErrorReported
    with the source's DisplayName instead of being silently swallowed.
  - CaptureController.CropAndScaleNearest cropped path: single
    allocation now. SKCanvas.DrawImage with srcRect/dstRect performs
    crop and scale in one operation, replacing the canvasBitmap +
    Resize pair. Out-of-bounds areas stay black via pre-Clear,
    matching the previous behavior.
  - PresetService.LoadPresetsAsync and LoadPremadeSplitsAsync: read
    every preset.json / splits.json in parallel via Task.WhenAll over
    per-file Load helpers. Per-file try/catch keeps WhenAll from
    fail-fasting; the failure tuple feeds the same LoadFailure list
    the UI surfaces.
  Windows taskbar 'Close all windows' fires Close on every top-level
  window in parallel, which exposed three issues:

  - TestOutputWindow.Closing set _isClosing only after the save dialog
    completed, so a second Closing event raced past the gate and
    spawned a duplicate dialog. _closingStarted is set synchronously
    before any await, and every re-entry now sets e.Cancel = true so
    Avalonia can't tear the window down while the prompt is still up.
  - MainWindow.OnClosing now defers when a live-tester is still
    shutting down and re-issues the parent close from the child's
    Closed event. Without this, MainWindow closing immediately ended
    the desktop lifetime and killed the dialog mid-flight.
  - CaptureController.DisposeAsync is idempotent via Interlocked
    guard. The double-close path was awaiting the disposed _swapLock
    and surfacing 'Cannot access a disposed object' through the new
    Dispatcher.UnhandledException hook.
@TorjeAmundsen
TorjeAmundsen merged commit 6021173 into main Jun 24, 2026
2 checks passed
@TorjeAmundsen
TorjeAmundsen deleted the bugfixes-and-performance branch June 26, 2026 06:13
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.

1 participant