Skip to content

devlanes is in beta and not ready for production use.Report an issue

Lane lifecycle

devlanes start brings up the lane of the current worktree: infra, setup, lane.create and lane.start tasks, services, then after: services tasks. It waits until every started unit is ready or one fails. Units that already run are left alone, so starting twice is safe, and Ctrl-C detaches without stopping anything. devlanes start api web starts only those services and devlanes start --profile e2e the units of a profile.

devlanes stop without unit names runs the lane’s lane.stop tasks and stops its processes; containers keep running (infra). devlanes stop api stops one service. devlanes restart api stops a unit and starts it again with the environment of the shell that ran restart. devlanes restart without unit names restarts the whole lane: it stops the lane as devlanes stop does, without forgetting what ran, then starts the same services and profiles again with infra, setup and tasks, as a plain start does. devlanes reload applies only what an edit of the project file changed (editing the project file).

A service that exits shows as failed and stays that way until you start or restart it. devlanes never restarts a process by itself.

The first plain devlanes start of a lane starts its default profile, or every service with autostart left on when the project has no default profile. After that, a plain start brings back what the lane ran: the services you started (by profile or by name) and did not stop, with the same profiles. That is what makes a reboot or a daemon crash cost one command. On a lane that still runs, a plain start also starts what its profiles select and is missing, such as a service you just added. devlanes stop without unit names forgets what ran, so the next plain start uses the default profile again; stopping one service only leaves that service out. Set start.restore: false in ~/.devlanes/config.yaml to always start the default profile.

A saved profile or service that the project file no longer has is left out of a plain start, lane resume and daemon restart, with a saved_profile_removed or saved_service_removed warning that names it; the rest of the lane starts. A profile typed on the command line still fails when it does not exist.

The daemon never stores a shell’s environment, so after a reboot, a crash or devlanes daemon stop each lane needs one start from a shell. A plain devlanes start brings back its own lane with the current shell’s environment and names the other lanes that ran. devlanes lane resume uses the current shell’s environment for every lane that was not stopped by hand and starts the services each one ran. devlanes daemon restart asks first when processes are running, then restarts the daemon and starts the services that ran again (restarting the daemon). lane resume and daemon restart bring services back even with start.restore: false.

devlanes daemon restart and R on the TUI’s daemon page start the new daemon first. It waits for the old one, which runs the lane.stop tasks and stops every process, then takes over. The restart is done as soon as the new daemon answers. The services that ran then start again as ordinary starts, so devlanes status, devlanes wait and every open TUI show them starting like any other start. Quitting the TUI or pressing Ctrl-C after the old daemon was asked to stop does not interrupt the restart.

The new daemon and the services it starts again get the environment of whoever asked for the restart: the shell that ran devlanes daemon restart, or the shell that opened the TUI for R. That is how a changed PATH, SSH_AUTH_SOCK or other variable reaches the daemon and its services, so restart from a shell that has it.

On a terminal, daemon stop and daemon restart show each step as it happens: closing box connections, waking idle lanes, running lane.stop tasks, stopping processes (with how many are left), saving state, then for a restart the new daemon’s start and the lanes it starts again, with how many services each. daemon.log records how long each shutdown step took and names any process that took more than 2 seconds to stop. While the daemon is away, every view of the TUI shows the same steps with the daemon log below them, and its devlanes logo turns grey while the daemon stops or starts and red while it is stopped or not answering.

devlanes reads a lane’s config again when any file it was loaded from changes: the project file, the files it extends, their local files (the primary worktree’s copy too, when the lane falls back to it) and the .env its expressions read. An edit never touches running units. Instead, each running unit shows how it differs from what the file would start now, and a missing one that the file would start shows too:

  • changed, with the fields that differ: run, cwd, ports, pty, ready, stop, files (a file devlanes writes for the unit, such as a preset’s config overlay) and env NAME for each variable, named without its value;
  • removed: it runs and the file no longer has it;
  • added: the lane’s profiles select it now, did not at the lane’s last whole start, and it is not running. A service you left out with devlanes start api never counts as added.

