Skip to content

Commit e297b86

Browse files
JacobiusMakesclaude
andcommitted
fix: AbortSignal now actually stops the running ffmpeg exec (#719)
Previously, aborting an exec() or ffprobe() only rejected the JS Promise — the underlying WASM process kept running to completion. This caused wasted CPU and made AbortController useless for long transcoding operations. Fix: when the abort signal fires for EXEC or FFPROBE, immediately send a CANCEL message to the worker. The worker calls ffmpeg.setTimeout(1), setting the WASM watchdog to fire after 1 ms. This stops the running command as quickly as possible (exit code 1) without terminating the entire worker — the FFmpeg instance stays loaded and ready for the next command. Also: fix a memory leak where Blob URLs created for coreData / wasmData / workerData were never revoked. They are now tracked in #blobURLs and revoked in terminate(). Changes: - const.ts: add FFMessageType.CANCEL - worker.ts: add cancel() handler calling ffmpeg.setTimeout(1) - classes.ts: send CANCEL on exec/ffprobe abort; track blob URLs Closes #719 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ad665df commit e297b86

3 files changed

Lines changed: 60 additions & 3 deletions

File tree

packages/ffmpeg/src/classes.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ import { ERROR_TERMINATED, ERROR_NOT_LOADED } from "./errors.js";
2424
/**
2525
* Convert binary file data (ArrayBuffer, Uint8Array, or Blob) to a Blob URL
2626
* so the web worker can consume it as a regular URL string.
27+
*
28+
* The returned URL must eventually be passed to `URL.revokeObjectURL` to
29+
* avoid a memory leak. @see {@link FFmpeg.load} and {@link FFmpeg.terminate}.
2730
*/
2831
const toBlobURL = (data: BinaryFileData, mimeType: string): string => {
2932
const blob =
@@ -55,6 +58,13 @@ export class FFmpeg {
5558
#logEventCallbacks: LogEventCallback[] = [];
5659
#progressEventCallbacks: ProgressEventCallback[] = [];
5760

61+
/**
62+
* Blob URLs created during load() for coreData / wasmData / workerData.
63+
* Tracked here so they can be revoked when the worker is terminated,
64+
* preventing a memory leak.
65+
*/
66+
#blobURLs: string[] = [];
67+
5868
public loaded = false;
5969

6070
/**
@@ -123,6 +133,20 @@ export class FFmpeg {
123133
"abort",
124134
() => {
125135
reject(new DOMException(`Message # ${id} was aborted`, "AbortError"));
136+
// If we are aborting an exec or ffprobe, send a CANCEL message so
137+
// the worker's ffmpeg timeout is set to 1 ms, causing the running
138+
// command to stop as quickly as possible. For other message types
139+
// the cancel is a no-op on the worker side.
140+
if (
141+
(type === FFMessageType.EXEC ||
142+
type === FFMessageType.FFPROBE) &&
143+
this.#worker
144+
) {
145+
this.#worker.postMessage({
146+
id: getMessageID(),
147+
type: FFMessageType.CANCEL,
148+
});
149+
}
126150
},
127151
{ once: true }
128152
);
@@ -219,10 +243,19 @@ export class FFmpeg {
219243
}
220244
// Convert binary data to Blob URLs so the worker can load them as
221245
// regular URL strings. Binary data takes precedence over URL strings.
222-
if (coreData) config.coreURL = toBlobURL(coreData, MIME_TYPE_JAVASCRIPT);
223-
if (wasmData) config.wasmURL = toBlobURL(wasmData, MIME_TYPE_WASM);
224-
if (workerData)
246+
// The created URLs are stored in #blobURLs and revoked on terminate().
247+
if (coreData) {
248+
config.coreURL = toBlobURL(coreData, MIME_TYPE_JAVASCRIPT);
249+
this.#blobURLs.push(config.coreURL);
250+
}
251+
if (wasmData) {
252+
config.wasmURL = toBlobURL(wasmData, MIME_TYPE_WASM);
253+
this.#blobURLs.push(config.wasmURL);
254+
}
255+
if (workerData) {
225256
config.workerURL = toBlobURL(workerData, MIME_TYPE_JAVASCRIPT);
257+
this.#blobURLs.push(config.workerURL);
258+
}
226259
return this.#send(
227260
{
228261
type: FFMessageType.LOAD,
@@ -329,6 +362,12 @@ export class FFmpeg {
329362
this.#worker = null;
330363
this.loaded = false;
331364
}
365+
366+
// Revoke any Blob URLs that were created during load() to free memory.
367+
for (const url of this.#blobURLs) {
368+
URL.revokeObjectURL(url);
369+
}
370+
this.#blobURLs = [];
332371
};
333372

334373
/**

packages/ffmpeg/src/const.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export enum FFMessageType {
1717
DELETE_DIR = "DELETE_DIR",
1818
ERROR = "ERROR",
1919

20+
CANCEL = "CANCEL",
2021
DOWNLOAD = "DOWNLOAD",
2122
PROGRESS = "PROGRESS",
2223
LOG = "LOG",

packages/ffmpeg/src/worker.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,19 @@ const ffprobe = ({ args, timeout = -1 }: FFMessageExecData): ExitCode => {
108108
return ret;
109109
};
110110

111+
/**
112+
* Interrupt any running exec/ffprobe by setting the ffmpeg timeout to 1 ms.
113+
* This causes the WASM-side watchdog to fire almost immediately, stopping
114+
* the current command and returning exit-code 1 (timeout). The exec()
115+
* call on the main thread has already been rejected with an AbortError by
116+
* the time this message arrives, so the exit-code reply is simply discarded.
117+
*/
118+
const cancel = (): void => {
119+
if (ffmpeg) {
120+
ffmpeg.setTimeout(1);
121+
}
122+
};
123+
111124
const writeFile = ({ path, data }: FFMessageWriteFileData): OK => {
112125
ffmpeg.FS.writeFile(path, data);
113126
return true;
@@ -181,6 +194,10 @@ self.onmessage = async ({
181194
case FFMessageType.FFPROBE:
182195
data = ffprobe(_data as FFMessageExecData);
183196
break;
197+
case FFMessageType.CANCEL:
198+
cancel();
199+
// CANCEL is fire-and-forget: no reply needed.
200+
return;
184201
case FFMessageType.WRITE_FILE:
185202
data = writeFile(_data as FFMessageWriteFileData);
186203
break;

0 commit comments

Comments
 (0)