Skip to content

feat(meteor): add Meteor.onShutdown() lifecycle hook#14434

Open
dupontbertrand wants to merge 5 commits into
meteor:develfrom
dupontbertrand:feature/meteor-shutdown
Open

feat(meteor): add Meteor.onShutdown() lifecycle hook#14434
dupontbertrand wants to merge 5 commits into
meteor:develfrom
dupontbertrand:feature/meteor-shutdown

Conversation

@dupontbertrand

@dupontbertrand dupontbertrand commented May 27, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR adds a server-side Meteor.onShutdown(fn) lifecycle hook, intended as the shutdown counterpart to Meteor.startup(fn).

Registered hooks are called on SIGTERM and SIGINT before the process exits, allowing applications to flush logs, drain queues, release locks, close sockets, or finish pending async cleanup before the supervisor escalates to SIGKILL.

Why

Meteor exposes Meteor.startup(fn) for the boot side of the lifecycle, but there's no symmetric API for shutdown. Today, applications that need to clean up on termination attach raw process.on('SIGTERM', …) handlers themselves, and the details that matter — per-hook timeout, error containment, ordering, single-point-of-exit — have to be reimplemented in each app.

This PR codifies the missing half of the lifecycle so applications can rely on a consistent, framework-level shutdown hook.

Forum context: https://forums.meteor.com/t/is-there-something-like-meteor-shutdown/64602

Use cases

Patterns this enables:

  • Drain in-flight DB writes / transactions before the driver connection closes.
  • Job-queue drain — finish persisting pending jobs before stopping the consumer, so redeploys don't lose work.
  • Telemetry / logger flushpino, winston, Sentry, OpenTelemetry exporters all have async flush methods that need to complete before exit.
  • Distributed lock release — Redis / etcd locks held by the process released proactively rather than waiting for TTL.

What changed

  • tools/static-assets/server/boot.jsshutdownHooks: [] bucket, callShutdownHooks(signal) runner, SIGTERM / SIGINT listeners.
  • packages/meteor/startup_server.js — public Meteor.onShutdown(fn) API.
  • docs/source/api/core.md — apibox entry + prose, placed right after Meteor.startup.

Usage

import { Meteor } from 'meteor/meteor';

Meteor.onShutdown(async (signal) => {
  console.log(`Shutting down on ${signal}, flushing pending writes…`);
  await jobQueue.flush();
  await mongoClient.close();
});

Hooks receive the triggering signal name ('SIGTERM' or 'SIGINT') as their argument.

Design choices

Decision Choice Rationale
API name Meteor.onShutdown The on prefix reads as a hook registration, not an imperative "shut the server down now"
Hook ordering LIFO Setup/teardown symmetry — resources initialised later often depend on resources initialised earlier, so they should usually close first
Concurrency Sequential (for…await) Deterministic; allows flushing DB writes before closing the driver
Hook throws Best-effort (log + continue) One bad hook shouldn't prevent the rest from releasing resources
Timeout Hard cap via METEOR_SHUTDOWN_TIMEOUT_MS (default 10000ms) Supervisors (Galaxy / K8s / systemd) escalate to SIGKILL — exit well before
METEOR_SHUTDOWN_TIMEOUT_MS=0 No cap (wait indefinitely) Opt-out for apps that manage their own deadline; a small value such as 1 exits almost immediately
Second signal during shutdown Immediate process.exit Restores the conventional double-Ctrl-C "force quit" escape hatch when a hook hangs
Late registration Warn + run via microtask Mirrors Meteor.startup's "execute immediately when bootstrap is done" path

Exit codes follow POSIX: SIGINT → 130, SIGTERM → 143.

Review feedback resolved

Following @italojs's review (the four open questions the draft was waiting on are now settled):

  1. API name — renamed Meteor.shutdownMeteor.onShutdown.
  2. Hook orderingLIFO (teardown symmetry).
  3. METEOR_SHUTDOWN_TIMEOUT_MS0 = no cap, 1 ≈ immediate, invalid/negative → default 10000 with a warning (no more silent coercion).
  4. Double Ctrl-C escape hatch — a second SIGTERM/SIGINT during shutdown now forces an immediate exit instead of being swallowed.
  5. Styleconst/let throughout the new code.

