Skip to content

protocol: silkd wire v2 — multiplexed streams + binary bulk frames over one long-lived connection #34

Description

@CMGS

Summary

Design for silkd wire protocol v2: multiplexed streams and binary bulk frames over one long-lived connection per sandbox, replacing one-connection-per-RPC + base64-in-JSON for payloads. This is the one remaining order-of-magnitude lever in this repo (per the #30 ledger) and a cross-cutting change to silkd (Rust), the Go SDK, the Python SDK, and the fixture corpus — worth a full design before any code. The relay is byte-blind and stays untouched.

Current state (surveyed)

  • One connection per RPC. Every verb pays a triple setup: SDK TCP dial + HTTP upgrade (GET /v1/sandboxes/{id}/agent) → relay hijack (server/relay.go:39) → fresh vsock dial to silkd (relay.go:62, DialSilkd). At exec-RTT p50 0.27ms (n=1000, bare metal, performance governor) the fixed setup cost is the bulk of the round trip.
  • A pre-dialed spare connection was measured and rejected (perf: remaining performance ledger after the 2026-07 hotspot round #30, H-4): p50 across 5 rounds 0.20/0.48/0.36/0.35/0.39ms vs a stable 0.27–0.31ms baseline, p90/p99 consistently worse — the background dial contends with the foreground RPC. Do not re-litigate; multiplexing is the fix.
  • Bulk data rides base64 in JSON data fields (sdk/go/silkd/frame.go:3, conn.go:70; mirrored in silkd/src/proto.rs and the Python SDK). Cost: +33% on the wire plus codec CPU. The perf: sandbox-side hotspot round — fork snapshot clones, codec fast path, async journal #28 fast path (fastBulk, frame.go:100-108) already bypasses full JSON decode for data frames, and fs_pull measures ~602 MiB/s / fs_push ~343 MiB/s steady — the remaining ceiling is the encoding itself, plus a per-chunk decode buffer that generates ~600 MB/s of GC garbage at max pull (perf: remaining performance ledger after the 2026-07 hotspot round #30 deferred item; needs a payload-ownership contract on Recv, which v2 framing provides naturally).
  • Server-side state is already connection-independent. Sessions and processes are addressed by id; a dropped connection loses nothing (attach/logs resume). The connection-bound verbs are the stream-shaped ones: fs_watch, pty, port_forward, lsp_request — each burns a full connection today (silkd/src/server.rs spawns a feeder per connection).
  • 8 MiB frame cap; requests carry "v": 1; unknown fields ignored. The corpus in protocol/fixtures/v1 is round-tripped by Rust, Go, and Python in CI.

Goals / non-goals

Goals: eliminate per-RPC connection setup; raw binary payloads (target fs_pull ≥ 800 MiB/s); N concurrent RPCs and streams per sandbox over one connection with per-stream flow control; strict backward compatibility (v1 stays; old SDK ↔ new silkd and new SDK ↔ old silkd both work); explicit reconnect semantics.

Non-goals: verb semantic changes (the v1 verb set carries over 1:1); TLS/transport security (the relay path is inside the deployment trust boundary — docs/security.md); compression (orthogonal, measure separately); changing the relay (it stays a byte pipe).

Proposal

1. Framing

Length-prefixed binary frames on the existing relay/vsock byte stream:

[u32 len][u32 stream_id][u8 opcode][payload]

Opcodes: OPEN (payload = the v1 JSON request object, unchanged), CTRL (payload = a v1 JSON response frame minus its data field), DATA (raw bytes — replaces every base64 data field), DATA_END (half-close, replaces stdin_close/data_end), RESET (abort stream), CREDIT (flow control), PING/PONG. Control payloads stay the v1 JSON objects, so the existing fixture corpus keeps pinning the semantic layer; a new protocol/fixtures/v2 corpus pins the binary framing (golden hex frames + a scripted mux exchange).

2. Multiplexing

Client-initiated streams only (silkd never initiates), monotonically increasing u32 ids. One OPEN per RPC; responses and data arrive as CTRL/DATA frames tagged with the stream id; terminal CTRL (exit/done/error) or RESET ends the stream. The stream-shaped verbs (fs_watch, pty, port_forward, lsp_request) map to long-lived streams — port_forward alone stops costing one TCP+vsock connection per forwarded socket. Stream cap (e.g. 256 per connection) bounds a misbehaving peer, alongside the existing 8 MiB frame cap.

3. Flow control (the hard part)

v1 gets per-RPC backpressure for free: each RPC owns a TCP connection, so a slow client paces the guest child via TCP. On a mux, one slow consumer must not head-of-line-block the connection: per-stream byte credit windows (initial e.g. 1 MiB, replenished by CREDIT), sender never exceeds granted credit. This preserves v1's documented exec-output pacing semantics per stream while keeping a stalled pty from freezing a concurrent fs_pull. This piece is non-negotiable — a mux without flow control is a regression dressed as a feature.

4. Negotiation and compatibility

First byte disambiguates: v1 requests start with {; a v2 client opens with magic SLK2 before the first frame. Old silkd sees the magic, fails the JSON parse, answers a v1 bad_request and closes — the SDK falls back to v1 and remembers per handle (one wasted round trip per sandbox, first contact only). New silkd serves both indefinitely; v1 costs nothing to keep. Old SDKs keep speaking v1 forever. The info verb's proto field reports the ceiling for debugging.

5. Reconnect semantics

A dropped connection now fails every in-flight stream at once. Rules, explicit and tested: all streams fail crisply (no partial replay); server-side state is untouched (sessions, procs — same as v1); the SDK reconnects lazily on next use; automatic retry only for idempotent verbs (fs_read/stat/list/find/ps/logs/info; fs_write is temp+rename atomic, so retry-safe); exec is never auto-retried — the error surfaces and the caller decides. No server-side request dedup in v2 (rejected below).

6. Rollout

silkd lands first (v1 default untouched, v2 behind the sniff), images rebake; Go SDK next (attempt-v2-with-fallback), Python after; benches and e2e on the testbed gate each step; flip nothing — v2 engages wherever both ends speak it. Multiple PRs, one design; mux and binary frames ship together because the negotiation, reconnect, and fixture work is shared (#30: "do both together or not at all").

Explicitly rejected

  • gRPC / HTTP/2 (h2c). The Python SDK is stdlib-only (no http2 in stdlib) and dependency-free SDKs are a product property; the relay is byte-blind so h2c through it means hand-implementing h2 framing in silkd anyway — all of the complexity, none of the ecosystem.
  • WebSocket framing. Provides message boundaries but neither multiplexing nor flow control; we'd still build both on top.
  • QUIC. Dependencies everywhere, and vsock has no datagram path here.
  • Pre-dialed connection pool. Rejected with data (perf: remaining performance ledger after the 2026-07 hotspot round #30 H-4).
  • Server-side idempotency keys / exactly-once retry. Session/proc state already survives reconnects; exec-retry policy belongs to callers; dedup tables in the guest daemon are complexity without a driving failure case.
  • Compression. Bulk payloads are often incompressible; CPU sits on the data path. Revisit with measurements if WAN relays appear.

Hot-path cost

Negative, by design: removes TCP dial + HTTP upgrade + vsock dial from every RPC after the first. Targets on the bench node (.79-class, bare metal): exec RTT p50 ≤ 0.15ms (from 0.27), fs_pull ≥ 800 MiB/s (from ~602), fs_push not regressed (its ceiling may be guest tar extract — measure before promising). Memory bounded by stream cap × credit window per connection. Claim path untouched.

Open questions

  • Connection pool >1 per sandbox for bulk parallelism (one TCP conn ≈ one relay goroutine pair ≈ one core of memcpy)? Measure single-conn ceiling first; the design leaves room (per-handle conn set) without committing.
  • Initial credit window and stream cap values — pick from bench data, not taste.
  • Keep the 8 MiB frame cap for DATA frames or raise it? Larger frames = fewer syscalls, coarser fairness granularity.
  • Python sync SDK: mux needs a demux reader — background thread guarded by a lock (proxy_port already spawns threads), or a v2-lite mode (binary frames, one stream per connection)? Leaning full mux for parity; the corpus pins both ends regardless.

Acceptance evidence

  • rpcbench n=1000, performance governor, bare metal: v2 p50 ≤ 0.15ms and p99 ≤ v1 p99; A/B against v1 in the same run.
  • pullbench 128 MiB ≥ 800 MiB/s; pushbench within noise of v1; GC garbage on sustained pull measurably reduced (the perf: remaining performance ledger after the 2026-07 hotspot round #30 deferred buffer item closes).
  • Full e2e verb smoke on both lanes over v2; fallback exercised against a v1-only silkd image (old image A/B) — both directions of the compat matrix.
  • Fault injection: kill the relay connection with ≥10 mixed in-flight streams (exec + pull + pty + watch) — every stream fails crisply, sessions/procs intact, watch re-arms, idempotent verbs auto-retry, exec surfaces the error.
  • Fairness soak: concurrent slow-consumer pty + full-rate fs_pull on one connection — pull throughput unaffected, memory bounded.
  • Fixture corpora (v1 JSON + v2 framing) round-tripped by Rust, Go, and Python in CI.

Estimated size: silkd ~600–900 lines (framing, mux, credit, sniff), Go SDK ~500–700, Python SDK ~400–600, fixtures/benches/e2e ~300. Tracks item 1 of the #30 ledger.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions