Hard Reset Investigation

June 19, 2026 · View on GitHub

Date: 2026-06-19 Symptom: The FFmpeg GUI app causes a hard system reset (abrupt reboot) on Windows 11 during video processing. Scope: Root-cause analysis of the Tauri 2.x / Rust backend and React renderer.

A "hard reset" on Windows 11 is an abrupt reboot. In practice it is one of:

  • A BSOD that auto-restarts (Windows 11 enables auto-restart on system failure by default).
  • A thermal shutdown (CPU/GPU hits Tjmax and the firmware cuts power).
  • A PSU overload / rail droop (sustained max draw drops voltage below the reset threshold).

None of these are caused by ordinary application bugs. They require sustained system-level stress: 100% CPU, RAM exhaustion, disk-I/O saturation, or GPU driver TDR (Timeout Detection & Recovery). This investigation identified eight such stress sources in the codebase.


Issue Summary

#SeverityLocationStress typeHard-reset vector
1Criticalcommands/process.rs:48100% CPUThermal / PSU
2Criticallib.rs:30 + process.rs:156Disk I/O saturationStorage WHEA
3CriticalProcessingPanel.tsx:98 + logging.rsGPU re-render floodGPU TDR → BSOD
4Highcommands/merge.rs:230RAM exhaustionOOM → BSOD
5Highcommands/process.rs:200Orphaned processesCompounding load
6Highcommands/video.rs:28,68 + merge.rs:329Console alloc burstsAmplifies #1–#3
7Mediumcommands/process.rs:144Alloc churnAmplifies #1
8Mediumcommands/process.rs:404(latent bug)UNC paths break

Critical Issues (likely root cause)

#1 — Regex recompiled on every FFmpeg stderr line → sustained 100% CPU

Location: src-tauri/src/commands/process.rs:48-57

pub fn parse_ffmpeg_time(line: &str) -> Option<f64> {
    let time_regex = Regex::new(r"time=(\d+):(\d+):(\d+\.?\d*)").unwrap(); // ← compiles every call
    time_regex.captures(line)...
}

parse_ffmpeg_time is called from monitor_ffmpeg_progress for every stderr line that contains a \r or \n (process.rs:163). FFmpeg emits progress lines frequently — tens per second. Each call to Regex::new recompiles the pattern (NFA→DFA build, VM bytecode generation, heap allocations). The regex crate's compile cost is significant — this is not a cheap operation.

Result: sustained 100% CPU on one core for the entire duration of every ffmpeg job (minutes to hours). On a thermally or PSU-marginal Windows 11 machine (laptops, mini-PCs, small-form-factor desktops), sustained 100% CPU is a documented trigger for thermal shutdown or PSU rail droop → hard reset.

