fix(gateway): crash safety on screen-share input timeout + proper error serialization

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).
This commit is contained in:
asepharyana
2026-08-12 16:59:41 +07:00
parent 354e378e74
commit c5898f7cf0
3 changed files with 30 additions and 8 deletions
+16 -3
View File
@@ -222,7 +222,10 @@ export async function initializeDiscordGateway() {
await initializeDatabase(); await initializeDatabase();
logger.info("PostgreSQL database initialized"); logger.info("PostgreSQL database initialized");
} catch (err) { } 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( throw new DatabaseError(
`Database initialization failed: ${err instanceof Error ? err.message : String(err)}`, `Database initialization failed: ${err instanceof Error ? err.message : String(err)}`,
); );
@@ -267,7 +270,10 @@ export async function initializeDiscordGateway() {
}); });
client.on("error", (err) => { 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", () => { process.on("SIGINT", () => {
@@ -298,7 +304,14 @@ export async function initializeDiscordGateway() {
); );
return; 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"); gracefulShutdown("uncaughtException");
}); });
@@ -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.stdout?.pipe(output);
proc.stderr?.on("data", () => { proc.stderr?.on("data", () => {
/* swallow ffmpeg stderr */ /* swallow ffmpeg stderr */
@@ -93,11 +93,11 @@ export class ScreenShareController {
const timer = setTimeout(() => { const timer = setTimeout(() => {
cleanup(); cleanup();
destroyInput(); destroyInput();
tee.destroy( // Listeners were just removed by cleanup() — destroying tee WITH
new Error( // an error would emit "error" on an unlistened PassThrough and
"Screen input produced no data within 12s — merge likely failed", // surface as an unhandled 'error' event (crash). Destroy
), // silently; the error lives in the rejection only.
); tee.destroy();
reject( reject(
new Error( new Error(
"Screen input produced no data within 12s — merge likely failed", "Screen input produced no data within 12s — merge likely failed",
@@ -131,6 +131,11 @@ export class ScreenShareController {
tee.once("end", onEnd); 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 // Pass the tee onward — the encoder consumes the same buffered
// stream, so no data from the merge is lost. // stream, so no data from the merge is lost.
return tee; return tee;