Create your own
Lesson illustration

Building a Resilient Asynchronous REPL with Node.js readline/promises

Hello again. In the previous lesson, you made the CLI and REPL share one terminal-command representation, one decoder, and one application handler. That gives this lesson a clean boundary: the REPL loop should repeatedly obtain a line, hand it to handleReplLine(), and remain responsible only for interaction lifetime—not process attachment rules or Frida details.

This lesson builds an asynchronous loop with node:readline/promises. By the end, invalid syntax and expected command failures will produce output and return to the prompt; an unexpected per-command exception will be contained, reported, and will not automatically end the interactive tool.


A REPL waits without blocking Node

A REPL has a deliberately sequential rhythm:

  1. Display a prompt.
  2. Wait for one submitted line.
  3. Process that line completely.
  4. Display the next prompt, unless the user chose to exit.

await suspends only the async function currently executing. It does not freeze Node’s event loop. While the REPL is waiting for terminal input—or while a Frida-backed command is awaiting I/O—Node can still process promise continuations, stream activity, and other asynchronous events.

This distinction matters later when the host receives trace events from an injected Frida agent. However, the REPL should not start accepting a second command while the first command is still executing. Instrumentation actions are stateful: overlapping attach, detach, probe, or recording commands would make session behavior difficult to reason about. Awaiting each command before asking the next question gives the terminal a simple, deterministic serialization boundary.

A Promise begins pending and eventually settles as fulfilled or rejected. In the REPL, `rl.question()` returns a Promise that fulfills with one input line; command code may likewise settle normally or reject unexpectedly.

The Async Await Episode I Promised

Watch “The Async Await Episode I Promised” by Fireship for a compact event-loop refresher. It clarifies why awaiting terminal input pauses the REPL routine without making the whole Node.js process inactive.

Watch the event loop model. Focus on the difference between synchronous work, scheduled tasks, and Promise microtasks. One practical implication for this project: wrapping CPU-heavy decoding or formatting in a Promise does not move it off the main thread.

For now, commands should execute one at a time. If a later command needs concurrent background work—such as recording events while the user enters commands—that concurrency belongs inside a carefully defined session or recorder component, not in a loop that fires terminal commands without awaiting them.


Readline’s promise-based interface

Node’s promise API is a natural fit for an ESM TypeScript application. createInterface() connects a readable input stream and writable output stream, while rl.question() writes a prompt and resolves with the submitted line.

Readline | Node.js v26.8.2 Documentation

Read the official Node.js documentation for the stable promise-based Readline interface. It establishes the exact contract your REPL relies on: interface construction, prompt output, Promise-based input, and lifecycle behavior.

In the Promises API section, read “Class: readlinePromises.Interface”, especially “rl.question(query[, options])”, then read “readlinePromises.createInterface(options)” through its basic construction example. Follow question behavior to see the input/output contract, and note the closed-interface rule. In the interface options, focus on input, output, terminal, and historySize; defer completer until the command-registry lesson.

At the outer Node boundary, creating an interface is straightforward:

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

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

const rl = createInterface({
  input: stdin,
  output: stdout,
  terminal: true,
  historySize: 100,
  removeHistoryDuplicates: true,
});

A few decisions are intentional:

  • stdin and stdout are infrastructure dependencies. They belong in the terminal adapter, not in application handlers.
  • terminal: true makes the intent explicit for an interactive session. In ordinary terminal use, Node also infers this from stdout.isTTY.
  • A bounded history is convenient but does not become application state. It is a local terminal affordance.
  • The readline interface must be closed when the REPL ends. Closing it releases its listeners and allows Node to finish naturally once no other work remains.

Decide which failures are recoverable

“Survive failures” should not mean “catch everything and pretend nothing happened.” The loop needs a clear policy based on where a failure originates.

