0030: Scratchpads run in a fresh process

On this page

Status: accepted

Context

A scratchpad runs code you write inside a site, with the app booted: its models, database, services and helpers, for any kind of site Short Leash serves (Laravel, WordPress, Symfony, Craft, Django, Flask, Rails, Node, Bun, Deno, Go). The code has to run with the site's own runtime and environment, show what came back (the value of the last line, what it printed, its dumps and queries, an error with its line), be stoppable, and never leave a stale app in memory after you edit the project's code.

Decision

  • A new process for each run. leashd starts the site's runtime in the site's folder with the environment its processes get (Engine::site_env), plus LEASH_SCRATCH_* variables naming the code, the events file, the project, the driver and the site's address. Nothing carries over between runs, so the code that runs is always the code on disk. The process gets its own process group: Stop and the time limit (60 seconds) kill the group. A run never listens on a port.
  • A driver per kind of site (leash_core::scratchpads::drivers), picked from what Short Leash detected for the site, with a plain one per language for everything else. Each runner boots its framework the way its own commands do:
    • PHP (runner.php): Laravel's console kernel, wp-load.php (with SAVEQUERIES), Symfony's kernel, Craft's console app, or Composer's autoloader.
    • Python (runner.py, through uv run as the site runs): django.setup() or a Flask app context.
    • Ruby (runner.rb): config/environment inside Rails' executor, or the bundle.
    • Node, Bun and Deno: the code arrives on stdin as a module that imports support.mjs first, so imports resolve from the project folder (tsx, when the project has it, adds its tsconfig paths).
    • Go: the code becomes a main package inside the module that exists only in a go run -overlay, so the project's folder is never written to.
  • The last line's value is shown, as a REPL does: PHP rewrites the last top-level statement to a return with PHP's own tokenizer; Python splits the last expression off with ast; Ruby's eval returns it; JavaScript and TypeScript use a scanner in leash-core (scratchpads::js). Every rewrite keeps your line numbers.
  • Runners report on a side channel. Each writes one JSON object per line to a file in a folder only you can open (0700, files 0600): the context, the value (with a table for lists of records, and JSON), dumps, queries, an error with the line in your code, and timing. Printed output stays plain stdout and stderr. leashd reads both about every 50 ms and sends them on as numbered scratchpadRun batches, like terminals' output (0026). For PHP, the dumps prepend (0011) writes dump() and Laravel's queries to that file while a scratchpad runs, instead of to the Dumps screen.
  • What's kept. Each scratchpad's title and code, and its last 50 runs (when, how it went, the code), are in Short Leash's database (0029); a site keeps up to 100 scratchpads. What a run produced stays in memory, at most 2 MB and 2,000 items (past that only the error and timing are kept), for the 20 scratchpads run most recently, until the next run or a restart: it can hold anything the app returned. The run's folder is removed when it ends.
  • The same runs for the CLI and AI tools: leash scratchpad run and the MCP run_code tool run code once in a site and return what came back.

Alternatives

  • A long-lived REPL per scratchpad (PsySH, IRB, a Node REPL) keeps variables between runs and saves the boot, but holds classes and modules from before you edited them, needs a restart to see new code, leaks memory over a day, and can't be stopped without losing everything.
  • Parsing stdout for results would need markers in the output that echo and print could produce too; a side channel keeps the two apart.
  • Writing the code into the project (a file under the project for Node or Go to import from) would show up in git status and editor watchers.

Consequences

  • Every run pays its framework's boot: about 60 ms for a Laravel app on a recent Mac, more for Rails or a large Symfony app.
  • Changes a run makes are real: rows written stay written. Rolling back database changes after a run is left for later.
  • Each framework's boot can change with its releases; the drivers are small and each has a test.
  • Node's scratchpads resolve imports from the project folder, but tsconfig paths need tsx in the project, and NestJS and AdonisJS run as plain Node in the project, without booting the app.