diff --git a/.github/workflows/boot-init.yml b/.github/workflows/boot-init.yml new file mode 100644 index 0000000..a240f02 --- /dev/null +++ b/.github/workflows/boot-init.yml @@ -0,0 +1,31 @@ +# sandbox-init is PID 1 in every guest, but its own suite only ran as part of +# the image chain on main — a PR could not fail on it. This gate is the +# source-level counterpart to silkd.yml. +name: boot-init + +on: + push: + branches: [main] + paths: + - "boot/init/**" + pull_request: + paths: + - "boot/init/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: fmt + clippy + test + working-directory: boot/init + run: | + cargo fmt --check + cargo clippy --all-targets -- -D warnings + cargo test diff --git a/.github/workflows/build-os-images.yml b/.github/workflows/build-os-images.yml index 073656f..0813630 100644 --- a/.github/workflows/build-os-images.yml +++ b/.github/workflows/build-os-images.yml @@ -257,6 +257,10 @@ jobs: - name: Checkout uses: actions/checkout@v7 + - name: Read versions + id: ver + run: echo "silkd=$(sed -n 's/^version = "\(.*\)"/\1/p' silkd/Cargo.toml | head -1)" >> "$GITHUB_OUTPUT" + - name: Set up QEMU uses: docker/setup-qemu-action@v4 @@ -321,6 +325,7 @@ jobs: # single base:24.04 tag; revisit when a second base tag appears. build-args: | BASE_IMAGE=ghcr.io/${{ github.repository }}/base:24.04 + SILKD_IMAGE=${{ inputs.silkd_image != '' && inputs.silkd_image || format('ghcr.io/{0}/silkd:{1}', github.repository, steps.ver.outputs.silkd) }} INPUTS_HASH=${{ steps.inputs.outputs.hash }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/sdk/python/cocoonsandbox/sandbox.py b/sdk/python/cocoonsandbox/sandbox.py index 979bd8c..3f43a88 100644 --- a/sdk/python/cocoonsandbox/sandbox.py +++ b/sdk/python/cocoonsandbox/sandbox.py @@ -66,10 +66,13 @@ def run(self, argv: list[str], cwd: str = "", env: dict | None = None, with self._dial() as conn: conn.send("exec", argv=argv, cwd=cwd or None, env=env, user=user or None, session=session or None) - if stdin: - _send_chunks(conn, stdin, op="stdin") - conn.send("stdin_close") + # The guest blocks writing output once its stdout buffer fills, and + # stops draining stdin while it does, so feeding stdin to completion + # before reading deadlocks on any payload past the socket buffers. + pump = threading.Thread(target=_feed_stdin, args=(conn, stdin), daemon=True) + pump.start() code = _pump_stdio(conn, on_stdout, on_stderr) + pump.join() if code is None: raise ProtocolError("exec stream ended without an exit frame") return code @@ -90,7 +93,7 @@ def ps(self) -> list[dict]: def kill(self, pid: int, signal: int | None = None) -> None: """Signals a tracked process (default SIGKILL); killing one that already exited is a no-op success.""" - self._done_rpc("kill", pid=pid, signal=signal) + self._done_rpc("kill", pid=pid, signal=signal or None) def logs(self, pid: int, on_stdout: Callable[[bytes], object] | None = None, on_stderr: Callable[[bytes], object] | None = None) -> int | None: @@ -347,7 +350,7 @@ def pump_out(): break guest.send(chunk) guest.close_write() - except Exception: + finally: guest.close() def _call(self, op: str, expect: str, **fields) -> dict: @@ -480,6 +483,13 @@ def _send_chunks(conn: Conn, data: bytes, op: str = "data", chunk: int = FS_CHUN conn.send(op, data=view[off:off + chunk]) +def _feed_stdin(conn: Conn, stdin: bytes) -> None: + with contextlib.suppress(Exception): # the reader reports the real failure + if stdin: + _send_chunks(conn, stdin, op="stdin") + conn.send("stdin_close") + + def _pump_stdio(conn: Conn, on_stdout, on_stderr) -> int | None: """Streams stdout/stderr frames into the callbacks until the terminal frame: the exit code, or None when the stream ends with done.""" diff --git a/sdk/python/tests/test_proc.py b/sdk/python/tests/test_proc.py index f7067ac..139f79a 100644 --- a/sdk/python/tests/test_proc.py +++ b/sdk/python/tests/test_proc.py @@ -1,7 +1,10 @@ """Process-management verbs against a scripted fake conn: pure frame plumbing, so the fake pins the framing and terminal semantics.""" +import threading + from cocoonsandbox import Client, Sandbox +from cocoonsandbox.frames import FS_CHUNK class FakeConn: @@ -77,3 +80,33 @@ def test_attach_returns_exit_code(monkeypatch): out = [] assert sb.attach(41, on_stdout=out.append) == 7 assert out == [b"late"] + + +class BlockingStdinConn(FakeConn): + """A guest that stops draining stdin until its output is read: send blocks + past the buffer, exactly as the real socket pair does.""" + + def __init__(self, frames, buffer_frames): + super().__init__(frames) + self._room = threading.Semaphore(buffer_frames) + self._read = False + + def send(self, op, **fields): + if op == "stdin" and not self._read and not self._room.acquire(blocking=False): + assert self._room.acquire(timeout=5), "stdin send deadlocked" + super().send(op, **fields) + + def recv(self): + self._read = True + self._room.release() + return super().recv() + + +def test_run_pumps_stdin_while_reading_output(monkeypatch): + sb, conn = fake_sandbox(monkeypatch, [{"type": "exit", "code": 0}]) + blocking = BlockingStdinConn([{"type": "exit", "code": 0}], buffer_frames=1) + monkeypatch.setattr(sb, "_dial", lambda: blocking) + + assert sb.run(["cat"], stdin=b"x" * (FS_CHUNK * 3)) == 0 + assert [op for op, _ in blocking.sent].count("stdin") == 3 + assert blocking.sent[-1][0] == "stdin_close" diff --git a/silkd/Dockerfile b/silkd/Dockerfile index 33907a5..5549106 100644 --- a/silkd/Dockerfile +++ b/silkd/Dockerfile @@ -20,8 +20,8 @@ RUN case "$TARGETARCH" in \ WORKDIR /silkd COPY Cargo.toml Cargo.lock ./ COPY src ./src -RUN cargo build --release && strip target/release/silkd -RUN cargo build --release --target "$(cat /musl-target)" && \ +RUN cargo build --release --locked && strip target/release/silkd +RUN cargo build --release --locked --target "$(cat /musl-target)" && \ strip "target/$(cat /musl-target)/release/silkd" && \ cp "target/$(cat /musl-target)/release/silkd" /silkd-static