Run dev servers

A dev server is a task with persistent: true on it. This guide covers starting every one the repo declares, starting just one, making a server wait for a build, and stopping everything.

For what persistent changes elsewhere, see Persistent tasks. For how the output mode is picked, see Output and logging.

Declare a dev task

Set two fields on the task in the root tasks map. persistent: true says it never exits and its output streams live. cache: false documents the intent; a persistent task is never cached whatever cache says.

{
  "tasks": {
    "dev": {
      "persistent": true,
      "cache": false
    }
  }
}

The examples below use this three-workspace repo: a shared package built once, a web app whose dev server needs it built first, and a service with its own watch command.

{
  "workspaces": [
    { "name": "ui-kit", "path": "packages/ui-kit" },
    { "name": "web", "path": "apps/web", "dependsOn": ["ui-kit"] },
    { "name": "api", "path": "services/api", "auto": false, "scripts": { "dev": "cargo watch -x run" } }
  ],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", "target/**"]
    },
    "dev": {
      "dependsOn": ["^build", "build"],
      "persistent": true,
      "cache": false
    }
  }
}

web and ui-kit are ordinary JavaScript workspaces, so their build and dev commands come from package.json scripts. A Rust driver has no notion of a watch mode, so api declares its dev command explicitly. A scripts entry always beats what a driver would have inferred.

A workspace driven by a task runner needs neither: just, task, turbo, nx, rake, and mix run the tasks the repo declared to them, so dev in a turbo.json workspace resolves to turbo run dev on its own. See Persistent tasks.

Check what resolves before you start anything:

$ lattice run dev --dry-run
❖ lattice  dry run · dev
  → ui-kit:build  npm run build
  → web:build  npm run build
  → api:dev  cargo watch -x run
  → web:dev  npm run dev

ui-kit has no dev script, so it contributes no dev node. An auto workspace with no command for a task is skipped silently. A workspace declared "auto": false is stricter: with no matching entry in its own scripts map, the run fails and names the fix.

Start every dev server at once

lattice run dev

Because the run pulls in a persistent task, Lattice uses raw line-by-line output for the whole run, even at a terminal. The live display repaints in place and cannot render a process that streams indefinitely. Every line is prefixed workspace:task:, and with several servers up their output interleaves in arrival order:

api:dev: running
ui-kit:build: running
api:dev: listening on 0.0.0.0:8080
ui-kit:build: done (0.23s)
web:build: running
web:build: done (0.19s)
web:dev: running
web:dev: 
web:dev: > web@1.0.0 dev
web:dev: > echo 'Local:   http://localhost:4321/' && sleep 30
web:dev: 
web:dev: Local:   http://localhost:4321/
lattice: 4 tasks, 0 cached, 0 failed, 4.00s

api:dev starts immediately because api has no build to wait on. web:dev waits for ui-kit:build and web:build. Neither dev task settles into a done line: a persistent task gets a line only if it exits.

Start one dev server only

lattice run dev --filter web

--filter matches a workspace’s name as a substring, and its matches are the roots of the run. Everything they depend on comes along, tagged (dependency). Everything else is dropped, not started and not waited on:

$ lattice run dev --filter web --dry-run
❖ lattice  dry run · dev
  → ui-kit:build (dependency)  npm run build
  → web:build  npm run build
  → web:dev  npm run dev

A pattern that matches nothing is a no-op, not a failure:

$ lattice run dev --filter zzz
lattice: no workspaces matched filter 'zzz'.

See Selecting what runs.

Make a server wait for a build

To make a dev server wait on a shared package’s build output, or on its own codegen step, put dependsOn on the dev task:

{
  "tasks": {
    "dev": {
      "dependsOn": ["^build", "build"]
    }
  }
}

^build runs build in every workspace this one dependsOn. The bare build runs this workspace’s own. Both are ordinary edges and must finish, or restore from cache, before the dev server starts. See Task graph.

The edge only runs in that direction. A persistent task must be a leaf, so nothing may depend on it:

Error: task 'dev' in workspace 'web' is persistent, so no other task may depend on it

If another task needs what a dev server produces, depend on the build step that produces it instead.

Stop everything

Once the rest of the graph has drained and the servers are up, lattice run waits, streaming their output. One Ctrl-C takes every still-running server down. On Unix, Lattice sends SIGTERM to each one’s whole process group, waits up to five seconds, then sends SIGKILL to whatever is left. A server launched through a shell dies with everything it spawned.

The first press is enough, whenever it lands. Lattice listens for the signal from the moment the run starts, so a Ctrl-C while the builds ahead of the servers are still going is the one that ends the run. It used to take a second press in that case. The wait began listening only once the graph had drained, and by then the first signal had come and gone.

SIGTERM ends the run the same way. A CI runner sends SIGTERM to cancel a job.

A second Ctrl-C exits at once, without finishing the teardown. It exists for the case the first press cannot cover: a launcher that starts its own children in a fresh process group leaves them outside the group Lattice signals, and one of them may hold the task’s output open. Lattice waits half a second for that output to close, warns that a process was left running, and exits anyway — so the second press is a way out of a wait, not something a normal run needs. tauri dev is the common launcher of this kind; it starts its beforeDevCommand in a group of its own.

An interrupted run exits 130, the shell’s convention for SIGINT, so a CI runner can tell a cancelled run from a failed one. The summary line still prints, and a server killed this way is not reported as having exited or as having failed.

Two other things end the run. One is the last persistent task exiting on its own. Nothing is left to wait for, so Lattice prints the summary without a Ctrl-C. The other is any task in the run failing. The failure stops the scheduler, and Lattice takes down the servers already up rather than leave them holding the run open with nothing left to schedule. A failed run used to wait for a signal only a person was going to send.

Read a server that exits

Lattice watches each server it starts. One that quits gets a line saying so, and a non-zero exit counts as a failed task:

$ lattice run dev --filter api
api:dev: running
api:dev: port 8080 already in use
api:dev: EXITED (code 1) after 0.01s
lattice: 1 tasks, 0 cached, 1 failed, 0.01s

The run reported it and exited non-zero instead of sitting there as if the server were up.

An exit code of 0 is reported the same way in lowercase and does not fail the run:

api:dev: running
api:dev: done
api:dev: exited (code 0) after 0.01s
lattice: 1 tasks, 0 cached, 0 failed, 0.01s

That is usually the sign that a command marked persistent: true was never a server to begin with.

With more than one server up, one exiting does not disturb the others. Their output keeps streaming and the run keeps waiting. The failure shows up in the summary at the end.

A command that never exits without persistent: true

Leave persistent: true off a task whose command does not exit, such as a dev server or a --watch build, and Lattice treats it as an ordinary task and waits on an exit code that never comes. The run prints workspace:task: running and then nothing, indefinitely. That costs you two things.

The task holds its concurrency permit for as long as the process runs, because the permit is released when the task finishes. With a small --concurrency, one forgotten dev-server task can starve everything waiting for a slot.

Its output stays collapsed. A run that pulls in a persistent task switches to raw, line-by-line output, where a server’s lines appear as it prints them. A task Lattice does not know is persistent does not trigger the switch, so the live display holds the task’s output behind a spinner that never resolves. Pass -v to see the output.

Ctrl-C still tears the process group down, because Lattice listens for the signal for the whole run and not only while a persistent task is up. The server does not outlive the run. What you lose is the run ever finishing on its own.

If a command is meant to keep running, mark it persistent: true. If it is meant to exit, leave persistent unset. The scheduler treats every task as one or the other.