Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ flued
/site/src/routeTree.gen.ts
# Wrangler's local cache from site deploys.
/site/.wrangler/
# tsc --noEmit with incremental drops this beside web/tsconfig.json.
web/tsconfig.tsbuildinfo
16 changes: 15 additions & 1 deletion internal/daemon/conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ const (
peekMaxBytes = 128 << 10
)

// capMultiplex is the welcome capability that says this daemon understands
// session groups and ephemeral sessions — the optional spawn and update
// fields added for the in-session multiplexer. Feature detection, not
// negotiation: the fields are additive either way, the cap only tells a
// client whether the affordances are worth drawing.
const capMultiplex = "multiplex"

var (
errConnClosed = errors.New("daemon: connection closed")
errConnBacklogged = errors.New("daemon: client is not draining its socket")
Expand Down Expand Up @@ -445,6 +452,12 @@ func (c *conn) serve() {
DaemonID: "local",
Host: c.srv.hostname,
Ver: c.srv.version,
// What this daemon can do beyond the base protocol, for a client to
// feature-detect on. "multiplex" says spawn accepts group and
// ephemeral, and update accepts ephemeral: a client talking to a
// daemon that does not say it hides the split and scratch affordances
// rather than spawning sessions whose extra fields silently dropped.
Caps: []string{capMultiplex},
// Read here, once, at the moment this connection opens. The status is
// not a stream — nothing pushes an update when the relay reconnects —
// so what a client holds is what was true when it arrived, which is
Expand Down Expand Up @@ -591,7 +604,7 @@ func (c *conn) handleControl(msg any) {
// spend that distinction on the way past. wire.Update and
// session.MetaPatch have the same shape for exactly this reason.
if _, err := c.srv.reg.UpdateMeta(m.ID, session.MetaPatch{
Name: m.Name, Tags: m.Tags, Pinned: m.Pinned,
Name: m.Name, Tags: m.Tags, Pinned: m.Pinned, Ephemeral: m.Ephemeral,
}); err != nil {
// The only thing UpdateMeta refuses is an id it does not hold, and a
// client editing a session that has just exited and been reaped is
Expand Down Expand Up @@ -682,6 +695,7 @@ func (c *conn) handleControl(msg any) {
case wire.Spawn:
s, err := c.srv.reg.Spawn(session.SpawnOpts{
Cwd: m.Cwd, Cmd: m.Cmd, Cols: m.Cols, Rows: m.Rows,
Group: m.Group, Ephemeral: m.Ephemeral,
})
if err != nil {
c.sendErrorFor(m.ReqID, "spawn_failed", err.Error())
Expand Down
128 changes: 128 additions & 0 deletions internal/daemon/multiplex_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package daemon

import (
"slices"
"testing"

"github.com/karnstack/flue/internal/session"
"github.com/karnstack/flue/internal/wire"
)

// TestWelcomeAnnouncesMultiplex pins the capability the web client
// feature-detects the split and scratch affordances on. Dropping it from the
// welcome silently hides both from every client, which is why it is a test
// and not just a literal in conn.go.
func TestWelcomeAnnouncesMultiplex(t *testing.T) {
ts, _ := newTestServer(t)
c := dial(t, ts)

readUntil(t, c, func(msg any, _ []byte) bool {
w, ok := msg.(wire.Welcome)
if !ok {
return false
}
if !slices.Contains(w.Caps, "multiplex") {
t.Fatalf("welcome caps = %v, want to contain %q", w.Caps, "multiplex")
}
return true
})
}

// TestSpawnCarriesGroupAndEphemeralOverTheWire: the optional spawn fields
// reach the registry, and the session list hands them back — which is how a
// group view learns its members and a list knows what to fold away.
func TestSpawnCarriesGroupAndEphemeralOverTheWire(t *testing.T) {
ts, reg := newTestServer(t)
anchor, err := reg.Spawn(session.SpawnOpts{Cmd: []string{"sleep", "2"}, Cols: 80, Rows: 24})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
defer anchor.Close()

c := dial(t, ts)
writeControl(t, c, wire.Hello{Ver: "test"})
writeControl(t, c, wire.Spawn{
Cmd: []string{"sleep", "2"}, Cols: 80, Rows: 24,
Group: anchor.ID(), Ephemeral: true, ReqID: 5,
})

var spawned string
readUntil(t, c, func(msg any, _ []byte) bool {
a, ok := msg.(wire.Attached)
if !ok || a.ReqID != 5 {
return false
}
spawned = a.ID
return true
})

s, ok := reg.Get(spawned)
if !ok {
t.Fatalf("spawned session %q not in the registry", spawned)
}
defer s.Close()
info := s.Info()
if info.Group != anchor.ID() {
t.Errorf("Group = %q, want %q", info.Group, anchor.ID())
}
if !info.Ephemeral {
t.Error("Ephemeral = false, want true")
}

// And the list reports what the spawn declared.
writeControl(t, c, wire.List{})
readUntil(t, c, func(msg any, _ []byte) bool {
l, ok := msg.(wire.Sessions)
if !ok {
return false
}
for _, row := range l.Sessions {
if row.ID == spawned {
if row.Group != anchor.ID() || !row.Ephemeral {
t.Errorf("listed row group=%q ephemeral=%v, want %q true",
row.Group, row.Ephemeral, anchor.ID())
}
return true
}
}
return false
})
}

// TestUpdateClearsEphemeralOverTheWire is the wire half of the keep
// affordance: an update carrying ephemeral=false promotes the scratch, and
// one carrying no ephemeral field leaves the flag alone.
func TestUpdateClearsEphemeralOverTheWire(t *testing.T) {
ts, reg := newTestServer(t)
anchor, err := reg.Spawn(session.SpawnOpts{Cmd: []string{"sleep", "2"}, Cols: 80, Rows: 24})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
defer anchor.Close()
scratch, err := reg.Spawn(session.SpawnOpts{
Cmd: []string{"sleep", "2"}, Cols: 80, Rows: 24,
Group: anchor.ID(), Ephemeral: true,
})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
defer scratch.Close()

c := dial(t, ts)
writeControl(t, c, wire.Hello{Ver: "test"})

// An edit about something else must not touch the flag.
name := "kept name"
writeControl(t, c, wire.Update{ID: scratch.ID(), Name: &name})
readUntil(t, c, func(msg any, _ []byte) bool { _, ok := msg.(wire.Sessions); return ok })
if info := scratch.Info(); !info.Ephemeral || info.Name != name {
t.Fatalf("after a name edit: ephemeral=%v name=%q, want true %q", info.Ephemeral, info.Name, name)
}

kept := false
writeControl(t, c, wire.Update{ID: scratch.ID(), Ephemeral: &kept})
readUntil(t, c, func(msg any, _ []byte) bool { _, ok := msg.(wire.Sessions); return ok })
if info := scratch.Info(); info.Ephemeral {
t.Fatal("Ephemeral still true after the clearing update")
}
}
210 changes: 210 additions & 0 deletions internal/session/multiplex_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
package session

import (
"testing"
"time"
)

// TestSpawnCarriesGroupAndEphemeral pins the two new SpawnOpts fields onto
// Info, and — the half that guards every session that exists today — that a
// spawn naming neither reports neither.
func TestSpawnCarriesGroupAndEphemeral(t *testing.T) {
r := NewRegistry(nil)
anchor := spawnRunning(t, r)

member, err := r.Spawn(SpawnOpts{
Cmd: []string{"sleep", "5"}, Cols: 80, Rows: 24,
Group: anchor.ID(), Ephemeral: true,
})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
t.Cleanup(func() { _ = member.Close() })

info := member.Info()
if info.Group != anchor.ID() {
t.Errorf("Group = %q, want %q", info.Group, anchor.ID())
}
if !info.Ephemeral {
t.Error("Ephemeral = false, want true")
}

plain := anchor.Info()
if plain.Group != "" || plain.Ephemeral {
t.Errorf("plain session carries group=%q ephemeral=%v, want neither", plain.Group, plain.Ephemeral)
}
}

// TestEphemeralExitedReapedFast pins the retention split: an exited scratch
// terminal leaves the registry after EphemeralRetention, while an ordinary
// exited session beside it waits out the full ExitedRetention.
func TestEphemeralExitedReapedFast(t *testing.T) {
now := time.Now()
r := NewRegistry(func() time.Time { return now })

plain, err := r.Spawn(SpawnOpts{Cmd: []string{"sh", "-c", "exit 0"}, Cols: 80, Rows: 24})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
scratch, err := r.Spawn(SpawnOpts{
Cmd: []string{"sh", "-c", "exit 0"}, Cols: 80, Rows: 24, Ephemeral: true,
})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
waitExited(t, plain, 5*time.Second)
waitExited(t, scratch, 5*time.Second)

now = now.Add(EphemeralRetention + time.Second)
r.Reap()
if _, ok := r.Get(scratch.ID()); ok {
t.Error("ephemeral session still present past EphemeralRetention")
}
if _, ok := r.Get(plain.ID()); !ok {
t.Error("ordinary session reaped on the ephemeral schedule")
}
}

// TestRunningEphemeralFollowsItsParent is the scratch terminal's lifecycle
// promise: dismissed or not, it runs while its parent runs — the first Reap
// with both alive touches nothing — and it is closed by the sweep once the
// parent has exited, without waiting for the parent to be reaped.
func TestRunningEphemeralFollowsItsParent(t *testing.T) {
now := time.Now()
r := NewRegistry(func() time.Time { return now })

parent, err := r.Spawn(SpawnOpts{Cmd: []string{"sleep", "0.2"}, Cols: 80, Rows: 24})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
t.Cleanup(func() { _ = parent.Close() })
scratch, err := r.Spawn(SpawnOpts{
Cmd: []string{"sleep", "60"}, Cols: 80, Rows: 24,
Group: parent.ID(), Ephemeral: true,
})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
t.Cleanup(func() { _ = scratch.Close() })

// Both alive: the sweep must leave the scratch running.
r.Reap()
if scratch.Info().State != "running" {
t.Fatal("scratch closed while its parent was still running")
}

waitExited(t, parent, 5*time.Second)
// The parent is exited but not yet reaped — still listable in its
// retention window — and that alone ends the scratch.
r.Reap()
waitExited(t, scratch, 5*time.Second)
}

// TestRunningEphemeralWithoutAParentIsLeftAlone: no group means no lifecycle
// to follow, and the sweep must not guess one.
func TestRunningEphemeralWithoutAParentIsLeftAlone(t *testing.T) {
r := NewRegistry(nil)
scratch, err := r.Spawn(SpawnOpts{
Cmd: []string{"sleep", "60"}, Cols: 80, Rows: 24, Ephemeral: true,
})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
t.Cleanup(func() { _ = scratch.Close() })

r.Reap()
if scratch.Info().State != "running" {
t.Fatal("ungrouped ephemeral session closed by the sweep")
}
}

// TestApplyMetaClearsEphemeral is the "keep" affordance: clearing the flag
// promotes a scratch to an ordinary member, after which the parent's exit no
// longer takes it down.
func TestApplyMetaClearsEphemeral(t *testing.T) {
now := time.Now()
r := NewRegistry(func() time.Time { return now })

parent, err := r.Spawn(SpawnOpts{Cmd: []string{"sh", "-c", "exit 0"}, Cols: 80, Rows: 24})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
t.Cleanup(func() { _ = parent.Close() })
scratch, err := r.Spawn(SpawnOpts{
Cmd: []string{"sleep", "60"}, Cols: 80, Rows: 24,
Group: parent.ID(), Ephemeral: true,
})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
t.Cleanup(func() { _ = scratch.Close() })

kept := false
info, err := r.UpdateMeta(scratch.ID(), MetaPatch{Ephemeral: &kept})
if err != nil {
t.Fatalf("UpdateMeta: %v", err)
}
if info.Ephemeral {
t.Fatal("Ephemeral still true after the clearing patch")
}
if info.Group != parent.ID() {
t.Errorf("Group = %q after the patch, want %q untouched", info.Group, parent.ID())
}

waitExited(t, parent, 5*time.Second)
r.Reap()
if scratch.Info().State != "running" {
t.Fatal("a kept session was closed with its parent; promotion did not stick")
}
}

// TestSnapshotSkipsEphemeralAndCarriesGroup pins the restart story: a split
// member revives as a member because its snapshot names the group, and a
// scratch terminal is not snapshotted at all — its life is bound to a process
// the restart does not preserve.
func TestSnapshotSkipsEphemeralAndCarriesGroup(t *testing.T) {
r := NewRegistry(nil)
anchor := spawnRunning(t, r)

member, err := r.Spawn(SpawnOpts{
Cmd: []string{"sleep", "5"}, Cols: 80, Rows: 24, Group: anchor.ID(),
})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
t.Cleanup(func() { _ = member.Close() })
scratch, err := r.Spawn(SpawnOpts{
Cmd: []string{"sleep", "5"}, Cols: 80, Rows: 24,
Group: anchor.ID(), Ephemeral: true,
})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
t.Cleanup(func() { _ = scratch.Close() })

if _, ok := scratch.Snapshot(); ok {
t.Error("an ephemeral session produced a snapshot")
}
snap, ok := member.Snapshot()
if !ok {
t.Fatal("a grouped member produced no snapshot")
}
if snap.Group != anchor.ID() {
t.Fatalf("snapshot Group = %q, want %q", snap.Group, anchor.ID())
}

// And the revival hands the link back.
_ = member.Close()
r2 := NewRegistry(nil)
revived, err := r2.Revive(snap)
if err != nil {
t.Fatalf("Revive: %v", err)
}
t.Cleanup(func() { _ = revived.Close() })
if got := revived.Info().Group; got != anchor.ID() {
t.Errorf("revived Group = %q, want %q", got, anchor.ID())
}
if revived.Info().Ephemeral {
t.Error("revived session reports Ephemeral = true")
}
}
Loading