Skip to content

[bug] Process signal handlers (SIGINT/SIGTERM/SIGQUIT) registered unconditionally even when using custom storage #198

@steve02081504

Description

@steve02081504

bug description:

When creating a Cap instance with custom storage hooks (and/or noFSState: true),
the constructor still unconditionally registers process.once handlers for SIGINT, SIGTERM,
and SIGQUIT, as well as a process.on("beforeExit", ...) handler:

process.on("beforeExit", () => this.cleanup());
["SIGINT", "SIGTERM", "SIGQUIT"].forEach((signal) => {
    process.once(signal, () => {
        this.cleanup()
            .then(() => process.exit(0))
            .catch(() => process.exit(1));
    });
});

These handlers are meant to persist in-memory token state to disk before the process exits.
However, when using custom storage hooks, there is no filesystem state to flush — the cleanup
is a no-op from a persistence standpoint. The unconditional registration causes serious
conflicts with host application process lifecycle management.

In contrast, _loadTokens() is correctly guarded:

if (!this.config.noFSState && !this.config.storage?.tokens) {
    this._loadTokens().catch(() => {});
}

The same guard should apply to the exit/signal handlers.

affects:

  • standalone
  • widget (ui)
  • widget (wasm solver)
  • js server
  • js solver

to reproduce:

Use Cap with custom storage hooks in a Node.js/Deno application that manages its own
process lifecycle (e.g. via on-shutdown,
which patches process.exit to run a graceful shutdown sequence):

import Cap from "@cap.js/server";

const cap = new Cap({
    noFSState: true,
    storage: {
        challenges: { store, read, delete, deleteExpired },
        tokens: { store, get, delete, deleteExpired },
    },
});

When an unhandledRejection occurs anywhere in the application before the host app
gets a chance to replace the signal handlers, on-shutdown's default unhandledRejection
handler fires shutdown(), which triggers application teardown. At that point
process.exit(0) is already patched to shutdown, so Cap's own SIGTERM handler also
calls shutdown() on signal delivery — resulting in double-shutdown or shutdown firing
before the application is fully initialized.

Observed in the fount project on Deno, CI log:

Initialize @cap.js/[email protected]
unhandledRejection
Error saving JSON file: undefined/config.json
Error: ENOENT: no such file or directory, open 'undefined/config.json'
    at save_config (src/server/server.mjs:45:2)
    at shutdown ([email protected]/main.mjs:13:9)
    at Process.<anonymous> ([email protected]/main.mjs:29:9)

Failed CI run: https://github.com/steve02081504/fount/actions/runs/22748003361/job/65976006396

expected behavior:

Signal/exit handlers should only be registered when there is actual filesystem state to
clean up — i.e. when noFSState is false and no custom storage.tokens hooks
are provided. Suggested fix:

if (!this.config.noFSState && !this.config.storage?.tokens) {
    this._loadTokens().catch(() => {});

    process.on("beforeExit", () => this.cleanup());
    ["SIGINT", "SIGTERM", "SIGQUIT"].forEach((signal) => {
        process.once(signal, () => {
            this.cleanup()
                .then(() => process.exit(0))
                .catch(() => process.exit(1));
        });
    });
}

versions and environment:

  • @cap.js/server: 4.0.5
  • Runtime: Deno 2.x (with Node.js compat layer), but also reproducible in Node.js when
    process.exit is patched by a lifecycle management library
  • OS: Ubuntu (GitHub Actions ubuntu-latest)

additional context:

The disableAutoCleanup option controls _lazyCleanup() inside methods, but has no
effect on these process-level signal registrations. There is currently no opt-out
mechanism for the signal handlers short of monkey-patching process.once before
constructing Cap.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions