DEV Community

Cover image for Why a scratchpad should run your code in a separate process
JSLab
JSLab

Posted on

Why a scratchpad should run your code in a separate process

A scratchpad runs code you haven't finished writing. That's the job. Which means it runs a lot of code like this:

let i = 0;
while (i < 10) {
  console.log(i);
  // forgot i++
}
Enter fullscreen mode Exit fullscreen mode

Where that code runs decides whether you lose a second or lose your editor.

Option 1: run it in the same thread

The simplest scratchpad is eval (or new Function) in the page that hosts the editor. It's fast and it's one line. It's also a trap.

JavaScript runs one thing at a time per thread. While that while loop spins, nothing else on the thread runs: not your Stop button's click handler, not the editor's rendering, not the code that would show an error. Paste it into a browser console and that tab freezes until the browser offers to kill it.

There's no try/catch for "this never returns".

Option 2: a worker

A Web Worker (or a Node worker_thread) runs on its own thread, so the UI stays responsive, and the host can stop it:

const worker = new Worker(new URL("./run.js", import.meta.url), { type: "module" });
setTimeout(() => worker.terminate(), 2000);
Enter fullscreen mode Exit fullscreen mode

Better. But a worker still lives in your process. It shares the process's environment, its working directory and its fate: if something in it brings the process down, the editor goes too.

Option 3: a separate process

Give each run its own OS process and the operating system becomes the boundary. Here's a minimal runner that works in Node and in Bun:

import { execFile } from "node:child_process";

function runFile(file, ms = 2000) {
  return new Promise((resolve) => {
    execFile(process.execPath, [file], { timeout: ms }, (error, stdout, stderr) => {
      resolve({ stdout, stderr, timedOut: error?.killed ?? false });
    });
  });
}

// never-ends.js contains: while (true) {}
await runFile("./never-ends.js");
// { stdout: "", stderr: "", timedOut: true }
Enter fullscreen mode Exit fullscreen mode

timeout makes Node send the child a signal when time's up. What that buys you:

  1. Kill always works. A process can be killed from outside no matter what it's doing. No cooperation needed.
  2. Crashes stay contained. A segfault in a native addon, an out-of-memory, a process.exit() in a library: the child dies, the host carries on.
  3. Every run starts clean. No globals left over from the last run, no timers still firing from three edits ago.
  4. Real process semantics. process.env, process.cwd(), stdout, exit codes and signals behave like they do in production, because it is a process.

The cost is start-up time on every run. That's why the runtime you pick matters: one that starts fast and runs TypeScript without a separate compile step keeps the loop tight.

How JSLab does it

JSLab, a JavaScript & TypeScript scratchpad for macOS, uses option 3. In a Bun tab, each run is a separate Bun process, using the Bun that ships inside the app. In a browser tab, each run is a freshly reloaded page. Either way, a crash or an infinite loop never takes the editor down.

On top of the process boundary:

  • ⇧⌘R (Stop) stops a run gracefully, and kills it after 500 ms if it doesn't respond. ⌥⌘R (Kill) kills it at once.
  • If a run stops answering for 3 seconds, JSLab asks: Kill or Wait.
  • Loop protection stops a runaway while loop like the one above after 2,000 iterations, with an error naming its line, before you even reach for Stop.

JSLab stopping a runaway loop: the code "let i = 0; while (i < 10) { i = i * 2; }" above, and below it the error "RangeError: Potential infinite loop: exceeded 2000 iterations (line 3). Disable Loop Protection or raise the limit in Settings → Advanced.", tagged to line 3.

The details of Stop, Kill and loop protection are in today's feature note.

JSLab launches Nov 15. How runs work: https://js-lab.app/docs/editor

Top comments (0)