relay

package
v0.0.11 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 10, 2026 License: Apache-2.0 Imports: 6 Imported by: 0

Documentation

Overview

Package relay multiplexes many client attachments over the single outbound WebSocket a session opens to runnerd (spec rule 3). One Frame = one event for one attachment, tagged by AttachID.

internal/relay/runnerd_side.go

internal/relay/session_side.go

internal/relay/wsconn.go

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Encode

func Encode(f Frame) ([]byte, error)

func ServeSession

func ServeSession(ctx context.Context, conn Conn, s *session.Session) error

ServeSession runs on the sessiond side: it reads Frames off conn (an already-established outbound conn to runnerd) and demultiplexes them onto s by AttachID — FrameOpen calls s.Attach and starts a per-attachment goroutine pumping s's ServerMsgs back as FrameServer; FrameClient carries a raw terminal.ClientMessage into s.Stdin/s.SetSize; FrameClose calls s.Detach. Returns when conn.Read errors (conn closed).

Types

type Conn

type Conn interface {
	Read(ctx context.Context) ([]byte, error)
	Write(ctx context.Context, b []byte) error
	Close() error
}

Conn is the minimal transport interface relay logic runs against, so it's testable with in-memory pipes instead of a real network. A *websocket.Conn adapter (read/write text frames) is added in Task 9/10 where the real transports are wired.

func WSConn

func WSConn(c *websocket.Conn) Conn

type ControlEvent

type ControlEvent struct {
	Kind string `json:"kind"`
	// ID correlates a request with its one response. It is per-direction and
	// assigned by whichever end originated the request, so the same number can
	// be in flight both ways at once without collision; each end matches a
	// response only against the requests it sent. Zero means "not an RPC" —
	// the fire-and-forget event shape — which is why it is omitempty rather
	// than a pointer: an event has no id to omit ambiguously.
	ID uint64 `json:"id,omitempty"`
	// OK is a response's verdict, meaningful only on Kind "resp". False is the
	// zero value and therefore absent from the wire, which is the safe
	// direction: a peer that fails to decode it reads a failure, never a
	// spurious success. The failure's detail lives in Payload.
	OK bool `json:"ok,omitempty"`
	// Payload is the method-specific body of a request or response, opaque to
	// relay and to runnerd's forwarding alike — only the two ends that speak
	// the method interpret it. RawMessage rather than []byte so it rides as
	// nested JSON instead of a base64 string, and so a forwarder can pass it
	// through without parsing it.
	Payload json.RawMessage `json:"payload,omitempty"`
	// Stage names which stage of the session's boot chain failed on a
	// "stage_failed": "setup", "clone", "agents", or "init". Sessiond does not send it
	// yet — the boot chain that has stages is a later task — but the field is
	// defined here, with the rest of the vocabulary, so that the sender and
	// the runnerd that decodes it are agreeing on one shape rather than two
	// that happen to match.
	Stage string `json:"stage,omitempty"`
	// RC is the failing stage's exit status, or -1 when the stage was still
	// running at its timeout and was terminated.
	RC int `json:"rc,omitempty"`
	// Tail is the last few KB of the session's own output on a stage failure —
	// what the script printed before it gave up — or the timeout message for
	// RC -1. It is the only diagnostic that reaches a user whose session never
	// came up, so it travels with the event rather than being left in a log
	// inside a container that is about to go away.
	Tail string `json:"tail,omitempty"`
}

ControlEvent is the JSON payload a FrameControl carries: a message about the session as a whole rather than about any one viewer. It has three shapes, distinguished by Kind and ID:

  • An EVENT — "setup_done", "setup_failed"/"stage_failed", "child_exited" — is fire-and-forget, carries ID 0, and travels upward only (sessiond → runnerd), where runnerd turns it into an rwire event for controld.
  • A REQUEST is Kind "req:<method>" with an ID greater than zero. Either end may originate one: the sandbox asking controld to mint a git credential goes up, a diff or a push/pull goes down.
  • A RESPONSE is Kind "resp" echoing that request's ID, with OK as its verdict and Payload as its body — exactly one per request.

