fix(smolmachines): write tar to VM file then machine_cp to host

Replace the Popen/stdout=PIPE approach with a write-then-copy
strategy that avoids binary-stdout piping through the smolvm exec
channel entirely:

1. Probe connectivity with `machine_exec(machine, ["true"])` first.
   If this fails while an interactive session is running, the error
   now says "concurrent exec not available" instead of the opaque
   "<no stderr>".

2. Run `tar --create --gzip --file=/var/tmp/.bot-bottle-commit.tar.gz`
   inside the VM via machine_exec (same mechanism used during
   provisioning). tar writes to a file in the VM, not stdout, so
   smolvm never has to transmit binary data over the exec channel.

3. Copy the compressed archive to the host with machine_cp.

4. Dockerfile switches to ADD rootfs.tar.gz / — Docker decompresses
   gzip tarballs automatically.
This commit is contained in:
2026-06-23 16:37:43 +00:00
committed by didericis
parent f2775101a0
commit 6040b20e6e
+56 -46
View File
@@ -2,34 +2,39 @@
`smolvm pack create --from-vm` requires the VM to be stopped, and smolvm `smolvm pack create --from-vm` requires the VM to be stopped, and smolvm
removes VMs when stopped (same issue as Apple Container). Instead, exec removes VMs when stopped (same issue as Apple Container). Instead, exec
into the running VM as root and stream the root filesystem via tar, build into the running VM as root to write a gzip-compressed tar of the root
a Docker image from the archive, convert it to a smolmachine artifact via filesystem to /var/tmp, then copy it to the host with `smolvm machine cp`,
the existing registry pipeline, and record the sidecar path. The VM stays build a Docker image from the archive, convert it to a smolmachine artifact
running throughout.""" via the existing registry pipeline, and record the sidecar path. The VM
stays running throughout."""
from __future__ import annotations from __future__ import annotations
import shutil
import subprocess
import tempfile import tempfile
import threading
from pathlib import Path from pathlib import Path
from .. import ActiveAgent from .. import ActiveAgent
from ..freeze import Freezer from ..freeze import Freezer
from ..docker import util as docker_mod from ..docker import util as docker_mod
from .local_registry import crane_push_tarball, ephemeral_registry from .local_registry import crane_push_tarball, ephemeral_registry
from .smolvm import pack_create from .smolvm import machine_cp, machine_exec, pack_create
from ...bottle_state import bottle_state_dir from ...bottle_state import bottle_state_dir
from ...log import die, info from ...log import die, info
# Temp file written inside the VM during commit. Lives in /var/tmp
# (on-disk, unlike tmpfs /tmp) to survive for machine_cp.
_VM_COMMIT_TAR = "/var/tmp/.bot-bottle-commit.tar.gz"
class SmolmachinesFreezer(Freezer): class SmolmachinesFreezer(Freezer):
"""Freezes a smolmachines bottle via exec-tar + Docker image + smolmachine pack. """Freezes a smolmachines bottle via exec-tar + Docker image + smolmachine pack.
The VM is NOT stopped. smolvm machine exec streams the root filesystem The VM is NOT stopped. We exec into the running VM to write a compressed
via tar; we build a Docker image from it and run the same image→registry→ tar of the root filesystem to /var/tmp, copy it to the host with
pack_create pipeline that _ensure_smolmachine uses for fresh builds.""" machine_cp, build a Docker image (Docker's ADD decompresses .tar.gz
automatically), then run the same image→registry→pack_create pipeline
that _ensure_smolmachine uses for fresh builds."""
backend_name = "smolmachines" backend_name = "smolmachines"
@@ -55,14 +60,16 @@ def _snapshot_running_vm(machine: str, image_ref: str, binary: Path) -> None:
""" """
with tempfile.TemporaryDirectory(prefix="bot-bottle-vm-commit.") as tmp: with tempfile.TemporaryDirectory(prefix="bot-bottle-vm-commit.") as tmp:
tmp_path = Path(tmp) tmp_path = Path(tmp)
rootfs_tar = tmp_path / "rootfs.tar" # Use .tar.gz — Docker ADD decompresses automatically and the
# compressed archive fits in the VM's /var/tmp more easily.
rootfs_tar_gz = tmp_path / "rootfs.tar.gz"
dockerfile = tmp_path / "Dockerfile" dockerfile = tmp_path / "Dockerfile"
_exec_tar_to_file(machine, rootfs_tar) _exec_tar_to_file(machine, rootfs_tar_gz)
dockerfile.write_text( dockerfile.write_text(
"FROM scratch\n" "FROM scratch\n"
"ADD rootfs.tar /\n" "ADD rootfs.tar.gz /\n"
"USER node\n" "USER node\n"
"WORKDIR /home/node\n" "WORKDIR /home/node\n"
) )
@@ -82,47 +89,50 @@ def _snapshot_running_vm(machine: str, image_ref: str, binary: Path) -> None:
def _exec_tar_to_file(machine: str, dest: Path) -> None: def _exec_tar_to_file(machine: str, dest: Path) -> None:
"""Stream the VM root filesystem as a tar archive to `dest`. """Snapshot the running VM's root filesystem to dest (.tar.gz).
smolvm machine exec requires stdout to be a pipe, not a regular file. Writes a gzip-compressed tar to _VM_COMMIT_TAR inside the VM via
We use Popen with stdout=PIPE and drain stderr in a background thread machine_exec (same mechanism as provisioning), then copies it to the
to avoid deadlock if either buffer fills while we're writing the other.""" host with machine_cp. This avoids binary-stdout piping through the
proc = subprocess.Popen( smolvm exec channel, which does not reliably handle large binary output.
A connectivity probe (machine_exec true) runs first so a concurrent-exec
limitation (smolvm may reject a second exec while -i -t is active) is
reported clearly rather than as a silent failure."""
# Connectivity probe — if smolvm rejects concurrent exec while an
# interactive session is running, fail clearly here.
probe = machine_exec(machine, ["true"])
if probe.returncode != 0:
die(
f"smolvm exec is not available for {machine!r} "
f"(exit {probe.returncode}: {probe.stderr.strip() or probe.stdout.strip() or '<no output>'}). "
f"If an interactive session is active, smolvm may not support concurrent exec."
)
# Create the compressed tar inside the VM.
# tar exits 1 when files change during archiving (normal for a live
# filesystem); only treat exit > 1 as fatal.
tar_result = machine_exec(
machine,
[ [
"smolvm", "machine", "exec", "tar", "--create", "--gzip",
"--name", machine, "--",
"tar", "--create",
"--exclude=./proc", "--exclude=./proc",
"--exclude=./sys", "--exclude=./sys",
"--exclude=./dev", "--exclude=./dev",
"--exclude=./run", "--exclude=./run",
"--file=-", f"--file={_VM_COMMIT_TAR}",
"--directory=/", "--directory=/",
".", ".",
], ],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
) )
if tar_result.returncode > 1:
stderr_chunks: list[bytes] = []
def _drain_stderr() -> None:
assert proc.stderr is not None
stderr_chunks.append(proc.stderr.read())
t = threading.Thread(target=_drain_stderr, daemon=True)
t.start()
assert proc.stdout is not None
with open(dest, "wb") as out:
shutil.copyfileobj(proc.stdout, out, length=65536)
t.join()
returncode = proc.wait()
stderr = b"".join(stderr_chunks).decode(errors="replace").strip()
if returncode != 0:
die( die(
f"smolvm exec tar {machine!r} failed: " f"smolvm exec tar {machine!r} failed (exit {tar_result.returncode}): "
f"{stderr or '<no stderr>'}" f"{tar_result.stderr.strip() or tar_result.stdout.strip() or '<no output>'}"
) )
# Copy from VM to host, then clean up.
try:
machine_cp(f"{machine}:{_VM_COMMIT_TAR}", str(dest))
finally:
machine_exec(machine, ["rm", "-f", _VM_COMMIT_TAR])