devlanes status says config changed on the lane with what differs and the command that applies it, and --json has each unit’s drift. In the TUI the lane and each unit that differs are marked, and the lane’s details pane lists them, such as api: run, env API_URL and worker: removed.

devlanes reload applies only that: it stops removed units, restarts changed ones, starts added ones and leaves the rest running. devlanes reload api applies api’s part alone. When the infra settings changed since the lane’s last whole start, or the edit needs ports the lane’s block cannot hold, reload restarts the whole lane instead, since infra comes up before services and the block only moves while nothing runs. A removed unit keeps running until a reload, a whole-lane restart or a stop. r on a lane in the TUI reloads when the lane has changes and restarts the whole lane otherwise; on a unit it restarts that unit.

While units run, a project file that stops loading, through a typo, a rebase conflict, deleting it or a project: that names another project, leaves the lane’s hostnames and routes on the last config that loaded, so the running services stay reachable. The lane shows the file’s error, and start, restart and reload refuse with it until the file loads again. devlanes debug config prints the merged config the lane loads, with expressions left unevaluated, and the files it came from.

A project is its project: name. Renaming it makes a new project with its own lanes, domains, ports and containers, and the next command in the worktree uses that one. The old project’s lanes stay with the reason config_names_other_project, and what they ran keeps running until you stop it. devlanes lane gc lists them along with lanes whose project file was deleted, and devlanes lane gc --yes cleans those that run nothing: it frees their ports, removes their own containers and deletes their data folders. Their lane.clean tasks and infra.teardown do not run, since the worktree’s file no longer declares them for that project.

Checking out a branch whose project file names another project moves the worktree’s lane the same way. Switching back finds the old lane as it was, with its index, ports and saved services, as long as lane gc --yes did not clean it in between.

Worktrees on other branches can hold other versions of the project file, and each lane runs its own. lanes.primary and lanes.max belong to the whole project and come from the primary worktree’s file. The shared infra (infra.compose, infra.project_name and containers that are not per_lane) is one set of containers for every lane, so the lane that starts last applies its version. A lane whose file sets any of these differently gets a project_setting_ignored or infra_spec_conflict warning naming the settings. devlanes lane gc keeps the port offsets of every service that some worktree’s file still has.

When a worktree’s folder is deleted, with git worktree remove after a merge or otherwise, the daemon sees it within a few seconds and applies lanes.removed_worktrees from that worktree’s project file. The default, stop, stops what the lane runs and keeps the lane: its index, port block, data folder and per-lane containers stay, and devlanes status shows it as worktree missing. devlanes lane gc lists the lanes whose folder is gone, devlanes lane gc --yes cleans those that run nothing, and devlanes lane clean <lane> --yes cleans one.

To have the daemon clean these lanes by itself:

lanes:
removed_worktrees: clean

A running lane is then cleaned as soon as its folder is gone, and an idle one once the folder has been gone for 5 seconds, so git worktree move keeps it. Cleaning stops the lane’s processes, removes its own containers and data folder, and frees its index and ports; its logs stay until log retention removes them. lane.clean tasks and infra.teardown need the worktree to run in, so they are skipped, and what they would drop from shared containers, such as a per-lane database in a shared Postgres, stays.

A worktree removed while the daemon is not running is noticed at the daemon’s next start. Its lane is kept even with clean, since the daemon can no longer read the deleted file to see that setting; devlanes lane gc --yes cleans it.

services:
storybook:
run_script:
file: packages/ui/package.json
name: storybook
hosts: "storybook.${{ lane.domain }}"
autostart: false
stripe-listen:
run: stripe listen --forward-to ${{ services.api.url }}/webhooks/stripe
autostart: false

devlanes start storybook or the TUI starts them; globs in profiles skip them, names and tags include them. The proxy answers their hostnames with a page that names the start command while they are stopped. autostart also takes an expression, such as ${{ lane.index == 0 }}, evaluated per lane.

tasks:
install:
run: [pnpm, install, --frozen-lockfile]
when: lane.create
timeout: 10m
codegen:
run_script:
file: package.json
name: codegen
when: lane.create
migrate:
run_script:
file: apps/api/package.json
name: db:migrate
when: lane.start
build-libs:
run_script:
file: package.json
name: build:libs
when: lane.start
profiles: [default, e2e]
seed:
run_script:
file: tools/seed/package.json
name: start
when: lane.create
after: services
timeout: 15m
reset-db:
steps: [infra.teardown.database, infra.setup.database, migrate, seed]
confirm: Drops ${{ lane.db_name }} and re-seeds it
prisma-login:
run: pnpm prisma login
interactive: true

Start order: infra, setup, lane.create tasks (until they succeed once per lane), lane.start tasks (every start, in file order), services together, after: services tasks. An infra.setup entry or lane.create task that you edit after it succeeded runs again at the next start; devlanes compares its declaration in the file, so an edit to a variable it reads does not count. A failed task before services fails the start and prints its log tail. devlanes run reset-db runs a task by hand; devlanes run --list shows them. Tasks with confirm need --yes without a terminal, and interactive tasks get your terminal and never run from a hook.

lane.stop tasks run before the processes stop when the whole lane stops: devlanes stop without unit names, devlanes daemon stop and daemon restart. lane.clean tasks run on devlanes lane clean, after the processes stop and before infra.teardown.

New worktrees have no node_modules, so install with when: lane.create is what makes git worktree add && devlanes start work with nothing else.

A lane that runs services and has no activity for lanes.throttle_after (default 5m) is throttled, and after lanes.freeze_after (default 15m) it is frozen. Both apply to the whole lane: activity on any of its services thaws every service of the lane and its own containers. The idle state in devlanes status is a different thing and means a lane or service that runs nothing.

A throttled lane’s processes run at background priority and keep serving requests. A frozen lane’s processes are all suspended, and its own containers are paused with docker pause (or docker compose pause for per_lane Compose services). Shared infra containers are never paused. Memory stays allocated while a lane is frozen, and devlanes does not trim it on any OS. Ports, PIDs, routes and the lane’s environment are kept. The kernel keeps accepting connections to a frozen server’s ports, and they are answered after the thaw. A request through the proxy to a frozen lane waits up to 10 seconds for the thaw before it is forwarded.

Both settings live in the user config:

~/.devlanes/config.yaml
lanes:
throttle_after: 5m
freeze_after: 30m

0s turns either one off. When both are set, throttle_after must be shorter than freeze_after. The daemon reads them when it starts, like the other lanes.* settings, so a change takes effect after devlanes daemon restart.

Each of these resets the lane’s timer, and thaws the lane when it is throttled or frozen:

  • a request through the devlanes proxy to one of the lane’s hostnames;
  • bytes the client sends on an open WebSocket or other upgraded connection through the proxy;
  • a new TCP connection to any of the lane’s ports: service ports, literal ports and the ports of the lane’s containers. This covers database clients, gRPC, direct http://127.0.0.1:PORT calls and the tunnels of a machine connected with devlanes remote connect;
  • the lane’s services together using more than 25% of one CPU core in a metrics sample;
  • a devlanes command that needs the lane’s processes: start, stop, restart, run, exec, lane clean, infra up and debug thaw;
  • devlanes attach with a terminal you can type into, and typing into an attached terminal.

Commands that only read, such as status, logs (with or without -f), url, env and wait, do not count, and neither does the TUI showing a unit’s output without you typing into it. infra down only touches shared containers and does not thaw a lane. New connections are checked every 5 seconds, and every half second while a lane is frozen, so a direct connection thaws a frozen lane within about half a second.

A lane is never throttled or frozen while a start, stop, clean or infra change runs on it, while a task, setup step or exec unit runs, while it has orphaned processes from a previous daemon, while it runs no service, or while a service with keep_awake: true runs.

services:
queue-worker:
run_script:
file: apps/worker/package.json
name: dev
keep_awake: true

While a service with keep_awake: true runs, its whole lane is never throttled or frozen. Set it on workers, queue consumers, schedulers and tunnels such as stripe listen, which do their work without inbound requests and would stall in a frozen lane. It takes an expression like other boolean fields (${{ lane.index == 0 }} keeps only the main lane awake), and works in templates and in devlanes.local.yaml.

Throttling sets every process of the lane to PRIO_DARWIN_BG: the lowest CPU priority, throttled disk and network I/O, and only the efficiency cores on Apple Silicon. Freezing sends SIGSTOP to each service’s process group and to the descendants devlanes tracks that left the group, and thawing sends SIGCONT. macOS compresses or swaps out the memory of suspended processes on its own when memory runs short.

Linux lanes are not throttled, because lowering a process’s priority in a way that can be undone needs privileges or cgroups. A lane goes straight from running to frozen after freeze_after, and throttle_after has no effect. Freezing works as on macOS, with SIGSTOP and SIGCONT. The kernel swaps out a frozen lane’s memory only when swap or zswap is configured.

Throttling puts every process of the lane in efficiency mode, what Task Manager’s “Efficiency mode” does: IDLE_PRIORITY_CLASS plus EcoQoS. Freezing calls NtSuspendProcess on every process in the service’s job object, and thawing calls NtResumeProcess. devlanes does not trim the memory of a frozen lane; Windows trims the working sets of idle processes itself when memory is short. Windows containers that use process isolation cannot be paused, so in a lane that has them only the processes freeze.

devlanes status shows frozen or throttled in place of ready or running for each service of the lane, and the lane line says how long it has been that way, such as frozen 12m. JSON keeps state: ready, since a frozen service still answers once thawed, and adds power (throttled or frozen) to each unit and power: {level, since_ms, last_activity_ms, last_activity} to the lane. Agent output keeps ready as well and adds power=frozen or power=throttled.

In the TUI a frozen service shows a blue ❄ in place of its dot and a throttled one a dim z (~ and z with ASCII glyphs), and the lane row says frozen or throttled after @N.

devlanes debug freeze freezes the current lane now, whatever the timers say, which is the quickest way to check that a project still works after a freeze. devlanes debug thaw resumes it. Both take a lane reference. Freeze refuses a lane that is not running, is busy, has a keep_awake service running or has orphaned processes.

A thaw unpauses the lane’s own containers before it resumes the services, so they find their database ready. If resuming fails, the lane stays marked frozen or throttled and the next activity tries again. A start or infra up also unpauses the lane’s containers first, so a lane whose earlier unpause failed still starts.

Stopping, restarting or cleaning a frozen lane thaws it first, so stop signals and lane.stop tasks behave as usual. devlanes daemon stop and daemon restart thaw lanes before stopping them. If the daemon crashes while a lane is frozen, the next daemon resumes the processes left over, on macOS, Linux and Windows, and unpauses the lane’s containers when it starts. daemon.exit_when_idle treats a frozen lane as running, so the daemon stays up to thaw it.

File watchers catch up on changes made while the lane was frozen. On Linux, a large checkout during a freeze can overflow the inotify queue and a watcher may miss changes; restart the service if it looks stale. Timers that came due while the lane was frozen fire at once. The other side may have closed database and other connections a service held, and most connection pools open new ones. A process you stopped with Ctrl-Z in devlanes attach resumes along with the lane.

Every byte a client sends on an open WebSocket counts as activity, so an open browser tab whose dev client sends messages keeps its lane awake. The Next.js dev client pings every few seconds and some HMR clients ping too; closing the tab lets the lane freeze. A dev server whose file watcher polls, or one that stays busy for another reason, can keep the lane above the CPU threshold. A running keep_awake service, orphaned processes from a previous daemon and a running task also keep a lane awake.

devlanes debug events -f shows lane.idle when a lane is throttled or frozen, lane.thawed with the kind of activity that thawed it (proxy, connection, cpu, command, or stop when a stop, restart or clean thawed it first), and lane.idle_failed when throttling or freezing failed and was undone. A thaw that failed shows as lane.idle_failed with level normal and code thaw_failed. devlanes status --json shows the lane’s power.last_activity while it is throttled or frozen.

To turn both off:

Terminal window
devlanes config set lanes.freeze_after 0s
devlanes config set lanes.throttle_after 0s
devlanes daemon restart