From afc8ed5f4b30f4359e1439c3f47e7891eb581d78 Mon Sep 17 00:00:00 2001 From: "otoneko." Date: Sat, 4 Jul 2026 21:34:50 +0900 Subject: [PATCH] feat: keep the process alive on errors that escape a jsk eval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jsk js/cjs/mjs/sh already catch and report errors within a command's own awaited chain, but an eval can also fail outside it (an unawaited promise that rejects later, an event listener it registered that throws) — Node's default for uncaughtException/unhandledRejection is to kill the process. Add catchProcessErrors (on by default) so djsk installs its own handlers that log instead of crashing, with an opt-out for bots that already handle these themselves. --- README.md | 7 +++ src/commands/cjs.test.ts | 2 +- src/commands/filesystem.test.ts | 5 ++- src/commands/js.test.ts | 2 +- src/commands/mjs.test.ts | 2 +- src/commands/shell.test.ts | 2 +- src/context.test.ts | 5 ++- src/jishaku.test.ts | 75 +++++++++++++++++++++++++++++++++ src/jishaku.ts | 19 +++++++++ src/types.ts | 17 ++++++++ src/util/paginate.test.ts | 5 ++- 11 files changed, 134 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 205488a..0a5e1e8 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,13 @@ client.login(process.env.DISCORD_TOKEN) | `evalTimeout` | `number` | `10000` | Cap on a single *synchronous* stretch of a `jsk js`/`jsk cjs` eval (see below). | | `shell` | `ShellOverride` | *(auto)* | Override which shell `jsk sh` spawns (see below). | | `evalModuleDir` | `string` | `process.cwd()` | Base directory `jsk cjs`'s `require` and `jsk mjs`'s `import` resolve modules from (see below). | +| `catchProcessErrors` | `boolean` | `true` | Keep the process alive on an `uncaughtException`/`unhandledRejection` that escapes an eval (see below). | + +### Process-wide error safety net + +`jsk js` / `jsk cjs` / `jsk mjs` / `jsk sh` already catch and report anything an eval throws or rejects with *within its own awaited chain* — that's just `jsk`'s normal ‼️ error reporting. But eval'd code can also fail *outside* that chain: a `fetch(...)` left unawaited that rejects after the command already returned, an event listener the eval registered (`client.on(...)`) that throws later, and so on. Node's default for both `uncaughtException` and `unhandledRejection` is to terminate the process, which would take the whole bot down over a mistake in a one-off debug snippet. + +With `catchProcessErrors` (on by default), djsk installs its own `uncaughtException`/`unhandledRejection` listeners for the life of the process, logging instead of crashing. This is process-wide, not scoped to djsk's own commands — set it to `false` if you already install your own top-level handlers (a process manager, crash reporter, ...) and don't want djsk's to shadow them. ### Security mode diff --git a/src/commands/cjs.test.ts b/src/commands/cjs.test.ts index b22a215..5591d94 100644 --- a/src/commands/cjs.test.ts +++ b/src/commands/cjs.test.ts @@ -12,7 +12,7 @@ function makeJsk(configOverrides: Record = {}): Jishaku { return new Jishaku( // biome-ignore lint/suspicious/noExplicitAny: minimal fake client for tests. { token: 't0ken-fake' } as any, - { consoleLog: false, ...configOverrides }, + { consoleLog: false, catchProcessErrors: false, ...configOverrides }, ) } diff --git a/src/commands/filesystem.test.ts b/src/commands/filesystem.test.ts index 6cd6607..7371448 100644 --- a/src/commands/filesystem.test.ts +++ b/src/commands/filesystem.test.ts @@ -11,7 +11,10 @@ const curlCommand = filesystemCommands[1] function makeJsk(): Jishaku { // biome-ignore lint/suspicious/noExplicitAny: minimal fake client for tests. - return new Jishaku({ token: 't0ken-fake' } as any, { consoleLog: false }) + return new Jishaku({ token: 't0ken-fake' } as any, { + consoleLog: false, + catchProcessErrors: false, + }) } function makeContext(command: string, args: string) { diff --git a/src/commands/js.test.ts b/src/commands/js.test.ts index 3a03d93..b40687a 100644 --- a/src/commands/js.test.ts +++ b/src/commands/js.test.ts @@ -9,7 +9,7 @@ function makeJsk(configOverrides: Record = {}): Jishaku { return new Jishaku( // biome-ignore lint/suspicious/noExplicitAny: minimal fake client for tests. { token: 't0ken-fake' } as any, - { consoleLog: false, ...configOverrides }, + { consoleLog: false, catchProcessErrors: false, ...configOverrides }, ) } diff --git a/src/commands/mjs.test.ts b/src/commands/mjs.test.ts index 3079a68..24c0d5b 100644 --- a/src/commands/mjs.test.ts +++ b/src/commands/mjs.test.ts @@ -12,7 +12,7 @@ function makeJsk(configOverrides: Record = {}): Jishaku { return new Jishaku( // biome-ignore lint/suspicious/noExplicitAny: minimal fake client for tests. { token: 't0ken-fake' } as any, - { consoleLog: false, ...configOverrides }, + { consoleLog: false, catchProcessErrors: false, ...configOverrides }, ) } diff --git a/src/commands/shell.test.ts b/src/commands/shell.test.ts index e427133..3642961 100644 --- a/src/commands/shell.test.ts +++ b/src/commands/shell.test.ts @@ -20,7 +20,7 @@ function makeJsk(configOverrides: Record = {}): Jishaku { return new Jishaku( // biome-ignore lint/suspicious/noExplicitAny: minimal fake client for tests. { token: 't0ken-fake' } as any, - { consoleLog: false, shellTimeout: 5000, ...configOverrides }, + { consoleLog: false, catchProcessErrors: false, shellTimeout: 5000, ...configOverrides }, ) } diff --git a/src/context.test.ts b/src/context.test.ts index a25f8b9..25d66b2 100644 --- a/src/context.test.ts +++ b/src/context.test.ts @@ -5,7 +5,10 @@ import { Jishaku } from './jishaku' function makeJsk(): Jishaku { // biome-ignore lint/suspicious/noExplicitAny: minimal fake client for tests. - return new Jishaku({ token: 't0ken-fake' } as any, { consoleLog: false }) + return new Jishaku({ token: 't0ken-fake' } as any, { + consoleLog: false, + catchProcessErrors: false, + }) } function makeMessageSource(overrides: { reply?: ReturnType } = {}) { diff --git a/src/jishaku.test.ts b/src/jishaku.test.ts index 23adcce..6bb0a19 100644 --- a/src/jishaku.test.ts +++ b/src/jishaku.test.ts @@ -68,3 +68,78 @@ describe('Jishaku — update check on construction', () => { info.mockRestore() }) }) + +describe('Jishaku — process-wide error safety net', () => { + // Jishaku registers real `process.on` listeners for the life of the process; every test here + // captures and removes exactly the ones its own instance added, so no listener leaks into + // later tests (or fires against an unrelated later `uncaughtException`/`unhandledRejection`). + const captureProcessListeners = () => { + const onSpy = vi.spyOn(process, 'on') + return { + onSpy, + cleanup: () => { + for (const [event, listener] of onSpy.mock.calls) { + if (event === 'uncaughtException' || event === 'unhandledRejection') { + process.removeListener(event, listener as (...args: unknown[]) => void) + } + } + onSpy.mockRestore() + }, + } + } + + it('installs uncaughtException/unhandledRejection listeners by default', () => { + const { onSpy, cleanup } = captureProcessListeners() + + new Jishaku(fakeClient, { consoleLog: false }) + + expect(onSpy).toHaveBeenCalledWith('uncaughtException', expect.any(Function)) + expect(onSpy).toHaveBeenCalledWith('unhandledRejection', expect.any(Function)) + cleanup() + }) + + it('does not install those listeners when catchProcessErrors is false', () => { + const { onSpy, cleanup } = captureProcessListeners() + + new Jishaku(fakeClient, { consoleLog: false, catchProcessErrors: false }) + + expect(onSpy).not.toHaveBeenCalledWith('uncaughtException', expect.any(Function)) + expect(onSpy).not.toHaveBeenCalledWith('unhandledRejection', expect.any(Function)) + cleanup() + }) + + it('logs an escaped error instead of letting it propagate', () => { + const { onSpy, cleanup } = captureProcessListeners() + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + + new Jishaku(fakeClient, { consoleLog: true }) + const handler = onSpy.mock.calls.find((call) => call[0] === 'uncaughtException')?.[1] as ( + err: unknown, + ) => void + + expect(() => handler(new Error('boom'))).not.toThrow() + expect(error).toHaveBeenCalledWith( + expect.stringContaining('Uncaught error'), + expect.stringContaining('boom'), + ) + + error.mockRestore() + cleanup() + }) + + it('does not log when consoleLog is off', () => { + const { onSpy, cleanup } = captureProcessListeners() + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + + new Jishaku(fakeClient, { consoleLog: false }) + const handler = onSpy.mock.calls.find((call) => call[0] === 'uncaughtException')?.[1] as ( + err: unknown, + ) => void + handler(new Error('boom')) + + expect(error).not.toHaveBeenCalled() + + error.mockRestore() + cleanup() + }) +}) diff --git a/src/jishaku.ts b/src/jishaku.ts index 1226682..c475118 100644 --- a/src/jishaku.ts +++ b/src/jishaku.ts @@ -64,6 +64,7 @@ function resolveConfig(config: JishakuConfig): ResolvedConfig { evalTimeout: config.evalTimeout ?? 10_000, shell: config.shell ?? null, evalModuleDir: config.evalModuleDir ?? process.cwd(), + catchProcessErrors: config.catchProcessErrors ?? true, } } @@ -125,6 +126,24 @@ export class Jishaku { } }) } + + if (this.config.catchProcessErrors) { + process.on('uncaughtException', this.handleProcessError) + process.on('unhandledRejection', this.handleProcessError) + } + } + + /** + * Logs an error that escaped every awaited chain djsk controls — see `catchProcessErrors` — + * instead of letting Node's default `uncaughtException`/`unhandledRejection` behavior + * terminate the process over it. An arrow-function class field (not a method) so the same + * bound reference is used for both `process.on` calls above. + */ + private readonly handleProcessError = (error: unknown): void => { + const text = error instanceof Error ? (error.stack ?? error.message) : String(error) + if (this.config.consoleLog) { + console.error('[djsk] Uncaught error (process kept alive):', this.scrub(text)) + } } /** diff --git a/src/types.ts b/src/types.ts index fe9e3dd..d2f8c6f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -106,6 +106,22 @@ export interface JishakuConfig { * subdirectory, cleaned up immediately after each eval). Default: `process.cwd()`. */ evalModuleDir?: string + /** + * When `true`, djsk installs process-wide `uncaughtException`/`unhandledRejection` + * listeners for the life of the process, so an error that escapes the awaited chain a `jsk + * js`/`jsk cjs`/`jsk mjs`/`jsk sh` eval runs in — a fire-and-forget promise the eval'd code + * left unawaited, an event listener it registered that throws later, ... — gets logged + * instead of taking the whole bot down. Node's default for both events is to terminate the + * process; djsk's own per-command try/catch (see {@link Jishaku.run}) only ever covers + * errors thrown or rejected within that command's own awaited chain, not ones like these. + * + * This is a process-wide safety net, not scoped to djsk's own commands — it also swallows + * crashes from unrelated parts of your bot that would otherwise have exited the process. If + * you already install your own top-level `uncaughtException`/`unhandledRejection` handlers + * (e.g. for a process manager or crash reporter), set this to `false` so djsk doesn't shadow + * them. Default: `true`. + */ + catchProcessErrors?: boolean } /** Fully-resolved configuration with defaults applied. */ @@ -123,4 +139,5 @@ export interface ResolvedConfig { evalTimeout: number shell: ShellOverride | null evalModuleDir: string + catchProcessErrors: boolean } diff --git a/src/util/paginate.test.ts b/src/util/paginate.test.ts index ec6e604..51c01c4 100644 --- a/src/util/paginate.test.ts +++ b/src/util/paginate.test.ts @@ -6,7 +6,10 @@ import { paginate } from './paginate' function makeJsk(): Jishaku { // biome-ignore lint/suspicious/noExplicitAny: minimal fake client for tests. - return new Jishaku({ token: 't0ken-fake' } as any, { consoleLog: false }) + return new Jishaku({ token: 't0ken-fake' } as any, { + consoleLog: false, + catchProcessErrors: false, + }) } function makeCtx(): Context {