fix(embedded-mpv): stop crashing on IPC for not-yet-created sessions

The renderer was reading session.id and forwarding it to the addon, but
during the loading window that id is the placeholder
"embedded-mpv-starting" set by createLoadingSession. If the user adjusted
volume, seeked, or toggled audio/subtitle/speed/aspect before the addon's
createSession returned the real id, that placeholder id reached the
addon — and the addon's getSessionOrThrow threw a raw std::runtime_error
which libc++abi terminated the process on.

Two fixes, defense in depth:

1. Renderer (session controller): use the canonical sessionId() signal,
   which is null until the addon hands back a real id, as the gate for all
   IPC calls. Wrap every IPC call in a guardIpc helper that swallows
   addon-side throws so a torn-down session or race won't surface as an
   uncaught promise rejection.

2. Native (embedded_mpv.mm): change getSessionOrThrow to take a
   Napi::Env and throw Napi::Error::New(env, ...) instead of
   std::runtime_error. node-addon-api converts Napi::Error to a JS
   exception cleanly; the previous std::runtime_error escaped the C++
   frame and aborted the process when the addon was built without
   NAPI_CPP_EXCEPTIONS translation. Refactor splits findSession (returns
   nullptr) from getSessionOrThrow (env-aware) so call paths that just
   probe a session's existence don't pay the throw cost.

The native fix needs an addon rebuild to take effect; the renderer fix
prevents the crash trigger immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-05-05 10:29:23 +02:00
1 parent 126025aa41
commit 9424dedafc
2 files changed
+73 -43

No files matched your search

