Skip to content

Job process teardown: bare await room.disconnect() races a prior disconnect — unhandled 'handle not found' rejection skips shutdownCallbacks and the done IPC #2157

Description

@ralonsom

Environment

  • @livekit/agents 1.5.5 (also present on main at time of filing)
  • @livekit/rtc-node 0.13.31
  • Node v22, Debian 12, self-hosted LiveKit server + SIP (telephony pipeline)

Bug

ipc/job_proc_lazy_main.js (the job process teardown, agents/src/ipc/job_proc_lazy_main.ts in source) runs:

try {
  await ctx._onSessionEnd();
} catch (error) {
  logger.error({ error }, "error in ctx._onSessionEnd");
}
await room.disconnect();               // <-- unguarded
logger.debug("disconnected from room");
const shutdownTasks = [];
for (const callback of ctx.shutdownCallbacks) { ... }
...
safeSend({ case: "done", value: void 0 });
joinFuture.resolve();

If the room was already disconnected by the time this runs (in our case: the agent calls ctx.shutdown() on call end, and the room disconnect races the job teardown), the room's FFI handle has been released and rtc-node throws:

Error: failed to handle request: invalid request: handle not found
  (FfiRequest { message: Some(Disconnect(DisconnectRequest { room_handle: 2, ... })) })
    at Room.disconnect (@livekit/rtc-node/dist/room.js:588)
    at ipc/job_proc_lazy_main.js:157

Because the call is not guarded (unlike its neighbours session.close() and ctx._onSessionEnd(), which both have try/catch), the throw:

  1. surfaces as an unhandled promise rejection in the job process, and
  2. skips everything after it: the registered shutdownCallbacks (where applications typically flush final transcripts/analytics) and the done IPC to the parent process, so the parent only recovers via its close-timeout path.

Observed in production on a normal, successfully-completed telephony call — the error fired ~call end and the loss of shutdown callbacks is silent.

Suggested fix

Wrap the disconnect like its neighbouring teardown steps:

try {
  await room.disconnect();
  logger.debug("disconnected from room");
} catch (error) {
  logger.warn({ error }, "error disconnecting from room (already disconnected?)");
}

An "already disconnected" state check on room would also work, but try/catch matches the surrounding code's style and covers any FFI-level race.

Related

Same teardown-race family as #1871 (AgentSession.closeImplInner reading this.activity across awaits) — both are cleanup paths that assume resources are still alive when a concurrent shutdown may have released them. We are patching both locally in the meantime.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions