From 71da879d400573a5785bdd16ee83c89b9325d677 Mon Sep 17 00:00:00 2001 From: CMGS Date: Tue, 22 Sep 2026 02:09:33 +0800 Subject: [PATCH 01/11] fix(android): give silkd a shell the image has pty_open spawns $SHELL and falls back to /bin/bash; the android userspace has neither the variable nor bash, so every pty on that flavor failed at spawn. The init service now sets SHELL to /system/bin/sh, the way it already sets SILKD_PORT and the proxy trio. The android smoke only runs exec, so nothing caught it. --- os-image/android/silkd.rc | 1 + 1 file changed, 1 insertion(+) diff --git a/os-image/android/silkd.rc b/os-image/android/silkd.rc index 0028d1e1..e508fe56 100644 --- a/os-image/android/silkd.rc +++ b/os-image/android/silkd.rc @@ -10,6 +10,7 @@ service silkd /system/bin/silkd user root group root setenv SILKD_PORT 2048 + setenv SHELL /system/bin/sh # Mirrors the base:24.04 unit; forwarded into execs only on the no-NIC lane. setenv http_proxy http://127.0.0.1:3128 setenv https_proxy http://127.0.0.1:3128 From 2b4a8cabacaa9ac3abbf00880b301a9341a124b1 Mon Sep 17 00:00:00 2001 From: CMGS Date: Tue, 22 Sep 2026 02:09:33 +0800 Subject: [PATCH 02/11] build: make base busts the secret-mount layer when its inputs change The base Dockerfile keys its install-agent RUN on INPUTS_HASH because a secret-mounted script never enters the layer cache; CI computes the hash, make base passed nothing, so a local edit to install-agent.sh silently rebuilt against the cached layer. The target now passes the same content hash CI does. The images target's help line said "all VM images" for two of nine; it now says what it builds. --- Makefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index d55553c3..c4dabce9 100644 --- a/Makefile +++ b/Makefile @@ -91,6 +91,7 @@ base: silkd-image ## base VM image against the local boot + silkd images docker build -t sandbox-base:dev \ --build-arg BOOT_IMAGE=$(BOOT_IMAGE) \ --build-arg SILKD_IMAGE=$(SILKD_IMAGE) \ + --build-arg INPUTS_HASH=$$(find os-image/base -type f ! -name Dockerfile -print0 | sort -z | xargs -0 shasum -a 256 | shasum -a 256 | cut -d' ' -f1) \ --secret id=sandbox_install_agent,src=os-image/base/install-agent.sh \ -f os-image/base/24.04/Dockerfile os-image/base @@ -99,4 +100,4 @@ python: base ## python flavor image on top of base --build-arg BASE_IMAGE=sandbox-base:dev \ -f os-image/python/3.12/Dockerfile os-image/python -images: base python ## all VM images +images: base python ## the local base and python images (CI builds every flavor) From 116473c72629d6a0ac188813d88a17bdeb62035e Mon Sep 17 00:00:00 2001 From: CMGS Date: Tue, 22 Sep 2026 02:09:33 +0800 Subject: [PATCH 03/11] build: one VIRTIO_CONSOLE assert; the chrome apt repo over https The amd64 branch asserted VIRTIO_CONSOLE in its arch list and again in the common list. The desktop image fetches Chrome's signed repo over http while the base image had just moved every apt source to https; the repo serves https and the key check is unchanged. --- boot/Dockerfile | 2 +- os-image/desktop/24.04/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/boot/Dockerfile b/boot/Dockerfile index 1720e9c3..c24c27c1 100644 --- a/boot/Dockerfile +++ b/boot/Dockerfile @@ -46,7 +46,7 @@ RUN set -e; \ case "$TARGETARCH" in \ amd64) make x86_64_defconfig kvm_guest.config; \ ./scripts/kconfig/merge_config.sh -m .config /tmp/sandbox.config; \ - arch_asserts="PVH VIRTIO_CONSOLE" ;; \ + arch_asserts="PVH" ;; \ arm64) make defconfig; \ ./scripts/kconfig/merge_config.sh -m .config /tmp/sandbox.config /tmp/sandbox-arm64.config; \ arch_asserts="RTC_DRV_PL031" ;; \ diff --git a/os-image/desktop/24.04/Dockerfile b/os-image/desktop/24.04/Dockerfile index 5d4cc481..9b448fc0 100644 --- a/os-image/desktop/24.04/Dockerfile +++ b/os-image/desktop/24.04/Dockerfile @@ -44,7 +44,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && install -d -m 0755 /etc/apt/keyrings \ && curl -fsSL https://dl.google.com/linux/linux_signing_key.pub \ | gpg --dearmor -o /etc/apt/keyrings/google-chrome.gpg \ - && echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" \ + && echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/google-chrome.gpg] https://dl.google.com/linux/chrome/deb/ stable main" \ > /etc/apt/sources.list.d/google-chrome.list \ && apt-get update && apt-get install -y --no-install-recommends google-chrome-stable \ && rm -rf /var/lib/apt/lists/* \ From 9aab47162e4b025126455a1944e8c0bfe85ec0f5 Mon Sep 17 00:00:00 2001 From: CMGS Date: Tue, 22 Sep 2026 02:12:59 +0800 Subject: [PATCH 04/11] bench: the image digest lookup survives a missing cocoon bench.sh runs under pipefail and every other probe on the results step is guarded; the digest pipeline was not, so a host without a cocoon on PATH lost the whole bench at the last step. --- scripts/bench.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/bench.sh b/scripts/bench.sh index 76a9a676..fb44f0c2 100755 --- a/scripts/bench.sh +++ b/scripts/bench.sh @@ -169,7 +169,7 @@ done virt=$(systemd-detect-virt 2>/dev/null || true); virt=${virt:-unknown} [[ $virt == none ]] && envlabel="bare metal" || envlabel="nested ($virt)" cpu=$(awk -F': ' '/model name/{print $2; exit}' /proc/cpuinfo 2>/dev/null || sysctl -n machdep.cpu.brand_string 2>/dev/null || echo unknown) -digest=$(cocoon image list 2>/dev/null | awk -v t="$TEMPLATE" '$2 ~ t {print $3; exit}') +digest=$(cocoon image list 2>/dev/null | awk -v t="$TEMPLATE" '$2 ~ t {print $3; exit}' || true) echo echo "== results (paste into docs/benchmarks.md)" From eddccf84a502f81e0f08def47097c3c068976dc7 Mon Sep 17 00:00:00 2001 From: CMGS Date: Tue, 22 Sep 2026 02:12:59 +0800 Subject: [PATCH 05/11] ci: the Rust gates build --locked; the PyPI gate pins its tools release.yml and both Dockerfiles build --locked, the four gate workflows did not, so a Cargo.toml bump without its lock went green in silkd/boot-init and failed in images. publish-pypi installed pytest and ruff unpinned where python.yml pins both. --- .github/workflows/boot-init.yml | 5 +++-- .github/workflows/build-boot.yml | 4 ++-- .github/workflows/build-silkd.yml | 4 ++-- .github/workflows/publish-pypi.yml | 2 +- .github/workflows/silkd.yml | 5 +++-- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/boot-init.yml b/.github/workflows/boot-init.yml index 2c834f44..59631a4e 100644 --- a/.github/workflows/boot-init.yml +++ b/.github/workflows/boot-init.yml @@ -8,6 +8,7 @@ on: branches: [main] paths: - "boot/init/**" + - ".github/workflows/boot-init.yml" pull_request: paths: - "boot/init/**" @@ -27,5 +28,5 @@ jobs: working-directory: boot/init run: | cargo fmt --check - cargo clippy --all-targets -- -D warnings - cargo test + cargo clippy --locked --all-targets -- -D warnings + cargo test --locked diff --git a/.github/workflows/build-boot.yml b/.github/workflows/build-boot.yml index 17d9b8a1..6037132c 100644 --- a/.github/workflows/build-boot.yml +++ b/.github/workflows/build-boot.yml @@ -38,8 +38,8 @@ jobs: working-directory: boot/init run: | cargo fmt --check - cargo clippy --all-targets -- -D warnings - cargo test + cargo clippy --locked --all-targets -- -D warnings + cargo test --locked build: needs: test diff --git a/.github/workflows/build-silkd.yml b/.github/workflows/build-silkd.yml index 27d6d644..fcde41a1 100644 --- a/.github/workflows/build-silkd.yml +++ b/.github/workflows/build-silkd.yml @@ -34,8 +34,8 @@ jobs: working-directory: silkd run: | cargo fmt --check - cargo clippy --all-targets -- -D warnings - cargo test + cargo clippy --locked --all-targets -- -D warnings + cargo test --locked build: needs: test diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 95c46158..f0812f54 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -58,7 +58,7 @@ jobs: # The in-repo base SDK installs first, so an adapter release never # resolves (or fails to resolve) cocoonstack-sandbox from PyPI. python -m pip install -q ./sdk/python - python -m pip install -q "${{ steps.pkg.outputs.dir }}" pytest ruff + python -m pip install -q "${{ steps.pkg.outputs.dir }}" ruff==0.15.20 pytest==8.4.2 (cd "${{ steps.pkg.outputs.dir }}" && ruff check . && python -m pytest -q) - name: Build sdist + wheel diff --git a/.github/workflows/silkd.yml b/.github/workflows/silkd.yml index 4ed2ecc8..849cff73 100644 --- a/.github/workflows/silkd.yml +++ b/.github/workflows/silkd.yml @@ -9,6 +9,7 @@ on: paths: - "silkd/**" - "protocol/**" + - ".github/workflows/silkd.yml" pull_request: paths: - "silkd/**" @@ -29,5 +30,5 @@ jobs: working-directory: silkd run: | cargo fmt --check - cargo clippy --all-targets -- -D warnings - cargo test + cargo clippy --locked --all-targets -- -D warnings + cargo test --locked From e4b6e25877a90e1aa554dd820bf44f7fc492bbee Mon Sep 17 00:00:00 2001 From: CMGS Date: Tue, 22 Sep 2026 02:12:59 +0800 Subject: [PATCH 06/11] ci: workflows fire on their own definition; docs edits keep the image cache python.yml ran on every branch push, so a PR push ran it twice; it now runs on main pushes, pull requests and dispatch like its siblings. images.yml, silkd.yml and boot-init.yml did not list their own files (or, for images, the three build workflows it calls), so a change to how an image is built rebuilt nothing. build-os-images hashed every non-Dockerfile input and treated any depth-3 file as cache-busting, so a README edit forced an uncached rebuild of the heaviest image; markdown now stays out of both. --- .github/workflows/build-os-images.yml | 6 +++--- .github/workflows/images.yml | 4 ++++ .github/workflows/python.yml | 3 +++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-os-images.yml b/.github/workflows/build-os-images.yml index 99fd6b43..b9fe0bf2 100644 --- a/.github/workflows/build-os-images.yml +++ b/.github/workflows/build-os-images.yml @@ -109,7 +109,7 @@ jobs: needs_no_cache="true" elif [[ -n "$all_changed" ]]; then while IFS= read -r f; do - [[ -z "$f" ]] && continue + [[ -z "$f" || "$f" == *.md ]] && continue depth=$(echo "$f" | awk -F'/' '{print NF}') base_name=$(basename "$f") if [[ "$depth" -eq 3 ]] || { [[ "$depth" -eq 4 ]] && [[ "$base_name" != "Dockerfile" ]]; }; then @@ -195,7 +195,7 @@ jobs: - name: Hash image inputs id: inputs run: | - hash=$(find "os-image/${{ matrix.name }}" -type f ! -name Dockerfile -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -d' ' -f1) + hash=$(find "os-image/${{ matrix.name }}" -type f ! -name Dockerfile ! -name '*.md' -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -d' ' -f1) echo "hash=$hash" >> "$GITHUB_OUTPUT" - name: Discover secret files @@ -286,7 +286,7 @@ jobs: - name: Hash image inputs id: inputs run: | - hash=$(find "os-image/${{ matrix.name }}" -type f ! -name Dockerfile -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -d' ' -f1) + hash=$(find "os-image/${{ matrix.name }}" -type f ! -name Dockerfile ! -name '*.md' -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -d' ' -f1) echo "hash=$hash" >> "$GITHUB_OUTPUT" - name: Discover secret files diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index 7ba5a3aa..ddd4cdd0 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -15,6 +15,10 @@ on: - "silkd/**" - "protocol/**" - "os-image/**" + - ".github/workflows/images.yml" + - ".github/workflows/build-os-images.yml" + - ".github/workflows/build-silkd.yml" + - ".github/workflows/build-boot.yml" workflow_dispatch: inputs: no-cache: diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 6ee70b0c..4effbbe4 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -2,6 +2,7 @@ name: python on: push: + branches: [main] paths: - "sdk/python/**" - "sdk/openai/**" @@ -20,6 +21,8 @@ on: - "e2e/**/*.py" - "ruff.toml" - "protocol/**" + - ".github/workflows/python.yml" + workflow_dispatch: jobs: test: From 24f49ae52954fddf30ff89f5533655b4778a28a1 Mon Sep 17 00:00:00 2001 From: CMGS Date: Tue, 22 Sep 2026 02:16:31 +0800 Subject: [PATCH 07/11] docs: what the boot table, the error tables and the driver roster left out The README promised sandbox.debug and sandbox.trace in the same table that describes the Firecracker lane, but the pinned kernel builds no 8250 UART and cocoon boots that lane with console=ttyS0, so nothing they write is visible there. The three error tables did not say that an unresolvable exec or pty user is a bad_request, nor that the guest now distinguishes an unknown name from a failing name service. The Python errors list omitted SandboxTimeout, which every timeout path raises. The e2e roster listed 17 drivers of 18. --- README.md | 9 +++++++-- docs/sdk-python.md | 6 ++++-- docs/sdk.md | 3 ++- docs/silkd.md | 2 +- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 79deb737..b9385c17 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,8 @@ performance) — source in cocoon+guest) plus bare-metal acceptance drivers under `cmd/`: `demo`, `smoke`, `meshsmoke`, `crossnode`, `coldproof`, `egresssmoke`, `interceptsmoke`, `sockssmoke`, `volumesmoke`, `lifecycle` (idle→hibernate→archive), - `androidsmoke`, `browsersmoke`, `desktopsmoke`, and the `pullbench`/`pushbench`/`rpcbench`/`qaab` - perf drivers + `androidsmoke`, `browsersmoke`, `desktopsmoke`, `ringsmoke` (output ring cap, exec as a user), + and the `pullbench`/`pushbench`/`rpcbench`/`qaab` perf drivers - `boot/kernel/` — kernel version pin (`VERSION` + matching tarball `SHA256`, bump both together) + config fragment (amd64: over `x86_64_defconfig` + `kvm_guest.config`; arm64: over `defconfig`, then `sandbox-arm64.config`) @@ -159,6 +159,11 @@ Boot contract (cmdline keys consumed by sandbox-init): | `sandbox.debug=1` | fatal errors drop to `/bin/sh` (debug initramfs) instead of poweroff | | `sandbox.trace=1` | emit one pre-handoff line with per-phase µs timings | +The pinned kernel builds no 8250 UART, so on cocoon's Firecracker lane +(`console=ttyS0`) the trace line, the debug shell and the fatal-error +message all go nowhere; they are visible on the Cloud Hypervisor lane's +virtio console. + `boot=cocoon-overlay` is ignored. Everything cocoon passes today keeps working — images built here boot with an unmodified cocoon. diff --git a/docs/sdk-python.md b/docs/sdk-python.md index ec92e265..121cdf0b 100644 --- a/docs/sdk-python.md +++ b/docs/sdk-python.md @@ -498,9 +498,11 @@ zero. - `APIError(verb, status, message)` — control plane (HTTP status) - `SilkdError(kind, message)` — typed guest failure; `kind` is `bad_request` (including a spawn the guest cannot start: missing binary, a - cwd that is not a directory, no exec bit) / `not_found` / `unimplemented` / - `internal` + cwd that is not a directory, no exec bit, a `user` the guest cannot + resolve) / `not_found` / `unimplemented` / `internal` - `ExitError(code, stderr, stdout)` — non-zero exit from `exec` +- `SandboxTimeout` — a caller's timeout or deadline expired before the call + finished; also a `TimeoutError` - `ProtocolError` — a failed relay dial or TLS handshake, or a broken stream (EOF, oversized or undecodable frame) diff --git a/docs/sdk.md b/docs/sdk.md index 1b5e5242..95cb8e44 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -629,7 +629,8 @@ claims; the fields are those of [`GET /v1/sandboxes`](sandboxd-api.md#get-v1sand - `*sandbox.ExitError` — non-zero exit from `Exec` (`Code`, `Stderr`) - `*wire.ErrorResp` — a typed guest-side failure; `Kind` is one of `wire.KindBadRequest` (including a spawn the guest cannot start: missing - binary, a cwd that is not a directory, no exec bit), `KindNotFound`, + binary, a cwd that is not a directory, no exec bit, a `User` the guest + cannot resolve), `KindNotFound`, `KindUnimplemented`, `KindInternal` (import `github.com/cocoonstack/sandbox/protocol/wire`) diff --git a/docs/silkd.md b/docs/silkd.md index 145fbdec..bca3387e 100644 --- a/docs/silkd.md +++ b/docs/silkd.md @@ -61,7 +61,7 @@ A failed verb terminates with `error {kind, message}`: | kind | meaning | |---|---| -| `bad_request` | malformed frame, empty argv, invalid pattern, an exec argv or cwd the guest cannot spawn (missing, not a directory, not executable) | +| `bad_request` | malformed frame, empty argv, invalid pattern, an exec argv or cwd the guest cannot spawn (missing, not a directory, not executable), an exec or pty `user` the guest cannot resolve (`unknown user`, or `look up user: ` when the name service itself fails) | | `not_found` | unknown pid/session/path, a `port_forward` port nothing listens on, a language with no LSP manifest | | `unimplemented` | verb unavailable on this lane — notably git clone/push/pull on the no-network lane, whose message points at `fs.push` | | `internal` | everything else (other spawn failures, git errors, I/O) | From 63ca50ba5636168218108bc497e2fd469fd2fcae Mon Sep 17 00:00:00 2001 From: CMGS Date: Tue, 22 Sep 2026 02:28:48 +0800 Subject: [PATCH 08/11] fix(sdk/python): a closed reader is a ProtocolError; timeout bounds the whole dial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conn.recv caught OSError only, so a read after close raised the file object's bare ValueError outside the SandboxError hierarchy a Watcher relies on. dial_agent applied its timeout to every read separately — connect, TLS, the status line, each header line — so a node answering one header per interval stretched a 0.2 s dial to as long as it liked; docs/sdk-python.md promises the timeout bounds the dial and upgrade. The dial now derives its own deadline from the timeout (the smaller of that and a caller's deadline) and every step spends from it. A single header line trickled byte by byte is still bounded per read, not per dial; that takes a hostile node. Two tests: recv after close, and headers each arriving under the per-read timeout — the second fails on the previous code. --- sdk/python/cocoonsandbox/conn.py | 4 ++- sdk/python/tests/test_hardening.py | 49 +++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/sdk/python/cocoonsandbox/conn.py b/sdk/python/cocoonsandbox/conn.py index a8018a2d..0dc4a264 100644 --- a/sdk/python/cocoonsandbox/conn.py +++ b/sdk/python/cocoonsandbox/conn.py @@ -57,7 +57,7 @@ def recv(self) -> dict[str, Any]: """Reads one frame or raises a typed protocol or guest error.""" try: line = self._reader.readline(MAX_FRAME + 1) - except OSError as exc: + except (OSError, ValueError) as exc: raise ProtocolError(f"read failed: {exc}") from exc if not line: raise ProtocolError("connection closed mid-stream") @@ -159,6 +159,8 @@ def dial_agent( ssl_context: ssl.SSLContext | None = None, ) -> Conn: """Opens one TCP/HTTP Upgrade relay within timeout and the optional deadline.""" + budget = time.monotonic() + timeout + deadline = budget if deadline is None else min(deadline, budget) for name, value in (("sandbox id", sandbox_id), ("token", token)): if any(c in value for c in "\r\n\0"): raise APIError("agent upgrade", 0, f"{name} contains a control character") diff --git a/sdk/python/tests/test_hardening.py b/sdk/python/tests/test_hardening.py index c2a97b2f..c0275a35 100644 --- a/sdk/python/tests/test_hardening.py +++ b/sdk/python/tests/test_hardening.py @@ -9,7 +9,18 @@ import pytest from conftest import sandbox_at -from cocoonsandbox import APIError, Client, Lsp, ProtocolError, Pty, SandboxError, Session, SilkdError, Watcher +from cocoonsandbox import ( + APIError, + Client, + Lsp, + ProtocolError, + Pty, + SandboxError, + SandboxTimeout, + Session, + SilkdError, + Watcher, +) from cocoonsandbox.conn import Conn, dial_agent, remaining_timeout from cocoonsandbox.frames import FS_CHUNK @@ -151,3 +162,39 @@ def test_sandbox_timeout_is_both_hierarchies(): remaining_timeout(1.0, time.monotonic() - 1, "probe") with pytest.raises(TimeoutError): remaining_timeout(1.0, time.monotonic() - 1, "probe") + + +def test_recv_after_close_is_a_protocol_error() -> None: + a, b = socket.socketpair() + conn = Conn(a, a.makefile("rb")) + conn.close() + b.close() + with pytest.raises(ProtocolError): + conn.recv() + + +def test_dial_timeout_bounds_the_whole_upgrade() -> None: + server = socket.socket() + server.bind(("127.0.0.1", 0)) + server.listen(1) + + def slow_headers() -> None: + peer, _ = server.accept() + peer.sendall(b"HTTP/1.1 101 Switching Protocols\r\n") + for n in range(8): + time.sleep(0.15) + try: + peer.sendall(f"X-Slow-{n}: 1\r\n".encode()) + except OSError: + break + peer.close() + + threading.Thread(target=slow_headers, daemon=True).start() + addr = f"127.0.0.1:{server.getsockname()[1]}" + started = time.monotonic() + with pytest.raises(SandboxTimeout): + dial_agent(addr, "sb_x", "tok", timeout=0.2) + assert time.monotonic() - started < 0.6, ( + "each header arrived under the per-read timeout; only a whole-dial budget stops this" + ) + server.close() From 96dfbea43201a98fe24c8acdff0ce59696d35c74 Mon Sep 17 00:00:00 2001 From: CMGS Date: Tue, 22 Sep 2026 02:28:48 +0800 Subject: [PATCH 09/11] perf(sdk/python): take the frame fast path only where it is fast decode_response slices an exactly shaped data frame and base64-decodes it with validate=True to skip json.loads. Below Python 3.11 that validation is a regular-expression scan of the payload and the path is 1.5x slower than the generic one (py3.9 1258 vs 839 us per 256 KiB frame, ABAB best of five); on 3.11 and 3.12 it is 0.55-0.58x. The package supports 3.9+, so the fast path is now gated on 3.11. --- sdk/python/cocoonsandbox/frames.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sdk/python/cocoonsandbox/frames.py b/sdk/python/cocoonsandbox/frames.py index 64dd4aa6..137bec13 100644 --- a/sdk/python/cocoonsandbox/frames.py +++ b/sdk/python/cocoonsandbox/frames.py @@ -5,6 +5,7 @@ import base64 import binascii import json +import sys from typing import Any PROTO_VERSION = 1 @@ -14,6 +15,7 @@ FS_CHUNK = 256 * 1024 # tar and port streams chunk at 1 MiB: fewer frames per byte, still under MAX_FRAME after base64 BULK_CHUNK = 1 << 20 +_FAST_DATA = sys.version_info >= (3, 11) def encode_request(op: str, **fields: object) -> bytes: @@ -34,7 +36,7 @@ def encode_request(op: str, **fields: object) -> bytes: def decode_response(line: bytes) -> dict[str, Any]: """Decodes one response with binary payloads under data.""" # base64 is JSON-escape-free, so an exactly-shaped data frame slices without json.loads. - if line.startswith(b'{"type":"'): + if _FAST_DATA and line.startswith(b'{"type":"'): te = line.find(b'"', 9) if te > 0 and line[9:te] in (b"stdout", b"stderr", b"data") and line.startswith(b'","data":"', te): de = line.find(b'"', te + 10) From a2436d04695a9ebc26dd894eb9233e9771e61d7d Mon Sep 17 00:00:00 2001 From: CMGS Date: Tue, 22 Sep 2026 09:33:01 +0800 Subject: [PATCH 10/11] ci: the image chain detects a change to its own build workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Listing the four workflow files in images.yml's paths made such a push start a run, but detect still only looked at boot/, silkd/, protocol/ and os-image/, so all three flags came back false, every job was skipped and the run went green having built nothing — worse than not triggering, because it looks like it built. Each area now carries its own build workflow in the paths it checks: build-boot.yml with boot/, build-silkd.yml with silkd/ and protocol/, build-os-images.yml and images.yml with os-image/. --- .github/workflows/images.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index ddd4cdd0..312bc82a 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -59,9 +59,12 @@ jobs: boot=true; silkd=true; osimage=true else changed() { git diff --name-only "$BEFORE" "$SHA" -- "$@" | grep -q .; } - changed boot/ && boot=true || boot=false - changed silkd/ protocol/ && silkd=true || silkd=false - changed os-image/ && osimage=true || osimage=false + # Each area carries its own build workflow: changing how an image is + # built must rebuild it, or the run goes green having built nothing. + wf=.github/workflows + changed boot/ $wf/build-boot.yml && boot=true || boot=false + changed silkd/ protocol/ $wf/build-silkd.yml && silkd=true || silkd=false + changed os-image/ $wf/build-os-images.yml $wf/images.yml && osimage=true || osimage=false fi { echo "boot=$boot" From 637a37f7e50f3ed225320376518bc17dab12cb07 Mon Sep 17 00:00:00 2001 From: CMGS Date: Tue, 22 Sep 2026 09:36:52 +0800 Subject: [PATCH 11/11] ci: the image matrix covers a change to how the images are built images.yml now starts a run when its own build workflows change, and a changed carrier already refolds into every image. A change to build-os-images.yml or images.yml alone reached the downstream job but its matrix comes from a git diff of os-image/ only, so the matrix was empty, both matrix jobs were skipped by their include[0] guards and the run went green having built nothing. The detect step now treats a change to either of those two workflows as an all-image rebuild, the way a carrier change already is. Cache is untouched: no image input changed, so the rebuild is cached and proves the workflow, not the layers. --- .github/workflows/build-os-images.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-os-images.yml b/.github/workflows/build-os-images.yml index b9fe0bf2..c566c451 100644 --- a/.github/workflows/build-os-images.yml +++ b/.github/workflows/build-os-images.yml @@ -76,9 +76,12 @@ jobs: } # A changed carrier (boot/silkd) refolds into every image; a manual - # run or a first push (no BEFORE) rebuilds all. Otherwise only the - # git-changed image dirs rebuild. - if [[ "$CARRIER_CHANGED" == "true" || "$MANUAL" == "true" || "$BEFORE" == "0000000000000000000000000000000000000000" ]]; then + # run, a first push (no BEFORE), or a change to how the images are + # built rebuilds all. Otherwise only the git-changed image dirs + # rebuild. The workflow diff is last so a manual run never runs git. + wf=.github/workflows + if [[ "$CARRIER_CHANGED" == "true" || "$MANUAL" == "true" || "$BEFORE" == "0000000000000000000000000000000000000000" ]] || + git diff --name-only "$BEFORE" "$SHA" -- "$wf/build-os-images.yml" "$wf/images.yml" | grep -q .; then all_names all_changed="" else