Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions apps/website/docs/getting-started/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 @@ -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];
Expand Down Expand Up @@ -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];
}
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 @@ -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.
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