Skip to content
academia.sh

Lesson 17 / 17

Debugging Tools

Connecting to the debugging interface, breakpoints and conditional breaks, watch expressions, stepping, and reading asynchronous call stacks.

Contents

Profiling told you where time is spent; it did not tell you what is wrong. For that, the program has to be stopped at a specific point and its variables examined.

This lesson takes up the second half of diagnostic tools. The examples will be built on the same processing step of the measurement stream; the mechanism used is the same debugging interface that interface-based tools also run on underneath.

The Debugging Interface

The runtime offers an interface that lets a client connected to it stop the program, read variables, and advance it step by step. Browser developer tools, editor extensions, and command-line clients all use the same interface.

There are three ways to connect. The runtime is started with the relevant option and a client connects; it is started with the option that pauses at the start, so it is tracked from the very first line; or the debugger statement is placed inside the code — if a client is connected, execution stops there.

In the examples below, the program itself is used in place of a client: the same interface is connected to from inside the program. This shows directly what principles interface-based tools work on.

Breakpoint, Condition, and Watch Expressions

A breakpoint is where execution is to be stopped. At the stopping moment, two things can be examined: local variables’ values and the call stack.

Stopping at every breakpoint is useless in a highly repetitive flow. A conditional breakpoint stops only when the given expression is true. The program below achieves this by evaluating an expression at the stopping moment and continuing right away when the condition is not met.

import { Session } from "node:inspector";

const session = new Session();
session.connect();
session.post("Debugger.enable");

function readExpression(frame, expression) {
  let result = null;
  session.post(
    "Debugger.evaluateOnCallFrame",
    { callFrameId: frame.callFrameId, expression },
    (error, response) => {
      result = response.result.value ?? response.result.description;
    },
  );
  return result;
}

session.on("Debugger.paused", (event) => {
  const frames = event.params.callFrames;
  const top = frames[0];
  const value = readExpression(top, "measurement.value");

  if (value > 20) {
    console.log("breakpoint:", top.functionName, "— line", top.location.lineNumber + 1);
    console.log("  watch: measurement.station =", readExpression(top, "measurement.station"));
    console.log("  watch: measurement.value =", value);
    console.log("  stack:", frames.slice(0, 3).map((c) => c.functionName).join(" <- "));
  }
  session.post("Debugger.resume");
});

function processMeasurement(measurement) {
  debugger;
  return { station: measurement.station, rounded: Math.round(measurement.value) };
}

function processStream(measurements) {
  return measurements.map(function oneMeasurement(measurement) {
    return processMeasurement(measurement);
  });
}

const result = processStream([
  { station: "A1", value: 21.4 },
  { station: "B2", value: 19.8 },
  { station: "C3", value: 23.1 },
]);

console.log("result:", result.map((s) => `${s.station}=${s.rounded}`).join(" "));
session.disconnect();
breakpoint: processMeasurement — line 34
  watch: measurement.station = A1
  watch: measurement.value = 21.4
  stack: processMeasurement <- oneMeasurement <- processStream
breakpoint: processMeasurement — line 34
  watch: measurement.station = C3
  watch: measurement.value = 23.1
  stack: processMeasurement <- oneMeasurement <- processStream
result: A1=21 B2=20 C3=23

Two of the three measurements met the condition and were reported; on the second measurement the program continued as if it had never paused at all. The printed line number is the line the debugger statement is on in the source file; if the code changes, this number changes too.

A watch expression is an expression evaluated at the stopping moment. It does not have to be just a variable name; an expression like measurement.value > 20, or even a method call, can be evaluated too. In interface-based tools, these expressions are written into a list and recalculated at every stop.

The call stack view comes from the same stop data too: processMeasurement was reached through oneMeasurement, which was reached through processStream. This chain is the answer to the question “where was this function called from.”

Stepping

There are three advancement commands at a stop. Step over moves to the next line; it does not enter called functions. Step into stops at the called function’s first line. Step out runs until the function currently in returns.

import { Session } from "node:inspector";

const session = new Session();
session.connect();
session.post("Debugger.enable");

let step = 0;

session.on("Debugger.paused", (event) => {
  const top = event.params.callFrames[0];
  console.log("step", step, "— line", top.location.lineNumber + 1);
  step += 1;
  if (step < 8) session.post("Debugger.stepOver");
  else session.post("Debugger.resume");
});

function computeAverage(measurements) {
  debugger;
  let total = 0;
  for (const measurement of measurements) {
    total += measurement.value;
  }
  const average = total / measurements.length;
  return average;
}

const result = computeAverage([
  { station: "A1", value: 21.4 },
  { station: "B2", value: 19.8 },
]);

console.log("average:", result.toFixed(2));
session.disconnect();
step 0 — line 18
step 1 — line 19
step 2 — line 20
step 3 — line 20
step 4 — line 21
step 5 — line 20
step 6 — line 21
step 7 — line 20
average: 20.60