The event shape predates the other two and its bytes must not change: every field added for the RPC shapes is omitempty, so a Plan 4 peer keeps seeing the same `{"kind":"setup_done"}` it has always seen (pinned by TestControlEventWireShape).

It lives here, beside the frame that carries it, so the two ends of that hop cannot drift: cmd/sessiond marshals this type and internal/runnerd unmarshals it. relay does not interpret it — a payload is opaque bytes to ControlSender.Send, Hub.SendControl, and both control handlers alike — but owning the shape is what keeps a field rename from silently becoming a dropped event.

type ControlSender

type ControlSender struct {
	// contains filtered or unexported fields
}

ControlSender emits sessiond-originated control frames upstream over the conn its ServeSessionWithControl call is serving: setup outcomes, the session-RPC requests this end originates, and the responses to requests that arrived on onControl. It is safe for concurrent use with that relay — and only with that one, since sharing the conn safely means sharing its writer, not just its Conn value.

func ServeSessionWithControl

func ServeSessionWithControl(ctx context.Context, conn Conn, s *session.Session, onControl func(payload []byte)) (*ControlSender, <-chan error)

ServeSessionWithControl is ServeSession plus a control channel in both directions: it runs the relay on its own goroutine and hands back a ControlSender sharing the relay's writer, so outbound control events and terminal frames cannot interleave on the conn, while onControl receives the control frames arriving the other way — the session-RPC requests runnerd sends down, and the responses to requests this end originated.

onControl is wired here, in the constructor, for the same two reasons the Hub's is (see NewHubWithControl): assigning it to a field afterwards would race the relay goroutine this call starts, and a request arriving on the conn's first frame would find no handler installed yet. nil means inbound control frames are read and dropped, which is exactly the Plan 4 behaviour for a session that never expects to be asked anything.

Each inbound frame is dispatched on its OWN goroutine, so onControl may take as long as its method needs — an RPC that shells out to git is the point of this channel — without stalling the demux that every attachment on this conn shares. Two consequences the handler must live with: frames are not ordered against each other once dispatched (correlate by ControlEvent.ID, never by arrival), and a handler that never returns leaks its goroutine for the life of the process, so anything unbounded inside it needs its own timeout. Replies go back through the returned ControlSender, whose Send is safe to call from those goroutines.

The returned channel is buffered and receives exactly one value — whatever ServeSession returned when the conn died. It is deliberately not closed afterwards: a nil second receive would read as "the relay is fine", so callers take the value once and treat that as the end of this conn's life.

func (*ControlSender) Send

func (c *ControlSender) Send(payload []byte) error

Send wraps payload in a FrameControl on AttachID 0 (no attachment owns a control event) and writes it. It returns the conn's write error once the conn is dead, so a caller learns its event was never delivered instead of dropping it silently.

It blocks while another frame is mid-write, which on a wedged (stalled but not yet failed) conn means blocking until the context ServeSessionWithControl was given is cancelled — the same bound every other write on this conn has. Callers that must stay responsive should Send from their own goroutine.

type Frame

type Frame struct {
	Type     FrameType `json:"t"`
	AttachID uint64    `json:"a"`
	// Since is a FrameOpen's attach cursor, interpreted by session.Attach: 0
	// asks for a snapshot of the current screen, terminal.SinceAll for the whole
	// event log, anything else resumes after that sequence number. The
	// omitempty is why "the whole log" has a value of its own instead of
	// being spelled 0 — a zero Since is absent from these bytes entirely, so
	// this hop cannot tell an explicit 0 from a Frame that never set it.
	Since   uint64 `json:"s,omitempty"`
	Cols    int    `json:"c,omitempty"`
	Rows    int    `json:"r,omitempty"`
	Payload []byte `json:"p,omitempty"`
}

func Decode

func Decode(b []byte) (Frame, error)

type FrameType

type FrameType uint8
const (
	FrameOpen FrameType = iota
	FrameClose
	FrameClient
	FrameServer
	// FrameControl carries a control event — a setup outcome, a session-RPC
	// request, its response — over the same conn as the terminal mux, tagged
	// with AttachID 0 because no attachment owns it. It travels in BOTH
	// directions: up from sessiond through ControlSender.Send, down from
	// runnerd through Hub.SendControl.
	// Its value is spelled out instead of left to iota because it is
	// wire-visible: the two ends of a live conn can be different builds, so
	// the numbers above must never renumber underneath it.
	FrameControl FrameType = 4
)

type Hub

type Hub struct {
	// contains filtered or unexported fields
}

Hub runs on the runnerd side: one Hub wraps one registered session conn (the single outbound WebSocket that session opened to runnerd) and lets many clients attach over it, each multiplexed by its own AttachID.

func NewHub

func NewHub(ctx context.Context, sessionConn Conn) *Hub

func NewHubWithControl

func NewHubWithControl(ctx context.Context, sessionConn Conn, onControl func(payload []byte)) *Hub

NewHubWithControl is NewHub plus a handler for the session's control events — the FrameControls that belong to the session itself (a setup outcome, a session-RPC request the sandbox originated, the response to one this hub sent with SendControl) rather than to any attachment, which is why they carry no meaningful AttachID and bypass the client demux in readLoop.

The handler is wired here, in the constructor, rather than assigned to a field afterwards, and that is deliberate on two counts. Correctness: the constructor starts readLoop, so a later assignment would be a plain write racing that goroutine's read under the Go memory model — the same shape runnerd's OnEvent had to fix in Plan 3. Delivery: wiring it before the goroutine starts means the handler exists from the hub's very first frame, so a control event that arrives the instant a session registers cannot land in the window before a caller got around to installing a handler.

onControl runs ON readLoop's goroutine, so it must hand off and return — the same contract as runnerd's OnEvent — because blocking in it stalls every attachment multiplexed over this conn, not just the control channel. nil means control frames are read and dropped.

func (*Hub) AttachClient

func (h *Hub) AttachClient(ctx context.Context, client Conn, since uint64, cols, rows int) error

AttachClient bridges a client conn to a new attachment over the session conn: it opens the attachment (FrameOpen), then pumps client → session as FrameClient until the client disconnects, at which point it tells the session to close the attachment too. Blocks until the client conn errors.

func (*Hub) Close

func (h *Hub) Close()

Close is idempotent and safe to call more than once, including concurrently with itself or with readLoop's own h.cancel() call: cancel (from context.WithCancel) tolerates repeat calls by contract, and coder/websocket's CloseNow is a best-effort immediate close that likewise tolerates being called again on an already-closed conn. Callers relying on this: runnerd's /register handler calls it after Done() fires, and sessionOp's DELETE branch calls it directly to force that same Done() — both can legitimately race to close the same hub.

func (*Hub) Done

func (h *Hub) Done() <-chan struct{}

Done reports when the session conn has died — either readLoop noticed h.conn.Read fail on its own (container/network death) and called h.cancel(), or a caller invoked Close() directly. Both converge on the same h.ctx cancellation, so this is the one signal a caller needs to learn "this hub is no longer serving a live session conn" without duplicating readLoop's own liveness detection.

func (*Hub) SendControl

func (h *Hub) SendControl(payload []byte) error

SendControl writes payload to the session as a FrameControl on AttachID 0: the runnerd → sessiond direction of the control channel, the mirror of ControlSender.Send on the other end. It is what carries a session-RPC request down into a sandbox (a diff, a push) and a response back to one the sandbox originated. Like Send, it returns the conn's write error rather than dropping the frame silently — a caller with a pending request needs to know its request never left, or it waits for a response no one will send.

Concurrency: this writes h.conn directly, with no mutex, unlike the sessiond side's connWriter. That is not an oversight — the hub already writes this conn from every concurrent AttachClient goroutine, and this write is sound for the same reason those are: coder/websocket serializes writers itself. Conn.Write takes the conn's message-writer lock before the first byte and holds it until the message is on the wire (v1.8.15, write.go: c.write → msgWriter.reset → mu.lock, released by the deferred unlock), so a second writer waits its turn instead of interleaving its frame's bytes into another's. A mutex here would re-serialize what the transport already serializes, and would not cover the AttachClient writes anyway.

It takes h.ctx, so a write on a dead hub fails at once rather than parking on that lock: readLoop cancels that context when the session conn dies, and coder/websocket's lock is context-aware.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL