You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
src/index.js registers SIGTERM/SIGINT handlers for graceful shutdown, but nowhere in the codebase does the process register process.on('unhandledRejection', ...) or process.on('uncaughtException', ...). Winston is configured with exitOnError: false (src/logger.js), which only prevents the logger itself from crashing the process on a logging-transport error — it has no bearing on unrelated unhandled promise rejections or synchronous uncaught exceptions elsewhere in the app.
Several code paths already discussed in other issues in this batch (unhandled Redis rejections deep in repository code, a Promise.all in webhookDispatcher.dispatch() that rejects on any single target's failure, the node-cron callback in priceRefresh.js which does catch its own errors but whose imported dependencies could still throw synchronously during module init) mean an unanticipated exception is a realistic, not merely theoretical, occurrence. Node's default behavior for an unhandled promise rejection (in modern Node versions, matching the engines.node: >=20.9.0 requirement in package.json) is to crash the process. Today, that means a single unlucky unhandled rejection anywhere — including inside a background job's async callback that escapes its own try/catch due to a bug, or a WebSocket handler's async callback — takes down the entire API server, dropping all in-flight requests and disconnecting every WebSocket client, rather than being logged and isolated.
Requirements
Register process.on('unhandledRejection', ...) and process.on('uncaughtException', ...) handlers in src/index.js that:
Log the error at error level with full stack trace via the existing logger.
For uncaughtException specifically (which Node's own documentation says leaves the process in an undefined state), perform a controlled shutdown (reuse the existing shutdown() function's cleanup: stop jobs, close the server, disconnect Redis) rather than either crashing immediately and uncontrolled, or — the worse alternative — silently swallowing it and continuing to run in a potentially corrupted state.
For unhandledRejection, log with enough context to identify the source, and make a deliberate, documented choice about whether to treat it as fatal (matching uncaughtException's behavior) or merely log-and-continue, given Node's evolving default behavior around unhandled rejections.
Ensure these new handlers do not mask or duplicate the existing per-job try/catch error handling already present in priceRefresh.js and webhookRetryWorker.js — they are a last-resort safety net, not a replacement for those.
Add a metric/counter (or at minimum a distinctly-tagged log line) for process-level crashes so they're operationally visible/alertable, distinct from routine logger.error calls already scattered through business logic.
Acceptance Criteria
An intentionally-thrown unhandled rejection in a test harness is caught by the new handler, logged, and triggers a graceful shutdown rather than an uncontrolled crash or silent continuation.
uncaughtException triggers the same graceful shutdown path (jobs stopped, server closed, Redis disconnected) before the process exits.
The new handlers are covered by at least one test that simulates each failure mode and asserts the graceful-shutdown sequence runs.
Existing shutdown behavior triggered by SIGTERM/SIGINT is unaffected and continues to pass any existing tests.
The behavioral choice for unhandledRejection (fatal vs. non-fatal) is explicitly documented in code comments and in the PR description.
Additional Notes
More precise references
src/index.js:52-62 (shutdown(signal)): confirmed the exact cleanup sequence — priceRefreshJob.stop(), webhookRetryWorker.stop(), require('./ws/PriceSubscriptionManager').stopHeartbeat(), server.close(), await cache.disconnect(), process.exit(0) — this is the sequence any new crash-handler should reuse.
src/index.js:72-73: confirmed only SIGTERM/SIGINT are registered; no process.on('unhandledRejection', ...) or process.on('uncaughtException', ...) anywhere in the file or, per a full read of logger.js, anywhere in the logger module either.
src/logger.js:109-114: confirmed exitOnError: false is set on the winston logger itself — this only governs winston's own internal transport-error handling (e.g. a broken file-transport write), and has no relationship to unrelated unhandled promise rejections/exceptions elsewhere in the app, exactly as the issue states.
src/jobs/priceRefresh.js:14-27: confirmed the cron callback already has its own try/catch wrapping refreshAllCachedPrices() and alertsService.evaluateAll(), logging via logger.error('Scheduled price refresh failed', ...) on failure — this existing per-job safety net should not be duplicated or masked by new process-level handlers, matching the issue's own caution.
src/jobs/webhookRetryWorker.js:11-30 (tick): confirmed similarly wrapped in try/catch/finally, with a running guard flag preventing overlapping ticks — another existing safety net to preserve.
src/services/webhookDispatcher.js:175-177 (dispatch): confirmed Promise.all(targets.map((webhook) => deliverToWebhook(...))) — if any single target's deliverToWebhook call rejects (not just resolves with a failure-status delivery record, but actually throws/rejects as a promise), Promise.all rejects the whole batch; tracing through deliverToWebhook → attempt(), most internal errors are caught and turned into delivery-status updates rather than thrown, but deliveryRepo.create/deliveryRepo.update/webhookRepo.findById calls themselves could still reject on a genuine Redis outage, and nothing in dispatch()'s caller chain currently catches that — worth confirming exactly which call site invokes dispatch() uncaught (likely from route handlers already wrapped in try/catch/next(err), but also potentially from the future Airdrops never auto-expire against expiry_ledger — no reconciliation job checks current chain state #88 expiry job, which needs its own handling regardless of this issue).
Additional edge cases
Node's default behavior for unhandled promise rejections has changed across major versions (from a DeprecationWarning to process-crashing-by-default as of Node 15+); given package.json's engines.node: >=20.9.0 constraint (confirmed via the issue text, not independently re-verified this pass), the crash-by-default behavior is guaranteed — meaning today, in production, an unhandled rejection already crashes the process silently with just a stack trace to stderr and no structured log entry via winston, since nothing intercepts it before Node's default handler runs.
A crash during the new uncaughtException-triggered graceful shutdown (e.g. cache.disconnect() itself throwing because Redis is the very thing that's unhealthy) needs a bounded timeout/fallback process.exit(1) so the process doesn't hang indefinitely in a broken shutdown sequence — Node's own docs explicitly warn that continuing after uncaughtException risks further corruption, so the shutdown path itself must be defensive and time-boxed (e.g. setTimeout(() => process.exit(1), 5000).unref() as a forced-exit fallback race against the graceful shutdown() promise).
WebSocket clients (priceWebSocket.attach(server), src/index.js:67) are explicitly called out in the issue as collateral damage from an uncontrolled crash — worth confirming whether PriceSubscriptionManager.stopHeartbeat() (already called in shutdown()) actually notifies connected clients of an impending disconnect (e.g. a WS close frame with a reason code) versus just stopping a server-side heartbeat timer and letting the TCP connections drop abruptly — a gracefully-closed WS connection is meaningfully better for client-side reconnect logic than an abrupt drop, though this may be pre-existing behavior out of this issue's strict scope.
Implementation sketch
// src/index.js, after existing SIGTERM/SIGINT registrationletshuttingDown=false;functioncrashShutdown(reason,err){if(shuttingDown)return;// avoid re-entrant shutdown if a second crash occurs mid-shutdownshuttingDown=true;logger.error(`Process crash: ${reason}`,{error: err?.message,stack: err?.stack});// metrics/log line distinctly tagged for alerting, per requirementslogger.error('PROCESS_CRASH',{ reason,fatal: true});constforceExit=setTimeout(()=>process.exit(1),5000);if(typeofforceExit.unref==='function')forceExit.unref();shutdown(reason)().catch(()=>process.exit(1));}process.on('uncaughtException',(err)=>crashShutdown('uncaughtException',err));process.on('unhandledRejection',(reason)=>{consterr=reasoninstanceofError ? reason : newError(String(reason));crashShutdown('unhandledRejection',err);// documented choice: treat as fatal, matching uncaughtException});
Refactor shutdown(signal) slightly so it can be invoked programmatically (not just as an process.on callback factory) without double-registering signal handlers — the sketch above assumes shutdown(reason)() still works given its current curried shape (confirmed compatible with src/index.js:52-53's existing function shutdown(signal) { return async () => {...} } structure).
Test/reproduction plan
Integration test: spawn the app in a child process (or use a test harness that can trigger process.emit('unhandledRejection', ...) directly against the in-process app under Jest), assert the logger received a PROCESS_CRASH-tagged error entry and that shutdown's cleanup steps (mocked priceRefreshJob.stop, webhookRetryWorker.stop, cache.disconnect) were each called exactly once.
Same for uncaughtException.
Assert existing SIGTERM/SIGINT-triggered shutdown tests (if any exist in test/) still pass unchanged — confirm the refactor to make shutdown callable both ways didn't break the signal-handler registration.
Test the forced-exit fallback: mock cache.disconnect() to hang forever (never-resolving promise); assert process.exit(1) is still called after the timeout rather than the process hanging (this may need to run in a genuinely separate process to actually observe process.exit, since Jest can't easily intercept a real process.exit call in the same process without a mock).
Overview
src/index.jsregistersSIGTERM/SIGINThandlers for graceful shutdown, but nowhere in the codebase does the process registerprocess.on('unhandledRejection', ...)orprocess.on('uncaughtException', ...). Winston is configured withexitOnError: false(src/logger.js), which only prevents the logger itself from crashing the process on a logging-transport error — it has no bearing on unrelated unhandled promise rejections or synchronous uncaught exceptions elsewhere in the app.Several code paths already discussed in other issues in this batch (unhandled Redis rejections deep in repository code, a
Promise.allinwebhookDispatcher.dispatch()that rejects on any single target's failure, thenode-croncallback inpriceRefresh.jswhich does catch its own errors but whose imported dependencies could still throw synchronously during module init) mean an unanticipated exception is a realistic, not merely theoretical, occurrence. Node's default behavior for an unhandled promise rejection (in modern Node versions, matching theengines.node: >=20.9.0requirement inpackage.json) is to crash the process. Today, that means a single unlucky unhandled rejection anywhere — including inside a background job's async callback that escapes its owntry/catchdue to a bug, or a WebSocket handler's async callback — takes down the entire API server, dropping all in-flight requests and disconnecting every WebSocket client, rather than being logged and isolated.Requirements
process.on('unhandledRejection', ...)andprocess.on('uncaughtException', ...)handlers insrc/index.jsthat:errorlevel with full stack trace via the existinglogger.uncaughtExceptionspecifically (which Node's own documentation says leaves the process in an undefined state), perform a controlled shutdown (reuse the existingshutdown()function's cleanup: stop jobs, close the server, disconnect Redis) rather than either crashing immediately and uncontrolled, or — the worse alternative — silently swallowing it and continuing to run in a potentially corrupted state.unhandledRejection, log with enough context to identify the source, and make a deliberate, documented choice about whether to treat it as fatal (matchinguncaughtException's behavior) or merely log-and-continue, given Node's evolving default behavior around unhandled rejections.try/catcherror handling already present inpriceRefresh.jsandwebhookRetryWorker.js— they are a last-resort safety net, not a replacement for those.logger.errorcalls already scattered through business logic.Acceptance Criteria
uncaughtExceptiontriggers the same graceful shutdown path (jobs stopped, server closed, Redis disconnected) before the process exits.SIGTERM/SIGINTis unaffected and continues to pass any existing tests.unhandledRejection(fatal vs. non-fatal) is explicitly documented in code comments and in the PR description.Additional Notes
More precise references
src/index.js:52-62(shutdown(signal)): confirmed the exact cleanup sequence —priceRefreshJob.stop(),webhookRetryWorker.stop(),require('./ws/PriceSubscriptionManager').stopHeartbeat(),server.close(),await cache.disconnect(),process.exit(0)— this is the sequence any new crash-handler should reuse.src/index.js:72-73: confirmed onlySIGTERM/SIGINTare registered; noprocess.on('unhandledRejection', ...)orprocess.on('uncaughtException', ...)anywhere in the file or, per a full read oflogger.js, anywhere in the logger module either.src/logger.js:109-114: confirmedexitOnError: falseis set on the winston logger itself — this only governs winston's own internal transport-error handling (e.g. a broken file-transport write), and has no relationship to unrelated unhandled promise rejections/exceptions elsewhere in the app, exactly as the issue states.src/jobs/priceRefresh.js:14-27: confirmed the cron callback already has its owntry/catchwrappingrefreshAllCachedPrices()andalertsService.evaluateAll(), logging vialogger.error('Scheduled price refresh failed', ...)on failure — this existing per-job safety net should not be duplicated or masked by new process-level handlers, matching the issue's own caution.src/jobs/webhookRetryWorker.js:11-30(tick): confirmed similarly wrapped intry/catch/finally, with arunningguard flag preventing overlapping ticks — another existing safety net to preserve.src/services/webhookDispatcher.js:175-177(dispatch): confirmedPromise.all(targets.map((webhook) => deliverToWebhook(...)))— if any single target'sdeliverToWebhookcall rejects (not just resolves with a failure-status delivery record, but actually throws/rejects as a promise),Promise.allrejects the whole batch; tracing throughdeliverToWebhook→attempt(), most internal errors are caught and turned into delivery-status updates rather than thrown, butdeliveryRepo.create/deliveryRepo.update/webhookRepo.findByIdcalls themselves could still reject on a genuine Redis outage, and nothing indispatch()'s caller chain currently catches that — worth confirming exactly which call site invokesdispatch()uncaught (likely from route handlers already wrapped intry/catch/next(err), but also potentially from the future Airdrops never auto-expire against expiry_ledger — no reconciliation job checks current chain state #88 expiry job, which needs its own handling regardless of this issue).Additional edge cases
DeprecationWarningto process-crashing-by-default as of Node 15+); givenpackage.json'sengines.node: >=20.9.0constraint (confirmed via the issue text, not independently re-verified this pass), the crash-by-default behavior is guaranteed — meaning today, in production, an unhandled rejection already crashes the process silently with just a stack trace to stderr and no structured log entry via winston, since nothing intercepts it before Node's default handler runs.uncaughtException-triggered graceful shutdown (e.g.cache.disconnect()itself throwing because Redis is the very thing that's unhealthy) needs a bounded timeout/fallbackprocess.exit(1)so the process doesn't hang indefinitely in a broken shutdown sequence — Node's own docs explicitly warn that continuing afteruncaughtExceptionrisks further corruption, so the shutdown path itself must be defensive and time-boxed (e.g.setTimeout(() => process.exit(1), 5000).unref()as a forced-exit fallback race against the gracefulshutdown()promise).priceWebSocket.attach(server),src/index.js:67) are explicitly called out in the issue as collateral damage from an uncontrolled crash — worth confirming whetherPriceSubscriptionManager.stopHeartbeat()(already called inshutdown()) actually notifies connected clients of an impending disconnect (e.g. a WS close frame with a reason code) versus just stopping a server-side heartbeat timer and letting the TCP connections drop abruptly — a gracefully-closed WS connection is meaningfully better for client-side reconnect logic than an abrupt drop, though this may be pre-existing behavior out of this issue's strict scope.Implementation sketch
Refactor
shutdown(signal)slightly so it can be invoked programmatically (not just as anprocess.oncallback factory) without double-registering signal handlers — the sketch above assumesshutdown(reason)()still works given its current curried shape (confirmed compatible withsrc/index.js:52-53's existingfunction shutdown(signal) { return async () => {...} }structure).Test/reproduction plan
process.emit('unhandledRejection', ...)directly against the in-process app under Jest), assert the logger received aPROCESS_CRASH-tagged error entry and thatshutdown's cleanup steps (mockedpriceRefreshJob.stop,webhookRetryWorker.stop,cache.disconnect) were each called exactly once.uncaughtException.SIGTERM/SIGINT-triggered shutdown tests (if any exist intest/) still pass unchanged — confirm the refactor to makeshutdowncallable both ways didn't break the signal-handler registration.cache.disconnect()to hang forever (never-resolving promise); assertprocess.exit(1)is still called after the timeout rather than the process hanging (this may need to run in a genuinely separate process to actually observeprocess.exit, since Jest can't easily intercept a realprocess.exitcall in the same process without a mock).Cross-references
/metricsendpoint and Prometheus counter infrastructure Missing production metrics for webhook delivery outcomes, retry-queue depth, and price-source failure rates #93 introduces; if Missing production metrics for webhook delivery outcomes, retry-queue depth, and price-source failure rates #93 lands first, this issue's crash-counter requirement should use it directly rather than inventing a separate ad hoc mechanism./healthfor the brief window between crash-detection and process exit, so a load balancer stops routing new traffic to it immediately; worth cross-referencing Health check endpoint reports only Redis status — misses price-source, webhook-worker, and DB connectivity #92's health-status design so this issue's shutdown path can flip whatever health-state variable Health check endpoint reports only Redis status — misses price-source, webhook-worker, and DB connectivity #92 introduces before beginning cleanup.