Fix: Cache the compiled regex in a std::sync::OnceLock (stable since Rust 1.70, within the project's MSRV of 1.77.2 — no new dependency, no toolchain bump).


#2 — Debug-level logging of every stderr line → saturated disk I/O

Location: src-tauri/src/lib.rs:30 + src-tauri/src/commands/process.rs:156

// lib.rs — log plugin initialised at Debug level
.level(log::LevelFilter::Debug)

// process.rs — every stderr line logged
log::debug!("ffmpeg stderr [{}]: {}", job_id, trimmed);

The Tauri log plugin writes through to a log file on disk. With Debug enabled, every single ffmpeg stderr line (potentially thousands per minute) hits the disk synchronously. Combined with #1, the backend does regex-compile + disk-write per line, all on one tokio worker.

Result: sustained disk-I/O saturation. On Windows 11 with HDDs or SMR drives (common in OEM machines), sustained I/O saturation can trigger WHEA-Logger storage errors → BSOD → hard reset. Even on SSDs, it saturates the Tauri command queue and amplifies CPU load.

Fix: Set the log level to Info in production, and remove the per-line log::debug! of stderr (the stderr_tail ring buffer already captures the last 50 lines for error reporting).


#3 — Progress-event flood to WebView2 → GPU TDR → BSOD

Location (frontend): src/components/ProcessingPanel.tsx:95-110 Location (backend support): src-tauri/src/commands/logging.rs

unlistenProgress = await tauriAPI.onFFmpegProgress((event) => {
  void logger.log(`[ProcessingPanel] Progress event: ...`)   // ← per event, hits disk via IPC
  state.setProcessingProgress(...)                             // ← triggers React re-render
})

For every progress event from the backend:

  1. The frontend issues a Tauri IPC command (write_frontend_log) that opens a file handle, writes a line, and closes it (logging.rs:16-24) — per event.
  2. WebView2 (Edge Chromium, GPU-accelerated) re-renders the progress bar + percentage text.
  3. The transition-all duration-200 CSS on the progress bar (ProcessingPanel.tsx:467) forces a style recalculation + animation per update.

With high-frequency progress events (the same flood driven by #1 and #2), this means the Tauri IPC bus is saturated with file-write commands and WebView2 is re-rendering at >30 Hz indefinitely.

Result: On systems with marginal GPU drivers (Intel Arc, older NVIDIA Studio drivers, AMD Adrenalin on certain Windows 11 builds), sustained high-frequency WebView2 GPU re-rendering is a known trigger for GPU TDRVIDEO_TDR_FAILURE BSOD (0x100000ea) → Windows 11 auto-restarts → perceived as a "hard reset".

Fix (backend): Throttle app.emit("ffmpeg-progress", ...) to emit at most every 200 ms or on every 1% delta, whichever comes first. Fix (frontend): Remove the per-event logger.log call in the progress listener.


High-Severity Issues

#4 — merge_videos holds all input frames in RAM simultaneously

Location: src-tauri/src/commands/merge.rs:230-258

The concat filter (-filter_complex ...concat=n=N) requires ffmpeg to allocate and hold frames for all N inputs at once in the filtergraph. Merging 5–10 large source-quality videos can consume 8–20 GB of RAM.

Result: On a Windows 11 machine with 16 GB RAM and a small OEM pagefile, this triggers OOM. Win11 OOM normally kills the process, but if the pagefile is on a failing drive or the OOM coincides with a WHEA event, it cascades into a MEMORY_MANAGEMENT BSOD → hard reset. Only affects "Merge Videos" mode; trim/cut are unaffected.

Fix: Replace the concat filter with the concat demuxer (-f concat -i list.txt), which streams inputs sequentially instead of holding them all in RAM. Apply resolution normalisation via an output-side -vf scale=... filter (single stream, low memory).


#5 — Job-state mutex held across child.wait().await + orphaned processes on close

Location: src-tauri/src/commands/process.rs:200-203

let mut jobs = state.active_jobs.lock().await;   // ← lock acquired
if let Some(mut job) = jobs.remove(&job_id) {
    match job.child.wait().await {                 // ← mutex held across this await (entire job)

The tokio::sync::Mutex is acquired before child.wait().await and held until the wait completes — i.e. for the entire remaining duration of the ffmpeg process. Consequences:

  • cancel_process for any job cannot acquire the lock while a wait is in progress — cancellation of unrelated jobs fails with "Job not found".
  • The tokio::spawn monitor task is detached. If the user closes the app while ffmpeg is running, the AppHandle drops but the spawned task keeps running, holding the mutex, the child process, and the stderr reader. The ffmpeg process is orphaned — it keeps consuming CPU/RAM/disk with no parent to reap it. Repeating this (close → reopen → start job → close) accumulates multiple orphaned ffmpeg processes, compounding toward system instability.

Fix: Remove the job from the map under the lock, drop the lock, then wait().await.


#6 — ffprobe / ffmpeg -version spawned without CREATE_NO_WINDOW on Windows

Locations:

  • src-tauri/src/commands/video.rs:28 (get_duration)
  • src-tauri/src/commands/video.rs:68 (check_ffmpeg_availability)
  • src-tauri/src/commands/merge.rs:329 (check_audio_stream)

The CREATE_NO_WINDOW fix exists in process.rs:92-97 and merge.rs:142-147 but was not applied to the ffprobe paths. With windows_subsystem = "windows" (set in main.rs:2), every spawn without CREATE_NO_WINDOW allocates a new console buffer and connects conhost.exe. For merge_videos, check_audio_stream is called once per input file — 10 inputs = 10 console allocations, each a brief csrss.exe handle burst in the kernel.

Result: Not a hard-reset cause on its own, but it adds kernel handle-allocation bursts to an already-saturated system during heavy merge operations, amplifying #1–#3.

Fix: Extract a shared apply_no_window helper and apply it to all Command::new("ffmpeg"|"ffprobe") sites.


Medium-Severity Issues

#7 — Hand-rolled stderr parser with per-line allocation churn

Location: src-tauri/src/commands/process.rs:144-196

The split logic uses pending.split_at(pos), rest.chars().next(), rest_clean[1..].to_string() — multiple small string allocations per line. With high stderr throughput this is constant Gen0 allocation churn on the tokio runtime allocator. Amplifies #1. (Not fixed in this pass — the regex cache fix #1 removes the dominant cost; this is a future cleanup using tokio_util::codec::LinesCodec.)

#8 — escape_subtitle_path breaks UNC paths

Location: src-tauri/src/commands/process.rs:404-408

\\server\share\sub.srt becomes //server/share/sub.srt after backslash replacement, which ffmpeg may parse as a protocol specifier. Not a hard-reset cause; latent bug for UNC users. (Not fixed in this pass.)


Verdict — Most Likely Root Causes

In order of probability for a Windows 11 user:

  1. #1 + #2 + #3 together — sustained 100% CPU (regex recompile per line) + saturated disk I/O (debug log of every line + frontend log of every event) + saturated WebView2 re-rendering. This is the default behaviour for every user on every ffmpeg job. On a thermally or PSU-marginal Windows 11 machine, this alone causes a hard reset on a long job. Most likely root cause.

  2. #3 alone (GPU driver angle) — on systems with marginal GPU drivers, the WebView2 progress-event flood causes GPU TDR → VIDEO_TDR_FAILURE BSOD → auto-restart.

  3. #4 alone (OOM angle) — only when using "Merge Videos" with many large files, on low-RAM / small-pagefile systems.

  4. #5 compounding — repeated close-mid-job cycles leave orphaned ffmpeg processes that accumulate CPU load toward instability.


Lessons Learnt

  1. Hot-path regex is a CPU bomb. Regex::new is expensive. Any regex used in a per-line / per-event hot path must be compiled once and cached (OnceLock, LazyLock, or once_cell). The cost is invisible in a unit test (one call) and only surfaces under real load — exactly the scenario that produces hard resets.

  2. Log level matters in production. Shipping a desktop app with Debug-level logging means every chatty subsystem (ffmpeg stderr, IPC events) hits the disk synchronously. Production log level should be Info at most, and per-line debug logging of high-frequency streams must be removed or gated behind trace!.

  3. IPC events are not free. Every app.emitlistensetState → re-render is a cross-process + GPU pipeline round trip. High-frequency backend events must be throttled at the source (backend), not absorbed by the renderer. A progress bar does not need 60 Hz updates; 5 Hz (every 200 ms) is visually identical and ~12× cheaper.

  4. Don't log inside event handlers. A logger.log() call inside a listen callback that fires 30×/second means 30 file-open/write/close cycles per second via IPC. Logging belongs at lifecycle boundaries (start, complete, error), not per tick.

  5. Mutex + .await = accidental serialisation. Holding a tokio::sync::Mutex across a long .await (like child.wait()) serialises every other caller of that lock for the entire duration. The rule: take the lock, do the cheap mutation, drop the lock, then await the expensive operation.

  6. concat filter vs concat demuxer. The concat filter holds all inputs in memory simultaneously (O(N) RAM). The concat demuxer streams inputs sequentially (O(1) RAM). For merging arbitrary files, the demuxer is almost always the right choice. The filter is only appropriate when you need per-input filtergraph transformations that can't be expressed on the output stream.

  7. CREATE_NO_WINDOW must be applied to every process spawn on Windows GUI apps. Missing it on ffprobe paths (while present on ffmpeg paths) is an easy oversight. Centralise the flag in a helper to make omission structurally impossible.

  8. Detached tasks need cleanup. tokio::spawn without a join handle is a fire-and-forget that outlives the app if it holds a child process. On app close, enumerate and kill every active job — never assume the OS reaps children promptly (on Windows, orphaned console processes keep running).

  9. Hard resets are a system symptom, not an app symptom. When a user reports a hard reset / BSOD, the root cause is never a single off-by-one — it is sustained stress (CPU, RAM, disk, GPU). Investigate hot paths, resource holding, and event frequencies, not logic bugs.

  10. Test coverage hides load pathology. The existing unit tests for parse_ffmpeg_time and build_ffmpeg_args all pass — they exercise correctness, not performance under load. The regex-recompile-per-call bug is invisible to unit tests. Load pathology needs either a benchmark, a code review focused on hot paths, or (as here) a production failure report.


Fixes Applied

All fixes below were implemented and verified with cargo test (34 passed, 0 failed) and npm run type-check (clean). The npm run lint failure is pre-existing and unrelated (.eslintrc.js uses CommonJS while package.json declares "type": "module").

#1 — Regex cached via OnceLock

File: src-tauri/src/commands/process.rs parse_ffmpeg_time now stores the compiled Regex in a static OnceLock<Regex> and retrieves it via get_or_init. The pattern is compiled exactly once per process lifetime instead of per stderr line. Eliminates the sustained-100%-CPU-per-core hot path. OnceLock (stable since Rust 1.70) was chosen over LazyLock (1.80) to respect the project's 1.77.2 MSRV without adding a dependency.

#2 — Log level lowered to Info; per-line stderr debug log removed ✅

Files: src-tauri/src/lib.rs, src-tauri/src/commands/process.rs

  • lib.rs: tauri_plugin_log level changed from Debug to Info.
  • process.rs: removed the log::debug!("ffmpeg stderr ...") call and the log::info!("Emitted ffmpeg-progress ...") call from monitor_ffmpeg_progress. The stderr_tail ring buffer (last 50 lines) still captures stderr for error reporting, so no diagnostic capability is lost.

#3 — Progress emission throttled (backend) + per-event log removed (frontend) ✅

Files: src-tauri/src/commands/process.rs, src/components/ProcessingPanel.tsx

  • Backend: monitor_ffmpeg_progress now tracks last_emit (Instant) and last_percent. A ffmpeg-progress event is emitted only when ≥200 ms have elapsed or the percentage has changed by ≥1 point since the last emission. Reduces event volume by ~10–50× depending on ffmpeg output frequency.
  • Frontend: removed the void logger.log(...) call inside the onFFmpegProgress listener in ProcessingPanel.tsx. This eliminates a per-event IPC round-trip + file open/write/close cycle.

#5 — child.wait() moved outside the mutex ✅

File: src-tauri/src/commands/process.rs monitor_ffmpeg_progress now removes the job from active_jobs under the lock, drops the lock, then calls job.child.wait().await. Cancellation of unrelated jobs is no longer blocked for the entire duration of a running process.

#6 — apply_no_window helper applied to all ffmpeg/ffprobe spawns ✅

Files: src-tauri/src/commands/mod.rs, commands/video.rs, commands/merge.rs, commands/process.rs

  • Added a centralised apply_no_window(&mut tokio::process::Command) helper in commands/mod.rs (cfg-gated for Windows, no-op elsewhere).
  • Applied to: get_duration (ffprobe), check_ffmpeg_availability (ffmpeg + ffprobe), check_audio_stream (ffprobe), process_video (ffmpeg), multi_cut_merge (ffmpeg), merge_videos (ffmpeg). Replaced the two pre-existing inline #[cfg(windows)] blocks with the helper for consistency.

#7 — merge_videos rewritten to use the concat demuxer ✅

File: src-tauri/src/commands/merge.rs Replaced the concat filter (which holds all N input frames in RAM simultaneously — O(N) memory, an OOM/hard-reset vector) with the concat demuxer (-f concat -safe 0 -i list.txt), which streams inputs sequentially (O(1) memory).

  • A temp list file (ffmpeg_concat_list_<job_id>.txt in the OS temp dir) is written via a new write_concat_list helper, with single-quote escaping and backslash→forward-slash normalisation (a Windows-specific hazard in the demuxer syntax).
  • Resolution/audio normalisation is preserved via output-side filters: -vf scale=1920:1080:... and -af aresample=44100 (or -an when any input lacks audio).
  • The temp list file is cleaned up after the ffmpeg process exits via the new cleanup_path parameter on monitor_ffmpeg_progress (passed None by process_video and multi_cut_merge, Some(path) by merge_videos).

Not fixed in this pass

  • #7 (stderr parser alloc churn): the hand-rolled byte-buffer split loop still allocates per line. Fixed in follow-up pass — see "Follow-up Fixes" below.
  • #8 (UNC path escaping in escape_subtitle_path): latent bug for \\server\share\ subtitle paths. Fixed in follow-up pass — see "Follow-up Fixes" below.
  • Orphaned-process cleanup on app close (#5 resource-leak angle): the app does not yet enumerate and kill active jobs on window close. Fixed in follow-up pass — see "Follow-up Fixes" below.

Verification

cargo test          → 34 passed; 0 failed  (1 pre-existing dead_code warning in state.rs)
npm run type-check  → clean
npm run lint        → pre-existing failure (ESM/CJS config conflict, unrelated to these changes)

Follow-up Fixes (second pass)

After the initial six fixes, a second pass addressed the remaining items, plus a Rust MSRV bump.

MSRV bump: 1.77.2 → 1.80.0

File: src-tauri/Cargo.toml The installed toolchain is rustc 1.91.1 (Nov 2025); pinning MSRV at 1.77.2 was unnecessarily conservative. Bumping to 1.80.0 unlocks std::sync::LazyLock (stabilised in 1.80), which gives cleaner static-init semantics than OnceLock::get_or_init. Edition 2024 (1.85) was intentionally not adopted — it carries breaking lint changes (unsafe_attr_outside_unsafe, stricter closure capture) that belong in a dedicated migration, not a hot-fix pass.

#1 refined — OnceLockLazyLock

File: src-tauri/src/commands/process.rs TIME_REGEX is now static TIME_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(...).unwrap()) and parse_ffmpeg_time references it directly (TIME_REGEX.captures(line)) — no more get_or_init closure. Identical runtime behaviour, clearer intent.

#7 — stderr parser allocation churn reduced

File: src-tauri/src/commands/process.rs The previous per-line split logic used split_at + chars().next() + collect::<String>() + [1..].to_string() — three to four String allocations per stderr line. Replaced with a single pending.drain(..end).collect::<String>() that consumes the line + its delimiter(s) in one operation, with paired-delimiter (\r\n / \n\r) detection done via byte inspection before draining. Fewer allocations, no UTF-8 re-validation of the remainder. (tokio_util::codec::LinesCodec was rejected because it only splits on \n and would buffer ffmpeg's \r-overwritten stats lines into one giant line until the final \n, breaking progress parsing.)

#8 — UNC path escaping fixed

File: src-tauri/src/commands/process.rs escape_subtitle_path now detects the \\ UNC prefix and preserves it as an escaped literal (\\\\ in the filter string → \\ literal) instead of blanket-converting to //, which ffmpeg's lavf could misinterpret as a protocol specifier. Backslashes after the UNC prefix are still normalised to /. Drive-letter paths (C:\...) are unaffected — the existing tests pass unchanged. New test test_build_ffmpeg_args_with_unc_subtitle_path covers \\NAS\media\subs\movie.srtsubtitles=filename='\\\\NAS/media/subs/movie.srt'.

Orphaned-process kill on app exit

File: src-tauri/src/lib.rs Restructured run() from Builder::run(context) to Builder::build(context)?.run(|handle, event|) so a RunEvent::Exit handler can run cleanup. On exit, it clones the Arc<Mutex<HashMap>> out of AppState (to break the borrow from the temporary State wrapper), acquires the lock via try_lock().ok() (sync, deadlock-safe — never blocks if a monitor task holds the lock), and calls child.start_kill() (synchronous SIGKILL/terminate — safe to call from the sync handler, unlike the async kill().await) on every still-running ffmpeg child. Best-effort: if try_lock fails, a warning is logged and the OS is left to reap the children.

This closes the resource-leak angle of #5: closing the app mid-job no longer leaves orphaned ffmpeg processes accumulating CPU load.

Follow-up verification

cargo test          → 35 passed; 0 failed  (1 pre-existing dead_code warning in state.rs)
npm run type-check  → clean