Signal coverage stays SIGINT/SIGTERM only; SIGHUP and Node's 'exit' remain out of scope for a follow-up.

Known limitations / out of scope

This PR does not refactor existing shutdown-related listeners such as packages/webapp/socket_file.js, and it does not integrate the dev-mode parent watchdog (startCheckForLiveParent, boot.js:180) with the new hook runner. In dev mode, hooks taking longer than ~3s can therefore be pre-empted by the watchdog process.exit(1); in production builds (no METEOR_PARENT_PID) the METEOR_SHUTDOWN_TIMEOUT_MS cap is the sole guard. Both can be handled in follow-up PRs.

Validation

Manual validation against a fresh meteor create --blaze app.

Scenario Result
Baseline LIFO + sequential await ✅ C → B (300ms await respected) → A
Best-effort error handling ✅ Throwing hook logs, subsequent hooks still run
Hard timeout (METEOR_SHUTDOWN_TIMEOUT_MS=2000) ✅ Exits at T+2001ms, code 143
Disabled timeout (METEOR_SHUTDOWN_TIMEOUT_MS=0) ✅ Waits for hooks to finish, no forced exit
Second signal during shutdown ✅ Forces immediate exit
Late registration ✅ Warns and runs via microtask before process.exit
POSIX exit codes SIGINT → 130, SIGTERM → 143

@netlify

netlify Bot commented May 27, 2026

Copy link
Copy Markdown

Deploy Preview for v3-meteor-api-docs canceled.

Name Link
🔨 Latest commit 4099c77
🔍 Latest deploy log https://app.netlify.com/projects/v3-meteor-api-docs/deploys/6a29fe0a24801e0008635c5e

@netlify

netlify Bot commented May 27, 2026

Copy link
Copy Markdown

Deploy Preview for v3-migration-docs canceled.

Name Link
🔨 Latest commit 4099c77
🔍 Latest deploy log https://app.netlify.com/projects/v3-migration-docs/deploys/6a29fe0a85b14700088a375e

@coderabbitai

coderabbitai Bot commented Jun 1, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds server shutdown infrastructure and a public registration API Meteor.onShutdown(callback): bootstrap storage for shutdown hooks, signal handlers for SIGTERM/SIGINT that run hooks LIFO with timing and error logging, a configurable global timeout (METEOR_SHUTDOWN_TIMEOUT_MS), and documentation.

Changes

Server Shutdown

Layer / File(s) Summary
Shutdown infrastructure and bootstrap initialization
tools/static-assets/server/boot.js
__meteor_bootstrap__ gains a shutdownHooks array; adds callShutdownHooks(signal) with a shutdownInProgress guard, LIFO hook execution timed with Profile.time, error logging, METEOR_SHUTDOWN_TIMEOUT_MS timeout handling (default 10000ms, fallback for invalid values), POSIX-style exit code computation, and SIGTERM/SIGINT listeners.
Public Meteor.onShutdown API
packages/meteor/startup_server.js
Exports Meteor.onShutdown(callback) which wraps callbacks and appends them to __meteor_bootstrap__.shutdownHooks when present; if bootstrap or hooks are missing (shutdown already started), it warns and runs the callback as a microtask, with errors logged.
Shutdown API documentation
docs/source/api/core.md
Documents Meteor.onShutdown semantics: reverse-order (LIFO) execution, async hook support, continue-on-error best-effort behavior, METEOR_SHUTDOWN_TIMEOUT_MS behavior (default 10000ms; 0 disables), force-exit on second signal, POSIX exit codes (SIGINT → 130, SIGTERM → 143), server-only, and includes an example showing flushing work and closing a Mongo client.

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(meteor): add Meteor.onShutdown() lifecycle hook' is fully related to the main change—it clearly summarizes the new server-side shutdown lifecycle hook being introduced across documentation, API implementation, and boot initialization.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tools/static-assets/server/boot.js`:
- Around line 473-477: The safety timeout created by setTimeout (the variable
timer) must not be unreferenced because timer.unref() can allow the event loop
to empty and prevent the timeout from firing; remove the call to timer.unref()
so the timeout reliably keeps the process alive until either clearTimeout(timer)
is called or it fires and calls process.exit(exitCode), leaving the existing
clearTimeout(timer) and the timeoutMs/exitCode behavior unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a069aa43-2e0e-4e97-b82d-7ff730a7a183

📥 Commits

Reviewing files that changed from the base of the PR and between 76f4402 and cfed879.

📒 Files selected for processing (3)
  • docs/source/api/core.md
  • packages/meteor/startup_server.js
  • tools/static-assets/server/boot.js

Comment thread tools/static-assets/server/boot.js Outdated
@italojs italojs added this to the Release 3.6 milestone Jun 2, 2026
Comment thread tools/static-assets/server/boot.js Outdated
// Setting this to null tells Meteor.shutdown that shutdown has begun.
__meteor_bootstrap__.shutdownHooks = null;

var timeoutMs = parseInt(process.env.METEOR_SHUTDOWN_TIMEOUT_MS, 10) || 10000;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

METEOR_SHUTDOWN_TIMEOUT_MS=0 cannot disable the timeout, is it intentional?

parseInt('0', 10) || 10000 evaluates to 10000, so 0 (and any invalid value) silently falls back to the default (10000). If "no timeout" isn't meant to be supported, worth documenting that; if you want to allow it, prefer an explicit check:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch. I think the user should be able to disable it completely, but I find using 0 to disable it a bit ambiguous.

To me, 0 could potentially mean two very different things: either an “infinite timeout” or an “immediate exit”.

What would you recommend?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ping @italojs

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interesting, IMO:
0 -> infinite timeout
1 -> immediate exit

I mean, I havent a strong argument about this DX, you can follow what you feel more confortable

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with your suggestion: 0 = no cap (wait indefinitely), a small value like 1 exits almost immediately, and invalid/negative values now fall back to the 10000ms default with a warning instead of being silently coerced. Documented in core.md.

Comment thread tools/static-assets/server/boot.js Outdated
var shutdownInProgress = false;

