Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4bfe9b9
pool: settle a failed capture's intent before a fork or checkpoint ca…
CMGS Nov 5, 2026
8ff8f78
server: answer a preview of a released sandbox 404 through the shared…
CMGS Nov 5, 2026
0bae2a0
silkd: drop a dead session command's session only if its id still nam…
CMGS Nov 5, 2026
983ea14
silkd: count a frame's newline against the cap every client enforces
CMGS Nov 5, 2026
3f8a2e0
usage: warn when the last landed line rotated out of the journal
CMGS Nov 5, 2026
ccc31d0
ci: run the moves registry's PostgreSQL tests with the other meta-sto…
CMGS Nov 5, 2026
08ae7bf
docs: match the API, deploy, cluster, SDK and benchmark docs and the …
CMGS Nov 5, 2026
dc1b411
review: delete restating, stale and narrative comments and fold two-l…
CMGS Nov 5, 2026
f92b29c
review: order declarations per /code, name each logger after its func…
CMGS Nov 5, 2026
27a87e7
cut: inline single-caller helpers, drop a dead guard, a dead JSON tag…
CMGS Nov 5, 2026
48c3351
cut: silkd drops the detached stdin task's flag, a no-op inline hint …
CMGS Nov 5, 2026
ac58b2c
cut: python SDK drops an unreachable status check and names its chunk…
CMGS Nov 5, 2026
ceb0fb9
ci: build the silkd carrier only when silkd changes, and let a manual…
CMGS Nov 5, 2026
ea61deb
test: drop assertions that cannot fail, fail loudly where a wait time…
CMGS Nov 5, 2026
ef52a34
review: keep the volume patch tags the SDK pins through the wire-pari…
CMGS Nov 5, 2026
b8a3be1
cut: drop the info probe kept for silkd before proto 2; info on a sle…
CMGS Nov 5, 2026
cf34ade
cut: drop the claim-wide volumes_attach_only flag, WithVolumesAttachO…
CMGS Nov 5, 2026
3417bb2
build: drop the silkd build's unused toolchain downloads, the strips …
CMGS Nov 5, 2026
6820972
test: keep one-RPC fakes off connection reuse now that the probe no l…
CMGS Nov 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/build-silkd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ on:
description: sha-pinned silkd image ref this run pushed
value: ${{ jobs.merge.outputs.image }}
workflow_dispatch:
inputs:
no_cache:
type: boolean
default: false

permissions:
contents: read
Expand Down
3 changes: 1 addition & 2 deletions .github/workflows/images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ on:
paths:
- "boot/**"
- "silkd/**"
- "protocol/**"
- "os-image/**"
- ".github/workflows/images.yml"
- ".github/workflows/build-os-images.yml"
Expand Down Expand Up @@ -72,7 +71,7 @@ jobs:
# 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 silkd/ $wf/build-silkd.yml && silkd=true || silkd=false
changed os-image/ $wf/build-os-images.yml $wf/images.yml && osimage=true || osimage=false
fi
{
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ jobs:
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends musl-tools
(cd silkd && cargo build --release --locked --target x86_64-unknown-linux-musl)
strip silkd/target/x86_64-unknown-linux-musl/release/silkd
mkdir -p dist-extra
tar -C silkd/target/x86_64-unknown-linux-musl/release \
-czf "dist-extra/silkd_${GITHUB_REF_NAME#v}_Linux_x86_64.tar.gz" silkd
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/sandboxd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ jobs:
env:
SANDBOXD_TEST_PG_URL: postgres://postgres:sbxci@localhost:5432/postgres?sslmode=disable
run: |
GOWORK=off go test -race -count=1 -v ./tenants/... ./poolset/... ./archives/... ./usage/... | tee "$RUNNER_TEMP/pg-test.log"
GOWORK=off go test -race -count=1 -v ./tenants/... ./poolset/... ./archives/... ./usage/... ./moves/... | tee "$RUNNER_TEMP/pg-test.log"
GOWORK=off go test -race -count=1 -v -run 'TestTwoNodesOnOneMetaStore|TestNodesOfACell|TestAMetaStoreNodeWithoutACell' ./pool/ | tee -a "$RUNNER_TEMP/pg-test.log"
! grep -- '--- SKIP' "$RUNNER_TEMP/pg-test.log"

Expand Down
2 changes: 1 addition & 1 deletion boot/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential bc bison flex libelf-dev libssl-dev \
xz-utils curl ca-certificates python3 \
&& rm -rf /var/lib/apt/lists/*
ARG KERNEL_VERSION=6.18.37
ARG KERNEL_VERSION
# Mirror chain: KERNEL_MIRROR (cdn.kernel.org by default), then kernel.org's
# edge mirror and an rsync mirror. Download to a file first — a mid-stream
# failure must not leave a half-extracted tree — and verify against the pinned SHA256
Expand Down
2 changes: 1 addition & 1 deletion boot/init/src/cfg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ pub struct BootCfg<'a> {
pub layers: Vec<&'a str>,
/// Writable ext4 COW disk ID (same resolution rules as layers).
pub cow: &'a str,
/// Wait budget for the whole disk set; NICs get a fixed 200 ms.
/// Wait budget for the whole disk set.
pub timeout: Duration,
pub hostname: Option<&'a str>,
/// Static per-NIC config, persisted as networkd units and never applied in the initramfs.
Expand Down
7 changes: 3 additions & 4 deletions docs/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,9 @@ delta is the host, not the round's code (cocoon-specs
| cold boot (unpooled ghcr.io/cocoonstack/sandbox/python:3.12) | 215.4 ms | 215.4 ms | 221.5 ms | 3 |
| burst: 16 concurrent clones | 102.1 ms | 179.3 ms | 234.5 ms | 16 |

The clone-tier row measured warm hits this round: the pipelined refill
(clone and probe gated separately) refills between back-to-back claims, so
the adaptive watermark wins the race the harness assumes it loses. The
burst row is the real clone-path number.
The clone-tier row measured warm hits this round: before #185 the config
default turned the golden-only pool's `warm: 0` into 4, so its claims popped
warm VMs. The burst row is the real clone-path number.

| data plane | measured |
|---|---|
Expand Down
3 changes: 3 additions & 0 deletions docs/cluster.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,12 @@ curl -s -H "Authorization: Bearer $TOKEN" http://node-a:7777/v1/info | jq .
{"key": {"template": "base:24.04", "net": "none", "size": "small"},
"warm": 4, "refilling": 0, "target": 4, "golden": true}
],
"templates": [],
"claimed": 2,
"running": 2,
"hibernated": 0,
"archived": 0,
"advertise_addr": "10.0.0.5:7777",
"peers": ["10.0.0.6:7777"]
}
```
Expand Down
11 changes: 5 additions & 6 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,7 @@ taken. Unpooled claims (template and checkpoint claims) always wake.
connections to finish, and the next connection wakes the claim.

A reader is a warm VM taken from the claim's own pool, so it boots the claim's
image with the claim's layers, and it has that pool's lane: no NIC on the none
lane, the bridge NIC the claim had on the bridge lane (egress-lane claims never
image with the claim's layers, and it has no NIC (egress-lane claims never
hibernate). Taking one removes a VM from the pool until the refill replaces it,
and an empty pool never cold-boots one: the call wakes the claim. A reader is never shared: it
serves one claim, and only the claim's own token reaches it. The guest's own
Expand Down Expand Up @@ -383,8 +382,8 @@ with concurrent readers.
proceed; a crashed writer leaves it, and `ro` claims are refused until one
`rw` claim replays and releases cleanly.

**Attach-only claims opt out of the marker, not out of admission.** A claim
sent with `volumes_attach_only` gets the device attached and nothing else, so
**Attach-only claims opt out of the marker, not out of admission.** A volume
claimed with `attach_only` gets the device attached and nothing else, so
sandboxd neither writes nor clears `<path>.dirty` for it — it cannot verify a
mount it did not perform. Everything in this section still applies to the
default, mounting claims, and the exclusion rules above apply to attach-only
Expand Down Expand Up @@ -600,7 +599,7 @@ can delete only its own checkpoints and templates (root sees everything).
Operator surfaces stay root-only — per-id sandbox reads and stats,
`PUT/DELETE /v1/volumes/{name}`, `PUT /v1/sandboxes/{id}/instance-metadata`,
`GET /v1/checkpoints/{id}/blob`, `GET /v1/info`, `PUT /v1/pools`,
`/v1/tenants`, `/v1/config/reload`, `POST/DELETE /v1/drain`, `/metrics`, `GET /v1/usage`; a
`/v1/tenants`, `/v1/config/reload`, `POST/DELETE /v1/drain`, `POST /v1/sandboxes/{id}/move` and the peer `/v1/moves/{id}` calls, `/metrics`, `GET /v1/usage`; a
tenant token there is authenticated but not authorized, so it answers 403 (a
wrong token stays 401). Per-sandbox
tokens are unchanged: whoever holds a sandbox's token drives that sandbox.
Expand Down Expand Up @@ -1213,7 +1212,7 @@ HTTP port under a signed, expiring shareable URL; a node that cannot bind it
exits. The whole mechanism is in sandboxd:

- **Minting** (`sb.PreviewURL(ctx, port, ttl)`): the owner node signs a token
embedding `{sandbox, port, owner, exp}` with `preview_secret`; the URL's
embedding `{id, port, owner, exp}` with `preview_secret`; the URL's
life is clamped to the claim's lease.
- **Serving**: any node's preview listener verifies the token (no shared
state), then reverse-proxies to the guest port over the relay if it owns
Expand Down
2 changes: 1 addition & 1 deletion docs/e2b.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ for the truth.
`e2b-ci` is built from e2b's own recipe, `e2b-dev/code-interpreter`
`template/` at the `CI_RECIPE_SHA` the Dockerfile pins: the build fetches that
commit's archive, checks its sha256, and applies the one local change listed in
`os-image/e2b-ci/PATCHES`; e2b's license ships in the image under
`os-image/e2b-ci/PATCHES.md`; e2b's license ships in the image under
`/usr/share/doc/e2b-code-interpreter/`. Moving to a newer recipe is a bump of the
two `CI_RECIPE_*` args plus a refresh of the constraints files. On top of `e2b-rt`'s contents it runs a Jupyter server on
loopback 8888 and the code-interpreter API on 49999, both enabled at boot, and
Expand Down
3 changes: 2 additions & 1 deletion docs/egress.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,8 @@ it, each allowed request reads the claim's env snapshot, without a lock.
tunnel or request when it ends, with the payload bytes it moved (`tx` guest to
destination, `rx` back) and its upstream, so per-exit traffic can be billed.
The counts come from the copies the proxy already runs; the cost is one more
usage-journal append per connection, which is why it is opt-in.
usage-journal append per connection, which is why it is opt-in. A tunnel that a
sandboxd stop cuts records no `egress_bytes` event.

## HTTPS interception

Expand Down
5 changes: 2 additions & 3 deletions docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ keep an idle relay for 30 seconds and send later RPCs on it; use mode C of

Every data-plane RPC used to pay a TCP dial, an HTTP Upgrade and, behind a
TLS edge, a handshake. From proto 2 silkd serves RPCs back to back, and both
SDKs keep one relay connection per handle with a 30s idle window.
SDKs park up to 8 idle relay connections per handle for a 30s idle window.

`e2e/cmd/rpcbench`, warm `rt:24.04` sandbox, `net=none`, n=200 `fs_stat`
RPCs per arm, bare-metal host B, client on the node, 2026-09-16 at
Expand All @@ -88,8 +88,7 @@ loaded node moved every arm together and left the ratio intact.

The edge here is a local HTTP/1.1 TLS terminator on loopback, so its
handshake carries no network RTT — a client across a WAN saves more, not
less. An old daemon reporting proto 1 makes the SDK dial per call, which is
the plain dial-per-RPC row.
less.

The node's own hybrid-vsock `CONNECT` handshake read its `OK <port>` reply one
byte per `read(2)` until #304 moved it to one buffered read: 13 fewer syscalls
Expand Down
21 changes: 8 additions & 13 deletions docs/sandboxd-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ All bodies are JSON. Three token kinds:
preview mint) and the tenant-scoped listings/deletes below; everything a
tenant creates is stamped with its name. Operator surfaces
(the per-id sandbox reads, `PUT /v1/sandboxes/{id}/instance-metadata`, `GET /v1/info`,
`PUT /v1/pools`, `PUT/DELETE /v1/volumes/{name}`, `POST/DELETE /v1/drain`,
`PUT /v1/pools`, `POST /v1/config/reload`, the [tenant API](#tenants-v1tenants),
`POST /v1/sandboxes/{id}/move`, `PUT/DELETE /v1/volumes/{name}`, `POST/DELETE /v1/drain`,
`GET /metrics`, `GET /v1/usage`, `GET /v1/checkpoints/{id}/blob`)
answer a tenant token `403` — authenticated but not authorized; an unknown
token stays `401`. On a node with a shared
Expand Down Expand Up @@ -112,9 +113,6 @@ Auth: `Authorization: Bearer <api_token>` (when configured).
entry's `writable: true` (see [deploy](deploy.md#dataset-volumes)).
`attach_only: true` attaches that entry without mounting it and requires an
empty or omitted `mount`. Mounted and attach-only entries can share a list
- `volumes_attach_only` (default `false`) is the legacy claim-wide form of
`attach_only`. The server expands it onto every entry before validation.
It requires at least one volume and rejects a nonempty `mount`.

Success:

Expand Down Expand Up @@ -250,7 +248,7 @@ an unknown and a forbidden volume return the same error text.

Errors: 400 unknown template axis, `claim_ref` or metadata over a bound (the
message names it), a bad `env` entry, an unknown `on_expire`, invalid/duplicate volumes,
`volumes_attach_only` with no volumes, or an attach-only entry with a nonempty `mount`,
an attach-only entry with a nonempty `mount`,
`mode: "rw"`
against a non-writable entry, or a volume that is unknown or forbidden (the
latter two are deliberately indistinguishable), or
Expand Down Expand Up @@ -448,9 +446,7 @@ The per-entry field requires coordinated daemon and SDK releases. Older daemons
ignore `attach_only` in claims and reject it in live PUT and PATCH with 400.
Older Python SDKs reject the field when
returned descriptors are reused in requests. Responses without the field do
not safely roundtrip attach-only intent through new clients. The server still
accepts the legacy claim-wide request flag and normalizes old persisted empty
mounts when loading claims.
not safely roundtrip attach-only intent through new clients.

### Partial failure and recovery

Expand Down Expand Up @@ -900,8 +896,8 @@ URL; releasing the sandbox ends it early either way.
400 a port of 0, 404 unknown id or wrong sandbox token, 501 when the node
has no `preview_listen`. The signed token embeds the sandbox id, port, and
owner `advertise_addr`, so any node's preview listener can serve it (forwarding
to the owner's main listener) and a released sandbox's URL simply stops
resolving — no revocation list. A browser request to the minted URL answers 503
to the owner's main listener) and a released sandbox's URL answers 404 —
no revocation list. A browser request to the minted URL answers 503
`lease lost; retry` for a replicated claim while its node's lease is overdue, 503 `sandbox moving to another node; retry` while an [evacuation](#post-v1drain) has yet to move it, and 429 when it has to wake a sandbox while the disk reserve is low or the node is at `max_running`. See [deploy](deploy.md#preview-urls).

## POST /v1/sandboxes/{id}/checkpoint
Expand Down Expand Up @@ -1236,9 +1232,8 @@ With it, the node answers the `101` before it touches the sandbox, reads the
first frame, and serves `fs_stat`, `fs_list`, `fs_read` and `fs_pull` from a
reader VM, a warm VM of the sandbox's pool that mounts a copy of the sandbox's disk read-only: the sandbox stays
hibernated or archived, no `wake` usage event is recorded, and the read stamps
neither the idle nor the archive clock. An `info` frame pipelined ahead of the
call (the SDKs' first frames) rides along to the reader; one alone, with no call
behind it for 100 ms, wakes the sandbox. A second `info` frame before any call wakes it too. The first frame on the connection that
neither the idle nor the archive clock. An `info` frame wakes the sandbox, since a
reader would answer with its own processes and uptime. The first frame on the connection that
is anything else, or any later one, wakes the sandbox and hands the same
connection to its silkd with the unread bytes replayed, so a client sees one
silkd and no close. When the sandbox sleeps again meanwhile (a new hibernate or
Expand Down
4 changes: 2 additions & 2 deletions docs/sdk-python.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,8 @@ Volume sandboxes cannot hibernate, fork, checkpoint, or promote. Egress-lane
sandboxes cannot either; each answers 409. Checkpoint
branches do not accept volumes of either mode in this version.

`mount=False` on `client.new` or `template.new` leaves the mount to you: the
dictionaries in `sb.volumes` carry no `mount` key; see
`{"attach_only": True}` on a volume entry leaves the mount to you: that
entry in `sb.volumes` carries no `mount` key; see
[attach-only volumes](sandboxd-api.md#attach-only-volumes) for finding the device
and the consistency contract.
For a root client managing a tenant claim, see the
Expand Down
11 changes: 5 additions & 6 deletions docs/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,6 @@ defer sb.Close()
| `WithNetwork(n)` | `NetNone`, `NetEgress` | `NetNone` | Cloud Hypervisor network shape: `NetNone` disables the NIC and uses vsock-only I/O; `NetEgress` attaches a bridge/CNI NIC |
| `WithSize(s)` | `Small`, `Medium`, `Large`, `XLarge`, `XXLarge` | `Small` | resource tier: 1cpu/512M, 2cpu/1G, 4cpu/4G, 4cpu/8G, 8cpu/16G |
| `WithVolumes(volumes...)` | `Volume{Name, Mount?, Mode?, AttachOnly?}` entries | none | attach and mount up to eight unique catalog dataset disks; `Mount` defaults to `/volumes/<name>`; `Mode` is `"ro"` (default) or `"rw"` — `"rw"` requires the catalog entry's `writable: true`; supported by `Client.New` and `Template.New` |
| `WithVolumesAttachOnly()` | — | mount | legacy claim-wide form of `Volume.AttachOnly`: attach every requested volume without mounting it; the workload finds each device and owns the mount. sandboxd rejects a `Volume.Mount` with 400 |
| `WithTimeout(d)` | duration | server default 5m | sandbox TTL, rounded up to seconds, server-capped at 24h. The node reaps the sandbox after the TTL even if the client vanishes |

`New` returns when the sandbox's silkd answers: a warm hit is milliseconds,
Expand All @@ -232,9 +231,9 @@ moved to another node with a `Lookup`, also when that node is gone, releasing
one already gone is not an error, and `Close` is bounded internally so it stays defer-friendly). Any other
call on a handle answers 404 once its sandbox has moved; run `Lookup` to tell
that from a released sandbox.
Volume sandboxes cannot hibernate, fork, checkpoint, or promote. Egress-lane
sandboxes cannot either, nor take `WithArchiveOnExpire`; each answers 409. Passing
`WithVolumes`, `WithVolumesAttachOnly` or `WithClaimRef` to `Checkpoint.New` returns a local error:
Volume sandboxes and egress-lane sandboxes cannot hibernate, fork, checkpoint,
or promote, nor take `WithArchiveOnExpire`; each answers 409. Passing
`WithVolumes` or `WithClaimRef` to `Checkpoint.New` returns a local error:
checkpoint branches support neither volumes nor a claim reference in this version.
`WithNetwork` or `WithSize` to `Checkpoint.New` or `Template.New` does too: the
snapshot pins the lane and size.
Expand Down Expand Up @@ -297,8 +296,8 @@ refusal, the release hand-off and a failed sandbox's release-only rule are in th
[same-sandbox replacement tutorial](live-volumes.md). A settled retained entry
receives no new attach or mount calls.

`WithVolumesAttachOnly()` leaves the mount to you: the entries in
`Sandbox.Volumes` carry an empty `Mount`; see
`Volume{AttachOnly: true}` leaves the mount to you: the entry in
`Sandbox.Volumes` carries an empty `Mount`; see
[attach-only volumes](sandboxd-api.md#attach-only-volumes) for finding the device
and the consistency contract.

Expand Down
3 changes: 1 addition & 2 deletions e2e/cmd/androidsmoke/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,7 @@ func adbHandshake(ctx context.Context, sb *sandbox.Sandbox) error {
}
}

// dialAdb sends one ADB CNXN message; adbd replies CNXN (unauthenticated)
// or AUTH — either proves a live endpoint.
// dialAdb sends one ADB CNXN; a CNXN or AUTH reply proves a live adbd.
func dialAdb(ctx context.Context, sb *sandbox.Sandbox) (string, error) {
pc, err := sb.DialPort(ctx, adbPort)
if err != nil {
Expand Down
58 changes: 29 additions & 29 deletions e2e/cmd/guestserver/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,35 +18,6 @@ import (
"sync"
)

func main() {
addr := flag.String("addr", "127.0.0.1:49983", "loopback address to listen on")
halfClose := flag.String("half-close-addr", "", "raw address that greets, shuts its write side, then reads")
flag.Parse()

var seen halfCloseSink
if *halfClose != "" {
go seen.serve(*halfClose)
}

mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("GET /half-close-seen", func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, seen.read())
})
mux.HandleFunc("/", report)

var protocols http.Protocols
protocols.SetHTTP1(true)
protocols.SetUnencryptedHTTP2(true)
srv := &http.Server{Addr: *addr, Handler: mux, Protocols: &protocols} //nolint:gosec // loopback test server inside a sandbox; no header timeout by design
if err := srv.ListenAndServe(); err != nil {
fmt.Fprintln(os.Stderr, "guestserver:", err)
os.Exit(1)
}
}

// halfCloseSink greets, shuts its write side, and keeps reading what the client sends after.
type halfCloseSink struct {
mu sync.Mutex
Expand Down Expand Up @@ -96,6 +67,35 @@ func (h *halfCloseSink) read() string {
return h.got
}

func main() {
addr := flag.String("addr", "127.0.0.1:49983", "loopback address to listen on")
halfClose := flag.String("half-close-addr", "", "raw address that greets, shuts its write side, then reads")
flag.Parse()

var seen halfCloseSink
if *halfClose != "" {
go seen.serve(*halfClose)
}

mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("GET /half-close-seen", func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, seen.read())
})
mux.HandleFunc("/", report)

var protocols http.Protocols
protocols.SetHTTP1(true)
protocols.SetUnencryptedHTTP2(true)
srv := &http.Server{Addr: *addr, Handler: mux, Protocols: &protocols} //nolint:gosec // loopback test server inside a sandbox; no header timeout by design
if err := srv.ListenAndServe(); err != nil {
fmt.Fprintln(os.Stderr, "guestserver:", err)
os.Exit(1)
}
}

// report answers with what the guest received, so a caller can assert the relay changed nothing.
func report(w http.ResponseWriter, r *http.Request) {
body := new(strings.Builder)
Expand Down
Loading
Loading