Skip to content

Native registry process does not exit after registry.shutdown() resolves (GLOBAL_ENVOY retains referenced TSFNs) #5766

Description

@dallenpyrah

Description

After await registry.shutdown() resolves, a native (NAPI) RivetKit process never exits on its own. The event loop is held open by referenced uv async handles, and the only way out is an external signal or process.exit().

It reproduces with stock RivetKit 2.3.17 and no application code: an empty registry, a registry with a plain actor, and a registry with a db() actor. It happens under both Bun and Node.

Environment

  • RivetKit 2.3.17, runtime: "native", a serverful remote Engine 2.3.17 (local, disposable)
  • Bun 1.4.2 and Node 26.8.1, macOS arm64
  • Also checked 2.3.18-rc.2 and main (78336a1): the relevant code is unchanged

Reproduction

import { actor, setup } from "rivetkit";
import { db } from "rivetkit/db";
import { createClient } from "rivetkit/client";

const endpoint = process.env.RIVET_ENDPOINT!;
const token = process.env.RIVET_TOKEN!;
const sql = actor({ db: db(), actions: { exercise: async (c) => c.db.execute("SELECT 1 AS value") } });
const registry = setup({
  endpoint, token, startEngine: false, startServices: false, runtime: "native",
  envoy: { poolName: "default", version: 1 },
  shutdown: { gracePeriodMs: 45_000, disableSignalHandlers: true },
  noWelcome: true,
  use: { probe: sql },
});
process.on("exit", (code) => console.log("exit", code));
await registry.startAndWait();
const client = createClient<typeof registry>({ endpoint, token });
await client.probe.getOrCreate([`probe-${process.pid}`]).exercise();
await client.dispose();
await registry.shutdown();
console.log("shutdown resolved");
setTimeout(() => console.log((process as any).getActiveResourcesInfo()), 1000).unref();

Observed

  • shutdown resolved prints; exit never does. In 5 out of 5 runs (bun/node × empty/no-db/sql), none exited within 20 s after shutdown.
  • Node reports 7 active, referenced uv async handles after shutdown. A forced GC (--expose-gc) does not release them.
  • With the db() actor, shutdown also logs transaction_closed for SELECT MIN(trigger_at) FROM _rivet_schedule_events followed by failed to sync scheduled actor alarm. This looks like registry.shutdown() queries scheduled alarms after SQLite coordinator closes #5555 still reproducing on 2.3.17.

Expected

Once registry.shutdown() resolves, the process exits naturally, with no leftover referenced handles.

Source analysis (at the 2.3.17 tag, fcf9535)

  • engine/sdks/rust/envoy-client/src/envoy.rs stores the serve-mode envoy handle in the process-wide static GLOBAL_ENVOY. It is only replaced by a later start and is never cleared on shutdown.
  • rivetkit-core registry/mod.rs serve_with_config_and_handle_observer passes not_global: false. The cached handle holds RegistryCallbacks → dispatcher → factories → CallbackBindings. Those contain the NAPI ThreadsafeFunctions created in rivetkit-napi/src/actor_factory.rs (create_tsfn), which are referenced by default. These appear to be the live uv async handles.
  • There is no public API to clear that slot or unref the TSFNs. serverless.rs already uses not_global: true, and its comments acknowledge the retained-handle behavior.
  • For the alarm error: the TS onSleep/onDestroy path calls closeNativeSqlDatabase before Rust finish_shutdown_cleanup_with_ctx calls sync_alarm_logged.

Possible fix

After a completed shutdown, clear GLOBAL_ENVOY, or drop/unref the callback TSFNs as part of shutdown. Separately, sync alarms before the SQL coordinator closes, or skip the sync once it is closed. I'm happy to open a PR if a direction is preferred.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions