Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

- `load({ classWorkerURL })` works from the UMD build. It resolves the URL
against the page instead of failing with `Cannot find module`.
- 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
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
Expand Down
18 changes: 18 additions & 0 deletions apps/website/docs/getting-started/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,24 @@ controller.abort();
await running.catch((e) => e.name); // "AbortError"
```

## Recover from a core crash

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 {
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
Expand Down
21 changes: 17 additions & 4 deletions packages/ffmpeg/src/classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -105,7 +110,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];
Expand Down Expand Up @@ -359,11 +370,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];
}
Expand Down
3 changes: 3 additions & 0 deletions packages/ffmpeg/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
Expand Down
25 changes: 25 additions & 0 deletions tests/ffmpeg-node.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,31 @@ describe(genName("repeated calls and exit codes"), 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.
Expand Down
63 changes: 63 additions & 0 deletions tests/ffmpeg.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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())"
Expand Down
Loading