400 lines
13 KiB
Go
400 lines
13 KiB
Go
package terminal
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/cdc"
|
|
)
|
|
|
|
// fakeConn is an in-memory wsConn driven by channels.
|
|
type fakeConn struct {
|
|
in chan clientMsg
|
|
out chan serverMsg
|
|
pings int32
|
|
once sync.Once
|
|
closed chan struct{}
|
|
}
|
|
|
|
func newFakeConn() *fakeConn {
|
|
return &fakeConn{in: make(chan clientMsg, 16), out: make(chan serverMsg, 64), closed: make(chan struct{})}
|
|
}
|
|
|
|
func (c *fakeConn) ReadJSON(ctx context.Context, v any) error {
|
|
select {
|
|
case m := <-c.in:
|
|
*(v.(*clientMsg)) = m
|
|
return nil
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-c.closed:
|
|
return context.Canceled
|
|
}
|
|
}
|
|
|
|
func (c *fakeConn) WriteJSON(_ context.Context, v any) error {
|
|
c.out <- v.(serverMsg)
|
|
return nil
|
|
}
|
|
|
|
func (c *fakeConn) Ping(context.Context) error {
|
|
atomic.AddInt32(&c.pings, 1)
|
|
return nil
|
|
}
|
|
|
|
func (c *fakeConn) Close(string) error {
|
|
c.once.Do(func() { close(c.closed) })
|
|
return nil
|
|
}
|
|
|
|
// recv waits for a frame of the given channel+type, draining others.
|
|
func recv(t *testing.T, c *fakeConn, ch, typ string, d time.Duration) serverMsg {
|
|
t.Helper()
|
|
deadline := time.After(d)
|
|
for {
|
|
select {
|
|
case m := <-c.out:
|
|
if m.Ch == ch && m.Type == typ {
|
|
return m
|
|
}
|
|
case <-deadline:
|
|
t.Fatalf("did not receive %s/%s within %s", ch, typ, d)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServeOpenStreamsAndWritesTerminal(t *testing.T) {
|
|
src := &fakeSource{alive: true}
|
|
pty := newFakePTY()
|
|
sp := &fakeSpawner{ptys: []*fakePTY{pty}}
|
|
mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0))
|
|
defer mgr.Close()
|
|
|
|
conn := newFakeConn()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go mgr.Serve(ctx, conn)
|
|
|
|
conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen}
|
|
recv(t, conn, chTerminal, msgOpened, time.Second)
|
|
|
|
pty.push([]byte("prompt$ "))
|
|
data := recv(t, conn, chTerminal, msgData, time.Second)
|
|
got, _ := base64.StdEncoding.DecodeString(data.Data)
|
|
if string(got) != "prompt$ " {
|
|
t.Fatalf("streamed data = %q", got)
|
|
}
|
|
|
|
conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgData, Data: base64.StdEncoding.EncodeToString([]byte("whoami\n"))}
|
|
eventually(t, time.Second, func() bool { return string(pty.writtenBytes()) == "whoami\n" })
|
|
|
|
conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgResize, Rows: 30, Cols: 100}
|
|
eventually(t, time.Second, func() bool {
|
|
rs := pty.resizeCalls()
|
|
return len(rs) == 1 && rs[0] == [2]uint16{30, 100}
|
|
})
|
|
}
|
|
|
|
// nextTerminal returns the next frame on conn.out (no skipping), so callers can
|
|
// assert frame ordering rather than just presence.
|
|
func nextTerminal(t *testing.T, c *fakeConn) serverMsg {
|
|
t.Helper()
|
|
select {
|
|
case m := <-c.out:
|
|
return m
|
|
case <-time.After(time.Second):
|
|
t.Fatal("no frame within 1s")
|
|
return serverMsg{}
|
|
}
|
|
}
|
|
|
|
// Opening a pane whose runtime is already dead must (1) send opened before
|
|
// exited (the dead pane is reported, not errored) and (2) clear the conn's
|
|
// entry, so a later open for the same id on this connection is still served
|
|
// instead of being silently dropped by the already-open guard.
|
|
func TestServeOpenDeadRuntimeReportsExitedAndAllowsReopen(t *testing.T) {
|
|
src := &fakeSource{alive: false}
|
|
sp := &fakeSpawner{}
|
|
mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0))
|
|
defer mgr.Close()
|
|
|
|
conn := newFakeConn()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go mgr.Serve(ctx, conn)
|
|
|
|
conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen}
|
|
if m := nextTerminal(t, conn); m.Type != msgOpened {
|
|
t.Fatalf("first frame = %q, want opened", m.Type)
|
|
}
|
|
if m := nextTerminal(t, conn); m.Type != msgExited {
|
|
t.Fatalf("second frame = %q, want exited", m.Type)
|
|
}
|
|
if got := sp.calls(); got != 0 {
|
|
t.Fatalf("attach must never run against a dead runtime, got %d spawns", got)
|
|
}
|
|
|
|
conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen}
|
|
if m := nextTerminal(t, conn); m.Type != msgOpened {
|
|
t.Fatalf("re-open frame = %q, want opened (open was dropped, entry stuck)", m.Type)
|
|
}
|
|
}
|
|
|
|
// A session that exits after being opened must clear its connection entry on
|
|
// exit, so a later open for the same id is served rather than dropped by the
|
|
// already-open guard.
|
|
func TestServeExitAfterOpenClearsEntryAllowingReopen(t *testing.T) {
|
|
src := &fakeSource{alive: true} // alive for the first attach
|
|
p := newFakePTY()
|
|
sp := &fakeSpawner{ptys: []*fakePTY{p}}
|
|
mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0))
|
|
defer mgr.Close()
|
|
|
|
conn := newFakeConn()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go mgr.Serve(ctx, conn)
|
|
|
|
conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen}
|
|
recv(t, conn, chTerminal, msgOpened, time.Second)
|
|
|
|
src.setAlive(false) // a dropped pty must not re-attach -> session exits
|
|
p.Close() // drop the pty; IsAlive false => session exits, no re-attach
|
|
recv(t, conn, chTerminal, msgExited, time.Second)
|
|
|
|
conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen}
|
|
recv(t, conn, chTerminal, msgOpened, 2*time.Second)
|
|
}
|
|
|
|
// An attachment that exits the moment it is opened (dead runtime) fires onExit
|
|
// from its run goroutine, racing the reopen that follows the exited frame. The
|
|
// identity-guarded delete in onExit must never evict a successor attachment
|
|
// registered under the same id: every reopen must be served (opened), never
|
|
// silently dropped by the already-open guard. Stressed across many iterations
|
|
// to shake the exit/reopen interleavings out.
|
|
func TestServeReopenAfterImmediateExitNeverStuck(t *testing.T) {
|
|
for i := 0; i < 400; i++ {
|
|
src := &fakeSource{}
|
|
src.setAlive(false) // dead runtime -> the open exits without attaching
|
|
sp := &fakeSpawner{}
|
|
mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0))
|
|
|
|
conn := newFakeConn()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go mgr.Serve(ctx, conn)
|
|
|
|
conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen}
|
|
|
|
recv(t, conn, chTerminal, msgOpened, time.Second)
|
|
recv(t, conn, chTerminal, msgExited, time.Second)
|
|
|
|
// The reopen must be served even while the first open's exit teardown is
|
|
// still in flight.
|
|
conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen}
|
|
recv(t, conn, chTerminal, msgOpened, time.Second)
|
|
|
|
cancel()
|
|
mgr.Close()
|
|
}
|
|
}
|
|
|
|
func TestServeRejectsOpenWithoutID(t *testing.T) {
|
|
mgr := NewManager(&fakeSource{}, nil, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(0))
|
|
defer mgr.Close()
|
|
conn := newFakeConn()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go mgr.Serve(ctx, conn)
|
|
|
|
conn.in <- clientMsg{Ch: chTerminal, Type: msgOpen}
|
|
msg := recv(t, conn, chTerminal, msgError, time.Second)
|
|
if msg.Error == "" {
|
|
t.Fatal("expected an error message for open without id")
|
|
}
|
|
}
|
|
|
|
func TestServeForwardsSessionChannelFromCDC(t *testing.T) {
|
|
bc := cdc.NewBroadcaster()
|
|
mgr := NewManager(&fakeSource{}, bc, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(0))
|
|
defer mgr.Close()
|
|
|
|
conn := newFakeConn()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go mgr.Serve(ctx, conn)
|
|
|
|
conn.in <- clientMsg{Ch: chSubscribe, Type: msgSubscribe}
|
|
// Give the subscription time to register before publishing.
|
|
eventually(t, time.Second, func() bool {
|
|
bc.Publish(cdc.Event{Seq: 9, ProjectID: "p1", SessionID: "s1", Type: cdc.EventSessionUpdated})
|
|
select {
|
|
case m := <-conn.out:
|
|
return m.Ch == chSessions && m.Session != nil && m.Session.Seq == 9
|
|
default:
|
|
return false
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestServeSystemPingGetsPong(t *testing.T) {
|
|
mgr := NewManager(&fakeSource{}, nil, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(0))
|
|
defer mgr.Close()
|
|
conn := newFakeConn()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go mgr.Serve(ctx, conn)
|
|
|
|
conn.in <- clientMsg{Ch: chSystem, Type: msgPing}
|
|
recv(t, conn, chSystem, msgPong, time.Second)
|
|
}
|
|
|
|
func TestServeHeartbeatPings(t *testing.T) {
|
|
mgr := NewManager(&fakeSource{}, nil, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(10*time.Millisecond))
|
|
defer mgr.Close()
|
|
conn := newFakeConn()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go mgr.Serve(ctx, conn)
|
|
|
|
eventually(t, time.Second, func() bool { return atomic.LoadInt32(&conn.pings) >= 2 })
|
|
}
|
|
|
|
func TestServeClosesConnOnReadEnd(t *testing.T) {
|
|
mgr := NewManager(&fakeSource{}, nil, testLogger(), WithSpawn((&fakeSpawner{}).spawn), WithHeartbeat(0))
|
|
defer mgr.Close()
|
|
conn := newFakeConn()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go mgr.Serve(ctx, conn)
|
|
|
|
cancel() // client/server context ends
|
|
select {
|
|
case <-conn.closed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Serve must close the conn when the context is cancelled")
|
|
}
|
|
}
|
|
|
|
// Each connection opening the same pane gets its OWN attach PTY — that is the
|
|
// per-client model: zellij replays its init handshake + full repaint to every
|
|
// fresh attach, so no client depends on bytes another client consumed. Output
|
|
// pushed to one client's PTY must reach only that client, and closing one
|
|
// client's terminal must not touch the other's PTY.
|
|
func TestServePerClientAttachIsolation(t *testing.T) {
|
|
src := &fakeSource{alive: true}
|
|
p1, p2 := newFakePTY(), newFakePTY()
|
|
sp := &fakeSpawner{ptys: []*fakePTY{p1, p2}}
|
|
mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0))
|
|
defer mgr.Close()
|
|
|
|
connA, connB := newFakeConn(), newFakeConn()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go mgr.Serve(ctx, connA)
|
|
go mgr.Serve(ctx, connB)
|
|
|
|
connA.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen}
|
|
recv(t, connA, chTerminal, msgOpened, time.Second)
|
|
eventually(t, time.Second, func() bool { return sp.calls() == 1 })
|
|
|
|
connB.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen}
|
|
recv(t, connB, chTerminal, msgOpened, time.Second)
|
|
eventually(t, time.Second, func() bool { return sp.calls() == 2 })
|
|
|
|
// Zellij fans output out per attach; here each fake PTY stands in for one
|
|
// attach process, so its bytes must reach exactly its own connection.
|
|
p1.push([]byte("for-A"))
|
|
data := recv(t, connA, chTerminal, msgData, time.Second)
|
|
got, _ := base64.StdEncoding.DecodeString(data.Data)
|
|
if string(got) != "for-A" {
|
|
t.Fatalf("conn A data = %q", got)
|
|
}
|
|
select {
|
|
case m := <-connB.out:
|
|
t.Fatalf("conn B received %s/%s for conn A's PTY output", m.Ch, m.Type)
|
|
default:
|
|
}
|
|
|
|
// Closing A's terminal detaches A only: B's PTY stays live.
|
|
connA.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgClose}
|
|
eventually(t, time.Second, func() bool {
|
|
select {
|
|
case <-p1.closed:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
})
|
|
select {
|
|
case <-p2.closed:
|
|
t.Fatal("closing conn A's terminal must not close conn B's PTY")
|
|
default:
|
|
}
|
|
}
|
|
|
|
// The open frame carries the client's grid; the PTY must start at that size
|
|
// rather than the kernel default, even though the attach is asynchronous.
|
|
func TestServeOpenAppliesInitialSize(t *testing.T) {
|
|
src := &fakeSource{alive: true}
|
|
pty := newFakePTY()
|
|
sp := &fakeSpawner{ptys: []*fakePTY{pty}}
|
|
mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0))
|
|
defer mgr.Close()
|
|
|
|
conn := newFakeConn()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go mgr.Serve(ctx, conn)
|
|
|
|
conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen, Rows: 40, Cols: 120}
|
|
recv(t, conn, chTerminal, msgOpened, time.Second)
|
|
eventually(t, time.Second, func() bool {
|
|
rs := pty.resizeCalls()
|
|
return len(rs) == 1 && rs[0] == [2]uint16{40, 120}
|
|
})
|
|
}
|
|
|
|
// Manager.Close must kill every live attach PTY: a PTY left open keeps its
|
|
// attach process running and deadlocks daemon shutdown.
|
|
func TestManagerCloseKillsLiveAttachments(t *testing.T) {
|
|
src := &fakeSource{alive: true}
|
|
pty := newFakePTY()
|
|
sp := &fakeSpawner{ptys: []*fakePTY{pty}}
|
|
mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0))
|
|
|
|
conn := newFakeConn()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go mgr.Serve(ctx, conn)
|
|
|
|
conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen}
|
|
recv(t, conn, chTerminal, msgOpened, time.Second)
|
|
eventually(t, time.Second, func() bool { return sp.calls() == 1 })
|
|
|
|
mgr.Close()
|
|
select {
|
|
case <-pty.closed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Manager.Close must close live attach PTYs")
|
|
}
|
|
}
|
|
|
|
func TestEnqueueOverflowCancelsConn(t *testing.T) {
|
|
cancelled := make(chan struct{})
|
|
c := &connState{
|
|
out: make(chan serverMsg, 1),
|
|
cancel: func() { close(cancelled) },
|
|
terms: map[string]*attachment{},
|
|
}
|
|
c.enqueue(serverMsg{Ch: chTerminal, Type: msgData}) // fills buffer
|
|
c.enqueue(serverMsg{Ch: chTerminal, Type: msgData}) // overflow -> cancel
|
|
select {
|
|
case <-cancelled:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("overflow must cancel the connection")
|
|
}
|
|
}
|