From bfa27381e1902693735d1e8451fff0306758498a Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 11:12:58 -0700 Subject: [PATCH] fix(engine): give the ffmpeg children the signal that actually aborts them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The internal AbortController exists so a fatal FX error can cancel in-flight ffmpeg before the finally-block deletes workDir. It reached the entry guard and applyAudioFxChain, and nothing else: the trim, the video extract and the download were all still handed the CALLER's signal. So `internalController .abort()` cancelled nothing, and the `rmSync(workDir, { recursive: true })` immediately after it ran while those children were still writing into the directory. Concretely: track A's FX render fails while track B is mid-trim, with no external cancellation. B's ffmpeg keeps writing `${id}-trimmed.wav` into an unlinked directory until it finishes. The comment above the controller describes a mechanism that was never wired to the processes it names. The download also had no signal at all, though downloadToTemp accepts one. No test: the three changes are which variable is passed to a child process helper, and observing the difference means driving real ffmpeg to the point of cancellation. The old wiring's inertness is provable by reading — the caller's signal is never aborted by this function — and the new wiring is the same three calls with the controller the function already built. --- packages/engine/src/services/audioMixer.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/engine/src/services/audioMixer.ts b/packages/engine/src/services/audioMixer.ts index 369562e328..8a2dff3135 100644 --- a/packages/engine/src/services/audioMixer.ts +++ b/packages/engine/src/services/audioMixer.ts @@ -765,6 +765,12 @@ export async function processCompositionAudio( // be able to abort the in-flight ffmpeg runs before the finally-block removes // workDir out from under them. Chained off the caller's signal so external // cancellation still behaves as before. + // + // Every child that can outlive a sibling's failure has to be given THIS + // signal, not the caller's: the trim, the video extract and the download all + // took `signal`, so `internalController.abort()` cancelled nothing and the + // `rmSync(workDir)` on the next line ran while their ffmpeg children were + // still writing into it. const internalController = new AbortController(); const effectiveSignal = internalController.signal; if (signal) { @@ -795,7 +801,7 @@ export async function processCompositionAudio( if (isHttpUrl(srcPath)) { try { - srcPath = await downloadToTemp(srcPath, workDir); + srcPath = await downloadToTemp(srcPath, workDir, undefined, effectiveSignal); } catch (err: unknown) { failures.push( downloadFailure(err instanceof Error ? err.message : String(err), element.id), @@ -842,7 +848,7 @@ export async function processCompositionAudio( startTime: element.mediaStart, duration: element.end - element.start, }, - signal, + effectiveSignal, config, ); if (!extractResult.success) { @@ -868,7 +874,7 @@ export async function processCompositionAudio( trimmedPath, element.mediaStart, element.end - element.start, - signal, + effectiveSignal, config, ); if (!prepResult.success) {