var callShutdownHooks = Profile("Call Meteor.shutdown hooks", async function (signal) {
if (shutdownInProgress) return;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm not sure but I guess we loses the "double Ctrl-C to force exit" escape hatch

Since the SIGINT/SIGTERM listeners suppress Node's default termination behavior, a second signal also lands here and is discarded by the return. If a hook hangs, the operator is stuck until METEOR_SHUTDOWN_TIMEOUT_MS (10s default) with no way out

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed. A second SIGTERM/SIGINT received while shutdown is already in flight now forces an immediate process.exit instead of being swallowed, so the double-Ctrl-C "force quit" escape hatch is back when a hook hangs.

Comment thread packages/meteor/startup_server.js Outdated
*/
Meteor.shutdown = function shutdown(callback) {
callback = Meteor.wrapFn(callback);
var bootstrap = global.__meteor_bootstrap__;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we use let/const instead var?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — const/let throughout the shutdown internals (both startup_server.js and boot.js).

Symmetric to Meteor.startup(), Meteor.shutdown(fn) registers a callback
fired on SIGTERM / SIGINT before process exit. Useful for closing DB
connections, flushing queues, releasing locks, etc.

Hooks run in LIFO order sequentially with await. Per-hook errors are
logged but do not abort subsequent hooks (best-effort cleanup). Total
runtime is capped by METEOR_SHUTDOWN_TIMEOUT_MS (default 10000ms) to
avoid stalling supervisor escalation (Galaxy, K8s, systemd) to SIGKILL.

Exit codes follow POSIX: SIGINT -> 130, SIGTERM -> 143.

Forum thread: https://forums.meteor.com/t/is-there-something-like-meteor-shutdown/64602
Adds the JSDoc @summary block consumed by the apibox tag plus an entry
in docs/source/api/core.md, placed right after Meteor.startup since
the two are conceptually paired.

The prose covers: LIFO ordering with sequential await, best-effort
error handling, hard timeout via METEOR_SHUTDOWN_TIMEOUT_MS, POSIX
exit codes, server-only locus.
An unref()-ed timer does not keep the event loop alive. If a shutdown
hook hangs on a promise with no other pending I/O (e.g. after earlier
hooks have closed the server's sockets and Mongo connection), the loop
empties and the process exits 0 before the timeout fires, defeating the
documented METEOR_SHUTDOWN_TIMEOUT_MS hard cap. clearTimeout() already
prevents the timer from delaying the fast path, so unref() only weakened
the guarantee.
…meout

Addresses italojs's review (CHANGES_REQUESTED, 2026-06-02):

- Rename the public API Meteor.shutdown -> Meteor.onShutdown. The "on"
  prefix reads as a hook registration rather than an imperative
  "shut the server down now".
- A second SIGTERM/SIGINT during an in-flight shutdown now forces an
  immediate process.exit instead of being swallowed, restoring the
  conventional double-Ctrl-C "force quit" escape hatch when a hook hangs.
- METEOR_SHUTDOWN_TIMEOUT_MS gains explicit semantics: 0 disables the cap
  (wait for hooks indefinitely), a small value (e.g. 1) exits almost
  immediately, and invalid/negative values fall back to the 10000ms
  default with a warning instead of being silently coerced.
- Use const/let instead of var in the shutdown internals.

Signal coverage stays SIGINT/SIGTERM only; SIGHUP and 'exit' remain
out of scope for a follow-up.
@dupontbertrand dupontbertrand force-pushed the feature/meteor-shutdown branch from 7d808fa to 13c7fbd Compare June 11, 2026 00:07
@dupontbertrand

Copy link
Copy Markdown
Contributor Author

Heads-up: I also resolved open question #1 by renaming the API Meteor.shutdownMeteor.onShutdown (reads as a hook registration rather than an imperative "shut the server down now"). Flagging since the review above referenced the old name. All four review threads are addressed in 13c7fbd; the branch is rebased on current devel.

@dupontbertrand dupontbertrand changed the title feat(meteor): add Meteor.shutdown() lifecycle hook feat(meteor): add Meteor.onShutdown() lifecycle hook Jun 11, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/meteor/startup_server.js (1)

33-49: ⚡ Quick win

Missing stack capture for profiling parity with Meteor.startup.

Meteor.startup captures a stack trace when METEOR_PROFILE is enabled and attaches it as callback.stack (lines 3-16). The downstream callShutdownHooks in boot.js uses hook.stack || "(unknown)" for profile labels. Without the same capture here, all shutdown hooks will appear as "(unknown)" in profiling output.

♻️ Suggested fix to add stack capture
 Meteor.onShutdown = function onShutdown(callback) {
   callback = Meteor.wrapFn(callback);
+  if (process.env.METEOR_PROFILE) {
+    var error = new Error("Meteor.onShutdown");
+    Error.captureStackTrace(error, onShutdown);
+    callback.stack = error.stack
+      .split(/\n\s*/)
+      .slice(0, 2)
+      .join(" ")
+      .replace(/^Error: /, "");
+  }
   const bootstrap = global.__meteor_bootstrap__;
   if (bootstrap && bootstrap.shutdownHooks) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/meteor/startup_server.js` around lines 33 - 49, Add the same
profiling stack capture that Meteor.startup does to Meteor.onShutdown: when
METEOR_PROFILE is set, capture a new Error().stack (or equivalent) and assign it
to callback.stack before wrapping/queueing the callback so downstream
callShutdownHooks can use hook.stack instead of "(unknown)"; update
Meteor.onShutdown (the function handling bootstrap.shutdownHooks and the late
microtask branch) to set callback.stack accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/meteor/startup_server.js`:
- Around line 33-49: Add the same profiling stack capture that Meteor.startup
does to Meteor.onShutdown: when METEOR_PROFILE is set, capture a new
Error().stack (or equivalent) and assign it to callback.stack before
wrapping/queueing the callback so downstream callShutdownHooks can use
hook.stack instead of "(unknown)"; update Meteor.onShutdown (the function
handling bootstrap.shutdownHooks and the late microtask branch) to set
callback.stack accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: de55c243-c584-4273-9720-5a5f9bb8a98a

📥 Commits

Reviewing files that changed from the base of the PR and between 7d808fa and 13c7fbd.

📒 Files selected for processing (3)
  • docs/source/api/core.md
  • packages/meteor/startup_server.js
  • tools/static-assets/server/boot.js
✅ Files skipped from review due to trivial changes (1)
  • docs/source/api/core.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • tools/static-assets/server/boot.js

…OR_PROFILE

Mirror Meteor.startup's profiling stack capture so shutdown hooks show
their registration call-site in the profiler instead of "(unknown)".
callShutdownHooks already reads hook.stack; this populates it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants