> ## Documentation Index
> Fetch the complete documentation index at: https://docs.boxlite.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Lifecycle

> The states a box moves through — created, running, stopped, removed — and what survives each transition.

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

```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}}
stateDiagram-v2
    direction LR
    [*] --> Created: SimpleBox(...) / runtime.create()
    Created --> Running: async with entry, start(), or first exec()
    Running --> Stopped: stop()
    Stopped --> Running: start() or exec() again
    Stopped --> [*]: runtime.remove(id, force=)
    Running --> [*]: auto_remove=True on stop

    note right of Created
        Lazy: the handle exists,
        the microVM is not up yet
    end note
    note right of Stopped
        VM released, metadata kept —
        still visible to get() / list_info()
    end note
```

| State       | How you get there                                           | What exists                                   | What you can do                                                                              |
| ----------- | ----------------------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------- |
| **Created** | `SimpleBox(...)` or `await runtime.create(...)`             | A handle and a database record. **No VM yet** | Nothing on the box itself — the next `exec` or `async with` entry starts it                  |
| **Running** | `async with` entry, `start()`, or the first `exec()`        | VM up, rootfs mounted                         | `exec`, `copy_in` / `copy_out`, `metrics()`. `start()` is idempotent                         |
| **Stopped** | `stop()`                                                    | VM released, **rootfs and metadata kept**     | Still found by `get()` / `list_info()`; `exec` or `start()` brings it back with files intact |
| **Removed** | `runtime.remove(id, force=)`, or `auto_remove=True` on stop | Nothing                                       | Gone from the runtime and from disk                                                          |

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 `box.info().state.status`. **`info()` is synchronous** — do not await it, and it triggers no VM operation. Field names are exact and easy to get wrong: see [`BoxInfo` / `BoxStateInfo`](#boxinfo-boxstateinfo-state-access).

## 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.

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
import asyncio
import boxlite

async def main():
    # synchronous context manager: initializes the runtime on entry, calls close() on exit
    with boxlite.Boxlite.default() as runtime:
        box_id = None
        try:
            # 1. create (note: when called via the wrapper layer, create returns an awaitable; must await)
            box = await runtime.create(
                boxlite.BoxOptions(
                    image="alpine:latest",   # pulled over the network on first run
                    cpus=2,
                    memory_mib=512,
                    auto_remove=False,       # keep the box after stopping, to demonstrate remove
                )
            )
            box_id = box.id
            print(f"created: {box_id}")

            # 2. the first exec automatically starts the box (start is idempotent; explicit call is optional)
            execution = await box.exec("echo", ["hello from box"])
            # the underlying execution.stdout() yields already-decoded str chunks; use them directly, do not call .decode()
            async for chunk in execution.stdout():
                print("  ", chunk.strip())
            result = await execution.wait()
            print(f"  exit_code = {result.exit_code}")  # a non-zero exit code does not raise; check it yourself

            # 3. state query: info() is synchronous; do not await
            info = box.info()
            print(f"  state = {info.state.status}, running = {info.state.running}")

            # 4. stop (shut down the VM, preserve rootfs)
            await box.stop()
            print("stopped")

        finally:
            # 5. remove: called on the runtime, not box.remove()
            if box_id:
                try:
                    await runtime.remove(box_id, force=True)
                    print("removed")
                except Exception as exc:
                    print(f"remove failed: {exc}")

if __name__ == "__main__":
    asyncio.run(main())
```

> Key point: `Boxlite` is a **synchronous** context manager (`with`), whereas a `Box`'s methods (`exec`/`stop`/`metrics`) are **async** (require `await`). `box.info()` is synchronous and must not be awaited.

***

## detach and auto\_remove: two independent switches

`BoxOptions` has two boolean switches that control the lifecycle and do not affect each other:

| Parameter     | Type | Default | Meaning                                                                                                                                                                                   |
| ------------- | ---- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `auto_remove` | bool | `True`  | `True`: remove the Box automatically on `stop()` (like Docker `--rm`); `False`: keep it after stopping so it can be restarted, requiring a manual `remove()`                              |
| `detach`      | bool | `False` | `False`: the Box is bound to the parent process and stops when the parent exits (prevents orphans); `True`: the Box runs independently and survives the parent exiting (like Docker `-d`) |

Common combinations:

| Combination                                | Scenario                                                                                             |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| `auto_remove=True, detach=False` (default) | One-off tasks, ephemeral code execution; cleaned up as soon as it finishes                           |
| `auto_remove=False, detach=False`          | Iterative development / debugging: preserve the disk after stopping and continue on the next restart |
| `auto_remove=False, detach=True`           | Long-running services / daemons: survive the parent process and reattach later                       |

> `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:

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
import asyncio
import boxlite

async def main():
    with boxlite.Boxlite.default() as runtime:
        box_id = None
        try:
            box = await runtime.create(
                boxlite.BoxOptions(image="alpine:latest", auto_remove=False)
            )
            box_id = box.id

            # write a file to prove the disk persists
            await (await box.exec("sh", ["-c", "echo data > /root/note.txt"])).wait()

            await box.stop()
            print("stopped; rootfs preserved")

            # re-acquire the handle (the box is stopped; get still returns a handle)
            box = await runtime.get(box_id)
            if box is None:
                raise RuntimeError(f"box {box_id} not found")

            # exec triggers a restart, reusing the original rootfs
            execution = await box.exec("cat", ["/root/note.txt"])
            async for chunk in execution.stdout():  # stdout() yields str
                print("  file after restart:", chunk.strip())
            await execution.wait()

            await box.stop()
        finally:
            if box_id:
                try:
                    await runtime.remove(box_id, force=True)
                except Exception as exc:
                    print(f"remove failed: {exc}")

if __name__ == "__main__":
    asyncio.run(main())
```

> 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.

***

## 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).

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
import asyncio
import boxlite

async def main():
    with boxlite.Boxlite.default() as runtime:
        box_id = None
        try:
            box = await runtime.create(
                boxlite.BoxOptions(image="alpine:latest", auto_remove=False)
            )
            box_id = box.id
            await (await box.exec("echo", ["running"])).wait()

            # acquire a second handle pointing to the same box
            handle2 = await runtime.get(box_id)
            if handle2 is None:
                raise RuntimeError("reattach failed: box not found")

            execution = await handle2.exec("echo", ["via second handle"])
            async for chunk in execution.stdout():  # stdout() yields str
                print("  ", chunk.strip())
            await execution.wait()

            await box.stop()
        finally:
            if box_id:
                try:
                    await runtime.remove(box_id, force=True)
                except Exception as exc:
                    print(f"remove failed: {exc}")

if __name__ == "__main__":
    asyncio.run(main())
```

***

## 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.

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
import asyncio
import subprocess
import sys

import boxlite

async def child_create_box():
    """Child process: create a detached box, print its id, then exit (the box stays alive)."""
    # construct a releasable runtime with Options(); the lock is released on exit
    runtime = boxlite.Boxlite(boxlite.Options())
    box = await runtime.create(
        boxlite.BoxOptions(image="alpine:latest", detach=True, auto_remove=False)
    )
    # exec to ensure the box finishes initialization
    await (await box.exec("echo", ["initialized"])).wait()
    print(f"BOX_ID:{box.id}")
    sys.stdout.flush()
    # do not call stop(); the box keeps running. The runtime is dropped and the lock is released

async def parent_reattach():
    """Parent process: fork a child to create the box, then take it over from this process."""
    proc = subprocess.run(
        [sys.executable, __file__, "--child"],
        capture_output=True, text=True, timeout=120,
    )
    if proc.returncode != 0:
        raise RuntimeError(f"child failed: {proc.stderr}")

    box_id = None
    for line in proc.stdout.splitlines():
        if line.startswith("BOX_ID:"):
            box_id = line.split(":", 1)[1].strip()
            break
    if not box_id:
        raise RuntimeError(f"no box id from child; stdout={proc.stdout!r}")
    print(f"child created box: {box_id}")

    # the parent process also uses Options() rather than default()
    runtime = boxlite.Boxlite(boxlite.Options())
    try:
        info = await runtime.get_info(box_id)
        if info is None:
            raise RuntimeError("box not found in DB")
        print(f"box state in DB: {info.state.status}")

        box = await runtime.get(box_id)  # take over
        if box is None:
            raise RuntimeError("reattach failed")
        execution = await box.exec("echo", ["hello from parent process"])
        async for chunk in execution.stdout():  # stdout() yields str
            print("  ", chunk.strip())
        await execution.wait()
        print("cross-process reattach OK")
    finally:
        try:
            await runtime.remove(box_id, force=True)
        except Exception as exc:
            print(f"cleanup failed: {exc}")

if __name__ == "__main__":
    if len(sys.argv) > 1 and sys.argv[1] == "--child":
        asyncio.run(child_create_box())
    else:
        asyncio.run(parent_reattach())
```

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.

***

## 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`.

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
import asyncio
import boxlite

async def main():
    runtime = boxlite.Boxlite.default()

    boxes = []
    for i in range(3):
        box = await runtime.create(boxlite.BoxOptions(image="alpine:latest"))
        boxes.append(box)
        print(f"created box {i + 1}: {box.id}")
        await (await box.exec("echo", [f"hi from box {i + 1}"])).wait()

    metrics = await runtime.metrics()
    print(f"running boxes before shutdown: {metrics.num_running_boxes}")

    await runtime.shutdown(timeout=5)  # 5-second timeout
    print("shutdown complete")

    # creation fails after shutdown
    try:
        await runtime.create(boxlite.BoxOptions(image="alpine:latest"))
        print("ERROR: expected failure")
    except RuntimeError as exc:
        print(f"expected error after shutdown: {exc}")

if __name__ == "__main__":
    asyncio.run(main())
```

***

## Parameters and Returns

### `BoxOptions` (lifecycle-related fields)

| Field         | Type | Required   | Default                                                        | Description                                                                                                   |
| ------------- | ---- | ---------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `image`       | str  | Optional\* | —                                                              | Image reference, e.g. `alpine:latest`. At least one of `image` and `rootfs_path`                              |
| `rootfs_path` | str  | Optional\* | —                                                              | Path to a local OCI image layout directory (the on-disk form of a container image); an alternative to `image` |
| `cpus`        | int  | Optional   | 1 (see [Compute resources](/manage-sandbox/compute-resources)) | Number of vCPUs                                                                                               |
| `memory_mib`  | int  | Optional   | 1024                                                           | Memory in MiB                                                                                                 |
| `auto_remove` | bool | Optional   | `True`                                                         | Whether to remove automatically on `stop()` (mutually exclusive with `detach=True`\*\*)                       |
| `detach`      | bool | Optional   | `False`                                                        | Whether to survive the parent process (mutually exclusive with `auto_remove=True`\*\*)                        |

> 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)

| Method                              | Returns           | Description                                                             |
| ----------------------------------- | ----------------- | ----------------------------------------------------------------------- |
| `create(options, name=None)`        | `Box`             | Create and register a Box                                               |
| `get_or_create(options, name=None)` | `(Box, bool)`     | Returns the Box and a `created` flag (reuses by name)                   |
| `get(id_or_name)`                   | `Box \| None`     | Get a handle to an existing Box; returns `None` if not found            |
| `get_info(id_or_name)`              | `BoxInfo \| None` | Read Box info (without bringing up the VM); returns `None` if not found |
| `list_info(_state=None)`            | `list[BoxInfo]`   | List all Boxes (newest first); **not `list()`**                         |
| `remove(id_or_name, force=False)`   | —                 | Remove a Box; `force=False` requires the Box to be stopped              |
| `metrics()`                         | `RuntimeMetrics`  | Runtime aggregate metrics                                               |
| `shutdown(timeout=None)`            | —                 | Shut down all Boxes (seconds; `None`=10s, `-1`=indefinite)              |
| `close()`                           | —                 | Close the runtime handle (synchronous)                                  |

### `Box` methods (async, require `await`, except `info()`)

| Method                          | Returns      | Description                                                       |
| ------------------------------- | ------------ | ----------------------------------------------------------------- |
| `start()`                       | —            | Bring up the VM, idempotent                                       |
| `stop()`                        | —            | Shut down the VM, preserve the rootfs (unless `auto_remove=True`) |
| `exec(command, args=None, ...)` | `Execution`  | Run a command; triggers a restart on a stopped Box                |
| `info()`                        | `BoxInfo`    | **Synchronous**, do not `await`; does not trigger the VM          |
| `metrics()`                     | `BoxMetrics` | Per-Box metrics                                                   |
| `id` / `name`                   | str          | Properties                                                        |

### `BoxInfo` / `BoxStateInfo` (state access)

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

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
info = box.info()           # synchronous
print(info.state.status)    # state string
print(info.state.running)   # bool
print(info.id, info.image, info.cpus, info.memory_mib)
```

| Path                 | Type           | Description                                     |
| -------------------- | -------------- | ----------------------------------------------- |
| `info.state`         | `BoxStateInfo` | The outer field is `state` (not `status`)       |
| `info.state.status`  | str            | The inner field is named `status` (not `state`) |
| `info.state.running` | bool           | Whether it is running                           |
| `info.state.pid`     | int \| None    | Process id (if any)                             |

***

## Troubleshooting

### Awaiting `Box.info()` as if it were async

`info()` is a **synchronous** method.

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
# Wrong: await box.info()  ->  TypeError: object BoxInfo can't be used in 'await' expression
info = box.info()                 # correct
print(info.state.status)          # not info.status, and not info.state.state
```

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

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

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
# Wrong: await box.remove()           -> AttributeError
# Wrong: await runtime.list()         -> AttributeError
await runtime.remove(box.id, force=True)   # correct
infos = await runtime.list_info()          # correct
```

### 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):

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
# calling remove(force=False) on a running box raises (the box is still running)
await box.stop()
await runtime.remove(box.id, force=False)   # or: runtime.remove(box.id, force=True)
```

### 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'`.

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
execution = await box.exec("echo", ["hi"])
async for chunk in execution.stdout():
    print(chunk.strip())          # correct: chunk is already a str
    # print(chunk.decode())       # wrong: str has no decode -> AttributeError
```

### `exec` exits non-zero without raising

`exec` reports a non-zero exit through `exit_code` / `exitCode`, not by raising. See [Error Handling](/guides/error-handling#troubleshooting).

### 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).

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
try:
    box = await runtime.create(boxlite.BoxOptions(image="alpine:latest"))
    await box.exec("echo", ["ok"])
except RuntimeError as exc:
    print(f"startup failed; check that virtualization is available (Linux requires KVM): {exc}")
```

***

## 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:

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
import asyncio
from boxlite import SimpleBox

async def main():
    # auto_remove defaults to True; when reuse_existing=True, reuse an existing box by name
    try:
        async with SimpleBox(image="alpine:latest", auto_remove=True) as box:
            result = await box.exec("echo", "hello")  # ExecResult
            print(result.stdout, "exit:", result.exit_code)
            print("state:", box.info().state.status)   # info() is synchronous
    except RuntimeError as exc:
        print(f"run failed (check virtualization): {exc}")

if __name__ == "__main__":
    asyncio.run(main())
```

> `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.
