Skip to content

[Windows] Transient ffmpeg/ffprobe spawn failures (EBUSY/ETXTBSY) fail the whole render with a bare "spawn EBUSY" — no retry, no context #4058

Open

Description

@CasbaL

Describe the bug

On Windows, spawning the managed ffmpeg/ffprobe binaries can fail transiently with EBUSY/ETXTBSY when the executable file is briefly locked — overwhelmingly antivirus real-time scanning a just-written or just-executed exe, occasionally a lingering handle from a concurrent sibling invocation (a single render shares one managed ffmpeg binary across many stages: media probing, frame extraction, audio mix, encode).

When that happens, the whole render fails with a bare, context-free error and there is no retry:

Error: spawn EBUSY

We observed this repeatedly in an Electron integration of the producer: a Delivery transcode step used the same managed ffmpeg.exe seconds before the render pipeline started; the render then failed on its first ffmpeg/ffprobe spawn and the user-facing export was lost. Re-running the export afterwards succeeded — confirming the condition is transient.

Root cause

All three spawn sites surface the raw child-process error without classification or retry:

  • packages/engine/src/utils/runFfmpeg.ts — the spawn error arrives inside the ManagedChildProcess outcome (reason: "spawn_error", raw error).
  • packages/producer/src/services/audioExtractor.ts (runFFmpeg) — ffmpeg.on("error", reject).
  • packages/producer/src/services/render/audioPadTrim.ts (runFfprobeJson) — throws outcome.error raw.

Node reports these as Error: spawn EBUSY (with .code set), so neither the user nor the logs can tell which binary, which stage, or that the condition usually clears on retry.

Expected behavior

  • A transient file-lock spawn failure should be retried a few times with a short backoff — a fresh spawn almost always succeeds once the AV scan finishes.
  • When retries are exhausted, the error should name the binary and the errno, and hint at the usual cause.

Environment

  • Windows 11 x64 (applicable to any Windows host with real-time AV scanning)
  • Producer used via an embedded runtime, ~v0.8.40
  • ffmpeg.exe/ffprobe.exe resolved via managed binary paths

Proposed fix

A shared engine helper, withTransientSpawnRetry, that:

  • classifies EBUSY, ETXTBSY, EAGAIN, EMFILE, ENFILE as transient file-lock errnos;
  • re-runs the caller's existing spawn-and-wait flow with exponential backoff (250 ms base, up to 3 attempts) and logs each retry with the binary path;
  • enriches exhausted failures with the binary path, errno, and a "transient file lock (often antivirus real-time scanning)" hint;
  • wraps the caller's flow rather than the raw spawn() call, so a healthy spawn keeps its exact synchronous timing.

A PR is on the way.

Activity

  1. added
    bugSomething isn't working
    triage/readyAccepted scope, current evidence, acceptance criteria, and validation path are recorded.
    difficulty/mediumBounded work requiring subsystem knowledge, related paths, or additional regression fixtures.
    help wantedReady, available work with maintainer review support; any difficulty.
    and removed
    triage/needs-triageNeeds current-behavior, related-work, and scope evaluation.
    on Oct 10, 2026
  2. chiarorosa commented on Oct 16, 2026

    @chiarorosa

    I'd like to take this one. The review on the closed #4059 asked for the real errno from Windows hardware, so I measured that first. It changes the shape of the fix.

    What Windows actually returns

    Windows 11 Pro 10.0.26200 x64, Node v24.20.0, ffmpeg 9.0.2 (gyan.dev). I spawned a copy of ffmpeg.exe while a second process held it open with different .NET FileAccess/FileShare modes. Defender real-time protection was off, so this simulates the lock instead of catching an AV scan in the act:

    Holder of the exe spawn() result After the holder closes
    read, FileShare.None sync throw EBUSY (-4082), syscall spawn ok
    write, FileShare.Read sync throw EBUSY ok
    write, FileShare.ReadWrite sync throw EBUSY ok
    read, FileShare.Read ok ok
    ACL denies execute (permanent) sync throw EPERM (-4048) ok after the ACL is restored
    missing exe 'error' event ENOENT n/a

    Rewriting the exe and spawning it right away 30 times, and spawning it 64 times at once without a lock, never failed. So the "sibling invocation" theory doesn't reproduce, but any handle with write access or no sharing does.

    Two consequences:

    1. EBUSY is the right errno, and EPERM should stay out: here EPERM is the permanent ACL case.
    2. EBUSY is thrown synchronously from spawn(). Node only delivers EACCES, EAGAIN, EMFILE, ENFILE and ENOENT through the 'error' event. Everything else throws before a ChildProcess exists (ChildProcess.prototype.spawn). So ManagedChildProcess.onError / reason: "spawn_error" never sees it. A retry keyed on the spawn_error outcome (the approach in fix(engine,producer): retry transient Windows spawn file locks on ffmpeg/ffprobe #4059) would not fire for this bug on runFfmpeg, ffprobe.ts or audioPadTrim.

    What each managed-binary call site does today

    There are 10 call sites that start getFfmpegBinary()/getFfprobeBinary() outside tests. With a sync EBUSY:

    • Fails the operation with a bare spawn EBUSY: runFfmpeg.ts (spawnFfmpeg), ffprobe.ts (runFfprobe), streamingEncoder.ts, audioExtractor.ts (inside the new Promise executor), audioPadTrim.ts (runFfprobeJson), gpuEncoder.ts (detectGpuEncoder), psnr.ts, distributed/shared.ts (readFfmpegVersion).
    • Silently degrades for the life of the process: gpuEncoder.ts canUseGpuEncoder → .catch(() => null) caches "no usable GPU encoder", and psnrFilterAvailability.ts caches null ("no filters") after its catch-all.

    Proposed fix

    • One engine helper that retries only the synchronous spawn() throw with code EBUSY or ETXTBSY (the POSIX equivalent). Nothing has started at that point, so the retry has no side effects. I'm proposing 3 attempts with 250 ms / 500 ms backoff, each retry logged with the binary path. The execFile-based sites (psnr, psnrFilterAvailability, readFfmpegVersion) retry the same rejection.
    • When the retries run out, the error keeps .code and names the binary path and errno, with the hint "another process held the executable open (often antivirus scanning it)".
    • Adopted at all 10 sites, so the two caches above can no longer store a result produced by a transient lock.
    • Tests: unit tests for the classification and backoff (a fake spawn that throws EBUSY n times, plus EPERM/ENOENT passing straight through), and a per-site test that the retry is wired in. I'll also attach the Windows repro script to the PR.

    Open question for @miguel-heygen / @miga-heygen: is 3 attempts / ~750 ms total acceptable, or would you rather allow a longer window (for example 5 attempts, ~4 s) since a full AV scan of a 100+ MB ffmpeg.exe can take longer?

  3. miguel-heygen commented on Oct 24, 2026

    @miguel-heygen
    Collaborator

    Thanks @CasbaL for the report, and @chiarorosa for measuring this on real Windows hardware. Showing that EBUSY is thrown synchronously from spawn(), and so never reaches the spawn_error path, explains why the first attempt (#4059) would not have helped.

    I checked current main (0.8.193): no spawn retry yet. runFfmpeg still calls spawn() directly, so a locked executable still fails the render with a bare spawn EBUSY.

    The fix is in #5698, which has review feedback to address. Discussion of the retry window is best kept on that PR so it stays in one place.

  4. removed
    help wantedReady, available work with maintainer review support; any difficulty.
    on Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdifficulty/mediumBounded work requiring subsystem knowledge, related paths, or additional regression fixtures.triage/readyAccepted scope, current evidence, acceptance criteria, and validation path are recorded.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions