From f0386c23977283eacc366f9f2c995a89cc492260 Mon Sep 17 00:00:00 2001 From: CMGS Date: Mon, 27 Jul 2026 01:06:04 +0800 Subject: [PATCH 1/4] sandboxd: sort template hashes so the mesh unchanged-guard holds Map-iteration order made UpdateSelf's order-sensitive compare misfire on most 1s ticks once two or more promoted templates existed: each misfire cost a spurious epoch bump, a double-fsync persist, and a cluster-wide re-merge of this node's state. --- sandboxd/mesh/mesh.go | 3 ++- sandboxd/pool/promote_test.go | 18 ++++++++++++++++++ sandboxd/pool/template.go | 5 ++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/sandboxd/mesh/mesh.go b/sandboxd/mesh/mesh.go index 34a37b8..a59f0e8 100644 --- a/sandboxd/mesh/mesh.go +++ b/sandboxd/mesh/mesh.go @@ -106,7 +106,8 @@ func (m *Mesh) Join(seeds []string) error { // UpdateSelf republishes this node's warm-pool counts and promoted-template // set, bumping the epoch so peers adopt the new view. An unchanged view is // not republished — the periodic tick would otherwise gossip a fresh epoch -// every second for nothing. +// every second for nothing. templates must arrive sorted: the unchanged +// compare is order-sensitive. func (m *Mesh) UpdateSelf(ctx context.Context, pools map[string]int, templates []string) { m.updateMu.Lock() defer m.updateMu.Unlock() diff --git a/sandboxd/pool/promote_test.go b/sandboxd/pool/promote_test.go index 8f581b4..5bffcfd 100644 --- a/sandboxd/pool/promote_test.go +++ b/sandboxd/pool/promote_test.go @@ -261,3 +261,21 @@ func TestPromoteRefusesCrossTenantOverwrite(t *testing.T) { t.Errorf("root replace: %v, want ok", err) } } + +func TestTemplateHashesSortedForMeshCompare(t *testing.T) { + eng := newFakeEngine() + m := newTestManager(t, eng) + parent := mustClaim(t, m, testKey) + for _, name := range []string{"tpl:a", "tpl:b", "tpl:c", "tpl:d"} { + if _, err := m.Promote(t.Context(), parent.ID, Cred{Token: parent.Token}, name, ""); err != nil { + t.Fatalf("Promote %s: %v", name, err) + } + } + hashes := m.TemplateHashes() + if len(hashes) != 4 { + t.Fatalf("got %d hashes, want 4: %v", len(hashes), hashes) + } + if !slices.IsSorted(hashes) { + t.Errorf("TemplateHashes not sorted: %v", hashes) + } +} diff --git a/sandboxd/pool/template.go b/sandboxd/pool/template.go index 528a65c..b8cdf77 100644 --- a/sandboxd/pool/template.go +++ b/sandboxd/pool/template.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "sync" "time" @@ -114,7 +115,8 @@ func (m *Manager) DeleteTemplate(ctx context.Context, key types.PoolKey, tenant // TemplateHashes lists the promoted-template key hashes for the mesh's // template gossip, from the in-memory set — the 1s gossip tick never -// touches the store backend. +// touches the store backend. Sorted: the mesh's unchanged-guard compares +// order-sensitively, and map iteration order would defeat it every tick. func (m *Manager) TemplateHashes() []string { m.mu.Lock() pooled := make(map[string]struct{}, len(m.pools)) @@ -131,6 +133,7 @@ func (m *Manager) TemplateHashes() []string { } } m.tplMu.Unlock() + slices.Sort(hashes) return hashes } From 676d2cff9277f2a53bd999151859d8766da98e6b Mon Sep 17 00:00:00 2001 From: CMGS Date: Mon, 27 Jul 2026 01:06:04 +0800 Subject: [PATCH 2/4] silkd: drop per-chunk zero-fill in write_chunk_frame The scratch buffer is now grow-only: resize's zero-fill ran on every output chunk (~349KB memset per 256KB chunk, immediately overwritten by encode_slice) and is now paid once at high-water. bench_chunk_render 32768 x 256KB chunks: 4.08s -> 2.31s (~1.77x) on the hand-rendered path. --- silkd/src/proto.rs | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/silkd/src/proto.rs b/silkd/src/proto.rs index 98ea6a4..0161548 100644 --- a/silkd/src/proto.rs +++ b/silkd/src/proto.rs @@ -429,19 +429,28 @@ pub async fn write_chunk_frame( kind: &str, data: &[u8], ) -> io::Result<()> { - buf.clear(); - buf.extend_from_slice(b"{\"type\":\""); - buf.extend_from_slice(kind.as_bytes()); - buf.extend_from_slice(b"\",\"data\":\""); - let start = buf.len(); + const PRE: &[u8] = b"{\"type\":\""; + const MID: &[u8] = b"\",\"data\":\""; + const END: &[u8] = b"\"}\n"; let b64_len = base64::encoded_len(data.len(), true) .ok_or_else(|| io::Error::other("chunk too large to encode"))?; - buf.resize(start + b64_len, 0); + let total = PRE.len() + kind.len() + MID.len() + b64_len + END.len(); + // Grow-only, never cleared: resize's zero-fill runs once at high-water + // instead of per chunk; buf[..total] is fully overwritten below. + if buf.len() < total { + buf.resize(total, 0); + } + let mut at = 0; + for part in [PRE, kind.as_bytes(), MID] { + buf[at..at + part.len()].copy_from_slice(part); + at += part.len(); + } STANDARD - .encode_slice(data, &mut buf[start..]) + .encode_slice(data, &mut buf[at..at + b64_len]) .map_err(io::Error::other)?; - buf.extend_from_slice(b"\"}\n"); - w.write_all(buf).await?; + at += b64_len; + buf[at..at + END.len()].copy_from_slice(END); + w.write_all(&buf[..total]).await?; w.flush().await } From 2b442d2c9480d220fef60f78c7c9acd74d018c79 Mon Sep 17 00:00:00 2001 From: CMGS Date: Mon, 27 Jul 2026 01:56:19 +0800 Subject: [PATCH 3/4] sdk: trim the per-RPC dial and read path dialAgent writes the fixed-shape upgrade request directly (16 -> 2 allocs/op) with the same control-character guard as the Python SDK; frame scanning moves to wire.NewFrameScanner shared with sandboxd's engine, dropping the 64KB pre-sized buffer both copies paid per conn; ReadFile retains decoded chunks instead of re-copying through append growth, with the retention contract documented on DecodeResponse. The silkdtest fake now chunks downloads at silkd's real frame size. --- protocol/wire/frame.go | 14 +++++++++++++- sandboxd/engine/silkd.go | 8 +------- sdk/go/files.go | 14 +++++++++++--- sdk/go/files_test.go | 17 +++++++++++++++++ sdk/go/sandbox_test.go | 13 +++++++++++++ sdk/go/silkd/conn.go | 4 +--- sdk/go/silkd/silkdtest/fake.go | 9 ++++++++- sdk/go/upgrade.go | 16 ++++++++-------- 8 files changed, 72 insertions(+), 23 deletions(-) diff --git a/protocol/wire/frame.go b/protocol/wire/frame.go index 5440c05..8a50ce4 100644 --- a/protocol/wire/frame.go +++ b/protocol/wire/frame.go @@ -7,10 +7,12 @@ package wire import ( + "bufio" "bytes" "encoding/base64" "encoding/json" "fmt" + "io" "strconv" ) @@ -657,7 +659,17 @@ func DecodeRequest(line []byte) (Request, error) { return dec(line) } -// DecodeResponse parses one frame into its type's concrete Go type. +// NewFrameScanner wraps r for newline-delimited frames capped at MaxFrame. +// No pre-sized buffer: bulk frames outgrow any fixed start anyway. +func NewFrameScanner(r io.Reader) *bufio.Scanner { + sc := bufio.NewScanner(r) + sc.Buffer(nil, MaxFrame) + return sc +} + +// DecodeResponse parses one frame into its type's concrete Go type. Byte +// fields are freshly allocated per frame, so callers may retain them; pooling +// them would require a copy-out at every retention site first. func DecodeResponse(line []byte) (Response, error) { typ, err := frameTag(line, respTagHead, "type") if err != nil { diff --git a/sandboxd/engine/silkd.go b/sandboxd/engine/silkd.go index 640c020..3c8d275 100644 --- a/sandboxd/engine/silkd.go +++ b/sandboxd/engine/silkd.go @@ -27,7 +27,7 @@ func (e *Engine) dialSilkdSession(ctx context.Context, vsockSocket string) (*sil } return &silkdSession{ conn: conn, - sc: silkdScanner(conn), + sc: wire.NewFrameScanner(conn), stop: context.AfterFunc(ctx, func() { _ = conn.Close() }), }, nil } @@ -57,9 +57,3 @@ func (s *silkdSession) close() { s.stop() _ = s.conn.Close() } - -func silkdScanner(conn net.Conn) *bufio.Scanner { - sc := bufio.NewScanner(conn) - sc.Buffer(make([]byte, 64*1024), wire.MaxFrame) - return sc -} diff --git a/sdk/go/files.go b/sdk/go/files.go index c1232bb..9e01325 100644 --- a/sdk/go/files.go +++ b/sdk/go/files.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "io" + "slices" "github.com/cocoonstack/sandbox/protocol/wire" "github.com/cocoonstack/sandbox/sdk/go/silkd" @@ -21,12 +22,19 @@ func (s *Sandbox) WriteFile(ctx context.Context, path string, data []byte, mode // ReadFile returns the contents of path. func (s *Sandbox) ReadFile(ctx context.Context, path string) ([]byte, error) { - var out []byte + // Retaining b is safe: fastBulk decodes each frame into a fresh buffer. + var chunks [][]byte err := s.downloadRPC(ctx, &wire.FsRead{Path: path}, func(b []byte) error { - out = append(out, b...) + chunks = append(chunks, b) return nil }) - return out, err + if err != nil || len(chunks) == 0 { + return nil, err + } + if len(chunks) == 1 { + return chunks[0], nil + } + return slices.Concat(chunks...), nil } // ListDir returns the entries of a directory (batched frames are concatenated). diff --git a/sdk/go/files_test.go b/sdk/go/files_test.go index dc22d1b..5acf2cb 100644 --- a/sdk/go/files_test.go +++ b/sdk/go/files_test.go @@ -1,6 +1,7 @@ package sandbox import ( + "bytes" "testing" "github.com/cocoonstack/sandbox/sdk/go/silkd/silkdtest" @@ -94,6 +95,22 @@ func TestSessionLifecycle(t *testing.T) { // fakeSandbox wires a Sandbox whose data plane is served by a temp-dir-backed // silkd Fake behind a hijacking agent endpoint. +func TestReadFileMultiChunk(t *testing.T) { + sb := fakeSandbox(t) + ctx := t.Context() + want := bytes.Repeat([]byte("abcdefgh"), 80*1024) + if err := sb.WriteFile(ctx, "/big.bin", want, nil); err != nil { + t.Fatalf("write: %v", err) + } + got, err := sb.ReadFile(ctx, "/big.bin") + if err != nil { + t.Fatalf("read: %v", err) + } + if !bytes.Equal(got, want) { + t.Errorf("read %d bytes, want %d", len(got), len(want)) + } +} + func fakeSandbox(t *testing.T) *Sandbox { t.Helper() fake := silkdtest.NewFake(t.TempDir()) diff --git a/sdk/go/sandbox_test.go b/sdk/go/sandbox_test.go index b710fc2..594b24a 100644 --- a/sdk/go/sandbox_test.go +++ b/sdk/go/sandbox_test.go @@ -126,6 +126,19 @@ func TestRunRejectedUpgrade(t *testing.T) { } } +func TestDialAgentRejectsControlChars(t *testing.T) { + ts := newAgentServer(t, func(conn net.Conn) { _ = conn.Close() }) + c := testClient(t, ts) + for _, bad := range []struct{ id, token string }{ + {"sb\r\nInjected: 1", "tok"}, + {"sb_1", "tok\r\nInjected: 1"}, + } { + if _, err := c.dialAgent(t.Context(), c.addr, bad.id, bad.token); err == nil { + t.Errorf("dialAgent(%q, %q) succeeded, want control-character error", bad.id, bad.token) + } + } +} + // newAgentServer serves the agent endpoint by hijacking and handing the conn // to serve (a fake silkd), mirroring sandboxd's relay from the SDK's // perspective. diff --git a/sdk/go/silkd/conn.go b/sdk/go/silkd/conn.go index 2be470f..5e3ed7a 100644 --- a/sdk/go/silkd/conn.go +++ b/sdk/go/silkd/conn.go @@ -21,9 +21,7 @@ type Conn struct { // NewConn wraps rwc; the caller keeps ownership of closing via Close. func NewConn(rwc io.ReadWriteCloser) *Conn { - sc := bufio.NewScanner(rwc) - sc.Buffer(make([]byte, 64*1024), wire.MaxFrame) - return &Conn{rwc: rwc, sc: sc} + return &Conn{rwc: rwc, sc: wire.NewFrameScanner(rwc)} } // Send writes one request frame. diff --git a/sdk/go/silkd/silkdtest/fake.go b/sdk/go/silkd/silkdtest/fake.go index f551ce3..4991762 100644 --- a/sdk/go/silkd/silkdtest/fake.go +++ b/sdk/go/silkd/silkdtest/fake.go @@ -23,6 +23,9 @@ import ( // and tracking sessions, so an SDK write-then-read round-trips through it. // exec/info reuse the stateless handlers. It exists for host-side unit tests; // the authoritative fs/session behavior is silkd's own Rust test suite. +// readChunk mirrors silkd's BULK_CHUNK so downloads exercise real framing. +const readChunk = 256 * 1024 + type Fake struct { Root string @@ -119,7 +122,11 @@ func (f *Fake) fsRead(conn net.Conn, path string) { errFrame(conn, wire.KindNotFound, err.Error()) return } - send(conn, &wire.DataResp{Data: data}) + for len(data) > 0 { + n := min(readChunk, len(data)) + send(conn, &wire.DataResp{Data: data[:n]}) + data = data[n:] + } send(conn, wire.Done{}) } diff --git a/sdk/go/upgrade.go b/sdk/go/upgrade.go index cdac9d0..0539861 100644 --- a/sdk/go/upgrade.go +++ b/sdk/go/upgrade.go @@ -6,6 +6,7 @@ import ( "fmt" "net" "net/http" + "strings" "time" ) @@ -24,21 +25,20 @@ func (c *Client) dialAgent(ctx context.Context, addr, id, token string) (net.Con _ = raw.SetDeadline(deadline) } - req, err := http.NewRequest(http.MethodGet, "http://"+addr+"/v1/sandboxes/"+id+"/agent", nil) - if err != nil { + // id/token interpolate into the raw request; CR/LF would inject headers. + if strings.ContainsAny(id, "\r\n\x00") || strings.ContainsAny(token, "\r\n\x00") { _ = raw.Close() - return nil, err + return nil, fmt.Errorf("agent upgrade: id or token contains a control character") } - req.Header.Set("Connection", "Upgrade") - req.Header.Set("Upgrade", "silkd") - req.Header.Set("Authorization", "Bearer "+token) - if err = req.Write(raw); err != nil { + req := "GET /v1/sandboxes/" + id + "/agent HTTP/1.1\r\nHost: " + addr + + "\r\nConnection: Upgrade\r\nUpgrade: silkd\r\nAuthorization: Bearer " + token + "\r\n\r\n" + if _, err = raw.Write([]byte(req)); err != nil { _ = raw.Close() return nil, fmt.Errorf("write upgrade request: %w", err) } br := bufio.NewReader(raw) - resp, err := http.ReadResponse(br, req) + resp, err := http.ReadResponse(br, nil) if err != nil { _ = raw.Close() if ctxErr := ctx.Err(); ctxErr != nil { From 31522d53fa5b369778f2f07bd6fc781fc0624ea4 Mon Sep 17 00:00:00 2001 From: CMGS Date: Mon, 27 Jul 2026 01:56:19 +0800 Subject: [PATCH 4/4] sdk/python: TCP_NODELAY on the data plane, 256K fs chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raw data-plane sockets left Nagle on (Go's net.Dial disables it); small back-to-back frames risked delayed-ACK stalls off-loopback. FS_CHUNK now matches silkd's 256K frame chunk instead of 32K — 8x fewer frames on large writes. --- sdk/python/cocoonsandbox/conn.py | 2 ++ sdk/python/cocoonsandbox/frames.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/sdk/python/cocoonsandbox/conn.py b/sdk/python/cocoonsandbox/conn.py index e8a0d60..573268a 100644 --- a/sdk/python/cocoonsandbox/conn.py +++ b/sdk/python/cocoonsandbox/conn.py @@ -69,6 +69,8 @@ def dial_agent(addr: str, sandbox_id: str, token: str, timeout: float) -> Conn: raise APIError("agent upgrade", 0, f"{name} contains a control character") host, port = addr.rsplit(":", 1) sock = socket.create_connection((host, int(port)), timeout=timeout) + # Nagle off: exec/write send small back-to-back frames before the first read. + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) reader = None try: request = ( diff --git a/sdk/python/cocoonsandbox/frames.py b/sdk/python/cocoonsandbox/frames.py index 2ecb7ca..2d52d48 100644 --- a/sdk/python/cocoonsandbox/frames.py +++ b/sdk/python/cocoonsandbox/frames.py @@ -10,7 +10,7 @@ PROTO_VERSION = 1 MAX_FRAME = 8 * 1024 * 1024 -FS_CHUNK = 32 * 1024 +FS_CHUNK = 256 * 1024 # silkd's per-frame chunk size, distinct from BULK_CHUNK below # Bulk streams (push tars, port bytes) chunk larger — fewer frames for the # same bytes, still far under MAX_FRAME after base64; mirrors the Go SDK. BULK_CHUNK = 1 << 20