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:
- Display a prompt.
- Wait for one submitted line.
- Process that line completely.
- 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.

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:
stdinandstdoutare infrastructure dependencies. They belong in the terminal adapter, not in application handlers.terminal: truemakes the intent explicit for an interactive session. In ordinary terminal use, Node also infers this fromstdout.isTTY.- A bounded history is convenient but does not become application state. It is a local terminal affordance.
- The
readlineinterface 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.
| Situation | Typical representation | REPL action |
|---|---|---|
| Blank line | Local REPL decision | Show another prompt |
| Invalid quoting or command syntax | Result error from parsing | Render a usage message, continue |
| Unknown command | Typed terminal outcome | Render help-oriented feedback, continue |
| Expected operational failure, such as inaccessible PID | Result error from application handler | Render typed error, continue |
| Unexpected exception while processing one command | Rejected Promise or thrown value | Report safely, continue when the loop remains usable |
| Input/output infrastructure failure | Rejected rl.question() Promise | End the REPL and let bootstrap handle it |
Explicit .exit command | Local REPL control instruction | Leave 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:
-
await dependencies.question(prompt)is outside the innertryblock. 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. -
await executeReplIteration(...)is inside thetryblock. 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()ownsrl.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
finallycloses 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/promisesletsrl.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/catcharound 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
finallyblock 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