Skip to main content
Which state a box is in decides whether your next exec starts it, restarts it and reuses its disk, or fails. Two switches then decide how long it lives: auto_remove and detach.

The states

Two transitions are worth committing to memory:
  • Creation is lazy. Constructing a box returns a handle; the microVM comes up on first use. A box that never runs costs nothing but a database row.
  • Stop is not remove. stop() frees the VM but keeps the disk, so restarting reuses everything the box had installed. That is what makes a box a reusable workspace rather than a one-shot container.
Read the current state with await box.info(). info() is async everywhere — SimpleBox.info is async def, and the native Box.info returns an awaitable Future; neither is synchronous. Field names are exact and easy to get wrong.

The main command

Every box has one main command: the entrypoint + cmd it was created with, running as the container init — PID 1 inside the box. It is the process the box exists to run, and the box’s lifetime is tied to it. A main command of sh -c 'echo "pid=$$"; exit 7' prints pid=1, confirming it is init rather than a process alongside one. Three things stop a box, and only one of them is a call you make: Two rules follow:
  • Once the main command has exited, exec is refused: RuntimeError: invalid state: Cannot exec box <id>: it is stopped, and its main command has already exited. Bring the box back with start().
  • You cannot stop the main command from inside the box. kill -TERM 1 is dropped, because PID 1 in a PID namespace ignores signals it has no handler for. That is kernel behavior, not a BoxLite bug. It ends when it exits on its own, or when the host calls stop().
There is no restart policy. A main command that crashes stops the box; nothing restarts it. Supervise from the host — check the state and call start() — or use the pattern below that keeps your workload off the main command. Status flips about a second after the main command exits, so poll it rather than reading it immediately. For how entrypoint and cmd combine into the main command, see Environment and startup.

Exit codes

BoxStateInfo carries pid, running and status — no exit code. To read the main command’s exit code, attach to it. attach() with no argument follows the main command’s session, and its wait() returns the code:
Two constraints shape that example:
  • attach() exists on the box returned by runtime.create(). SimpleBox has no attach(), and neither does the sync API, so reading a main command’s exit code means using Boxlite and BoxOptions directly.
  • Exit codes from exec are separate and easier: ExecResult.exit_code, no attach needed. See Run any language.

Three ways to start work in a box

Which one you pick decides whether the box outlives the work. Most agent work wants the first: the agent is a job, and you still want to read its workspace after it finishes. A long-running service usually wants the second, so that a dead service means a dead box rather than a silent one.

Detached main command

detach=True keeps the box alive after the creating process exits. It rejects the default removal behavior, since a box that removes itself on stop cannot be managed later:
Set auto_delete=0 alongside it, and stop the box yourself when you are done. A local runtime has no idle timeout, so a detached box runs until something stops it.

Walking the states in code

The example below runs standalone: create → exec → stop → remove. auto_remove=False is used to demonstrate the separate stop and remove steps.
Key point: Boxlite is a synchronous context manager (with), whereas a Box’s methods (exec/stop/metrics) are async (require await). box.info() is async too — await it.
Verified: real run prints created: <box-id>, hello from box, exitCode = 0, state = running, stopped, removed.

detach and auto_remove: two independent switches

BoxOptions has two boolean switches that control the lifecycle and do not affect each other: Common combinations:
auto_remove=True together with detach=True is invalid (a detached Box needs manual lifecycle control); setting both fails validation with an error.

Restarting a stopped Box (rootfs reuse)

For an auto_remove=False Box, the disk persists after stop(). Re-acquire a handle and exec again, and the VM restarts reusing the original rootfs:
Note: data written to tmpfs paths such as /tmp does not persist across restarts; only data written to ordinary paths on the rootfs (such as /root) persists.
Verified: real run prints stopped; rootfs preserved then file after restart: data.

reattach: getting a second handle in the same process

runtime.get(id_or_name) returns a new handle to an existing Box; the Box may be running or stopped. When get finds nothing it returns None (it does not raise).
Verified: real run prints via second handle.

Cross-process sharing

One process creates and initializes a Box with detach=True and exits; the Box keeps running, and another process takes it over with runtime.get(box_id).
Important: do not use Boxlite.default() for cross-process scenarios. default() is a process-wide static singleton that keeps holding the runtime lock and does not release it even after a child process exits. Use boxlite.Boxlite(boxlite.Options()) instead to construct a releasable runtime instance.
The following is a self-contained script: the parent forks a child that creates a detached Box, and after the child exits the parent takes it over.
Box state persists in the runtime database, so it can be queried (get_info), taken over (get), and restarted (by exec-ing a stopped Box again) across process boundaries. Same shape in both languages: withDefaultConfig() / Boxlite.default() is the process-wide singleton and must not be used here — use new JsBoxlite({}) / boxlite.Boxlite(boxlite.Options()) for a releasable instance in both processes. Verified: real run (parent spawning a child Node process) prints child created box: <id>, box state in DB: running, hello from parent process, cross-process reattach OK.

Bulk shutdown: runtime.shutdown()

runtime.shutdown(timeout=None) gracefully shuts down every Box under that runtime. timeout is in seconds: None = default 10 seconds, -1 = wait indefinitely. After shutdown, any creation-type operation raises RuntimeError.
Verified: real run prints running boxes before shutdown: 3, shutdown complete, then expected error after shutdown: stopped: Cannot create box: runtime has been shut down.

Parameters and Returns

Note: name is not a BoxOptions field; it is a separate parameter of create(options, name=...) / get_or_create(options, name=...). Once named, you can operate by name with get(name) / remove(name).
* Provide one of image or rootfs_path; constructing with neither raises an error. ** auto_remove=True and detach=True are mutually exclusive (a detached Box needs manual lifecycle management); setting both fails at validation.

Runtime methods (called on a Boxlite instance)

Box methods (async, require await)

BoxInfo / BoxStateInfo (state access)

box.info() returns a BoxInfo; its state field is a BoxStateInfo, and the state string is at state.status:

Troubleshooting

info() returns a coroutine, or refuses to be awaited

info() differs between the two layers, and each one fails differently when you guess wrong.

Calling remove() on a Box / using list()

Removal and listing both happen on the runtime, with the method names remove/list_info:

Removing a running Box errors

remove(id, force=False) requires the Box to be stopped, otherwise it errors. Either stop() first, or use force=True (stop then remove):

Using Boxlite.default() across processes leaves the lock unreleased

default() is a process-wide static singleton that holds the runtime lock until the process ends. Cross-process scenarios (a child creates, the parent takes over) must use boxlite.Boxlite(boxlite.Options()) to construct a releasable instance, otherwise the taking-over process blocks or fails because it cannot acquire the lock.
Current limitation: if the process holding the default() lock exits abnormally (crash, kill, or exiting without close()/with), a lock file may remain under $BOXLITE_HOME/locks/, causing the next Boxlite.default() to report Another BoxliteRuntime is already using directory. After confirming no boxlite process is alive (lsof $BOXLITE_HOME/locks/* shows no holder), you can clean it up manually with rm -f $BOXLITE_HOME/locks/* ($BOXLITE_HOME defaults to ~/.boxlite). A runtime that exits normally via with/close() cleans up after itself.

execution.stdout() yields str, not bytes

The underlying Execution.stdout() / Execution.stderr() async iterators yield already-decoded str chunks, not bytes. Use them directly; calling chunk.decode(...) as if they were bytes raises AttributeError: 'str' object has no attribute 'decode'.

exec exits non-zero without raising

exec reports a non-zero exit through exit_code / exitCode, not by raising. See Error Handling.

The box stopped without anyone calling stop()

Its main command exited. exec then fails with invalid state: Cannot exec box <id>: it is stopped, and its main command has already exited. Either keep the main command long-running, or treat the box as single-shot and read its exit code with attach(). See The main command.

kill -TERM 1 leaves the main command running

Expected: PID 1 in a PID namespace ignores signals with no handler. Stop the box from the host with stop() instead.

exec rejects its arguments with a type error

Box.exec takes a list, SimpleBox.exec takes varargs. Passing a list to the second raises TypeError: argument 'args': Can't extract str to Vec, which does not name the expected shape.

No virtualization causes startup failure (environment constraint)

BoxLite requires hardware virtualization:
  • Linux: requires KVM (/dev/kvm available; under WSL2 the user must be in the kvm group);
  • macOS: uses Apple Hypervisor.framework, no /dev/kvm required (macOS arm64 supported); macOS Intel is not supported.
Without virtualization, Box startup fails (the process stays alive and can be caught with try/except to inform the user).

Advanced: lifecycle when using SimpleBox

When you use the higher-level SimpleBox (an async context manager), it lazily creates and starts the Box on async with entry and cleans up on exit according to auto_remove. The lifecycle switches are passed the same way via constructor arguments:
SimpleBox.exec’s timeout parameter is timeout (float) and its env is a dict; whereas the underlying Box.exec’s timeout parameter is timeout_secs and its env is a list[tuple]. For detach / cross-process / explicit restart, continue to use Boxlite + Box directly, because those operations require access to the Box id and the runtime.