Skip to main content
When multiple users — or multiple CI jobs — share a Lager Box, locks prevent two callers from clobbering each other. Lager provides two locking mechanisms:
  1. Automatic test / admin locklager python and the box-mutating admin commands (lager install, lager uninstall, lager update, lager install-wheel) reserve the box for the lifetime of the command.
  2. User locklager boxes lock explicitly reserves a box until you unlock it.

Automatic test lock

Every lager python <runnable> invocation automatically acquires the box lock at start and releases it at end. This includes failures, Ctrl+C, crashes, and signal-killed runs. The lock is released through a finally block, a signal handler, an atexit net, and (worst case) a server-side TTL reap.

Which commands auto-lock

Read-only commands (lager hello, lager boxes list, lager boxes lock / unlock itself, net-listing paths like lager supply --box X with no subcommand, status / dry-run paths, etc.) do not acquire the auto-lock. The v0.12–0.13.3 design used a shared decorator on every command, and v0.13.4 reverted it. This implementation instead uses the same TTL + heartbeat + atexit infrastructure as lager python. That avoids the three corner cases that motivated the revert. See Backward compatibility below for the full history. The lock identity is CI-aware so concurrent test runs in CI mutually exclude correctly. Holder formats: The :<pid> part names the process that took the lock. It does not separate holders when the CLI compares them, as the next section describes.

How a command recognizes its own lock

Each lager command runs as a new process, so its holder string ends in a different pid. The CLI therefore compares holders by scope. It removes a :<digits> part that sits at the end of the holder, or just before @. As a result, a later command in the same CI job treats the auto-lock of that job as its own. It does not release that lock. Holders that differ in run, attempt, job or runner keep distinct scopes, so they still exclude each other. One rule decides whether a lock is yours. The lock check before a command, the auto-lock acquire and lager boxes unlock all use it. A lock is yours when its holder agrees with one of these:
  • Your holder string, or its scope.
  • Your plain user name. A lager boxes lock reservation by the runner account of a CI job is an example.
  • Your user name, when the holder has the form <origin>:<id>:<name>:<email> and <email> is that user name. The comparison ignores case. Another tool, such as a control plane dashboard, can write a holder in this form.
The box compares exact strings. Thus lager boxes unlock reads the lock first. If the lock is yours, unlock sends the holder string that the box stored. lager boxes unlock does not use the scope of a ci:generic:<host> holder. All CI jobs on that host share that scope. With the scope, unlock can release the live lock of a different job. Such a lock needs its exact holder, or --force.
The Jenkins, Drone and Bitbucket scopes identify a build on a host, not a stage of the build. Parallel stages of one build on the same host thus share a lock. Unlock in one stage can release the lock of a different stage.

Collision behavior

When lager python tries to acquire a lock that another holder owns:
  • On dev: prints an error and exits 1 immediately (no waiting).
  • In CI: waits up to LAGER_LOCK_WAIT seconds (default 1800, i.e. 30 min), polling every 2s, and only fails if the wait elapses. This lets matrix jobs queue against the same self-hosted box.
If you locked the box as yourself with lager boxes lock before you ran lager python on your computer, the CLI sees the lock as already-ours. It does not release the lock on exit, so your explicit reservation survives the test. The same applies under CI. The auto-lock holder is a ci: string, but a lager boxes lock reservation by the user name of the runner account is also yours. The command uses that reservation and does not release it.

TTL & heartbeat

Each test lock is written with ttl_seconds: 1800 and refreshed every 60 seconds by a background heartbeat thread inside the CLI. The TTL is not a cap on test runtime — as long as the heartbeat keeps refreshing last_heartbeat, the lock stays valid indefinitely. What the TTL actually bounds is the worst-case stale-lock dwell time after a CLI crash. If your laptop loses network, or the CI runner is hard-killed, the box reaps the lock once last_heartbeat + ttl_seconds falls in the past. Another caller therefore waits at most one TTL.

--detach hands the lock to the box

lager python script.py --detach has no CLI left to hold its lock — the client is answered and goes away, which is the point. So the box takes the lock’s lifetime over: it heartbeats while the detached job runs and releases when the job ends, however it ends. Nothing has to be unlocked by hand.
The box can only ever touch the lock the CLI handed it, and only a lock the run freshly acquired is handed over. A lager boxes lock reservation that a detached run merely resumed is never handed over and never released. That reservation is the whole point of taking one. Against a box too old to know about the handoff, the CLI keeps the previous behavior. That is an eternal hold, with the old “release with lager boxes unlock” message. The CLI arms the lapse TTL only after the box confirms that it heartbeats. So a newer CLI can never leave a lock that expires underneath a job that still runs.

Escape hatches

User lock

A user lock is an explicit, persistent reservation you place on a box. Unlike the automatic test lock, user locks never expire — you must manually unlock when you’re done. Use cases:
  • Reserving a box for an extended debugging session.
  • Preventing others from using a box during maintenance.
  • Claiming a box when you’re not actively running a command.

lager boxes lock

Options:
  • --box (required) — name of the box to lock.
  • --user — username to lock as (useful when running inside Docker where the user is otherwise root).
The box compares holder names as exact strings. The CLI uses the rule in How a command recognizes its own lock. Another tool, such as a control plane dashboard, can lock this box and write the holder as <origin>:<id>:<name>:<email>. lager boxes shows that holder as <name>. To make such a lock yours, set your user name to that email with lager defaults add --user or LAGER_USER. Example:
If the box is already locked by another user:

lager boxes unlock

Options:
  • --box (required) — name of the box to unlock.
  • --user — the holder to unlock as, for a lock recorded under a name other than your user name.
  • --force — force unlock even if the box was locked by another user (use this to clear a stale lager boxes lock left by a teammate).
Without --force, unlock reads the lock first. If the lock is yours by the rule above, unlock sends the holder string that the box stored. Examples:

Management operations skip the lock

The following sub-commands of lager python are management operations on already-running processes and intentionally skip both lock checks and auto-acquire:
  • lager python --kill <ID>
  • lager python --kill-all
  • lager python --reattach <ID>
  • lager python --continue <ID>
  • lager python --console <ID>
This is what lets you Ctrl+C a hung detached script and immediately --kill it without first having to fight an unrelated user lock.

lager boxes shows lock holders

When boxes are locked, lager boxes shows an extra column:
CI holders are formatted human-readably (e.g. github lager run 9182 job test on runner-3) rather than printed as raw colon-delimited strings. Holders that another service writes are shortened the same way: The same short form appears in the lager boxes lock and unlock messages, and in the error that a locked box gives. A holder in any other form is shown unchanged.

CI workflow example

The always-on auto-lock + CI auto-wait combination means a CI matrix job needs no special invocation:
Each item POSTs /lock with its CI holder. Whichever item loses the race waits up to 30 minutes for the winner to finish, and then retries. No lager boxes lock call needed.

Backward compatibility

  • lager boxes lock and lager boxes unlock behave exactly as before. The CLI now sends holder_type: "user" + ttl_seconds: null on the wire. A legacy client — an older CLI against the new box server — still gets the same eternal-lock behavior. The server treats a payload with neither field as legacy, and applies the same defaults.
  • _check_box_lock (the read-only lock check that already gates every command in resolve_and_validate_box) is unchanged.

How this differs from v0.13.0 – v0.13.3 (removed in v0.13.4)

v0.13.0 added an ephemeral “command-in-progress” lock that fired on every CLI command via a shared decorator, gated by a --force-command flag. v0.13.4 removed it because three corner cases were unfixable in that design: --force-command is gone. Collision policy is structured (fail-fast in dev, queue in CI) and the existing lager boxes lock --force is the escape hatch when you genuinely need to override.