SituationTypical representationREPL action
Blank lineLocal REPL decisionShow another prompt
Invalid quoting or command syntaxResult error from parsingRender a usage message, continue
Unknown commandTyped terminal outcomeRender help-oriented feedback, continue
Expected operational failure, such as inaccessible PIDResult error from application handlerRender typed error, continue
Unexpected exception while processing one commandRejected Promise or thrown valueReport safely, continue when the loop remains usable
Input/output infrastructure failureRejected rl.question() PromiseEnd the REPL and let bootstrap handle it
Explicit .exit commandLocal REPL control instructionLeave the loop and close Readline

The first four categories were designed in earlier lessons. parseReplLine() returns a typed parse result, and AttachToProcessHandler.execute() returns a typed application result. Therefore, a normal user mistake or an expected operational problem should not reach a JavaScript catch block at all.

That separation is valuable:

const result = await handler.execute(input);

if (!result.ok) {
  // Expected business or operational outcome.
  // Render it and keep the REPL alive.
}

A catch at the REPL boundary is a containment mechanism for defects and unanticipated adapter behavior, not the routine branch for “PID does not exist” or “a session is already active.”

Avoid this anti-pattern:

try {
  await handler.execute(input);
} catch {
  terminal.writeLine("Command failed.");
}

It erases the distinction between an expected failure that the user can correct and a programming error that developers need to investigate. It also risks hiding an error that left a future stateful adapter in an uncertain condition.


Extract the loop from the Node-specific adapter

You can keep the Node-specific setup small and make the actual looping behavior testable without a real terminal. The useful seam is a function that asks for a line, plus a function that handles a line.

First, define the loop dependencies.

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

import type { TerminalWriter } from "../shared/execute-and-render.js";

export type ReplLoopDependencies = Readonly<{
  question(prompt: string): Promise<string>;
  handleLine(line: string): Promise<void>;
  terminal: TerminalWriter;
  reportUnexpectedError(error: unknown): void;
}>;

export type ReplLoopExit = "user-requested";

question() is intentionally narrower than Node’s full readlinePromises.Interface. The loop needs only one capability: ask for a prompt and receive a line later. The real adapter supplies rl.question.bind(rl); a test supplies a deterministic scripted implementation.

Likewise, handleLine() represents the function from the previous lesson:

await handleReplLine(
  line,
  terminalCommandServices,
  terminalWriter,
);

That function already handles normal parse failures and typed command outcomes. The loop should not duplicate terminal parsing, command dispatch, rendering, or application behavior.


Implement one command iteration

Before writing the while loop, isolate the decision made for one received line. This gives .exit a narrow role as a REPL lifecycle instruction rather than an application command.

// src/interfaces/terminal/repl/execute-repl-iteration.ts

export type ReplIteration = "continue" | "exit";

export async function executeReplIteration(
  line: string,
  handleLine: (line: string) => Promise<void>,
): Promise<ReplIteration> {
  const trimmed = line.trim();

  if (trimmed === ".exit") {
    return "exit";
  }

  if (trimmed.length === 0) {
    return "continue";
  }

  await handleLine(line);

  return "continue";
}

This function deliberately passes the original line into handleLine(). It trims only to recognize REPL-local control input and blank input. Preserving the original text ensures the REPL tokenizer remains the sole authority for quoted argument syntax and whitespace behavior.

At this stage, .exit is a small built-in control line. It does not belong in the application layer because it does not express an instrumentation use case. The command registry introduced later can make discoverability, help, and completion extensible; do not create that registry prematurely just to support one lifecycle instruction.


Build the resilient asynchronous loop

Now place a containment boundary around a single command iteration, not around the entire REPL lifetime.

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

import {
  executeReplIteration,
  type ReplIteration,
} from "./execute-repl-iteration.js";
import type { ReplLoopDependencies, ReplLoopExit } from "./repl-loop-types.js";

const prompt = "grasp> ";

