From 92b398bfebd82bb42a5dab1925a841c317892324 Mon Sep 17 00:00:00 2001 From: CMGS Date: Fri, 14 Aug 2026 03:10:43 +0800 Subject: [PATCH 1/2] fix: stop shipping a stale silkd, and unbreak the Python stdio paths The flavor build never passed SILKD_IMAGE, so android baked in whatever the Dockerfile ARG defaulted to (silkd 0.1.0) on every rebuild, including rebuilds triggered by a silkd change - the chain's whole purpose. It now resolves the version the same way the base build does. silkd's own image built without --locked, so a Cargo.toml bump could ship dependencies no one reviewed. sandbox-init had no PR gate at all: its suite ran only after a push to main. Python run() fed stdin to completion before reading a byte of output, so any payload past the socket buffers deadlocked against a guest that had stopped draining stdin - it now pumps stdin on its own thread, pinned by a test that deadlocks on the old code. proxy_port leaked the relay connection on every proxied stream, and kill(pid, 0) sent signal 0 (a POSIX existence probe, a no-op) where Go sends SIGKILL. --- .github/workflows/boot-init.yml | 31 ++++++++++++++++++++++++ .github/workflows/build-os-images.yml | 5 ++++ sdk/python/cocoonsandbox/sandbox.py | 20 ++++++++++++---- sdk/python/tests/test_proc.py | 34 +++++++++++++++++++++++++++ silkd/Dockerfile | 4 ++-- 5 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/boot-init.yml diff --git a/.github/workflows/boot-init.yml b/.github/workflows/boot-init.yml new file mode 100644 index 00000000..a240f028 --- /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 073656f3..08136300 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 979bd8c2..3f43a884 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 f7067ac7..73ece02d 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,34 @@ 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: + if 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 33907a55..55491067 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 From 7470dd447809188183bf19478b0a97657a009b03 Mon Sep 17 00:00:00 2001 From: CMGS Date: Fri, 14 Aug 2026 03:23:20 +0800 Subject: [PATCH 2/2] test(python): fold the stdin guard into one condition ruff SIM102. --- sdk/python/tests/test_proc.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/sdk/python/tests/test_proc.py b/sdk/python/tests/test_proc.py index 73ece02d..139f79a8 100644 --- a/sdk/python/tests/test_proc.py +++ b/sdk/python/tests/test_proc.py @@ -92,9 +92,8 @@ def __init__(self, frames, buffer_frames): self._read = False def send(self, op, **fields): - if op == "stdin" and not self._read: - if not self._room.acquire(blocking=False): - assert self._room.acquire(timeout=5), "stdin send deadlocked" + 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):