ExportXMLWordPrintableJSON

    • Type: Bug
    • Resolution: Fixed
    • Priority: Major - P3
    • 2.14.2
    • Affects Version/s: None
    • Component/s: None
    • None
    • Environment:
      OS:
      node.js / npm versions:
      Additional info:
    • Needed
    • Hide

      Fixed: a second Ctrl+C during an in-flight interrupt no longer exits without closing the connection or flushing history and telemetry: it now attempts a graceful shutdown, hard-exiting only after 3s or a third Ctrl+C.

      Show
      Fixed: a second Ctrl+C during an in-flight interrupt no longer exits without closing the connection or flushing history and telemetry: it now attempts a graceful shutdown, hard-exiting only after 3s or a third Ctrl+C.
    • Developer Tools

      Summary

      During an asynchronous evaluation, the first Ctrl+C is handled. A second Ctrl+C before that handler finishes takes Node's default SIGINT action and exits the process. CliRepl.close() never runs, so the driver connection is not closed, the REPL history file is not flushed, and telemetry is not flushed. The lock that telemetry holds is left behind.

      A single Ctrl+C is supposed to print Stopping execution..., stop the in-flight work, reconnect, and return to the prompt with the shell state intact. The second signal is what kills the process.

      This is present on main at mongosh 2.14.1 (a49b3fc93). It is not MONGOSH-3498 (nightly Node does not exit on exit / SIGTERM), not MONGOSH-1492 (embedded shell keeps running user JS), and not MONGOSH-1243 (deleteMany not killed).

      Mechanism

      packages/cli-repl/src/async-repl.ts installs the interrupt listener with process.once('SIGINT', ...) and repl.once('SIGINT', ...) after disableEvent has removed every existing SIGINT listener.

      EventEmitter.once removes its listener before calling it. The listener then await}}s {{onAsyncSigint() in packages/cli-repl/src/mongosh-repl.ts. Until that promise settles, process.listenerCount('SIGINT') is 0, and the finally that restores the previous listeners has not run.

      Raw mode is turned off for the whole async eval so that Ctrl+C is a real SIGINT rather than a readline key. With no process SIGINT listener, Node restores the terminal and exits. The parent shell reports status 130 (128 + SIGINT). Listeners on process 'exit' run, but they cannot finish asynchronous work, and the REPL 'exit' event is not emitted.

      onAsyncSigint() does all of the following before it returns true:

      • writes Stopping execution...
      • sets the shell interrupt flag, which rejects in-flight shell operations
      • Mongo._suspend() -> NodeDriverServiceProvider.suspend() -> MongoClient.close() for every tracked Mongo
      • waits until the evaluation notices the interrupt
      • onResumeExecution() -> resetConnectionOptions({}), which constructs a new client and runs server selection

      That whole stretch is the zero-listener window. On a localhost server it is often tens or hundreds of milliseconds (client close plus a new handshake). Against a remote deployment, server selection uses the URI's serverSelectionTimeoutMS, or the driver default of 30 seconds. A selection that never succeeds stays inside the window until that timeout.

      load() is a different path. External code uses process.addListener('SIGINT', ...), not once, so a second Ctrl+C during load() is not this bug. The bug is the async-eval path, which is every command typed at the prompt once onAsyncSigint is installed (the normal interactive shell).

      Failure modes

      1. The shell exits while "Stopping execution..." is still on screen

      db.huge.find({ status: 'open' }).forEach(function (doc) {
        // client-side loop, or any command that returns a Promise and does not settle
      })
      

      Press Ctrl+C once. mongosh prints Stopping execution... and starts closing the client. Press Ctrl+C again before the new handshake finishes. The process is gone. There is no prompt, no stack, and no "interrupted" error. Exit status seen by the parent shell is 130.

      The same sequence on a remote replica set, while an aggregation or a find() cursor is blocked in getMore:

      mongosh "mongodb+srv://user:secret@cluster.example.net/app"
      
      db.events.aggregate([
        { $match: { ts: { $gt: ISODate('2020-01-01') } } },
        { $group: { _id: '$userId', n: { $sum: 1 } } }
      ])
      

      The first Ctrl+C closes the client and then waits on server selection for the replacement client. A second Ctrl+C at any point in that wait, including a full 30 second serverSelectionTimeoutMS, exits. The designed outcome of one Ctrl+C is that onAsyncSigint returns true, the in-flight eval is rejected, and the prompt comes back on the same CliRepl.

      2. Double-tap, key repeat, and a parent that signals twice

      The window does not require a long command. Close plus a new handshake on localhost is enough for a double-tap or a repeating Ctrl+C.

      The same window is hit when something other than the keyboard delivers SIGINT twice:

      • a wrapper that forwards SIGINT to the process group, then forwards it again
      • a terminal multiplexer or an IDE terminal that synthesizes a second SIGINT
      • kill -INT - of a process group that still contains mongosh while mongosh is already inside onAsyncSigint

      The first signal starts the orderly interrupt. The second finds no listener and exits.

      3. The driver client, sessions, and cursors are not closed

      CliRepl.close() (packages/cli-repl/src/cli-repl.ts) is what runs on a normal exit, including a later Ctrl+C or Ctrl+D at the prompt. It calls mongoshRepl.close(), flushes output, flushes telemetry, and flushes the log writer. The default SIGINT action never calls it.

      Two timings:

      • The second signal arrives before MongoClient.close() finishes. endSessions is not sent. Pooled sockets disappear only because the process exits (the kernel reclaims the fds). Server-side cursors and logical sessions stay until the server times them out. Logical session timeout is commonly 30 minutes. Cursor timeout is the server's cursorTimeoutMillis.
      • close() of the old client already finished, and resetConnectionOptions is mid-handshake. That new client is abandoned as well. The shell state that one Ctrl+C would have kept (current db, variables, open cursors that were not the interrupted operation) is gone because the process is gone.

      4. The newest history line can miss the history file

      Node's REPL history writer (lib/internal/repl/history.js, Node 24) does two things:

      • On the REPL 'line' event it schedules a flush 15ms later (kDebounceHistoryMS). The flush is an async filehandle.write from offset 0.
      • On the REPL 'exit' event it finishes any in-flight flush and closes the handle.

      mongosh reaches that 'exit' event from mongoshRepl.close() -> repl.close(), which CliRepl.close() calls. A signal death does not emit REPL 'exit', and it abandons the 15ms timer.

      Example: submit a command and double-tap Ctrl+C before that 15ms flush runs.

      db.orders.find({ sku: 'a-very-specific-sku' }).forEach(function () {})
      

      That line is in the in-memory history (and redactHistory, default remove, has already had its chance to drop it on 'line'). It never reaches the history file mongosh_repl_history under the shell roaming directory, because neither the debounce nor the exit flush completes. Lines from earlier in the session, whose debounce already fired, stay on disk. A flush that has started but not finished is abandoned, so the file can also be left mid-write.

      5. Telemetry stays locked for other mongosh processes

      When telemetry is enabled (an interactive terminal with a telemetry endpoint), ThrottledAnalytics takes a lock directory at startup: am-<deviceId>.json.lock under the shell local data path. The lock helper's default stale duration is 43 200 000 ms (12 hours). lockfileStaleDuration is not overridden in packages/cli-repl/src/setup-analytics.ts.

      analytics.flush() inside CliRepl.close() writes the throttle JSON and removes that directory. The hard exit skips flush():

      • events still queued in the telemetry client are never sent
      • the lock directory remains
      • a later mongosh, or a second mongosh that shares that metadata path, fails to take the lock and disables telemetry until the directory is 12 hours old or somebody removes it

      The log line "Persisted telemetry throttle state" is also skipped, because logWriter.flush() is in the same close().

      6. What is not this bug

      • One Ctrl+C. The listener is installed for the eval. The first signal runs onAsyncSigint and, when that returns true, rejects the eval and returns to the prompt.
      • Ctrl+C at the prompt, when evaluation is not in progress. The REPL's own SIGINT listener is installed then, and exit goes through CliRepl.close().
      • load() of a script file. That path keeps a normal addListener, so a second signal is still delivered to mongosh.
      • Alphanumeric-only vs special characters, connection-string parsing, and the TLS key password prompt. Those are separate.

      Expected

      The SIGINT listener stays installed for the whole await onAsyncSigint(). A second Ctrl+C in that window is ignored, so it cannot take the default action and it cannot reject the eval twice. When the interrupt finishes, the listener is removed and the previous listeners are restored, which the finally in async-repl.ts already does. A later Ctrl+C or Ctrl+D at the prompt still runs CliRepl.close().

      Tradeoff: a second Ctrl+C is no longer an emergency kill during a hung reconnect. Server selection is bounded by serverSelectionTimeoutMS. A reconnect that never settles after that needs SIGTERM or SIGKILL.

      Suggested change

      In packages/cli-repl/src/async-repl.ts, register the listener with process.on / repl.on instead of once, and ignore a SIGINT that arrives after the first one has set a flag synchronously, before any await. Keep that listener until onAsyncSigint() settles. Do not treat a second signal as a reason to exit.

      A regression test can assert process.listenerCount('SIGINT') > 0 from inside onAsyncSigint while it is still blocked, then deliver a second SIGINT and assert the handler was not entered again. The second signal must be sent only after that count is known to be non-zero. Sending it while the count is 0 kills the test process.

            Assignee:
            Edjan Michiles
            Reporter:
            Luke Prochazka (EXT)
            Votes:
            0 Vote for this issue
            Watchers:
            2 Start watching this issue

              Created:
              Updated:
              Resolved: