Create your own
Lesson illustration

Graceful Ctrl+C Cancellation Without Exiting the REPL

Welcome back. The REPL can now parse input, route it through a command registry, offer help and completion, and survive ordinary command failures. The remaining interaction gap is important for an instrumentation tool: a command may take long enough that the user needs to stop that command without losing the interactive session.

This lesson gives Ctrl+C a narrow, deliberate meaning: request cancellation of the currently running command. The REPL remains alive, returns to its prompt, and is ready for the next command. This is particularly important once commands may wait on process discovery, attachment, tracing, or recording operations.


Ctrl+C has two possible owners

At the operating-system level, Ctrl+C commonly generates an interrupt signal named SIGINT. Node can surface it at the whole-process level through process.on("SIGINT", ...), but that is too broad for this feature.

A process-level listener affects the lifetime of the entire CLI. It is appropriate later for graceful application shutdown, but it should not be the mechanism for cancelling a single REPL command. If every Ctrl+C means “shut down the host,” a user cannot safely interrupt a slow operation.

For the REPL, listen on the specific readline.Interface instead:

rl.on("SIGINT", () => {
  // Interpret Ctrl+C for this interactive interface.
});

Node’s Readline interface emits its own SIGINT event when its input receives Ctrl+C. Crucially, registering this listener prevents Readline’s default fallback behavior, which can otherwise close or pause the interface. This is supported on Windows terminals as well: Node documents terminal-generated SIGINT as available on all platforms.

Readline | Node.js v26.8.2 Documentation

Read the official Node.js Readline documentation to establish the exact default behavior that your REPL is overriding. The distinction between an interface-level interrupt and a closed interface is central to keeping the REPL alive.

In the Event: 'SIGINT' subsection, read the full explanation beginning with the paragraph that says Ctrl+C is received by the input stream and ending at the callback detail. Then read the Event: 'close' subsection immediately above its list of closing conditions. Focus on the fact that Ctrl+C can close the Readline interface when no 'SIGINT' listener exists.

For this REPL, establish the following policy:

SituationCtrl+C behavior
A command is runningRequest cancellation of that command
Cancellation was already requestedDo nothing further; cancellation is already in progress
The REPL is waiting for inputDo not close the REPL
The REPL is shutting down through .exit, EOF, or later application shutdownDo not start a new command

The final two policies can become more sophisticated later. For example, you may eventually use Ctrl+C at an empty prompt to clear partially typed input. For now, the essential guarantee is simpler: Ctrl+C must not accidentally end the interactive session.


Cancellation is cooperative, not forced termination

JavaScript cancellation is normally cooperative. You do not forcibly stop arbitrary code in the middle of execution. Instead, one component requests cancellation, and the operation being run observes that request at a safe point.

The standard JavaScript mechanism is AbortController:

  • A controller is owned by the component allowed to request cancellation.
  • Its signal is passed to the operation that may be cancelled.
  • Calling controller.abort(reason) marks the signal as aborted and notifies listeners.
  • A controller is one-shot. Once aborted, it must never be reused for a later command.

For this tool, ownership should be explicit:

ResponsibilityOwner
Detect Ctrl+CReadline adapter
Create a controller for one command invocationREPL loop
Request cancellationREPL interrupt handler
Observe cancellation and stop work safelyApplication handlers and infrastructure adapters
Translate cancellation into typed outputTerminal command adapter
Clean up command-local stateREPL loop, always in finally

This structure matters because the REPL should not know how an attachment, trace, or recorder stops. Conversely, a Frida adapter should not know that Ctrl+C was pressed. The only cross-layer contract is the cancellation signal.

A cancellation request enters at one root controller and must reach every active operation that can safely stop: host communication, spawned work, nested controllers, pending dialogs, and audit or recording paths. In this REPL, Ctrl+C is the root event and the current command’s `AbortSignal` is the propagation mechanism.

The image’s “every leaf” idea is a useful design test. Cancellation is incomplete if the command returns promptly but leaves a child operation, stream, timer, or target-process interaction running unnoticed.

Before implementing the coordinator, watch a focused explanation of the signal model and of adding cancellation to your own operations.

I Cannot Believe Abort Controller Can Do This

Watch “I Cannot Believe Abort Controller Can Do This” from Web Dev Simplified for a compact introduction to the controller-signal relationship, followed by the custom-operation example. The goal is not fetch itself; it is understanding how a caller requests cancellation and how an operation responds.

Watch the core model to establish the difference between an AbortController and its signal. Then watch custom cancellation, focusing on how an operation subscribes to an abort event and releases resources such as a connection.

A crucial limitation follows from this model: cancellation cannot interrupt CPU-bound synchronous code that never yields to the event loop. This will not react promptly:

function expensiveSynchronousLoop(): void {
  for (let index = 0; index < 10_000_000_000; index += 1) {
    // Ctrl+C cannot be observed here until JavaScript yields.
  }
}

For asynchronous operations, check the signal before beginning work, pass it into APIs that support it, and check it again at meaningful boundaries. For CPU-heavy work, redesign the operation into chunks that periodically yield or move it to a worker process or worker thread. The forthcoming Frida host operations will mainly be asynchronous, but the cooperative rule still applies.


Add cancellation to the terminal command contract

In the previous lesson, a terminal command had this shape:

execute(
  command: ParsedTerminalCommand,
  interaction: CommandInteraction,
): Promise<ReplDisposition>;

Extend it with a per-invocation execution context. The context carries no Readline object, no terminal writer, and no global process object. It only carries the capability needed by a command that may run for a while.

// src/interfaces/terminal/commands/command-definition.ts

export type CommandExecutionContext = Readonly<{
  signal: AbortSignal;
}>;

export type TerminalCommandDefinition = Readonly<{
  name: string;
  aliases: readonly string[];

  help: CommandHelp;

  complete?(
    context: CommandCompletionContext,
  ): readonly string[];

  execute(
    command: ParsedTerminalCommand,
    interaction: CommandInteraction,
    execution: CommandExecutionContext,
  ): Promise<ReplDisposition>;
}>;

Commands such as .help and .exit can ignore the third argument:

export const exitCommand: TerminalCommandDefinition = {
  name: ".exit",
  aliases: [".quit", ".q"],

  help: {
    usage: ".exit",
    summary: "Close the interactive REPL.",
  },

  async execute(): Promise<ReplDisposition> {
    return "exit";
  },
};

An application-backed command passes the signal into its handler. The precise names will differ in your codebase, but the dependency direction should remain the same.

// src/interfaces/terminal/commands/list-processes-command.ts

export function createListProcessesCommand(
  handler: ListProcessesHandler,
  renderer: ProcessListRenderer,
): TerminalCommandDefinition {
  return {
    name: "list-processes",
    aliases: ["ps"],

    help: {
      usage: "list-processes [--filter <text>]",
      summary: "List local processes.",
    },

    async execute(
      command,
      _interaction,
      execution,
    ): Promise<ReplDisposition> {
      const result = await handler.handle(
        {
          filter: command.options.filter,
        },
        {
          signal: execution.signal,
        },
      );

      renderer.render(result);

      return "continue";
    },
  };
}

The handler is still independent of parsing and terminal rendering. It receives typed input and a cancellation context; it returns a typed result.

For application code, use a failure variant that makes cancellation explicit:

export type ListProcessesError =
  | Readonly<{
      kind: "process-provider-unavailable";
      message: string;
    }>
  | Readonly<{
      kind: "command-cancelled";
      reason: "user-interrupt";
    }>;

A cancellation is not an infrastructure failure and not an unexpected exception. The user explicitly asked for it. Treating it as a typed, expected outcome lets the renderer produce a concise message such as:

Command cancelled.

rather than a stack trace or a generic “unexpected failure.”


Create one controller per active command

The REPL needs a small object that tracks exactly one active controller. This is an interface concern: it coordinates terminal interrupts with command execution, but it does not contain business logic.

// src/interfaces/terminal/repl/active-command-cancellation.ts

export type UserInterruptReason = Readonly<{
  kind: "user-interrupt";
}>;

export const USER_INTERRUPT: UserInterruptReason = Object.freeze({
  kind: "user-interrupt",
});

export type InterruptResult =
  | "no-active-command"
  | "cancellation-requested"
  | "already-requested";

export class ActiveCommandCancellation {
  #active: AbortController | undefined;

  begin(): AbortController {
    if (this.#active !== undefined) {
      throw new Error(
        "A command cancellation controller is already active.",
      );
    }

    const controller = new AbortController();

    this.#active = controller;

    return controller;
  }

  complete(controller: AbortController): void {
    if (this.#active === controller) {
      this.#active = undefined;
    }
  }

  interrupt(): InterruptResult {
    const controller = this.#active;

    if (controller === undefined) {
      return "no-active-command";
    }

    if (controller.signal.aborted) {
      return "already-requested";
    }

    controller.abort(USER_INTERRUPT);

    return "cancellation-requested";
  }
}

There are a few details worth preserving:

  1. The active controller remains registered until the command settles. A second Ctrl+C does not create another cancellation request or start a new command.
  2. complete() compares object identity. This protects against accidental cleanup by a stale operation if the implementation evolves later.
  3. begin() rejects concurrent command execution. The REPL is deliberately sequential. Background tracing and recording will later have their own lifecycles rather than silently becoming “another active REPL command.”
  4. The reason is structured data. Avoid relying on a display string buried in an Error. A typed reason can be mapped consistently to a typed application failure.

Do not create one controller when the REPL starts and share it for every command. One Ctrl+C would permanently abort that controller, causing every later command to start in an already-cancelled state. A fresh command must receive a fresh controller.


Wire Readline to the active command

Refactor handleReplLine() to accept the execution context and pass it to the resolved command:

export async function handleReplLine(
  line: string,
  registry: CommandRegistry,
  terminal: TerminalWriter,
  execution: CommandExecutionContext,
): Promise<ReplDisposition> {
  const parsed = parseReplLine(line);

  if (!parsed.ok) {
    terminal.writeLine(parsed.error.message);
    return "continue";
  }

  const command = registry.find(parsed.value.name);

  if (command === undefined) {
    terminal.writeLine(`Unknown command: ${parsed.value.name}`);
    terminal.writeLine("Try .help for commands.");
    return "continue";
  }

  return command.execute(
    parsed.value,
    {
      renderHelp(targetName: string | undefined): void {
        const help = buildHelp(registry, targetName);

        for (const helpLine of help.lines) {
          terminal.writeLine(helpLine);
        }
      },
    },
    execution,
  );
}

The REPL loop creates the controller immediately before executing a non-empty command and clears it in finally. The SIGINT listener itself should do very little: request cancellation and optionally report that the request was received. It must not await cleanup or attempt to close Readline.

// src/interfaces/terminal/repl/run-interactive-repl.ts

import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";

export async function runInteractiveRepl(
  registry: CommandRegistry,
  terminal: TerminalWriter,
): Promise<void> {
  const rl = createInterface({
    input: stdin,
    output: stdout,
    terminal: true,
    completer(line: string): [readonly string[], string] {
      return [
        registry.completion(line),
        completionSubstring(line),
      ];
    },
  });

  const cancellation = new ActiveCommandCancellation();

  const onSigint = (): void => {
    const result = cancellation.interrupt();

    if (result === "cancellation-requested") {
      terminal.writeLine("Cancellation requested.");
    }
  };

  rl.on("SIGINT", onSigint);

  try {
    while (true) {
      const line = await rl.question("grasp> ");

      if (line.trim().length === 0) {
        continue;
      }

      const controller = cancellation.begin();

      try {
        const disposition = await handleReplLine(
          line,
          registry,
          terminal,
          {
            signal: controller.signal,
          },
        );

        if (disposition === "exit") {
          return;
        }
      } catch (error: unknown) {
        if (controller.signal.aborted) {
          terminal.writeLine("Command cancelled.");
        } else {
          terminal.writeLine(
            formatUnexpectedReplFailure(error),
          );
        }
      } finally {
        cancellation.complete(controller);
      }
    }
  } finally {
    rl.off("SIGINT", onSigint);
    rl.close();
  }
}

The catch is a defensive boundary. Well-designed command adapters should convert cancellation into typed results before they reach it. But if a lower-level library rejects with an abort-related exception, the REPL still survives and reports cancellation rather than treating a user interrupt as an application crash.

A successful interaction should feel like this:

grasp> list-processes --filter service
Cancellation requested.
Command cancelled.
grasp> .help
Commands:
  ...
grasp>

The prompt returns because the command’s promise settles, finally clears the active controller, and the loop begins another iteration.


Make handlers and adapters observe the signal

Passing a signal is only useful if the receiving code honors it.

For a read-only operation such as process discovery, a handler can check the signal before and after calling its port. Any adapter that supports native cancellation should receive the signal as well.

export async function listProcesses(
  input: ListProcessesInput,
  context: Readonly<{ signal: AbortSignal }>,
): Promise<Result<readonly ProcessSummary[], ListProcessesError>> {
  if (context.signal.aborted) {
    return err({
      kind: "command-cancelled",
      reason: "user-interrupt",
    });
  }

  try {
    const processes = await processProvider.enumerate({
      filter: input.filter,
      signal: context.signal,
    });

    if (context.signal.aborted) {
      return err({
        kind: "command-cancelled",
        reason: "user-interrupt",
      });
    }

    return ok(processes);
  } catch (error: unknown) {
    if (context.signal.aborted) {
      return err({
        kind: "command-cancelled",
        reason: "user-interrupt",
      });
    }

    return err(toProcessProviderError(error));
  }
}

The port remains free of Frida-specific types:

export type ProcessProvider = Readonly<{
  enumerate(input: Readonly<{
    filter: string | undefined;
    signal: AbortSignal;
  }>): Promise<readonly ProcessSummary[]>;
}>;

This does not mean every operation can instantly stop. A promise race such as Promise.race([operation, abortedPromise]) only stops waiting for the original promise; it does not stop the underlying operation. That is dangerous when the operation owns resources or might mutate instrumentation state.

For an effectful command, cancellation needs an explicit safety policy:

Command typeAppropriate cancellation behavior
Read-only discoveryStop waiting and return a cancelled result
Attach operationIf attachment completed during cancellation, detach or reconcile session state
Hook installationRemove a partially installed hook before reporting cancellation
Recording startupClose opened file handles and discard incomplete setup
Long-lived recordingStop intake, flush or discard according to a defined recording policy

Those operations are introduced later in the course. The immediate design principle is enough: a cancellation result must not conceal a resource that continues running without an owner.


Test cancellation without sending real terminal signals

Unit tests should not send Ctrl+C to the test runner. Test the coordinator as a deterministic unit, then test handlers with an already-aborted signal or a controlled pending promise.

// src/interfaces/terminal/repl/active-command-cancellation.test.ts

import { describe, expect, it } from "vitest";

import {
  ActiveCommandCancellation,
  USER_INTERRUPT,
} from "./active-command-cancellation.js";

describe("ActiveCommandCancellation", () => {
  it("aborts the active command with the user-interrupt reason", () => {
    const cancellation = new ActiveCommandCancellation();
    const controller = cancellation.begin();

    expect(cancellation.interrupt()).toBe(
      "cancellation-requested",
    );

    expect(controller.signal.aborted).toBe(true);
    expect(controller.signal.reason).toEqual(USER_INTERRUPT);
    expect(cancellation.interrupt()).toBe("already-requested");
  });

  it("does nothing when there is no active command", () => {
    const cancellation = new ActiveCommandCancellation();

    expect(cancellation.interrupt()).toBe("no-active-command");
  });

  it("creates a fresh controller after command completion", () => {
    const cancellation = new ActiveCommandCancellation();
    const first = cancellation.begin();

    cancellation.complete(first);

    const second = cancellation.begin();

    expect(second).not.toBe(first);
    expect(second.signal.aborted).toBe(false);
  });
});

Also add a handler-level test in the style established in the earlier Vitest lesson:

  • Create an AbortController.
  • Pass its signal to the handler.
  • Abort it before the fake provider resolves.
  • Assert that the handler returns { kind: "command-cancelled", reason: "user-interrupt" }.
  • Assert that the renderer receives the normal concise cancellation presentation rather than an unexpected-error message.

Finally, verify the actual terminal behavior manually on Windows 11:

  1. Start a deliberately slow, cancellable test command.
  2. Press Ctrl+C once.
  3. Confirm the command reports cancellation.
  4. Run .help or another harmless command.
  5. Confirm the REPL is still accepting input.
  6. Use .exit to close normally.

This test validates the specific integration point that unit tests cannot fully simulate: Readline’s handling of the terminal’s Ctrl+C input.


Key takeaways

Ctrl+C cancellation becomes reliable when it has a deliberately narrow scope:

  • Listen for 'SIGINT' on the readline.Interface, not on process, to avoid turning a command interrupt into whole-application shutdown.
  • Create one AbortController for each active command and pass only its AbortSignal down the command, application, and adapter path.
  • Treat cancellation as an expected typed result, not as an unexpected exception.
  • Keep the controller active until the command settles, then clear it in finally.
  • Design operations to cooperate with cancellation and clean up any resources they started.
  • Do not confuse “stop awaiting a promise” with “stop the underlying work.”

Next, you will standardize how the CLI and REPL render typed success values and typed errors, including the cancellation result introduced here.

Can't find a good explanation? Sign up and we'll make it for you

Sign up