Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/commands/cjs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ function makeJsk(configOverrides: Record<string, unknown> = {}): 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 },
)
}

Expand Down
5 changes: 4 additions & 1 deletion src/commands/filesystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/js.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ function makeJsk(configOverrides: Record<string, unknown> = {}): 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 },
)
}

Expand Down
2 changes: 1 addition & 1 deletion src/commands/mjs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ function makeJsk(configOverrides: Record<string, unknown> = {}): 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 },
)
}

Expand Down
2 changes: 1 addition & 1 deletion src/commands/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function makeJsk(configOverrides: Record<string, unknown> = {}): 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 },
)
}

Expand Down
5 changes: 4 additions & 1 deletion src/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn> } = {}) {
Expand Down
75 changes: 75 additions & 0 deletions src/jishaku.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
19 changes: 19 additions & 0 deletions src/jishaku.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -125,6 +126,24 @@ export class Jishaku<C = AnyClient> {
}
})
}

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))
}
}

/**
Expand Down
17 changes: 17 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -123,4 +139,5 @@ export interface ResolvedConfig {
evalTimeout: number
shell: ShellOverride | null
evalModuleDir: string
catchProcessErrors: boolean
}
5 changes: 4 additions & 1 deletion src/util/paginate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading