Skip to content

Ctrl+C does not stop the dev server: raw mode suppresses SIGINT and close() never exits the process #98

Description

@bitcavern

Summary

With node ace serve --watch, pressing Ctrl+C does not stop the dev server. The terminal stays wedged and the only way out is closing the terminal tab, which leaves orphaned node ace serve --watch processes re-parented to init (ppid=1).

Crucially, pressing Ctrl+C repeatedly (quickly or slowly) has no effect either, because in this state no signal is ever generated — so there is no default-terminate fallback to rely on.

Steps to reproduce

  1. node ace serve --watch
  2. Wait for watching file system for changes...
  3. Press Ctrl+C

Expected: the dev server shuts down and the shell prompt returns.

Actual: ^C is echoed, nothing else happens. The process keeps running. Closing the terminal tab leaves the server running in the background.

Analysis

ShortcutsManager.setup() puts stdin into raw mode so it can implement the r/c/o/h shortcuts:

setup() {
  if (!process.stdin.isTTY) return;
  process.stdin.setRawMode(true);
  this.#keyPressHandler = (data) => this.#handleKeyPress(data.toString());
  process.stdin.on("data", this.#keyPressHandler);
}

Raw mode clears ISIG, so the terminal no longer translates Ctrl+C into SIGINT. The keypress is instead delivered as a raw byte and handled explicitly:

#handleKeyPress(key) {
  if (key === "\x03" || key === "\x04") return this.#callbacks.onQuit();
  ...
}

onQuit is () => this.close(), and DevServer.close() is:

async close() {
  this.#cleanupKeyboardShortcuts();
  await this.#watcher?.close();
  if (this.#httpServer) {
    this.#httpServer.removeAllListeners();
    this.#httpServer.kill("SIGKILL");
  }
}

close() never calls process.exit() — it relies on the event loop draining naturally. If anything still holds a referenced handle in the parent process (or close() itself does not settle), the process simply never exits. And because raw mode has suppressed SIGINT, the usual safety net — the OS killing the process on a second Ctrl+C — is gone.

Note removeAllListeners() is called on the child immediately before kill("SIGKILL"), which also removes the exit/close listeners that would normally let the parent observe and reap it.

Not fixed by upgrading

I diffed the published builds: ShortcutsManager.setup, #handleKeyPress and DevServer.close() are byte-identical in 8.0.0 (what we run) and 8.4.0 (current latest), so this is not a regression that a version bump resolves.

Workaround

Making stdin a non-TTY causes setup() to return before enabling raw mode, which restores normal terminal signal handling:

// package.json
"dev": "node ace serve --watch < /dev/null"

Ctrl+C then works as expected. The cost is losing the keyboard shortcuts, so we keep the original as a second script for anyone who wants them.

Worth noting for others hitting this: if your app registers its own SIGINT handler (job runners commonly do, to drain in-flight work), you may need to disable that in development too — otherwise the restored signal is trapped by the child instead.

Suggested fix

Guarantee the process actually exits on the quit shortcut, e.g. await this.close() then process.exit(0) in the onQuit path; or restore raw mode and re-raise SIGINT (process.kill(process.pid, 'SIGINT')) so normal signal semantics apply. Either would also make a second Ctrl+C effective as a fallback.

Environment

@adonisjs/assembler 8.0.0 (behaviour confirmed unchanged in 8.4.0)
@adonisjs/core 7.3.1
Node v24.14.0
npm 11.9.0
OS macOS 26.3 (arm64)

Happy to test a patch or open a PR if the suggested direction looks right.

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