export async function runReplLoop(
  dependencies: ReplLoopDependencies,
): Promise<ReplLoopExit> {
  while (true) {
    // A rejected question() is an input/output infrastructure failure.
    // It is intentionally allowed to leave this function.
    const line = await dependencies.question(prompt);

    try {
      const iteration: ReplIteration = await executeReplIteration(
        line,
        dependencies.handleLine,
      );

      if (iteration === "exit") {
        dependencies.terminal.writeLine("Closing Grasp.");
        return "user-requested";
      }
    } catch (error: unknown) {
      dependencies.reportUnexpectedError(error);

      dependencies.terminal.writeLine(
        "Unexpected command failure. The REPL is still available.",
      );
    }
  }
}

There are two independent await points with two different policies:

  1. await dependencies.question(prompt) is outside the inner try block. If Readline cannot continue obtaining input, the interactive adapter cannot reliably remain interactive. The caller must close resources and decide how to report or set an exit code.

  2. await executeReplIteration(...) is inside the try block. A bug or rejected Promise from an individual command is isolated to that iteration. The loop reports it and returns to the prompt.

This scope is the key design choice. A broad outer try/catch that surrounds the whole while loop would normally exit after the first failure, because control reaches the catch after leaving the loop. Catching around each iteration is what makes the REPL resilient.

Also note what the loop does not do:

  • It does not call process.exit().
  • It does not know attach, list-processes, or any future command names.
  • It does not inspect Frida exceptions.
  • It does not convert every exception into a domain error.
  • It does not start command processing concurrently.

The loop has one job: maintain a usable interaction session while command-level failures remain recoverable.


Connect the loop to Node Readline

The Node adapter creates and owns the readline resource. It closes that resource in a finally block, whether the user enters .exit or an infrastructure-level failure escapes the loop.

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

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

import type { TerminalCommandServices } from "../shared/dispatch-terminal-command.js";
import type { TerminalWriter } from "../shared/execute-and-render.js";
import { handleReplLine } from "./handle-repl-line.js";
import { runReplLoop } from "./run-repl-loop.js";

export type InteractiveReplDependencies = Readonly<{
  services: TerminalCommandServices;
  terminal: TerminalWriter;
  reportUnexpectedError(error: unknown): void;
}>;

export async function runInteractiveRepl(
  dependencies: InteractiveReplDependencies,
): Promise<void> {
  const rl = createInterface({
    input: stdin,
    output: stdout,
    terminal: true,
    historySize: 100,
    removeHistoryDuplicates: true,
  });

  try {
    await runReplLoop({
      question(prompt: string): Promise<string> {
        return rl.question(prompt);
      },

      async handleLine(line: string): Promise<void> {
        await handleReplLine(
          line,
          dependencies.services,
          dependencies.terminal,
        );
      },

      terminal: dependencies.terminal,
      reportUnexpectedError: dependencies.reportUnexpectedError,
    });
  } finally {
    rl.close();
  }
}

The nested method syntax avoids leaking rl outside the adapter. It also makes dependency ownership visible:

  • runInteractiveRepl() owns rl.
  • runReplLoop() owns the interaction policy.
  • handleReplLine() owns parse, dispatch, and rendering of normal terminal outcomes.
  • The composition root supplies shared handler references and an unexpected-error reporter.

At bootstrap, an initial reporter can be modest:

await runInteractiveRepl({
  services: terminalCommandServices,
  terminal: stdoutTerminalWriter,

  reportUnexpectedError(error: unknown): void {
    console.error(error);
  },
});

For a development build, retaining the original error in diagnostics is useful. The user-facing line should remain stable and should not dump a stack trace, internal paths, or potentially sensitive target-process information directly into the normal command transcript. Later, structured diagnostic events can replace this simple development reporter.


Trace one failure through the layers

Consider these three inputs:

grasp> attach --pid=not-a-number
grasp> attach --pid=8420
grasp> .exit

For the malformed PID:

  • rl.question() fulfills with the submitted text.
  • executeReplIteration() recognizes that this is neither blank nor .exit.
  • handleReplLine() parses and dispatches.
  • The command decoder produces a typed usage error.
  • The terminal renders the usage error.
  • handleReplLine() resolves normally.
  • The loop asks the next question.

For a valid PID that produces an expected failure—perhaps the process has already ended:

  • The parsed command reaches AttachToProcessHandler.
  • The handler returns a typed failure result.
  • The terminal renderer displays a meaningful operational error.
  • The loop remains active because this was a valid command execution path.

For .exit:

  • No parser, dispatcher, or application handler is involved.
  • The iteration returns "exit".
  • The loop prints a closing line and resolves.
  • The outer finally closes the Readline interface.

This is exactly the behavior needed for a session-oriented tool: a bad command should not destroy an otherwise usable investigative session.


Test recovery without a terminal

Because runReplLoop() depends on a small question() function rather than directly on stdin, you can test recovery with no TTY, no readline instance, no Frida process, and no captured console output.

// src/interfaces/terminal/repl/run-repl-loop.test.ts

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

import { runReplLoop } from "./run-repl-loop.js";

describe("runReplLoop", () => {
  it("continues after an unexpected command failure", async () => {
    const submittedLines = [
      "attach --pid=8420",
      "list-processes",
      ".exit",
    ];

    const terminalLines: string[] = [];

    const handleLine = vi.fn();
    handleLine.mockRejectedValueOnce(new Error("adapter defect"));
    handleLine.mockResolvedValueOnce(undefined);

    const reportUnexpectedError = vi.fn();

    await runReplLoop({
      async question(): Promise<string> {
        const line = submittedLines.shift();

        if (line === undefined) {
          throw new Error("Test supplied no more input.");
        }

        return line;
      },

      async handleLine(line: string): Promise<void> {
        await handleLine(line);
      },

      terminal: {
        writeLine(line: string): void {
          terminalLines.push(line);
        },
      },

      reportUnexpectedError,
    });

    expect(handleLine).toHaveBeenCalledTimes(2);
    expect(handleLine).toHaveBeenNthCalledWith(
      1,
      "attach --pid=8420",
    );
    expect(handleLine).toHaveBeenNthCalledWith(
      2,
      "list-processes",
    );

    expect(reportUnexpectedError).toHaveBeenCalledTimes(1);
    expect(terminalLines).toContain(
      "Unexpected command failure. The REPL is still available.",
    );
    expect(terminalLines).toContain("Closing Grasp.");
  });
});

This test proves a specific recovery guarantee: after the first command rejects unexpectedly, the second submitted command is still processed.

Keep a separate test for the expected-failure path. There, use the real handleReplLine() with a fake application handler that returns an error Result. That test should demonstrate that expected application failures are rendered normally and do not reach reportUnexpectedError.

A short manual smoke check is also worthwhile once the Node adapter is wired:

pnpm grasp repl
grasp> attach --pid=not-a-number
grasp> definitely-not-a-command
grasp> .exit

Each of the first two inputs should return control to grasp> . The final input should close the interface cleanly rather than forcing process termination.


Key takeaways

An asynchronous REPL is a small but important lifecycle adapter:

  • readline/promises lets rl.question() provide terminal input as an awaited Promise.
  • Awaiting a prompt or command serializes terminal commands without blocking Node’s event loop.
  • Parse errors and expected application failures should travel as typed outcomes, be rendered, and resolve normally.
  • Put try/catch around one command iteration, not around the whole REPL, so an unexpected command failure does not collapse the session.
  • Let input/output infrastructure failures escape the loop, while a finally block closes the Readline interface.
  • Extracting the loop from Node-specific streams makes recovery behavior deterministic to test.

Next, you will replace the fixed command routing approach with an extensible command registry that can provide command help text and tab-completion candidates.

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

Sign up