From 8674f522a4a79af0c4c116b58edd1aef4a3bd461 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:14:14 -0500 Subject: [PATCH 1/2] fix(ffmpeg): unload the instance when the core traps or aborts --- CHANGELOG.md | 5 ++ apps/website/docs/getting-started/usage.md | 16 ++++++ packages/ffmpeg/src/classes.ts | 21 ++++++-- packages/ffmpeg/src/errors.ts | 3 ++ tests/ffmpeg-node.test.mjs | 25 +++++++++ tests/ffmpeg.test.js | 63 ++++++++++++++++++++++ 6 files changed, 129 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13792b42777..06d6a189a97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,11 @@ follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- When the core traps or aborts during a call, that call rejects with the + error, pending calls reject, and the `FFmpeg` instance is unloaded + (`loaded` is `false`) instead of running more commands on a broken core. + Call `load()` to start a new one (backport of upstream + ffmpegwasm/ffmpeg.wasm#979). - The core's `setTimeout()` no longer replaces the global `setTimeout` inside the core, which Emscripten's own timers (`poll()` and async callbacks) use. - The core reads the `wasmURL` hash only when locating the `.wasm` file, and diff --git a/apps/website/docs/getting-started/usage.md b/apps/website/docs/getting-started/usage.md index 5c972adf13e..4f022d5dc2d 100644 --- a/apps/website/docs/getting-started/usage.md +++ b/apps/website/docs/getting-started/usage.md @@ -446,6 +446,22 @@ Required: Please check this PR: [abort signal](https://github.com/ffmpegwasm/ffmpeg.wasm/pull/573) +## Recover from a core crash + +If the core traps or aborts (a `RuntimeError`) during a call, that call +rejects with the error, every pending call rejects, and the instance is +unloaded: `ffmpeg.loaded` is `false` and later calls reject with a "not +loaded" error. The core's memory may be corrupt after a trap, so call +`load()` to start a new one. + +```ts +try { + await ffmpeg.exec(args); +} catch (e) { + if (!ffmpeg.loaded) await ffmpeg.load(); +} +``` + ## Read and write files in chunks `open()`, `read()`, `write()` and `close()` work on a file descriptor, so a diff --git a/packages/ffmpeg/src/classes.ts b/packages/ffmpeg/src/classes.ts index da570163e79..e1980a30c21 100644 --- a/packages/ffmpeg/src/classes.ts +++ b/packages/ffmpeg/src/classes.ts @@ -18,7 +18,12 @@ import { FFFSPath, } from "./types.js"; import { getMessageID } from "./utils.js"; -import { ERROR_TERMINATED, ERROR_NOT_LOADED, ERROR_WORKER } from "./errors.js"; +import { + ERROR_TERMINATED, + ERROR_NOT_LOADED, + ERROR_WORKER, + ERROR_CRASHED, +} from "./errors.js"; declare const __FFMPEG_WORKER_TYPE__: WorkerType; @@ -98,7 +103,13 @@ export class FFmpeg { break; case FFMessageType.ERROR: this.#rejects[id](data); - break; + delete this.#resolves[id]; + delete this.#rejects[id]; + // A trap or abort leaves the core's memory in an unknown state. + if (typeof data === "string" && /^RuntimeError\b/.test(data)) { + this.#terminate(ERROR_CRASHED); + } + return; } delete this.#resolves[id]; delete this.#rejects[id]; @@ -330,11 +341,13 @@ export class FFmpeg { * * @category FFmpeg */ - public terminate = (): void => { + public terminate = (): void => this.#terminate(ERROR_TERMINATED); + + #terminate = (error: Error): void => { const ids = Object.keys(this.#rejects); // rejects all incomplete Promises. for (const id of ids) { - this.#rejects[id](ERROR_TERMINATED); + this.#rejects[id](error); delete this.#rejects[id]; delete this.#resolves[id]; } diff --git a/packages/ffmpeg/src/errors.ts b/packages/ffmpeg/src/errors.ts index 0fd5cb5f018..79e57aed1d4 100644 --- a/packages/ffmpeg/src/errors.ts +++ b/packages/ffmpeg/src/errors.ts @@ -3,6 +3,9 @@ export const ERROR_NOT_LOADED = new Error( "ffmpeg is not loaded, call `await ffmpeg.load()` first" ); export const ERROR_TERMINATED = new Error("called FFmpeg.terminate()"); +export const ERROR_CRASHED = new Error( + "ffmpeg-core crashed, call `await ffmpeg.load()` to start a new one" +); export const ERROR_IMPORT_FAILURE = new Error( "failed to import ffmpeg-core.js" ); diff --git a/tests/ffmpeg-node.test.mjs b/tests/ffmpeg-node.test.mjs index 75129df7450..6211ecb23d4 100644 --- a/tests/ffmpeg-node.test.mjs +++ b/tests/ffmpeg-node.test.mjs @@ -430,6 +430,31 @@ describe(genName("FFmpeg.terminate()"), function () { }); }); +describe(genName("errors from the core"), function () { + this.timeout(60000); + + // No known command traps the core, and the Node worker cannot be swapped + // for a stand-in; the trap path is covered by the browser suite. This + // checks that an ordinary error does not unload the instance. + it("keeps the instance loaded after an ordinary error", async () => { + const ffmpeg = new FFmpeg(); + await load(ffmpeg); + try { + let rejected = false; + try { + await ffmpeg.listDir("/does-not-exist"); + } catch { + rejected = true; + } + expect(rejected).to.be.true; + expect(ffmpeg.loaded).to.be.true; + expect(await ffmpeg.exec(["-h"])).to.equal(0); + } finally { + ffmpeg.terminate(); + } + }); +}); + describe(genName("core glue"), function () { // bind.js shares a scope with Emscripten's glue, so a top-level function // named like a global replaces it for the glue too. diff --git a/tests/ffmpeg.test.js b/tests/ffmpeg.test.js index c258ddeda28..db8029d0e43 100644 --- a/tests/ffmpeg.test.js +++ b/tests/ffmpeg.test.js @@ -33,6 +33,69 @@ describe(genName("concurrent load()"), () => { }); }); +describe(genName("core trap"), function () { + // No known command traps the core, so a stand-in worker answers with one. + const trappingWorker = URL.createObjectURL( + new Blob( + [ + `self.onmessage = ({ data: { id, type } }) => + postMessage(type === "LOAD" ? + { id, type, data: true } : + { id, type: "ERROR", data: "RuntimeError: unreachable" });`, + ], + { type: "text/javascript" } + ) + ); + + it("rejects the call, unloads, and loads again", async () => { + const ffmpeg = new FFmpeg(); + try { + await ffmpeg.load({ classWorkerURL: trappingWorker }); + expect(ffmpeg.loaded).to.be.true; + + let error; + try { + await ffmpeg.exec(["-version"]); + } catch (e) { + error = e; + } + expect(error).to.match(/^RuntimeError: unreachable/); + expect(ffmpeg.loaded).to.be.false; + + let notLoaded; + try { + await ffmpeg.listDir("/"); + } catch (e) { + notLoaded = e; + } + expect(String(notLoaded)).to.include("not loaded"); + + await ffmpeg.load({ coreURL: CORE_URL, thread: FFMPEG_TYPE === "mt" }); + expect(ffmpeg.loaded).to.be.true; + expect(await ffmpeg.exec(["-version"])).to.equal(0); + } finally { + ffmpeg.terminate(); + } + }); + + it("keeps the instance loaded after an ordinary error", async () => { + const ffmpeg = await createFFmpeg(); + try { + let rejected = false; + try { + await ffmpeg.listDir("/does-not-exist"); + } catch { + rejected = true; + } + expect(rejected).to.be.true; + expect(ffmpeg.loaded).to.be.true; + expect(await ffmpeg.exec(["-version"])).to.equal(0); + } finally { + ffmpeg.terminate(); + } + }); +}); + describe( genName( "FFmpeg directory APIs (createDir(), listDir(), deleteDir(), rename())" From d6835debb0814edf3ee970a504f7b13cef6470ad Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:38:43 -0500 Subject: [PATCH 2/2] docs: say that only traps unload the core, not aborts inside exec() --- CHANGELOG.md | 3 ++- apps/website/docs/getting-started/usage.md | 12 +++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06d6a189a97..ad854ab8312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,8 @@ follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed -- When the core traps or aborts during a call, that call rejects with the +- When the core traps during a call (a wasm `RuntimeError`, such as + `unreachable` or an out-of-bounds access), that call rejects with the error, pending calls reject, and the `FFmpeg` instance is unloaded (`loaded` is `false`) instead of running more commands on a broken core. Call `load()` to start a new one (backport of upstream diff --git a/apps/website/docs/getting-started/usage.md b/apps/website/docs/getting-started/usage.md index 4f022d5dc2d..5a882067edf 100644 --- a/apps/website/docs/getting-started/usage.md +++ b/apps/website/docs/getting-started/usage.md @@ -448,11 +448,13 @@ Please check this PR: [abort signal](https://github.com/ffmpegwasm/ffmpeg.wasm/p ## Recover from a core crash -If the core traps or aborts (a `RuntimeError`) during a call, that call -rejects with the error, every pending call rejects, and the instance is -unloaded: `ffmpeg.loaded` is `false` and later calls reject with a "not -loaded" error. The core's memory may be corrupt after a trap, so call -`load()` to start a new one. +If the core traps (a wasm `RuntimeError`) during a call, that call rejects +with the error, every pending call rejects, and the instance is unloaded: +`ffmpeg.loaded` is `false` and later calls reject with a "not loaded" error. +The core's memory may be corrupt after a trap, so call `load()` to start a +new one. An `abort()` inside FFmpeg during `exec()` or `ffprobe()` is not +treated as a trap: the call resolves with a nonzero exit code and the core +stays loaded. ```ts try {