The line sequence exposes the loop’s structure: line 20 is the loop header and appears twice per round — once to take a value from the iterator, once to end the round. Line 21 is the body and runs twice for two measurements.

Stepping has a limit in asynchronous code: when an await expression is stepped over, execution leaves that function and returns to the event loop. Its continuation runs on another turn, once the relevant promise settles. For this reason, in asynchronous flows, placing a breakpoint directly at the point of interest is more effective than stepping.

Asynchronous Call Stack

As shown in the course’s first topic, a callback runs on an empty stack. The diagnostic consequence of this is heavy: an error occurring inside a callback carries, on its stack, no information about who started that job.

Runtimes close this gap with asynchronous call stacks: the chain of functions bound to each other with await is preserved and appears on the error stack marked with async.

function printFrames(error) {
  const frames = error.stack
    .split("\n")
    .slice(1)
    .map((line) => line.trim())
    .filter((line) => !line.includes("node:"))
    .map((line) => line.replace(/\s*\(?file:.*$/, ""))
    .filter((line) => line !== "at async");
  for (const frame of frames) console.log("  ", frame);
}

async function fetchMeasurement(station) {
  await new Promise((resolve) => setTimeout(resolve, 5));
  throw new Error(`station did not respond: ${station}`);
}

async function readMeasurement(station) {
  return await fetchMeasurement(station);
}

async function processStream(stations) {
  const results = [];
  for (const station of stations) {
    results.push(await readMeasurement(station));
  }
  return results;
}

function fetchWithCallback(station, callback) {
  setTimeout(() => callback(new Error(`station did not respond: ${station}`)), 5);
}

try {
  await processStream(["A1"]);
} catch (error) {
  console.log("await chain —", error.message);
  printFrames(error);
}

fetchWithCallback("B2", (error) => {
  console.log("callback chain —", error.message);
  printFrames(error);
});
await chain — station did not respond: A1
   at fetchMeasurement
   at async readMeasurement
   at async processStream
callback chain — station did not respond: B2
   at Timeout._onTimeout

The difference is striking. In the await chain, the error’s path is visible start to finish: which station was being read, which stream was being processed, all readable. In the callback chain, there is only the timer frame; the information of who started the request has been lost.

The file paths and the runtime’s internal frames in the output were filtered out here for printing; in a real stack, these are present too and vary by machine. Interface-based tools do the same filtering in the form of “hide library code.”

The design rule that follows adds one more to the reasoning from the course’s callback lesson: code written with promises and await is not just more readable, it is more diagnosable.

Debugging by Printing

Printing to the console looks primitive, but in some cases it is the only workable method: when the problem occurs rarely, is in production, or stopping itself changes the behavior.

Two improvements make it a serious tool. First, records carrying context: which station, which attempt, which idempotency key. A context-free “got here” line does not say which request it belongs to in an asynchronous flow.

Second, using a logpoint: it is set up like a breakpoint, but instead of stopping it prints an expression and continues. This way an observation can be added without changing or restarting the code.

Summary

  • The debugging interface offers stopping the program, reading variables, and advancing step by step; interface-based tools use this same interface too.
  • A breakpoint can be bound to a condition; watch expressions are evaluated at the stopping moment, and the call stack shows where the call came from.
  • Stepping commands are step over, step into, and step out; when await is stepped over, execution returns to the event loop.
  • An asynchronous call stack preserves the chain bound by await; in callback-based code, this chain is lost.
  • Debugging by printing is a serious tool once records carry context and logpoints are used.

Course Wrap-Up

The course started with a single question: how does a single-threaded language meet waiting? The answer was built step by step.

First the model was built: user code runs on a single thread, to completion; the event loop takes one job from the task queue on every turn and then completely drains the microtask queue. This rule was the reasoning behind every output order in the course.

Then the abstraction was built: the gap the callback left — the inability to compose, manually carrying the error path, contract violations — was closed with the promise’s state machine. Combinators gave waiting its structure, async and await fixed how it reads; both were layers sitting on top of the promise, not a new asynchrony mechanism.

Next, control was built: the abort signal, timeouts, and retry patterns. The distinction here is the course’s most practical lesson — cutting the wait does not cut the job.

Finally, observation was built: the reachability-based memory model, leak diagnosis by snapshot comparison, main-thread profiling, and state inspection with breakpoints. The measurement stream example went from a single timer callback to a stream that is cancelable, has a timeout, is resilient, and leaves no leak.

What remains is how this code gets packaged and shipped. The next course — Modules, Tooling, and the Ecosystem — takes up module systems, how packages get resolved, dependency versioning, and build tools. This course touched on module format a few times and left the detail for there; its turn has come.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close