diff --git a/docs/src/relationship-to-shepherd.md b/docs/src/relationship-to-shepherd.md index e58f302..3dad8bc 100644 --- a/docs/src/relationship-to-shepherd.md +++ b/docs/src/relationship-to-shepherd.md @@ -25,15 +25,18 @@ ownership: shepherd user. Shepherd owns the workspace-membership story, the resumable session ID model, and the persistent state. -## What shepherd will pull from mosey +## What shepherd pulls from mosey -In its current form, shepherd vendors a copy of these primitives. -The migration path is to swap the vendored copy for a direct -dependency on `github.com/firefly-engineering/mosey` once the -v0.1 surface is stable enough to commit to. +Shepherd already depends on `github.com/firefly-engineering/mosey` +directly — it no longer vendors a copy. This list is therefore the +authoritative *committed surface*: the exported API mosey keeps +stable for shepherd. Anything exported but absent from this list and +unused inside mosey is a deletion candidate before the v0.1 surface +freezes. -Specifically, shepherd needs: +Specifically, shepherd imports: +- `api` for the wire protocol IDs and message types. - `transport.Transport` + libp2p backend for cross-host attaches. - `auth.Wrap` + cert authenticator for workspace membership. - `vterm.Session` for the agent-side process under PTY. diff --git a/screen/screen.go b/screen/screen.go deleted file mode 100644 index a16a913..0000000 --- a/screen/screen.go +++ /dev/null @@ -1,119 +0,0 @@ -// Package screen renders raw PTY bytes into a rendered terminal -// surface, so downstream observers can pattern-match the *displayed* -// state of an attached vterm instead of fighting ANSI escapes and -// cursor moves in the byte stream. -// -// Typical use is alongside an attach client: feed every byte the -// attach output goroutine reads into [Screen.Write], then snapshot -// [Screen.Text] (or inspect individual [Cell] values) whenever the -// observer wants to evaluate a match. -// -// s := screen.New(80, 24) -// go func() { -// buf := make([]byte, 4096) -// for { -// n, err := ptyOutput.Read(buf) -// if n > 0 { _, _ = s.Write(buf[:n]) } -// if err != nil { return } -// } -// }() -// // later: -// if strings.Contains(s.Text(), "Waiting for input") { ... } -// -// The implementation wraps a vt100 emulator ([vt10x.Terminal]). -// Geometry is fixed by [New] / [Screen.Resize]; mosey does not -// coordinate resize with the upstream vterm here — the consumer -// picks whatever (cols, rows) is convenient for their matching -// rules, and the emulator letterboxes / clips as needed. -package screen - -import ( - "io" - - "github.com/hinshun/vt10x" -) - -// Cell is a single grid position's rendered state. Just the rune -// today; colour / attribute fields can land as observer use cases -// demand them. -type Cell struct { - // Char is the displayed rune at this cell. Cells that have - // never been written are blank spaces. - Char rune -} - -// Screen is the rendered terminal surface produced by replaying -// PTY bytes through a vt100 emulator. -type Screen interface { - // Writer accepts raw PTY bytes (escape sequences, cursor moves, - // printable text) and updates the rendered grid. Always - // consumes the full input — n == len(p), err == nil — short of - // catastrophic emulator failure. - io.Writer - - // Size returns the configured (cols, rows). Stable across - // reads; changes only via Resize. - Size() (cols, rows int) - - // Resize reshapes the grid. The underlying emulator's reflow - // rules apply; in particular, narrowing the grid clips, not - // wraps, the offscreen content. - Resize(cols, rows int) - - // Text returns the rendered grid as one string: rows joined - // with '\n', each row padded with spaces out to the grid's - // configured width and terminated with a trailing '\n'. Stable - // shape regardless of what was written — easy to substring / - // regex against. - Text() string - - // Cell returns the rune at (col, row). Out-of-bounds positions - // return the zero Cell. - Cell(col, row int) Cell - - // Cursor returns the current cursor position (col, row). - Cursor() (col, row int) -} - -// New constructs a Screen of the given dimensions. cols and rows -// must be positive; the function does not validate — passing zero -// or negative values produces an emulator that ignores writes. -func New(cols, rows int) Screen { - return &vtScreen{ - term: vt10x.New(vt10x.WithSize(cols, rows)), - cols: cols, - rows: rows, - } -} - -// vtScreen is the only Screen implementation. Kept private so a -// future swap to a different emulator doesn't break callers. -type vtScreen struct { - term vt10x.Terminal - cols int - rows int -} - -func (s *vtScreen) Write(p []byte) (int, error) { return s.term.Write(p) } - -func (s *vtScreen) Size() (int, int) { return s.term.Size() } - -func (s *vtScreen) Resize(cols, rows int) { - s.term.Resize(cols, rows) - s.cols, s.rows = cols, rows -} - -func (s *vtScreen) Text() string { return s.term.String() } - -func (s *vtScreen) Cell(col, row int) Cell { - cols, rows := s.term.Size() - if col < 0 || row < 0 || col >= cols || row >= rows { - return Cell{} - } - return Cell{Char: s.term.Cell(col, row).Char} -} - -func (s *vtScreen) Cursor() (int, int) { - c := s.term.Cursor() - return c.X, c.Y -} diff --git a/screen/screen_test.go b/screen/screen_test.go deleted file mode 100644 index 6e3f67e..0000000 --- a/screen/screen_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package screen_test - -import ( - "strings" - "testing" - - "github.com/firefly-engineering/mosey/screen" -) - -// pad returns s right-padded with spaces to width cols. Mirrors -// what the underlying vt100 emulator writes when it pads the grid -// out to its configured width, so tests can build the expected -// Text() output without manually counting spaces. -func pad(s string, cols int) string { - if len(s) >= cols { - return s[:cols] - } - return s + strings.Repeat(" ", cols-len(s)) -} - -func TestNew_BlankGrid(t *testing.T) { - s := screen.New(10, 3) - cols, rows := s.Size() - if cols != 10 || rows != 3 { - t.Fatalf("Size = (%d, %d), want (10, 3)", cols, rows) - } - got := s.Text() - want := strings.Repeat(pad("", 10)+"\n", 3) - if got != want { - t.Fatalf("blank grid text mismatch:\ngot %q\nwant %q", got, want) - } - if col, row := s.Cursor(); col != 0 || row != 0 { - t.Fatalf("cursor = (%d, %d), want (0, 0)", col, row) - } -} - -func TestWrite_PlainText(t *testing.T) { - s := screen.New(10, 2) - if _, err := s.Write([]byte("hello")); err != nil { - t.Fatalf("write: %v", err) - } - if got, want := s.Text(), pad("hello", 10)+"\n"+pad("", 10)+"\n"; got != want { - t.Fatalf("text mismatch:\ngot %q\nwant %q", got, want) - } - if c := s.Cell(0, 0); c.Char != 'h' { - t.Errorf("Cell(0,0).Char = %q, want 'h'", c.Char) - } - if c := s.Cell(4, 0); c.Char != 'o' { - t.Errorf("Cell(4,0).Char = %q, want 'o'", c.Char) - } - if c := s.Cell(5, 0); c.Char != ' ' { - t.Errorf("Cell(5,0).Char = %q, want ' '", c.Char) - } - if col, row := s.Cursor(); col != 5 || row != 0 { - t.Errorf("cursor = (%d, %d), want (5, 0)", col, row) - } -} - -func TestWrite_NewlineAdvancesRow(t *testing.T) { - s := screen.New(10, 3) - if _, err := s.Write([]byte("abc\r\ndef")); err != nil { - t.Fatalf("write: %v", err) - } - if got, want := s.Text(), pad("abc", 10)+"\n"+pad("def", 10)+"\n"+pad("", 10)+"\n"; got != want { - t.Fatalf("text mismatch:\ngot %q\nwant %q", got, want) - } - if col, row := s.Cursor(); col != 3 || row != 1 { - t.Errorf("cursor = (%d, %d), want (3, 1)", col, row) - } -} - -func TestWrite_AnsiColourIsStripped(t *testing.T) { - // SGR colour escapes shouldn't appear in Text(); only the - // printable runes do. This is the property observers most rely - // on — regex against rendered output, not raw stream. - s := screen.New(10, 1) - if _, err := s.Write([]byte("\x1b[31mRED\x1b[0m")); err != nil { - t.Fatalf("write: %v", err) - } - if got, want := s.Text(), pad("RED", 10)+"\n"; got != want { - t.Fatalf("text mismatch:\ngot %q\nwant %q", got, want) - } -} - -func TestWrite_CursorMoveOverwrites(t *testing.T) { - s := screen.New(10, 1) - // Write "hello", move cursor to col 0 via CR, overwrite with "HE". - if _, err := s.Write([]byte("hello\rHE")); err != nil { - t.Fatalf("write: %v", err) - } - if got, want := s.Text(), pad("HEllo", 10)+"\n"; got != want { - t.Fatalf("text mismatch:\ngot %q\nwant %q", got, want) - } -} - -func TestCell_OutOfBoundsReturnsZero(t *testing.T) { - s := screen.New(5, 2) - for _, p := range [][2]int{{-1, 0}, {0, -1}, {5, 0}, {0, 2}, {10, 10}} { - if c := s.Cell(p[0], p[1]); c != (screen.Cell{}) { - t.Errorf("Cell(%d, %d) = %+v, want zero", p[0], p[1], c) - } - } -} - -func TestResize_ChangesSize(t *testing.T) { - s := screen.New(10, 3) - s.Resize(20, 5) - if cols, rows := s.Size(); cols != 20 || rows != 5 { - t.Fatalf("after resize Size = (%d, %d), want (20, 5)", cols, rows) - } - // Blank grid in the new geometry. - if got, want := s.Text(), strings.Repeat(pad("", 20)+"\n", 5); got != want { - t.Fatalf("post-resize blank text mismatch:\ngot %q\nwant %q", got, want) - } -} diff --git a/streambuf/doc.go b/streambuf/doc.go index 2c8ff13..11d5528 100644 --- a/streambuf/doc.go +++ b/streambuf/doc.go @@ -2,22 +2,19 @@ // uses for disconnection tolerance, per // docs/src/internals/process-model.md "Disconnection tolerance". // -// Two types live here: +// One type lives here: // // - [OutputRing] — a sequence-numbered ring buffer of agent → pane // output bytes. Owned by shepherd-d for as long as the agent is // running; replayed on reattach so a brief network blip or a // daemon-side relay restart doesn't lose rendered output. -// - [InputQueue] — a bounded FIFO of pane → agent input chunks the -// stream-agent holds across a disconnect. Drained back into the -// stream once a fresh AttachAgent is open. // // The wire format that resumes a session (PaneAttach.resume_seq plus // AgentReady.first_seq / next_seq) lives in // internal/api/v1/shepherd.proto; the relay glue that actually pumps -// bytes through these buffers is added by shep-d3f.7 (libp2p relay) on -// top of the v0.2 attach skeleton (shep-d3f.3). The types themselves -// are kept self-contained and unit-testable so the wiring layer is a +// bytes through this buffer is added by shep-d3f.7 (libp2p relay) on +// top of the v0.2 attach skeleton (shep-d3f.3). The type itself +// is kept self-contained and unit-testable so the wiring layer is a // thin straightforward addition rather than a redesign. // // Defaults are documented on the constructors and exported as diff --git a/streambuf/input_queue.go b/streambuf/input_queue.go deleted file mode 100644 index 623bc0a..0000000 --- a/streambuf/input_queue.go +++ /dev/null @@ -1,152 +0,0 @@ -package streambuf - -import ( - "errors" - "sync" -) - -// DefaultInputQueueBytes is the default total byte capacity of -// [InputQueue]. 64 KiB is large enough for a brief network blip's -// worth of typing — at human typing speed of 5 cps a user can fill -// at most ~300 bytes per minute, so this is over three hours of -// pent-up input. Bigger doesn't help, smaller risks dropping under -// paste loads. -const DefaultInputQueueBytes = 64 * 1024 - -// DropPolicy selects what an InputQueue does when an Enqueue would -// exceed the configured capacity. -type DropPolicy int - -const ( - // DropOldest evicts the oldest queued chunks until the new write - // fits. This is the default and the right answer when the bytes - // being typed are interactive (terminal input) — the user's - // latest keystrokes are more useful than something they typed - // minutes ago. - DropOldest DropPolicy = iota - // DropNew refuses the write; Enqueue returns [ErrQueueFull] and - // the queue is unchanged. Use this when callers want a clear - // signal that data was dropped (e.g. structured input). - DropNew -) - -// ErrQueueFull is returned by [InputQueue.Enqueue] under DropNew when -// the queue can't fit the incoming chunk. -var ErrQueueFull = errors.New("streambuf: input queue is full and drop policy is DropNew") - -// InputQueue is a bounded FIFO of byte chunks the stream-agent -// accumulates while the AttachAgent stream is disconnected. Chunks -// preserve message boundaries: the queue does not splice chunks -// together so the daemon receives bytes in the same shape they -// arrived from the PTY. -// -// Safe for concurrent use. -type InputQueue struct { - mu sync.Mutex - chunks [][]byte - bytes int - capacity int - policy DropPolicy -} - -// NewInputQueue constructs an InputQueue capped at capacity bytes -// across all queued chunks. capacity <= 0 means -// [DefaultInputQueueBytes]. policy controls eviction when the queue -// fills. -func NewInputQueue(capacity int, policy DropPolicy) *InputQueue { - if capacity <= 0 { - capacity = DefaultInputQueueBytes - } - return &InputQueue{capacity: capacity, policy: policy} -} - -// Capacity returns the configured maximum number of bytes the queue -// will retain. -func (q *InputQueue) Capacity() int { return q.capacity } - -// Len returns the number of queued chunks (not bytes). -func (q *InputQueue) Len() int { - q.mu.Lock() - defer q.mu.Unlock() - return len(q.chunks) -} - -// Bytes returns the number of bytes currently queued. -func (q *InputQueue) Bytes() int { - q.mu.Lock() - defer q.mu.Unlock() - return q.bytes -} - -// Enqueue appends chunk to the queue. The chunk is copied so the -// caller's buffer is free to be reused. Returns the number of bytes -// dropped to make room (0 in the common case) or [ErrQueueFull] under -// DropNew. A chunk larger than capacity itself is special-cased: it -// replaces the whole queue with its trailing capacity bytes (under -// DropOldest) or is rejected (under DropNew). -func (q *InputQueue) Enqueue(chunk []byte) (dropped int, err error) { - if len(chunk) == 0 { - return 0, nil - } - q.mu.Lock() - defer q.mu.Unlock() - - if len(chunk) > q.capacity { - if q.policy == DropNew { - return 0, ErrQueueFull - } - // Replace queue with trailing capacity bytes of chunk. - tail := chunk[len(chunk)-q.capacity:] - droppedBefore := q.bytes - q.chunks = q.chunks[:0] - buf := make([]byte, len(tail)) - copy(buf, tail) - q.chunks = append(q.chunks, buf) - q.bytes = len(buf) - return droppedBefore + (len(chunk) - q.capacity), nil - } - - if q.bytes+len(chunk) <= q.capacity { - buf := make([]byte, len(chunk)) - copy(buf, chunk) - q.chunks = append(q.chunks, buf) - q.bytes += len(buf) - return 0, nil - } - - if q.policy == DropNew { - return 0, ErrQueueFull - } - for q.bytes+len(chunk) > q.capacity && len(q.chunks) > 0 { - head := q.chunks[0] - dropped += len(head) - q.bytes -= len(head) - q.chunks = q.chunks[1:] - } - buf := make([]byte, len(chunk)) - copy(buf, chunk) - q.chunks = append(q.chunks, buf) - q.bytes += len(buf) - return dropped, nil -} - -// Drain removes all queued chunks and returns them in FIFO order. -// The queue is empty on return. Callers typically Drain right after -// reconnect and re-send each chunk on the new stream. -func (q *InputQueue) Drain() [][]byte { - q.mu.Lock() - defer q.mu.Unlock() - out := q.chunks - q.chunks = nil - q.bytes = 0 - return out -} - -// Reset drops everything queued without returning it. Primarily for -// tests. -func (q *InputQueue) Reset() { - q.mu.Lock() - defer q.mu.Unlock() - q.chunks = nil - q.bytes = 0 -} diff --git a/streambuf/input_queue_test.go b/streambuf/input_queue_test.go deleted file mode 100644 index 5af2acd..0000000 --- a/streambuf/input_queue_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package streambuf_test - -import ( - "errors" - "runtime" - "sync" - "testing" - - "github.com/firefly-engineering/mosey/streambuf" -) - -func TestInputQueue_DefaultCapacity(t *testing.T) { - t.Parallel() - q := streambuf.NewInputQueue(0, streambuf.DropOldest) - if got, want := q.Capacity(), streambuf.DefaultInputQueueBytes; got != want { - t.Fatalf("Capacity = %d, want %d", got, want) - } -} - -func TestInputQueue_EnqueueDrainPreservesChunkBoundaries(t *testing.T) { - t.Parallel() - q := streambuf.NewInputQueue(64, streambuf.DropOldest) - for _, c := range []string{"alpha", "beta", "gamma"} { - if dropped, err := q.Enqueue([]byte(c)); dropped != 0 || err != nil { - t.Fatalf("Enqueue(%q): dropped=%d err=%v", c, dropped, err) - } - } - chunks := q.Drain() - if got := []string{string(chunks[0]), string(chunks[1]), string(chunks[2])}; got[0] != "alpha" || got[1] != "beta" || got[2] != "gamma" { - t.Fatalf("Drain = %v, want [alpha beta gamma]", got) - } - if q.Len() != 0 || q.Bytes() != 0 { - t.Fatalf("post-Drain: Len=%d Bytes=%d, want 0,0", q.Len(), q.Bytes()) - } -} - -func TestInputQueue_EnqueueCopiesCallerBuffer(t *testing.T) { - t.Parallel() - q := streambuf.NewInputQueue(64, streambuf.DropOldest) - caller := []byte("hello") - if _, err := q.Enqueue(caller); err != nil { - t.Fatalf("Enqueue: %v", err) - } - // Mutate the caller's buffer — the queued copy must not change. - caller[0] = 'X' - chunks := q.Drain() - if string(chunks[0]) != "hello" { - t.Fatalf("queued chunk = %q, want hello (caller mutation must not leak)", chunks[0]) - } -} - -func TestInputQueue_DropOldestOnOverflow(t *testing.T) { - t.Parallel() - q := streambuf.NewInputQueue(8, streambuf.DropOldest) - _, _ = q.Enqueue([]byte("aaaa")) // 4 bytes - _, _ = q.Enqueue([]byte("bbbb")) // 8 total - dropped, err := q.Enqueue([]byte("cc")) - if err != nil { - t.Fatalf("Enqueue overflow: %v", err) - } - if dropped != 4 { - t.Fatalf("dropped = %d, want 4 (aaaa evicted)", dropped) - } - chunks := q.Drain() - if len(chunks) != 2 || string(chunks[0]) != "bbbb" || string(chunks[1]) != "cc" { - t.Fatalf("Drain = %v, want [bbbb cc]", chunks) - } -} - -func TestInputQueue_DropNewReturnsErrQueueFull(t *testing.T) { - t.Parallel() - q := streambuf.NewInputQueue(4, streambuf.DropNew) - if _, err := q.Enqueue([]byte("aaaa")); err != nil { - t.Fatalf("Enqueue: %v", err) - } - dropped, err := q.Enqueue([]byte("b")) - if !errors.Is(err, streambuf.ErrQueueFull) { - t.Fatalf("Enqueue overflow: err = %v, want ErrQueueFull", err) - } - if dropped != 0 { - t.Fatalf("DropNew dropped = %d, want 0 (queue unchanged)", dropped) - } - // Original chunk must still be queued. - if got := q.Bytes(); got != 4 { - t.Fatalf("Bytes = %d, want 4 (queue unchanged on DropNew rejection)", got) - } -} - -func TestInputQueue_SingleWriteLargerThanCapacity_DropOldest(t *testing.T) { - t.Parallel() - q := streambuf.NewInputQueue(4, streambuf.DropOldest) - _, _ = q.Enqueue([]byte("XX")) - dropped, err := q.Enqueue([]byte("abcdefgh")) - if err != nil { - t.Fatalf("Enqueue large: %v", err) - } - // Both the old "XX" and the leading 4 bytes of the new chunk are - // dropped; only the trailing capacity bytes survive. - if dropped != 2+4 { - t.Fatalf("dropped = %d, want 6", dropped) - } - chunks := q.Drain() - if len(chunks) != 1 || string(chunks[0]) != "efgh" { - t.Fatalf("Drain = %v, want [efgh]", chunks) - } -} - -func TestInputQueue_SingleWriteLargerThanCapacity_DropNew(t *testing.T) { - t.Parallel() - q := streambuf.NewInputQueue(4, streambuf.DropNew) - _, _ = q.Enqueue([]byte("XX")) - _, err := q.Enqueue([]byte("abcdefgh")) - if !errors.Is(err, streambuf.ErrQueueFull) { - t.Fatalf("Enqueue large: err = %v, want ErrQueueFull", err) - } - if got := q.Bytes(); got != 2 { - t.Fatalf("Bytes = %d, want 2 (queue unchanged on rejection)", got) - } -} - -func TestInputQueue_EmptyEnqueueIsNoop(t *testing.T) { - t.Parallel() - q := streambuf.NewInputQueue(64, streambuf.DropOldest) - dropped, err := q.Enqueue(nil) - if dropped != 0 || err != nil { - t.Fatalf("Enqueue(nil): dropped=%d err=%v", dropped, err) - } -} - -func TestInputQueue_Reset(t *testing.T) { - t.Parallel() - q := streambuf.NewInputQueue(64, streambuf.DropOldest) - _, _ = q.Enqueue([]byte("payload")) - q.Reset() - if q.Bytes() != 0 { - t.Fatalf("Bytes after Reset = %d, want 0", q.Bytes()) - } -} - -func TestInputQueue_DrainEmptyReturnsNil(t *testing.T) { - t.Parallel() - q := streambuf.NewInputQueue(64, streambuf.DropOldest) - if got := q.Drain(); got != nil { - t.Fatalf("Drain empty = %v, want nil", got) - } -} - -func TestInputQueue_ConcurrentEnqueueDrain(t *testing.T) { - t.Parallel() - q := streambuf.NewInputQueue(4096, streambuf.DropOldest) - stop := make(chan struct{}) - var wg sync.WaitGroup - wg.Go(func() { - for { - select { - case <-stop: - return - default: - _, _ = q.Enqueue([]byte("xxxx")) - runtime.Gosched() - } - } - }) - for range 500 { - _ = q.Drain() - } - close(stop) - wg.Wait() -} diff --git a/streambuf/output_ring.go b/streambuf/output_ring.go index 47e40d9..ab7432a 100644 --- a/streambuf/output_ring.go +++ b/streambuf/output_ring.go @@ -49,13 +49,14 @@ func NewOutputRing(capacity int) *OutputRing { // retains. func (r *OutputRing) Capacity() int { return r.capacity } -// Write appends p, evicting oldest bytes if needed. Returns -// (len(p), nil) — never partial, never an error. The returned `seq` -// is the sequence of the *first* byte of p as written into the ring. -// (Callers needing the sequence of the last byte add len(p)-1.) +// Write appends p, evicting oldest bytes if needed. Never partial, +// never an error: it returns firstSeq — the sequence of the *first* +// byte of p as written into the ring — and n == len(p). (Callers +// needing the sequence of the last byte add len(p)-1.) // -// Implements [io.Writer] minus the seq return so callers that don't -// care about replay can [io.Copy] straight in via [OutputRing.WriteBytes]. +// The (firstSeq, n) return is deliberately not [io.Writer]-shaped: +// the sequence number is load-bearing for replay, so OutputRing is +// not an io.Writer and cannot be used as an [io.Copy] destination. func (r *OutputRing) Write(p []byte) (firstSeq uint64, n int) { if len(p) == 0 { r.mu.Lock() diff --git a/transport/http2/integration_test.go b/transport/http2/integration_test.go index c08acd7..90d0efd 100644 --- a/transport/http2/integration_test.go +++ b/transport/http2/integration_test.go @@ -83,7 +83,11 @@ func TestBackend_RoundTrip(t *testing.T) { } // Half-close write side; server should see EOF on its Read. - if err := stream.CloseWrite(); err != nil { + hc, ok := stream.(transport.HalfCloser) + if !ok { + t.Fatalf("http2 client stream should implement transport.HalfCloser") + } + if err := hc.CloseWrite(); err != nil { t.Fatalf("CloseWrite: %v", err) } select { diff --git a/transport/http2/stream.go b/transport/http2/stream.go index b0ecda4..c4eb94a 100644 --- a/transport/http2/stream.go +++ b/transport/http2/stream.go @@ -69,11 +69,10 @@ func (s *serverStream) Close() error { return nil } -// CloseWrite isn't a thing on the server side: returning from the -// handler is the only way to end the response body. Implementations -// must signal "I'm done writing" by closing the stream entirely. -// We return ErrUnsupported so callers know to use Close instead. -func (s *serverStream) CloseWrite() error { return transport.ErrUnsupported } +// serverStream deliberately does not implement [transport.HalfCloser]: +// returning from the handler is the only way to end the response body, +// so there is no half-close motion to expose. Callers signal "done +// writing" by closing the stream entirely. // RemoteID returns the client's [http.Request.RemoteAddr] — typically // the peer's IP:port. HTTPS / mTLS deployments can plumb in cert diff --git a/transport/libp2p/libp2p_test.go b/transport/libp2p/libp2p_test.go index 908de63..4d51e84 100644 --- a/transport/libp2p/libp2p_test.go +++ b/transport/libp2p/libp2p_test.go @@ -86,7 +86,9 @@ func TestBYOHost(t *testing.T) { if _, err := s.Write([]byte("hi-mosey")); err != nil { t.Fatalf("write mosey: %v", err) } - _ = s.CloseWrite() + if hc, ok := s.(transport.HalfCloser); ok { + _ = hc.CloseWrite() + } select { case got := <-gotMosey: if got != "hi-mosey" { diff --git a/transport/transport.go b/transport/transport.go index 0ec04c7..2db3c00 100644 --- a/transport/transport.go +++ b/transport/transport.go @@ -27,11 +27,6 @@ var ErrUnsupportedScheme = errors.New("transport: endpoint scheme not supported type Stream interface { io.ReadWriteCloser - // CloseWrite half-closes the local write side so the peer sees - // a clean EOF on its read. Optional: backends that can't model - // half-close (raw TCP without framing) return [ErrUnsupported]. - CloseWrite() error - // RemoteID returns a backend-specific identifier for the remote // peer (libp2p peer id, TLS subject CN, etc.). Used for log // tagging; not for authentication. May be empty when the @@ -40,10 +35,21 @@ type Stream interface { RemoteID() string } -// ErrUnsupported is returned by optional [Stream] methods when the -// underlying transport can't honor the request (e.g. CloseWrite on -// a transport without half-close semantics). -var ErrUnsupported = errors.New("transport: operation not supported by this backend") +// HalfCloser is the optional half-close capability, kept off the +// mandatory [Stream] surface. Backends whose streams can signal a +// one-way FIN (unix sockets, libp2p muxer streams, the HTTP/2 client +// request body) implement it; backends that can't model half-close +// (WebSocket, the HTTP/2 server response) simply don't. Callers that +// want the peer to see a clean EOF type-assert for it: +// +// if hc, ok := s.(HalfCloser); ok { +// _ = hc.CloseWrite() +// } +type HalfCloser interface { + // CloseWrite half-closes the local write side so the peer sees a + // clean EOF on its read, without disturbing the read side. + CloseWrite() error +} // Handler is invoked for each inbound stream of a registered // protocol. The handler owns the stream — it must Close it before diff --git a/transport/unix/unix.go b/transport/unix/unix.go index d453c21..db5eae4 100644 --- a/transport/unix/unix.go +++ b/transport/unix/unix.go @@ -156,11 +156,6 @@ func (b *Backend) Dial(ctx context.Context, endpoint, proto string) (transport.S return &stream{conn: uc, remote: "unix://" + path}, nil } -// Serve implements [transport.Transport]. Unix's accept loop is -// already running from New (see acceptLoop); Serve is a no-op so -// callers can use the same lifecycle pattern as other backends. -func (b *Backend) Serve() {} - // Close implements [transport.Transport]. Closes the listener and // removes the bound path. Already-open streams keep working until // the caller closes them. diff --git a/transport/unix/unix_test.go b/transport/unix/unix_test.go index 25ccfed..15cd289 100644 --- a/transport/unix/unix_test.go +++ b/transport/unix/unix_test.go @@ -50,7 +50,11 @@ func TestBackend_DialEchoesBytes(t *testing.T) { if _, err := stream.Write([]byte(sent)); err != nil { t.Fatalf("Write: %v", err) } - if err := stream.CloseWrite(); err != nil { + hc, ok := stream.(transport.HalfCloser) + if !ok { + t.Fatalf("unix stream should implement transport.HalfCloser") + } + if err := hc.CloseWrite(); err != nil { t.Fatalf("CloseWrite: %v", err) } got, err := io.ReadAll(stream) diff --git a/transport/websocket/stream.go b/transport/websocket/stream.go index 689347a..bc1d9b7 100644 --- a/transport/websocket/stream.go +++ b/transport/websocket/stream.go @@ -71,12 +71,10 @@ func (s *stream) Close() error { return s.closeErr } -// CloseWrite isn't a thing on WebSocket — the Close frame is a -// full-connection close, not a half-close. mosey's existing -// callers (auth.Wrap, attach.Run, vterm) never call CloseWrite on -// the streams they use; returning ErrUnsupported keeps any future -// caller from quietly believing they sent a one-way FIN. -func (s *stream) CloseWrite() error { return transport.ErrUnsupported } +// WebSocket has no half-close — the Close frame is a full-connection +// close — so the stream deliberately does not implement +// [transport.HalfCloser]. Callers that want a one-way FIN type-assert +// and skip it when absent. // RemoteID returns a string identifying the remote peer: // diff --git a/transport/websocket/websocket.go b/transport/websocket/websocket.go index f19eec1..4cabb42 100644 --- a/transport/websocket/websocket.go +++ b/transport/websocket/websocket.go @@ -228,12 +228,6 @@ func (b *Backend) Dial(ctx context.Context, endpoint, proto string) (transport.S return &stream{conn: conn, remote: target.Scheme + "://" + target.Host}, nil } -// Serve implements [transport.Transport]. The accept loop already -// runs from New (via http.Server.Serve in its own goroutine); Serve -// is a no-op so callers can use the same lifecycle pattern as other -// backends. -func (b *Backend) Serve() {} - // Close implements [transport.Transport]. func (b *Backend) Close() error { var err error diff --git a/vterm/session.go b/vterm/session.go index 0e0c1e7..64a4256 100644 --- a/vterm/session.go +++ b/vterm/session.go @@ -10,7 +10,6 @@ import ( "github.com/creack/pty" "github.com/firefly-engineering/mosey/auth" - "github.com/firefly-engineering/mosey/streambuf" "github.com/firefly-engineering/mosey/transport" ) @@ -45,20 +44,6 @@ func readResumeSeq(r io.Reader) (uint64, error) { return 0, fmt.Errorf("resume_seq varint overflow") } -// encodeResumeSeq is the encoder counterpart of [readResumeSeq]. -// Returns 1–10 bytes — proto-wire varint format. -func encodeResumeSeq(v uint64) []byte { - var out [10]byte - i := 0 - for v >= 0x80 { - out[i] = byte(v) | 0x80 - v >>= 7 - i++ - } - out[i] = byte(v) - return out[:i+1] -} - // outputRingCapacity bounds the PTY-output replay buffer. ~256 KiB // is comfortably above a screenful of `top` and lets a freshly // attaching client see the last few seconds of output without @@ -483,9 +468,3 @@ func (s *Session) pumpOutput() { } } } - -// newOutputRing is a thin wrapper so tests can swap in a smaller -// ring for replay-edge cases. -func newOutputRing() *streambuf.OutputRing { - return streambuf.NewOutputRing(outputRingCapacity) -} diff --git a/vterm/vterm.go b/vterm/vterm.go index e83b2ef..545722b 100644 --- a/vterm/vterm.go +++ b/vterm/vterm.go @@ -130,7 +130,7 @@ func Run(ctx context.Context, opts Options, argv []string) error { cmd: cmd, ptyf: ptmx, mode: mode, - output: newOutputRing(), + output: streambuf.NewOutputRing(outputRingCapacity), clients: map[int64]*sessionClient{}, pendingResize: map[string]pendingResize{}, }