site preview: the site says how to preview itself, the agent runs it, the hypervisor forwards one port #240

Closed
opened 2026-09-27 23:45:15 +01:00 by allod-agent · 4 comments
Member

Let an agent in a dev VM serve the site it is working on, and let the owner look at it in a browser on the hypervisor with one command, for a site built with any tool, with nothing left running or listening when either side stops.

This replaces allod/tools#234 and allod/tools#238. The first attempt, allod/tools#236, was closed unmerged at about 2,200 lines; what went wrong is recorded there and turned into constraints under "What not to build" below.

Primary goals:

  • The site says how to preview itself — a site repository's flake exposes an app named preview. allod runs it and knows nothing about zola, Vite, or any other generator.
  • The agent owns the server — it starts, stops and reads the preview inside the VM, as a named user service. The owner never handles the server's mechanics.
  • The hypervisor only forwards a port — one foreground command opens the site's port over its own SSH connection and Ctrl-C closes it.
  • One port per site, written down once — the repository registry carries it, both machines read it from there, and two sites cannot collide.
  • Nothing to clean up — no process allod has to watch, no state file, no socket file, no shared connection, no open firewall port. systemd and ssh own every lifetime.
  • Small — the whole feature fits the size budget below, and a change that cannot is a sign the design is being left.

How it is used

Who, where Command Effect
Agent, in the VM, inside the site checkout allod site preview Starts the site's preview app as a user service on the site's port. Already running is success
Agent, in the VM allod site preview --stop Stops the service and everything it started
Owner, on the hypervisor allod site view <site> Makes sure the preview is started in the VM, prints http://127.0.0.1:<port>, and forwards that port until Ctrl-C
Owner, on the hypervisor allod site preview --stop --vm <name> <site> Stops the preview in the VM from outside

The browser is on the hypervisor. A browser on a third machine, and a VM reached over a real network, are on the roadmap and are not designed for here; nothing below rules them out.

The contract with a site repository

A previewable site's flake has apps.<system>.preview. The app:

  • listens on the TCP port in ALLOD_PREVIEW_PORT, on the address in ALLOD_PREVIEW_INTERFACE, which is always the literal 127.0.0.1 (localhost resolves to ::1 only on these machines);
  • serves everything the browser needs on that one port, reload channel included;
  • runs in the foreground and exits when sent SIGTERM;
  • reloads the browser when files change, by whatever means the tool has.

A flake with no such app is refused with a message that names the missing app and points at the examples. The documentation carries three worked examples, each a few lines of Nix: a zola site, a Vite site, and a directory of plain HTML served by a file server that reloads. Zola before 0.23 serves reload on a second port, 1024; a zola site meets the contract by pinning a newer zola.

The existing zola sites stop previewing until they add the app. The owner has accepted that break.

In the VM: allod site preview

allod site preview [--port <n>] [--stop] [--vm <name>] [<site>]

  • The site is the checkout found by walking up from the current directory to site.toml, or, when <site> is given, the checkout the registry names for that id.

  • The port is the site's preview_port in the registry. --port overrides it. A site with neither is refused by name.

  • Start is one command, and allod exits once it has reported the outcome:

    systemd-run --user --collect --quiet --unit allod-preview-<slug> --working-directory <root> -E ALLOD_PREVIEW_PORT=<port> -E ALLOD_PREVIEW_INTERFACE=127.0.0.1 -- nix run <root>#preview

    <slug> is the registry id, or the checkout path relative to $HOME, reduced to [A-Za-z0-9._-].

  • After starting, allod waits a bounded time for one of two things and reports which happened: the port accepts a connection (prints the address, exit 0), or the unit is no longer active (prints the tail of its log, exit non-zero). If neither happens in time it says the preview is still starting and names the command that follows its log. A start that reports success for a server that failed is the false green to avoid.

  • A unit that is already active is success: print the address and exit 0.

  • --stop is systemctl --user stop allod-preview-<slug>. A preview that is not running is reported as such and is not an error.

  • --vm <name> runs the same command inside that VM over a one-shot connection, ssh -o ControlMaster=no -o ControlPath=none -- <name> allod site preview ..., with every interpolated value shell-quoted.

  • The agent reads the server's output with journalctl --user -u allod-preview-<slug>, and finds out an edit broke the site by running the site's own build. allod adds no log file and no status command.

The flags --interface, --base-url, --drafts and --open, and the pass-through after --, are removed. They were zola's.

On the hypervisor: allod site view

allod site view [--vm <name>] [--port <n>] <site>

  1. Read the site's port from the registry. Find the VM: the one machine whose repository list in vm-specs.json contains the site, or --vm when none or several do.

  2. Run allod site preview <site> in the VM as above, so viewing a site that is not being served starts it. If that fails, stop and show why.

  3. Print http://127.0.0.1:<port>.

  4. Replace itself with:

    ssh -N -o ControlMaster=no -o ControlPath=none -o ExitOnForwardFailure=yes -o ServerAliveInterval=5 -o ServerAliveCountMax=3 -L 127.0.0.1:<port>:127.0.0.1:<port> -- <vm>

allod does not check whether the local port is free: ssh refuses a taken port by itself and says so. A stopped or unreachable VM is refused by ssh's own error; allod never starts a VM.

The connection is never a shared one. Measured on allod/tools#234: a forward added to a shared connection outlives the command that asked for it, and the usual way to release it closes every other session riding that connection.

Stopping the forward leaves the server running

Ctrl-C on the hypervisor closes the forward and nothing else. The server in the VM runs until the agent stops it, the owner stops it with --stop --vm, or the VM shuts down. Decided this way for three reasons:

  • The agent uses the server too, to fetch pages and check its own work. The owner closing a browser window must not break a task in progress.
  • When VMs are reached over a real network, a dropped link must not take the server down; reconnecting resumes where it was.
  • Tying the two lifetimes together is what needs a terminal on the VM side, detection of a dead client, and a process for allod to supervise. That was a third of allod/tools#236.

What it costs: a server can stay up in the VM after the owner has stopped looking. It listens on loopback only, under a known unit name, on a port reserved for that site, and starting the same preview again finds it and succeeds.

What ends what

Event Result
Ctrl-C on view, or its terminal closes ssh exits, the hypervisor port closes. Server unaffected
ssh is killed outright The same; the kernel closes the port
--stop, from either machine systemd stops the unit and every process in its control group
The server crashes The unit goes inactive and is collected. The forward stays up and the browser shows a connection error until the preview is started again
The VM shuts down The unit is stopped with the VM. ssh on the hypervisor exits at once or within about 15 seconds
The network between the machines drops silently ssh exits within about 15 seconds. Server unaffected

The registry field

inventory/scripts/repositories.json gains one optional field per repository, preview_port. Its presence means the repository is a site that can be previewed.

  • Not a separate site registry: it would repeat each repository's identity in a second list to keep in step.
  • Not a boolean: site.toml in the repository already marks it as a site, and a flag carries no fact a consumer can use.
  • A port per site gives each site its own browser origin, so one site's cached data and service workers do not leak into another's.
  • The registry validation in allod/inventory flake.nix refuses a preview_port that is not an integer from 1024 to 65535, and refuses two repositories with the same one. A sabotage fixture shows each refusal, beside the ones that check is already tested with.
  • Consumers that read the registry by field name are unaffected; confirm it for cmd/allod/registry.go, git-hooks/setup-tracked-hooks, and the nexus scripts that read repositories.json.

Measured, and to measure before writing code

Measured in a dev VM (systemd user manager running, Linger=no, Nix 2.34.8): a transient user unit started with the systemd-run line above finds nix, receives the -E variables and the working directory; systemctl --user stop takes its child processes down with it; a second start under the same unit name is refused by systemd; nothing is left listed after a stop.

Measured on allod/tools#234 and allod/tools#236: a development server that binds localhost listens on ::1 only; zola 0.21 serves reload on 1024 and zola 0.23.6 on the page port.

Not yet measured. Each is one plain command, and each is run before the code that depends on it is written; a result that contradicts the design changes the design here, in this issue, first.

Question Why it matters
Does systemctl --user work in a non-interactive ssh <vm> <command>? --vm, and step 2 of view
With Linger=no, what happens to the unit when the user's last login session in the VM ends? A preview started from the hypervisor while no agent is logged in may be stopped at once. If so, say so in the help text; do not turn lingering on to hide it
Does nix run of a real site's app work inside the unit: network, $HOME, caches? The unit's environment is not a login shell's
Does Vite's reload work through the one forwarded port? The one-port contract
Does ssh -N with the options above exit when the VM shuts down, and within about 15 seconds of a silent drop? The table above

What not to build

Each line is something allod/tools#236 built.

  • No knowledge of any generator in allod: no generator flags, no generator's port numbers.
  • No web server inside allod.
  • No process that allod starts and then watches. It replaces itself, or hands the process to systemd and exits.
  • No second way to do one thing: one kind of preview, one way to reach the VM.
  • No port probing, no choosing of free ports, no query for which ports to forward.
  • No heartbeat, no terminal allocation, no handling of Ctrl-Z.
  • No comments that restate the code.

If the implementation appears to need one of these, stop and write the reason on this issue before building it.

Size budget

Part Budget
Go for preview and view, tests excluded 250 lines
Go tests 300 lines
Documentation, the three examples included 150 lines

cmd/allod/site_preview.go on master is 159 lines, most of them comments about zola; it is replaced, not extended.

Validation

  • Go tests at the exec boundary, in the sandbox: the exact argument list of the systemd-run, systemctl and both ssh invocations; port and VM lookup from fixture registry files; every refusal (no app, no port, unknown site, no VM or several, a value that begins with -); shell quoting of a site id with a space.
  • A script run by hand in a dev VM against real Nix and real systemd, with a fixture site whose preview app is a few lines of shell: start serves a page; start again is success; stop leaves no process of the unit and the port binds again; an app that exits at once is reported as failed with its log; a flake without the app is refused.
  • One sabotage for each: make start report success without waiting, and show the script goes red.
  • Manual, by the owner on the hypervisor, since no agent can run it: view shows the page; an edit by the agent reloads the browser without a refresh; then each row of "What ends what", checking after each that the hypervisor port is free and that the VM is in the state the table says.

Order of work

  1. The unmeasured questions above.
  2. allod/inventory: the preview_port rule in the registry validation, its sabotage fixtures, and a line in the README. Refs.
  3. allod/tools: the two commands, tests, the by-hand script, documentation with the three examples. Closes.
  4. The site repositories add their preview app and their registry entry gets a port. These are the owner's repositories and are not part of the pull requests above.

Scope

In scope: allod site preview and allod site view in allod/tools, the registry field and its validation in allod/inventory, and the documentation for both.

Out of scope, with what would bring each in:

  • A shared helper that builds a preview app for a zola or Vite site, beside lib.buildZolaSite in allod/archetypes. It comes in when a third site repository copies the same few lines.
  • A browser on a machine other than the hypervisor, and VMs reached over a network. They come in with the remote-VM work on the roadmap.
  • Running the forward in the background. It comes in if holding a terminal open proves to be a nuisance in use.
  • Several previews of the same site at once.
  • allod site deploy, check and config, which do not change.
Let an agent in a dev VM serve the site it is working on, and let the owner look at it in a browser on the hypervisor with one command, for a site built with any tool, with nothing left running or listening when either side stops. This replaces allod/tools#234 and allod/tools#238. The first attempt, allod/tools#236, was closed unmerged at about 2,200 lines; what went wrong is recorded there and turned into constraints under "What not to build" below. Primary goals: - **The site says how to preview itself** — a site repository's flake exposes an app named `preview`. `allod` runs it and knows nothing about zola, Vite, or any other generator. - **The agent owns the server** — it starts, stops and reads the preview inside the VM, as a named user service. The owner never handles the server's mechanics. - **The hypervisor only forwards a port** — one foreground command opens the site's port over its own SSH connection and Ctrl-C closes it. - **One port per site, written down once** — the repository registry carries it, both machines read it from there, and two sites cannot collide. - **Nothing to clean up** — no process `allod` has to watch, no state file, no socket file, no shared connection, no open firewall port. systemd and `ssh` own every lifetime. - **Small** — the whole feature fits the size budget below, and a change that cannot is a sign the design is being left. ### How it is used | Who, where | Command | Effect | | --- | --- | --- | | Agent, in the VM, inside the site checkout | `allod site preview` | Starts the site's `preview` app as a user service on the site's port. Already running is success | | Agent, in the VM | `allod site preview --stop` | Stops the service and everything it started | | Owner, on the hypervisor | `allod site view <site>` | Makes sure the preview is started in the VM, prints `http://127.0.0.1:<port>`, and forwards that port until Ctrl-C | | Owner, on the hypervisor | `allod site preview --stop --vm <name> <site>` | Stops the preview in the VM from outside | The browser is on the hypervisor. A browser on a third machine, and a VM reached over a real network, are on the roadmap and are not designed for here; nothing below rules them out. ### The contract with a site repository A previewable site's flake has `apps.<system>.preview`. The app: - listens on the TCP port in `ALLOD_PREVIEW_PORT`, on the address in `ALLOD_PREVIEW_INTERFACE`, which is always the literal `127.0.0.1` (`localhost` resolves to `::1` only on these machines); - serves everything the browser needs on that one port, reload channel included; - runs in the foreground and exits when sent SIGTERM; - reloads the browser when files change, by whatever means the tool has. A flake with no such app is refused with a message that names the missing app and points at the examples. The documentation carries three worked examples, each a few lines of Nix: a zola site, a Vite site, and a directory of plain HTML served by a file server that reloads. Zola before 0.23 serves reload on a second port, `1024`; a zola site meets the contract by pinning a newer zola. The existing zola sites stop previewing until they add the app. The owner has accepted that break. ### In the VM: `allod site preview` `allod site preview [--port <n>] [--stop] [--vm <name>] [<site>]` - The site is the checkout found by walking up from the current directory to `site.toml`, or, when `<site>` is given, the checkout the registry names for that id. - The port is the site's `preview_port` in the registry. `--port` overrides it. A site with neither is refused by name. - Start is one command, and `allod` exits once it has reported the outcome: `systemd-run --user --collect --quiet --unit allod-preview-<slug> --working-directory <root> -E ALLOD_PREVIEW_PORT=<port> -E ALLOD_PREVIEW_INTERFACE=127.0.0.1 -- nix run <root>#preview` `<slug>` is the registry id, or the checkout path relative to `$HOME`, reduced to `[A-Za-z0-9._-]`. - After starting, `allod` waits a bounded time for one of two things and reports which happened: the port accepts a connection (prints the address, exit 0), or the unit is no longer active (prints the tail of its log, exit non-zero). If neither happens in time it says the preview is still starting and names the command that follows its log. A start that reports success for a server that failed is the false green to avoid. - A unit that is already active is success: print the address and exit 0. - `--stop` is `systemctl --user stop allod-preview-<slug>`. A preview that is not running is reported as such and is not an error. - `--vm <name>` runs the same command inside that VM over a one-shot connection, `ssh -o ControlMaster=no -o ControlPath=none -- <name> allod site preview ...`, with every interpolated value shell-quoted. - The agent reads the server's output with `journalctl --user -u allod-preview-<slug>`, and finds out an edit broke the site by running the site's own build. `allod` adds no log file and no status command. The flags `--interface`, `--base-url`, `--drafts` and `--open`, and the pass-through after `--`, are removed. They were zola's. ### On the hypervisor: `allod site view` `allod site view [--vm <name>] [--port <n>] <site>` 1. Read the site's port from the registry. Find the VM: the one machine whose repository list in `vm-specs.json` contains the site, or `--vm` when none or several do. 2. Run `allod site preview <site>` in the VM as above, so viewing a site that is not being served starts it. If that fails, stop and show why. 3. Print `http://127.0.0.1:<port>`. 4. Replace itself with: `ssh -N -o ControlMaster=no -o ControlPath=none -o ExitOnForwardFailure=yes -o ServerAliveInterval=5 -o ServerAliveCountMax=3 -L 127.0.0.1:<port>:127.0.0.1:<port> -- <vm>` `allod` does not check whether the local port is free: `ssh` refuses a taken port by itself and says so. A stopped or unreachable VM is refused by `ssh`'s own error; `allod` never starts a VM. The connection is never a shared one. Measured on allod/tools#234: a forward added to a shared connection outlives the command that asked for it, and the usual way to release it closes every other session riding that connection. ### Stopping the forward leaves the server running Ctrl-C on the hypervisor closes the forward and nothing else. The server in the VM runs until the agent stops it, the owner stops it with `--stop --vm`, or the VM shuts down. Decided this way for three reasons: - The agent uses the server too, to fetch pages and check its own work. The owner closing a browser window must not break a task in progress. - When VMs are reached over a real network, a dropped link must not take the server down; reconnecting resumes where it was. - Tying the two lifetimes together is what needs a terminal on the VM side, detection of a dead client, and a process for `allod` to supervise. That was a third of allod/tools#236. What it costs: a server can stay up in the VM after the owner has stopped looking. It listens on loopback only, under a known unit name, on a port reserved for that site, and starting the same preview again finds it and succeeds. ### What ends what | Event | Result | | --- | --- | | Ctrl-C on `view`, or its terminal closes | `ssh` exits, the hypervisor port closes. Server unaffected | | `ssh` is killed outright | The same; the kernel closes the port | | `--stop`, from either machine | systemd stops the unit and every process in its control group | | The server crashes | The unit goes inactive and is collected. The forward stays up and the browser shows a connection error until the preview is started again | | The VM shuts down | The unit is stopped with the VM. `ssh` on the hypervisor exits at once or within about 15 seconds | | The network between the machines drops silently | `ssh` exits within about 15 seconds. Server unaffected | ### The registry field `inventory/scripts/repositories.json` gains one optional field per repository, `preview_port`. Its presence means the repository is a site that can be previewed. - Not a separate site registry: it would repeat each repository's identity in a second list to keep in step. - Not a boolean: `site.toml` in the repository already marks it as a site, and a flag carries no fact a consumer can use. - A port per site gives each site its own browser origin, so one site's cached data and service workers do not leak into another's. - The registry validation in `allod/inventory` `flake.nix` refuses a `preview_port` that is not an integer from 1024 to 65535, and refuses two repositories with the same one. A sabotage fixture shows each refusal, beside the ones that check is already tested with. - Consumers that read the registry by field name are unaffected; confirm it for `cmd/allod/registry.go`, `git-hooks/setup-tracked-hooks`, and the `nexus` scripts that read `repositories.json`. ### Measured, and to measure before writing code Measured in a dev VM (systemd user manager running, `Linger=no`, Nix 2.34.8): a transient user unit started with the `systemd-run` line above finds `nix`, receives the `-E` variables and the working directory; `systemctl --user stop` takes its child processes down with it; a second start under the same unit name is refused by systemd; nothing is left listed after a stop. Measured on allod/tools#234 and allod/tools#236: a development server that binds `localhost` listens on `::1` only; zola 0.21 serves reload on `1024` and zola 0.23.6 on the page port. Not yet measured. Each is one plain command, and each is run before the code that depends on it is written; a result that contradicts the design changes the design here, in this issue, first. | Question | Why it matters | | --- | --- | | Does `systemctl --user` work in a non-interactive `ssh <vm> <command>`? | `--vm`, and step 2 of `view` | | With `Linger=no`, what happens to the unit when the user's last login session in the VM ends? | A preview started from the hypervisor while no agent is logged in may be stopped at once. If so, say so in the help text; do not turn lingering on to hide it | | Does `nix run` of a real site's app work inside the unit: network, `$HOME`, caches? | The unit's environment is not a login shell's | | Does Vite's reload work through the one forwarded port? | The one-port contract | | Does `ssh -N` with the options above exit when the VM shuts down, and within about 15 seconds of a silent drop? | The table above | ### What not to build Each line is something allod/tools#236 built. - No knowledge of any generator in `allod`: no generator flags, no generator's port numbers. - No web server inside `allod`. - No process that `allod` starts and then watches. It replaces itself, or hands the process to systemd and exits. - No second way to do one thing: one kind of preview, one way to reach the VM. - No port probing, no choosing of free ports, no query for which ports to forward. - No heartbeat, no terminal allocation, no handling of Ctrl-Z. - No comments that restate the code. If the implementation appears to need one of these, stop and write the reason on this issue before building it. ### Size budget | Part | Budget | | --- | --- | | Go for `preview` and `view`, tests excluded | 250 lines | | Go tests | 300 lines | | Documentation, the three examples included | 150 lines | `cmd/allod/site_preview.go` on `master` is 159 lines, most of them comments about zola; it is replaced, not extended. ### Validation - Go tests at the exec boundary, in the sandbox: the exact argument list of the `systemd-run`, `systemctl` and both `ssh` invocations; port and VM lookup from fixture registry files; every refusal (no app, no port, unknown site, no VM or several, a value that begins with `-`); shell quoting of a site id with a space. - A script run by hand in a dev VM against real Nix and real systemd, with a fixture site whose `preview` app is a few lines of shell: start serves a page; start again is success; stop leaves no process of the unit and the port binds again; an app that exits at once is reported as failed with its log; a flake without the app is refused. - One sabotage for each: make start report success without waiting, and show the script goes red. - Manual, by the owner on the hypervisor, since no agent can run it: `view` shows the page; an edit by the agent reloads the browser without a refresh; then each row of "What ends what", checking after each that the hypervisor port is free and that the VM is in the state the table says. ### Order of work 1. The unmeasured questions above. 2. `allod/inventory`: the `preview_port` rule in the registry validation, its sabotage fixtures, and a line in the README. `Refs`. 3. `allod/tools`: the two commands, tests, the by-hand script, documentation with the three examples. `Closes`. 4. The site repositories add their `preview` app and their registry entry gets a port. These are the owner's repositories and are not part of the pull requests above. ### Scope In scope: `allod site preview` and `allod site view` in `allod/tools`, the registry field and its validation in `allod/inventory`, and the documentation for both. Out of scope, with what would bring each in: - A shared helper that builds a `preview` app for a zola or Vite site, beside `lib.buildZolaSite` in `allod/archetypes`. It comes in when a third site repository copies the same few lines. - A browser on a machine other than the hypervisor, and VMs reached over a network. They come in with the remote-VM work on the roadmap. - Running the forward in the background. It comes in if holding a terminal open proves to be a nuisance in use. - Several previews of the same site at once. - `allod site deploy`, `check` and `config`, which do not change.
Author
Member

Two of the five open measurements are done, in the dev VM, and both agree with the design. The other three need the hypervisor and are waiting for the owner; the code for --vm and view is not being written until the first of them is in.

Measured

Dev VM, systemd 260.4, Nix 2.34.8, user manager running, Linger=no. Every start used the issue's systemd-run line unchanged.

Question Result
Does nix run of a site's app work inside the unit: network, $HOME, caches? Yes. In the unit HOME, USER, XDG_RUNTIME_DIR and the full profile PATH are set, the working directory is the site root, https://cache.nixos.org answers, and substituter downloads and a derivation build ran inside the unit. A cold start of a small fixture served its page after about 12 seconds
Does Vite's reload work through the one forwarded port? Yes, as far as a WebSocket client can show. Vite 7.3.6 listens on the page port only; its client takes the reload address from the page's own host and port; {"type":"full-reload"} arrived through a relay of that single port after a source file was edited

The relay stood in for ssh -L: a separate network namespace whose only way out was one unix socket carrying 127.0.0.1:<port>, checked from inside on every run (the relayed port answered, the VM's sshd did not). A client receiving the reload message is not a browser reloading; that remains the owner's manual check.

The same was measured for the other two documented examples. Zola 0.23.6 and live-server 0.11.1 each hold one listener, 127.0.0.1:<port>, and each delivered its reload message through the relay. For all three, systemctl --user stop ended every process of the unit and the port could be bound again.

Found on the way

  • systemctl --user stop on a unit that is not loaded prints Unit ... not loaded and exits 5. --stop therefore asks is-active first, so that a preview that is not running is reported as such and is not an error.
  • nix run on a site checkout with no flake.lock writes one into the checkout and adds it to the git index. A site with a committed lock file is not affected.
  • The nixpkgs revision allod/tools pins already carries zola 0.23.6, so a zola site on a current pin meets the contract without anything extra.
  • A Vite preview app has to get Vite from npm: nixpkgs has nodejs but not the JavaScript tool. The shortest honest example runs npm install at start, which writes about 21 MB of node_modules/ into the site checkout and needs the network when npm's cache is cold. The documentation will say so beside the example.
  • Vite moves to the next free port when the one it was given is taken, unless it is started with --strictPort. allod waits only for the site's own port, so a server that moves would be reported as failed or still starting while it serves somewhere the forward does not reach.
  • live-server's reload signal is a WebSocket text frame of length zero. websocat in line mode does not show it, so a re-measurement with that tool alone would wrongly conclude reload is broken.

Clarifications to the text above, made while briefing the implementation

Each is a choice where the issue left room. None adds anything from "What not to build".

Where Choice
"A unit that is already active is success" An active unit goes through the same bounded wait as a fresh start. One that is serving passes at once. One that is active but still building is reported as still starting, not as serving
The outcome when neither thing happens in time Exit 3, to tell "still starting" from "failed", which is exit 1. The wait is 60 seconds
"A flake with no such app is refused" One nix eval of apps.<system>.preview before the start
The contract with a site repository One line added to the documentation: the app fails when its port is taken and does not move to another

Waiting for the hypervisor

Question State
Does systemctl --user work in a non-interactive ssh <vm> <command>? Not measured. The VM's own key is not authorized to log in to the VM, and the agent's session is an interactive one with a terminal, so nothing inside the VM answers this. The same run should show whether allod is on the path of such a command
With Linger=no, what happens to the unit when the last login session ends? Not measured. It needs a moment when nobody is logged in to the VM, which ends the agent's session too
Does ssh -N exit when the VM shuts down, and within about 15 seconds of a silent drop? Not measured. Already part of the owner's manual validation
Two of the five open measurements are done, in the dev VM, and both agree with the design. The other three need the hypervisor and are waiting for the owner; the code for `--vm` and `view` is not being written until the first of them is in. ### Measured Dev VM, systemd 260.4, Nix 2.34.8, user manager running, `Linger=no`. Every start used the issue's `systemd-run` line unchanged. | Question | Result | | --- | --- | | Does `nix run` of a site's app work inside the unit: network, `$HOME`, caches? | Yes. In the unit `HOME`, `USER`, `XDG_RUNTIME_DIR` and the full profile `PATH` are set, the working directory is the site root, `https://cache.nixos.org` answers, and substituter downloads and a derivation build ran inside the unit. A cold start of a small fixture served its page after about 12 seconds | | Does Vite's reload work through the one forwarded port? | Yes, as far as a WebSocket client can show. Vite 7.3.6 listens on the page port only; its client takes the reload address from the page's own host and port; `{"type":"full-reload"}` arrived through a relay of that single port after a source file was edited | The relay stood in for `ssh -L`: a separate network namespace whose only way out was one unix socket carrying `127.0.0.1:<port>`, checked from inside on every run (the relayed port answered, the VM's sshd did not). A client receiving the reload message is not a browser reloading; that remains the owner's manual check. The same was measured for the other two documented examples. Zola 0.23.6 and live-server 0.11.1 each hold one listener, `127.0.0.1:<port>`, and each delivered its reload message through the relay. For all three, `systemctl --user stop` ended every process of the unit and the port could be bound again. ### Found on the way - `systemctl --user stop` on a unit that is not loaded prints `Unit ... not loaded` and exits 5. `--stop` therefore asks `is-active` first, so that a preview that is not running is reported as such and is not an error. - `nix run` on a site checkout with no `flake.lock` writes one into the checkout and adds it to the git index. A site with a committed lock file is not affected. - The nixpkgs revision `allod/tools` pins already carries zola 0.23.6, so a zola site on a current pin meets the contract without anything extra. - A Vite preview app has to get Vite from npm: nixpkgs has `nodejs` but not the JavaScript tool. The shortest honest example runs `npm install` at start, which writes about 21 MB of `node_modules/` into the site checkout and needs the network when npm's cache is cold. The documentation will say so beside the example. - Vite moves to the next free port when the one it was given is taken, unless it is started with `--strictPort`. `allod` waits only for the site's own port, so a server that moves would be reported as failed or still starting while it serves somewhere the forward does not reach. - live-server's reload signal is a WebSocket text frame of length zero. `websocat` in line mode does not show it, so a re-measurement with that tool alone would wrongly conclude reload is broken. ### Clarifications to the text above, made while briefing the implementation Each is a choice where the issue left room. None adds anything from "What not to build". | Where | Choice | | --- | --- | | "A unit that is already active is success" | An active unit goes through the same bounded wait as a fresh start. One that is serving passes at once. One that is active but still building is reported as still starting, not as serving | | The outcome when neither thing happens in time | Exit 3, to tell "still starting" from "failed", which is exit 1. The wait is 60 seconds | | "A flake with no such app is refused" | One `nix eval` of `apps.<system>.preview` before the start | | The contract with a site repository | One line added to the documentation: the app fails when its port is taken and does not move to another | ### Waiting for the hypervisor | Question | State | | --- | --- | | Does `systemctl --user` work in a non-interactive `ssh <vm> <command>`? | Not measured. The VM's own key is not authorized to log in to the VM, and the agent's session is an interactive one with a terminal, so nothing inside the VM answers this. The same run should show whether `allod` is on the path of such a command | | With `Linger=no`, what happens to the unit when the last login session ends? | Not measured. It needs a moment when nobody is logged in to the VM, which ends the agent's session too | | Does `ssh -N` exit when the VM shuts down, and within about 15 seconds of a silent drop? | Not measured. Already part of the owner's manual validation |
Author
Member

A question for the owner, found while checking the first half of the implementation: a site that an agent is editing in a worktree is not the site view will show.

What happens

Branch work on any repository is done in a worktree under ~/changes, made by allod change begin -d. The registry names a site's shared checkout, under ~/work. The issue finds the site by walking up from the current directory to site.toml and takes the port and the unit name from the registry entry for that directory. A worktree's directory has no entry. So today, in a worktree:

  • allod site preview finds no port and refuses, unless the agent passes --port;
  • with --port, the unit is named after the worktree's path, not after the site's id;
  • allod site view <site> on the hypervisor starts the preview by id, which serves the shared checkout on the default branch. If the agent's preview already holds the site's port, that start fails and view stops. If it does not, the owner sees the default branch and not the work in progress.

An agent that edits the shared checkout in place is not affected.

Proposed

When the directory found has no registry entry, ask git which repository the worktree belongs to (git rev-parse --git-common-dir) and look that checkout up instead. The worktree then previews under the site's own id: the same port, the same unit name allod-preview-<slug>, with the worktree as the working directory. view finds that unit active, starts nothing, and forwards to it. One preview per site still holds, which the issue already lists as the limit ("several previews of the same site at once" is out of scope).

What it costs: one git call and about six lines of Go, inside the size budget. What it changes in the issue's text: "the site is the checkout found by walking up" gains "or the repository that checkout is a worktree of".

Not built. It waits for the owner's answer.

Also found, being fixed

  • A preview_port of the wrong type on one registry entry made every registry lookup in allod fail, for commands that have nothing to do with sites, because the decoder rejected the whole file. The decoder will accept any value there, and allod site preview will refuse by name a port that is present and is not an integer from 1024 to 65535.
  • The checkout path was placed unescaped into the Nix expression that looks for the app. A site root with a character outside letters, digits and /._+- will be refused before anything is run.

Size

The in-VM command as first written is 204 lines of Go against the 250 for preview and view together, and its tests are 375 against 300. Both are being cut to leave room for view and --vm; the budget is not being raised.

A question for the owner, found while checking the first half of the implementation: a site that an agent is editing in a worktree is not the site `view` will show. ### What happens Branch work on any repository is done in a worktree under `~/changes`, made by `allod change begin -d`. The registry names a site's shared checkout, under `~/work`. The issue finds the site by walking up from the current directory to `site.toml` and takes the port and the unit name from the registry entry for that directory. A worktree's directory has no entry. So today, in a worktree: - `allod site preview` finds no port and refuses, unless the agent passes `--port`; - with `--port`, the unit is named after the worktree's path, not after the site's id; - `allod site view <site>` on the hypervisor starts the preview by id, which serves the shared checkout on the default branch. If the agent's preview already holds the site's port, that start fails and `view` stops. If it does not, the owner sees the default branch and not the work in progress. An agent that edits the shared checkout in place is not affected. ### Proposed When the directory found has no registry entry, ask git which repository the worktree belongs to (`git rev-parse --git-common-dir`) and look that checkout up instead. The worktree then previews under the site's own id: the same port, the same unit name `allod-preview-<slug>`, with the worktree as the working directory. `view` finds that unit active, starts nothing, and forwards to it. One preview per site still holds, which the issue already lists as the limit ("several previews of the same site at once" is out of scope). What it costs: one `git` call and about six lines of Go, inside the size budget. What it changes in the issue's text: "the site is the checkout found by walking up" gains "or the repository that checkout is a worktree of". Not built. It waits for the owner's answer. ### Also found, being fixed - A `preview_port` of the wrong type on one registry entry made every registry lookup in `allod` fail, for commands that have nothing to do with sites, because the decoder rejected the whole file. The decoder will accept any value there, and `allod site preview` will refuse by name a port that is present and is not an integer from 1024 to 65535. - The checkout path was placed unescaped into the Nix expression that looks for the app. A site root with a character outside letters, digits and `/._+-` will be refused before anything is run. ### Size The in-VM command as first written is 204 lines of Go against the 250 for `preview` and `view` together, and its tests are 375 against 300. Both are being cut to leave room for `view` and `--vm`; the budget is not being raised.
Author
Member

The owner ran the first hypervisor measurement and answered the two open questions, so --vm and view are now being written.

Measured on the hypervisor

Each line was run as ssh -o ControlMaster=no -o ControlPath=none -- allod-dev <command>, without a terminal.

Command in the VM Printed
systemctl --user is-system-running running
systemd-run --user --collect --quiet --unit allod-preview-q1 -- sleep 600 nothing
systemctl --user is-active allod-preview-q1, over a second connection active
allod site --help the usage text of the allod installed in the VM
systemctl --user stop allod-preview-q1 nothing

So systemctl --user works in a non-interactive ssh <vm> <command>, a unit started over one connection is still there for the next, and allod is on the path of such a command. Other sessions of the same user were open in the VM at the time, so this says nothing about what happens when the last session ends; that question stays open.

The VM answered with the old preview. view on the hypervisor runs allod site preview <site> in the VM, so the VM needs the new build before the hypervisor's view can work.

Decided by the owner

  • A site edited in a worktree previews under the site's own id. When the directory found has no registry entry, allod asks git once, locally, which repository the worktree belongs to, and uses that repository's entry. Condition: one git call, nothing over the network.
  • The size budget is an estimate and may be exceeded. The line count is still to be kept down.

One consequence for the order of steps

With a worktree previewing under the site's id, allod site preview <site> by id can find the unit already running from a worktree while the shared checkout does not have the preview app yet. The check for the app therefore runs only when a start is about to happen; an active unit goes straight to the wait.

Still not measured

Question State
With Linger=no, what happens to the unit when the last login session in the VM ends? Needs a moment when nobody is logged in to the VM. The documentation will say it is not measured
Does ssh -N exit when the VM shuts down, and within about 15 seconds of a silent drop? Part of the owner's manual validation
The owner ran the first hypervisor measurement and answered the two open questions, so `--vm` and `view` are now being written. ### Measured on the hypervisor Each line was run as `ssh -o ControlMaster=no -o ControlPath=none -- allod-dev <command>`, without a terminal. | Command in the VM | Printed | | --- | --- | | `systemctl --user is-system-running` | `running` | | `systemd-run --user --collect --quiet --unit allod-preview-q1 -- sleep 600` | nothing | | `systemctl --user is-active allod-preview-q1`, over a second connection | `active` | | `allod site --help` | the usage text of the `allod` installed in the VM | | `systemctl --user stop allod-preview-q1` | nothing | So `systemctl --user` works in a non-interactive `ssh <vm> <command>`, a unit started over one connection is still there for the next, and `allod` is on the path of such a command. Other sessions of the same user were open in the VM at the time, so this says nothing about what happens when the last session ends; that question stays open. The VM answered with the old `preview`. `view` on the hypervisor runs `allod site preview <site>` in the VM, so the VM needs the new build before the hypervisor's `view` can work. ### Decided by the owner - **A site edited in a worktree previews under the site's own id.** When the directory found has no registry entry, `allod` asks git once, locally, which repository the worktree belongs to, and uses that repository's entry. Condition: one `git` call, nothing over the network. - **The size budget is an estimate and may be exceeded.** The line count is still to be kept down. ### One consequence for the order of steps With a worktree previewing under the site's id, `allod site preview <site>` by id can find the unit already running from a worktree while the shared checkout does not have the `preview` app yet. The check for the app therefore runs only when a start is about to happen; an active unit goes straight to the wait. ### Still not measured | Question | State | | --- | --- | | With `Linger=no`, what happens to the unit when the last login session in the VM ends? | Needs a moment when nobody is logged in to the VM. The documentation will say it is not measured | | Does `ssh -N` exit when the VM shuts down, and within about 15 seconds of a silent drop? | Part of the owner's manual validation |
Author
Member

The command the issue calls allod site preview is allod site serve in the implementation, at the owner's request on allod/tools#242. allod site view keeps its name.

Only the command word changed. Wherever the issue's text uses preview as a noun it still holds: the flake app apps.<system>.preview, the registry field preview_port, ALLOD_PREVIEW_PORT and ALLOD_PREVIEW_INTERFACE, and the unit name allod-preview-<slug>. allod site preview is no longer a command and is not kept as an alias.

The command the issue calls `allod site preview` is `allod site serve` in the implementation, at the owner's request on allod/tools#242. `allod site view` keeps its name. Only the command word changed. Wherever the issue's text uses `preview` as a noun it still holds: the flake app `apps.<system>.preview`, the registry field `preview_port`, `ALLOD_PREVIEW_PORT` and `ALLOD_PREVIEW_INTERFACE`, and the unit name `allod-preview-<slug>`. `allod site preview` is no longer a command and is not kept as an alias.
vnprc closed this issue 2026-09-28 15:32:37 +01:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
allod/tools#240
No description provided.