From c5898f7cf05e6ebbe063c4e88de121600643d308 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Wed, 12 Aug 2026 16:59:41 +0700 Subject: [PATCH] fix(gateway): crash safety on screen-share input timeout + proper error serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of "langsung left": YouTube bot-block/403 on u_c1tRmj7E4 (live stream, LOGIN_REQUIRED) made yt-dlp timeout in resolveInputWithRetry (12s). The timeout handler did cleanup() (removing once() listeners) THEN tee.destroy(new Error(...)) — the PassThrough emitted 'error' with NO listener left → unhandled stream 'error' event → uncaughtException → gracefulShutdown → bot left voice. Fix: - resolveInputWithRetry: tee.destroy() silently after cleanup (error carried in the rejection only); add permanent no-op tee.on('error') safety. - prepareStream: output.on('error') no-op so ffmpeg spawn failure before playStream attaches a demux listener never crashes the gateway. - bootstrap: serialize uncaughtException/ClientError/DB errors with {err, errorMsg, stack} (pino only serializes the 'err' magic key — the old {error: err} key printed {} so crashes were invisible). --- services/discord-gateway/src/app/bootstrap.ts | 19 ++++++++++++++++--- .../src/goLive/prepareStream.ts | 4 ++++ .../voice-recording/screenShareController.ts | 15 ++++++++++----- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/services/discord-gateway/src/app/bootstrap.ts b/services/discord-gateway/src/app/bootstrap.ts index f366c32..c01d169 100644 --- a/services/discord-gateway/src/app/bootstrap.ts +++ b/services/discord-gateway/src/app/bootstrap.ts @@ -222,7 +222,10 @@ export async function initializeDiscordGateway() { await initializeDatabase(); logger.info("PostgreSQL database initialized"); } catch (err) { - logger.error({ error: err }, "Failed to initialize database"); + logger.error( + { err, errorMsg: err instanceof Error ? err.message : String(err) }, + "Failed to initialize database", + ); throw new DatabaseError( `Database initialization failed: ${err instanceof Error ? err.message : String(err)}`, ); @@ -267,7 +270,10 @@ export async function initializeDiscordGateway() { }); client.on("error", (err) => { - logger.error({ error: err }, "Client error"); + logger.error( + { err, errorMsg: err instanceof Error ? err.message : String(err) }, + "Client error", + ); }); process.on("SIGINT", () => { @@ -298,7 +304,14 @@ export async function initializeDiscordGateway() { ); return; } - logger.error({ error: err }, "Uncaught exception"); + logger.error( + { + err, + errorMsg: err instanceof Error ? err.message : String(err), + stack: err?.stack, + }, + "Uncaught exception", + ); gracefulShutdown("uncaughtException"); }); diff --git a/services/discord-gateway/src/goLive/prepareStream.ts b/services/discord-gateway/src/goLive/prepareStream.ts index a5a4af6..2c490c2 100644 --- a/services/discord-gateway/src/goLive/prepareStream.ts +++ b/services/discord-gateway/src/goLive/prepareStream.ts @@ -234,6 +234,10 @@ export function prepareStream( } } + // Safety: proc may error before playStream attaches a demux listener on + // `output`. A no-op listener here prevents an unhandled 'error' event + // on the PassThrough from crashing the gateway on ffmpeg spawn failure. + output.on("error", () => {}); proc.stdout?.pipe(output); proc.stderr?.on("data", () => { /* swallow ffmpeg stderr */ diff --git a/services/discord-gateway/src/modules/voice-recording/screenShareController.ts b/services/discord-gateway/src/modules/voice-recording/screenShareController.ts index f12de4e..fa66b43 100644 --- a/services/discord-gateway/src/modules/voice-recording/screenShareController.ts +++ b/services/discord-gateway/src/modules/voice-recording/screenShareController.ts @@ -93,11 +93,11 @@ export class ScreenShareController { const timer = setTimeout(() => { cleanup(); destroyInput(); - tee.destroy( - new Error( - "Screen input produced no data within 12s — merge likely failed", - ), - ); + // Listeners were just removed by cleanup() — destroying tee WITH + // an error would emit "error" on an unlistened PassThrough and + // surface as an unhandled 'error' event (crash). Destroy + // silently; the error lives in the rejection only. + tee.destroy(); reject( new Error( "Screen input produced no data within 12s — merge likely failed", @@ -131,6 +131,11 @@ export class ScreenShareController { tee.once("end", onEnd); }); + // Safety net: cleanup() removes the once() listeners on timeout/error, + // but a late error event from input.pipe(tee) can still fire on an + // unlistened PassThrough and crash the gateway (unhandled 'error'). + // A permanent no-op listener guarantees the event is always swallowed. + tee.on("error", () => {}); // Pass the tee onward — the encoder consumes the same buffered // stream, so no data from the merge is lost. return tee;