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
| # | Severity | Location | Stress type | Hard-reset vector |
|---|---|---|---|---|
| 1 | Critical | commands/process.rs:48 | 100% CPU | Thermal / PSU |
| 2 | Critical | lib.rs:30 + process.rs:156 | Disk I/O saturation | Storage WHEA |
| 3 | Critical | ProcessingPanel.tsx:98 + logging.rs | GPU re-render flood | GPU TDR → BSOD |
| 4 | High | commands/merge.rs:230 | RAM exhaustion | OOM → BSOD |
| 5 | High | commands/process.rs:200 | Orphaned processes | Compounding load |
| 6 | High | commands/video.rs:28,68 + merge.rs:329 | Console alloc bursts | Amplifies #1–#3 |
| 7 | Medium | commands/process.rs:144 | Alloc churn | Amplifies #1 |
| 8 | Medium | commands/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:
- 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. - WebView2 (Edge Chromium, GPU-accelerated) re-renders the progress bar + percentage text.
- The
transition-all duration-200CSS 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 TDR → VIDEO_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_processfor any job cannot acquire the lock while a wait is in progress — cancellation of unrelated jobs fails with "Job not found".- The
tokio::spawnmonitor task is detached. If the user closes the app while ffmpeg is running, theAppHandledrops 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 + #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.
-
#3 alone (GPU driver angle) — on systems with marginal GPU drivers, the WebView2 progress-event flood causes GPU TDR →
VIDEO_TDR_FAILUREBSOD → auto-restart. -
#4 alone (OOM angle) — only when using "Merge Videos" with many large files, on low-RAM / small-pagefile systems.
-
#5 compounding — repeated close-mid-job cycles leave orphaned ffmpeg processes that accumulate CPU load toward instability.
Lessons Learnt
-
Hot-path regex is a CPU bomb.
Regex::newis expensive. Any regex used in a per-line / per-event hot path must be compiled once and cached (OnceLock,LazyLock, oronce_cell). The cost is invisible in a unit test (one call) and only surfaces under real load — exactly the scenario that produces hard resets. -
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 beInfoat most, and per-line debug logging of high-frequency streams must be removed or gated behindtrace!. -
IPC events are not free. Every
app.emit→listen→setState→ 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. -
Don't log inside event handlers. A
logger.log()call inside alistencallback 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. -
Mutex +
.await= accidental serialisation. Holding atokio::sync::Mutexacross a long.await(likechild.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. -
concatfilter vsconcatdemuxer. Theconcatfilter holds all inputs in memory simultaneously (O(N) RAM). Theconcatdemuxer 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. -
CREATE_NO_WINDOWmust 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. -
Detached tasks need cleanup.
tokio::spawnwithout 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). -
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.
-
Test coverage hides load pathology. The existing unit tests for
parse_ffmpeg_timeandbuild_ffmpeg_argsall 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_loglevel changed fromDebugtoInfo.process.rs: removed thelog::debug!("ffmpeg stderr ...")call and thelog::info!("Emitted ffmpeg-progress ...")call frommonitor_ffmpeg_progress. Thestderr_tailring 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_progressnow trackslast_emit(Instant) andlast_percent. Affmpeg-progressevent 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 theonFFmpegProgresslistener inProcessingPanel.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 incommands/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>.txtin the OS temp dir) is written via a newwrite_concat_listhelper, 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-anwhen any input lacks audio). - The temp list file is cleaned up after the ffmpeg process exits via the new
cleanup_pathparameter onmonitor_ffmpeg_progress(passedNonebyprocess_videoandmulti_cut_merge,Some(path)bymerge_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 inFixed in follow-up pass — see "Follow-up Fixes" below.escape_subtitle_path): latent bug for\\server\share\subtitle paths.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 — OnceLock → LazyLock
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.srt → subtitles=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