diff --git a/packages/backend/src/boot/entry.ts b/packages/backend/src/boot/entry.ts index 8a965981d8..dc7d9cf054 100644 --- a/packages/backend/src/boot/entry.ts +++ b/packages/backend/src/boot/entry.ts @@ -8,8 +8,6 @@ */ import cluster from 'node:cluster'; -import { EventEmitter } from 'node:events'; -import { inspect } from 'node:util'; import chalk from 'chalk'; import Xev from 'xev'; import { coreLogger, coreEnvService, coreLoggerService } from '@/boot/coreLogger.js'; @@ -18,8 +16,6 @@ import { masterMain } from './master.js'; import { workerMain } from './worker.js'; import { readyRef } from './ready.js'; -import 'reflect-metadata'; - process.title = `Misskey (${cluster.isPrimary ? 'master' : 'worker'})`; prepEnv(); @@ -53,36 +49,6 @@ async function main() { cluster.fork(); }); - // Display detail of unhandled promise rejection - if (!envOption.quiet) { - process.on('unhandledRejection', e => { - logger.error('Unhandled rejection:', inspect(e)); - }); - } - - process.on('uncaughtException', (err) => { - // Workaround for https://github.com/node-fetch/node-fetch/issues/954 - if (String(err).match(/^TypeError: .+ is an? url with embedded credentials.$/)) { - logger.debug('Suppressed node-fetch issue#954, but the current job may fail.'); - return; - } - - // Workaround for https://github.com/node-fetch/node-fetch/issues/1845 - if (String(err) === 'TypeError: Cannot read properties of undefined (reading \'body\')') { - logger.debug('Suppressed node-fetch issue#1845, but the current job may fail.'); - return; - } - - // Throw all other errors to avoid inconsistent state. - // (per NodeJS docs, it's unsafe to suppress arbitrary errors in an uncaughtException handler.) - throw err; - }); - - // Display detail of uncaught exception - process.on('uncaughtExceptionMonitor', (err, origin) => { - logger.error(`Uncaught exception (${origin}):`, err); - }); - // Dying away... process.on('disconnect', () => { logger.warn('IPC channel disconnected! The process may soon die.'); diff --git a/packages/backend/src/boot/prepEnv.ts b/packages/backend/src/boot/prepEnv.ts index b8367ae22c..ab3e5cdf57 100644 --- a/packages/backend/src/boot/prepEnv.ts +++ b/packages/backend/src/boot/prepEnv.ts @@ -4,6 +4,14 @@ */ import { EventEmitter } from 'node:events'; +import { inspect } from 'node:util'; +import { coreLogger, coreEnvService } from '@/boot/coreLogger.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; +// import { patchPromiseTypes } from '@/misc/patch-promise-types.js'; + +// Polyfill reflection metadata *without* loading dependencies that may corrupt native types. +// https://github.com/microsoft/reflect-metadata?tab=readme-ov-file#es-modules-in-nodejsbrowser-typescriptbabel-bundlers +import 'reflect-metadata/lite'; /** * Configures Node.JS global runtime options for values appropriate for Sharkey. @@ -16,4 +24,41 @@ export function prepEnv() { // Avoid warnings like "11 message listeners added to [Commander]. MaxListeners is 10." // This is expected due to use of NestJS lifecycle hooks. EventEmitter.defaultMaxListeners = 128; + + // // In non-production environments, patch the Promise type to report unsafe usage. + // // This can identify subtle bugs at the expense of reduced JIT performance. + // const isProduction = coreEnvService.env.NODE_ENV === 'production'; + // if (!isProduction) { + // patchPromiseTypes(); + // } + + // Workaround certain 3rd-party bugs + process.on('uncaughtException', (err) => { + // Workaround for https://github.com/node-fetch/node-fetch/issues/954 + if (String(err).match(/^TypeError: .+ is an? url with embedded credentials.$/)) { + coreLogger.debug('Suppressed node-fetch issue#954, but the current job may fail.'); + return; + } + + // Workaround for https://github.com/node-fetch/node-fetch/issues/1845 + if (String(err) === 'TypeError: Cannot read properties of undefined (reading \'body\')') { + coreLogger.debug('Suppressed node-fetch issue#1845, but the current job may fail.'); + return; + } + + // Throw all other errors to avoid inconsistent state. + // (per NodeJS docs, it's unsafe to suppress arbitrary errors in an uncaughtException handler.) + coreLogger.error(`Uncaught exception: ${renderInlineError(err)}`, { + error: inspect(err), + }); + throw err; + }); + + // Log uncaught promise rejections + process.on('unhandledRejection', (error, promise) => { + coreLogger.error(`Unhandled rejection: ${renderInlineError(error)}`, { + error: inspect(error), + promise: inspect(promise), + }); + }); } diff --git a/packages/backend/src/misc/patch-promise-types.ts b/packages/backend/src/misc/patch-promise-types.ts new file mode 100644 index 0000000000..36ef57daad --- /dev/null +++ b/packages/backend/src/misc/patch-promise-types.ts @@ -0,0 +1,241 @@ +// /* +// * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors +// * SPDX-License-Identifier: AGPL-3.0-only +// */ +// +// import { coreLogger, coreEnvService } from '@/boot/coreLogger.js'; +// import { isError } from '@/misc/is-error.js'; +// import { promiseTry } from '@/misc/promise-try.js'; +// import type { EnvService } from '@/global/EnvService.js'; +// import type Logger from '@/logger.js'; +// +// // Make sure we only run it once! +// let haveRunPatch = false; +// +// // Back up the original unpatched implementations +// const nativeThen = Promise.prototype.then; +// const nativeCatch = Promise.prototype.catch; +// const nativeFinally = Promise.prototype.finally; +// const nativeReject = Promise.reject; +// const nativeTry = promiseTry; // native or polyfill +// const nativeWithResolvers = Promise.withResolvers; +// +// const isPatchedSymbol = Symbol('isPatched'); +// +// function makePatched(target: T): T { +// setPatched(target, true); +// return target; +// } +// +// function setPatched(target: object, isPatched: boolean) { +// Reflect.set(target, isPatchedSymbol, isPatched); +// } +// +// function isPatched(target: object) { +// return Reflect.get(target, isPatchedSymbol) === true; +// } +// +// /** +// * Patches the global Promise class and static methods to detect improper use. +// */ +// export function patchPromiseTypes(services?: { logger?: Logger, envService?: EnvService }) { +// const envService = services?.envService ?? coreEnvService; +// const logger = services?.logger ?? coreLogger; +// +// if (haveRunPatch) { +// logger.debug('Skipping patchPromiseTypes - already patched.'); +// return; +// } +// haveRunPatch = true; +// +// +// +// logger.info('Promise debugging is enabled; the global Promise type will be patched with additional verification routines.'); +// +// +// } +// +// export function installPromisePatches(logger: Logger) { +// // Defined here for access to services +// function check( +// error: unknown, +// promise: Promise | null, +// continuation: { +// resolve?: ((result: unknown) => unknown) | null, +// reject?: ((error: unknown) => unknown) | null, +// }, +// ) { +// // instanceof checks are not reliable under jest! +// // https://github.com/jestjs/jest/issues/2549#issuecomment-2800060383 +// if (!isError(error)) { +// const stack = new Error().stack; +// const type = error && typeof(error) === 'object' && 'name' in error && typeof(error.name) === 'string' +// ? `object[${error.name}]` +// : typeof(error); +// +// logger.error(`Detected improper use of Promise: rejected with non-Error type ${type}`, { promise, error, stack, continuation }); +// } +// +// if (String(error) === '#') { +// const stack = new Error().stack; +// logger.error('FOUND THE FUCKER:', { promise, error, stack, continuation }); +// } +// } +// +// function patchCallback Promise) | null | undefined>( +// callback: TCallback, +// checks: { +// input?: 'thrown' | 'returned' | 'both', +// output?: 'thrown' | 'returned' | 'both', +// }, +// meta: { +// promise?: Promise, +// continuation?: { +// resolve?: ((result: unknown) => unknown) | null, +// reject?: ((error: unknown) => unknown) | null, +// }, +// }, +// ): TCallback { +// if (callback == null) { +// return callback; +// } +// +// if (isPatched(callback)) { +// return callback; +// } +// +// async function checkSomething(thing: (() => T | Promise), mode: undefined | 'returned' | 'thrown' | 'both'): Promise { +// try { +// const returnedThing = await thing(); +// if (mode === 'returned' || mode === 'both') { +// check(returnedThing, meta); +// } +// return returnedThing; +// } catch (thrownThing) { +// if (mode === 'thrown' || mode === 'both') { +// check(thrownThing, meta); +// } +// throw thrownThing; +// } +// } +// +// return makePatched(async (input: TInput): Promise => { +// // Check input asynchronously +// const returnedInput = await checkSomething(() => input, checks.input); +// return await checkSomething(() => callback(returnedInput), checks.output); +// }) as TCallback; +// } +// +// // function patchProducer Promise) | null | undefined>( +// // producer: TProducer, +// // promise: Promise | null, +// // continuation: { +// // resolve?: ((result: unknown) => unknown) | null, +// // reject?: ((error: unknown) => unknown) | null, +// // }, +// // ): TProducer { +// // if (producer == null) { +// // return producer; +// // } +// // +// // if (isPatched(producer)) { +// // return producer; +// // } +// // +// // return makePatched(() => { +// // // Check the output +// // const result = nativeTry(producer); +// // result +// // .catch(resolvedError => { +// // check(resolvedError, promise, continuation); +// // }); +// // return result; +// // }) as TProducer; +// // } +// +// // Defined here for access to services and check() +// class PatchedPromise extends Promise { +// constructor(executor: (resolve: (value: (PromiseLike | T)) => void, reject: (reason?: unknown) => void) => void) { +// super((resolve, reject) => { +// reject = patchCallback(reject, { input: 'both' }, { promise: this }); +// executor(resolve, reject); +// }); +// setPatched(this, true); +// } +// } +// setPatched(PatchedPromise, true); +// +// logger.debug('Patching Promise.then prototype method...'); +// Promise.prototype.then = makePatched(function(this: Promise, resolve?: ((value: T) => (PromiseLike | TResult1)) | undefined | null, reject?: ((reason: unknown) => (PromiseLike | TResult2)) | undefined | null): Promise { +// reject = patchCombined(reject, this, { resolve, reject }); +// return nativeThen.call(this, resolve, reject) as Promise; +// }); +// +// logger.debug('Patching Promise.catch prototype method...'); +// Promise.prototype.catch = makePatched(function(this: Promise, reject?: ((reason: unknown) => TResult | PromiseLike) | undefined | null): Promise { +// reject = patchCombined(reject, this, { reject }); +// return nativeCatch.call(this, reject) as Promise; +// }); +// +// logger.debug('Patching Promise.finally prototype method...'); +// Promise.prototype.finally = makePatched(function(this: Promise, _finally?: (() => Promise | void) | undefined | null): Promise { +// _finally = patchProducer(_finally, this, { resolve: _finally, reject: _finally }); +// return nativeFinally.call(this, _finally) as Promise; +// }); +// +// logger.debug('Patching Promise.reject static method...'); +// Promise.reject = patchCombined(nativeReject, null, {}); +// +// logger.debug('Patching Promise.try static method...'); +// Promise.try = makePatched(async (callbackFn: (...args: U) => T | PromiseLike, ...args: U): Promise> => { +// const promise = nativeTry(callbackFn, ...args); +// try { +// return await promise; +// } catch (err) { +// check(err, promise, {}, 'source'); +// throw err; +// } +// }); +// +// logger.debug('Patching Promise.withResolvers static method...'); +// Promise.withResolvers = makePatched((): PromiseWithResolvers => { +// // let resolve: ((value: T | PromiseLike) => void) | undefined = undefined; +// // let reject: ((reason?: unknown) => void) | undefined = undefined; +// // const promise = new PatchedPromise((resolver, rejecter) => { +// // resolve = resolver; +// // reject = rejecter; +// // }); +// // +// // // eslint-disable-next-line @typescript-eslint/no-non-null-assertion +// // return { resolve: resolve!, reject: reject!, promise } as PromiseWithResolvers; +// const res = nativeWithResolvers(); +// if (isPatched(res.reject)) { +// return res; +// } +// +// const { promise, resolve, reject } = res; +// const patchedReject = async (err: unknown) => { +// check(await err, promise, {}, 'callback'); +// reject(err); +// }; +// return { promise, resolve, reject: patchedReject }; +// }); +// +// logger.debug('Patching Promise constructor...'); +// // Copy all new static methods from Promise to PatchedPromise +// for (const prop of Reflect.ownKeys(Promise)) { +// const hasExisting = Reflect.getOwnPropertyDescriptor(PatchedPromise, prop) != null; +// if (hasExisting) continue; +// +// const value = Reflect.get(Promise, prop); +// if (typeof(value) !== 'function') continue; +// +// const descriptor = Reflect.getOwnPropertyDescriptor(Promise, prop); +// if (!descriptor) continue; +// +// Object.defineProperty(PatchedPromise, prop, descriptor); +// } +// // Replace Promise with PatchedPromise +// global.Promise = PatchedPromise; +// globalThis.Promise = PatchedPromise; +// }