@@ -166,18 +166,27 @@ void runOnMainSync(dispatch_block_t block)
dispatch_sync(dispatch_get_main_queue(), block);
}
std::shared_ptr<Session> getSessionOrThrow(const std::string& sessionId)
std::shared_ptr<Session> findSession(const std::string& sessionId)
{
std::lock_guard<std::mutex> sessionsLock(gSessionsMutex);
const auto iterator = gSessions.find(sessionId);
if (iterator == gSessions.end()) {
throw std::runtime_error("Embedded MPV session not found.");
return nullptr;
}
return iterator->second;
}
std::shared_ptr<Session> getSessionOrThrow(
Napi::Env env,
const std::string& sessionId)
{
auto session = findSession(sessionId);
if (!session) {
throw Napi::Error::New(env, "Embedded MPV session not found.");
}
return session;
}
void scheduleRender(const std::shared_ptr<Session>& session);
void requestRender(const std::shared_ptr<Session>& session);
void updateSessionError(const std::shared_ptr<Session>& session, const std::string& error);
@@ -1604,7 +1613,7 @@ Napi::Value LoadPlayback(const Napi::CallbackInfo& info)
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const auto playback = info[1].As<Napi::Object>();
const auto session = getSessionOrThrow(sessionId);
const auto session = getSessionOrThrow(env, sessionId);
const std::string streamUrl = readOptionalString(playback, "streamUrl");
if (streamUrl.empty()) {
throw Napi::Error::New(env, "Embedded MPV playback requires a stream URL.");
@@ -1713,7 +1722,7 @@ Napi::Value SetBounds(const Napi::CallbackInfo& info)
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const auto bounds = info[1].As<Napi::Object>();
const auto session = getSessionOrThrow(sessionId);
const auto session = getSessionOrThrow(env, sessionId);
setSessionFrame(
session,
@@ -1734,7 +1743,7 @@ Napi::Value SetPaused(const Napi::CallbackInfo& info)
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const auto session = getSessionOrThrow(sessionId);
const auto session = getSessionOrThrow(env, sessionId);
int paused = info[1].As<Napi::Boolean>().Value() ? 1 : 0;
const int result = mpv_set_property_async(
session->handle,
@@ -1774,7 +1783,7 @@ Napi::Value Seek(const Napi::CallbackInfo& info)
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const auto session = getSessionOrThrow(sessionId);
const auto session = getSessionOrThrow(env, sessionId);
const auto target = info[1].As<Napi::Number>().DoubleValue();
const std::string targetValue = std::to_string(target);
const char* command[] = {
@@ -1813,7 +1822,7 @@ Napi::Value SetVolume(const Napi::CallbackInfo& info)
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const auto session = getSessionOrThrow(sessionId);
const auto session = getSessionOrThrow(env, sessionId);
double volume = clampVolumePercent(
info[1].As<Napi::Number>().DoubleValue()
);
@@ -1849,7 +1858,7 @@ Napi::Value SetAudioTrack(const Napi::CallbackInfo& info)
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const auto session = getSessionOrThrow(sessionId);
const auto session = getSessionOrThrow(env, sessionId);
int64_t trackId = info[1].As<Napi::Number>().Int64Value();
const int result = mpv_set_property_async(
session->handle,
@@ -1886,7 +1895,7 @@ Napi::Value SetSubtitleTrack(const Napi::CallbackInfo& info)
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const auto session = getSessionOrThrow(sessionId);
const auto session = getSessionOrThrow(env, sessionId);
int64_t trackId = info[1].As<Napi::Number>().Int64Value();
int result = -1;
@@ -1934,7 +1943,7 @@ Napi::Value SetSpeed(const Napi::CallbackInfo& info)
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const auto session = getSessionOrThrow(sessionId);
const auto session = getSessionOrThrow(env, sessionId);
double speed = info[1].As<Napi::Number>().DoubleValue();
speed = std::clamp(speed, 0.25, 4.0);
@@ -1973,7 +1982,7 @@ Napi::Value SetAspect(const Napi::CallbackInfo& info)
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const auto session = getSessionOrThrow(sessionId);
const auto session = getSessionOrThrow(env, sessionId);
std::string aspect = info[1].As<Napi::String>().Utf8Value();
if (aspect.empty()) {
aspect = "no";
@@ -194,13 +194,16 @@ export class EmbeddedMpvSessionController {
}
async togglePaused(): Promise<void> {
const id = this.sessionId();
const session = this.session();
if (!session?.id || !window.electron?.setEmbeddedMpvPaused) {
if (!id || !session || !window.electron?.setEmbeddedMpvPaused) {
return;
}
const updated = await window.electron.setEmbeddedMpvPaused(
session.id,
session.status !== 'paused'
const updated = await this.guardIpc(() =>
window.electron!.setEmbeddedMpvPaused(
id,
session.status !== 'paused'
)
);
if (updated) {
this.session.set(updated);
@@ -208,12 +211,15 @@ export class EmbeddedMpvSessionController {
}
async seekBy(deltaSeconds: number): Promise<boolean> {
const id = this.sessionId();
const session = this.session();
if (!session?.id || !window.electron?.seekEmbeddedMpv) {
if (!id || !session || !window.electron?.seekEmbeddedMpv) {
return false;
}
const next = Math.max(0, session.positionSeconds + deltaSeconds);
const updated = await window.electron.seekEmbeddedMpv(session.id, next);
const updated = await this.guardIpc(() =>
window.electron!.seekEmbeddedMpv(id, next)
);
if (updated) {
this.session.set(updated);
}
@@ -221,35 +227,38 @@ export class EmbeddedMpvSessionController {
}
async seekTo(seconds: number): Promise<void> {
const session = this.session();
if (!session?.id || !window.electron?.seekEmbeddedMpv) {
const id = this.sessionId();
if (!id || !window.electron?.seekEmbeddedMpv) {
return;
}
const updated = await window.electron.seekEmbeddedMpv(session.id, seconds);
const updated = await this.guardIpc(() =>
window.electron!.seekEmbeddedMpv(id, seconds)
);
if (updated) {
this.session.set(updated);
}
}
async applyVolume(value: number): Promise<void> {
const session = this.session();
if (!session?.id || !window.electron?.setEmbeddedMpvVolume) {
const id = this.sessionId();
if (!id || !window.electron?.setEmbeddedMpvVolume) {
return;
}
const updated = await window.electron.setEmbeddedMpvVolume(session.id, value);
const updated = await this.guardIpc(() =>
window.electron!.setEmbeddedMpvVolume(id, value)
);
if (updated) {
this.session.set(updated);
}
}
async setAudioTrack(trackId: number): Promise<void> {
const session = this.session();
if (!session?.id || !window.electron?.setEmbeddedMpvAudioTrack) {
const id = this.sessionId();
if (!id || !window.electron?.setEmbeddedMpvAudioTrack) {
return;
}
const updated = await window.electron.setEmbeddedMpvAudioTrack(
session.id,
trackId
const updated = await this.guardIpc(() =>
window.electron!.setEmbeddedMpvAudioTrack(id, trackId)
);
if (updated) {
this.session.set(updated);
@@ -257,13 +266,12 @@ export class EmbeddedMpvSessionController {
}
async setSubtitleTrack(trackId: number): Promise<void> {
const session = this.session();
if (!session?.id || !window.electron?.setEmbeddedMpvSubtitleTrack) {
const id = this.sessionId();
if (!id || !window.electron?.setEmbeddedMpvSubtitleTrack) {
return;
}
const updated = await window.electron.setEmbeddedMpvSubtitleTrack(
session.id,
trackId
const updated = await this.guardIpc(() =>
window.electron!.setEmbeddedMpvSubtitleTrack!(id, trackId)
);
if (updated) {
this.session.set(updated);
@@ -271,30 +279,43 @@ export class EmbeddedMpvSessionController {
}
async setSpeed(speed: number): Promise<void> {
const session = this.session();
if (!session?.id || !window.electron?.setEmbeddedMpvSpeed) {
const id = this.sessionId();
if (!id || !window.electron?.setEmbeddedMpvSpeed) {
return;
}
const updated = await window.electron.setEmbeddedMpvSpeed(session.id, speed);
const updated = await this.guardIpc(() =>
window.electron!.setEmbeddedMpvSpeed!(id, speed)
);
if (updated) {
this.session.set(updated);
}
}
async setAspect(aspect: string): Promise<void> {
const session = this.session();
if (!session?.id || !window.electron?.setEmbeddedMpvAspect) {
const id = this.sessionId();
if (!id || !window.electron?.setEmbeddedMpvAspect) {
return;
}
const updated = await window.electron.setEmbeddedMpvAspect(
session.id,
aspect
const updated = await this.guardIpc(() =>
window.electron!.setEmbeddedMpvAspect!(id, aspect)
);
if (updated) {
this.session.set(updated);
}
}
private async guardIpc<T>(
call: () => Promise<T>
): Promise<T | null> {
try {
return await call();
} catch {
// The session may have been torn down or an addon-side throw
// raced the IPC. Swallow — the next snapshot will resync state.
return null;
}
}
private async loadSupport(): Promise<void> {
try {
this.support.set(await window.electron!.getEmbeddedMpvSupport());