1package daemon
2
3import (
4 "sync"
5 "testing"
6
7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/protocol"
9 "github.com/stretchr/testify/require"
10)
11
12func TestEphemeralCloseOnExit(t *testing.T) {
13 tests := []struct {
14 name string
15 closeOnExit bool
16 intent uint8
17 sessionName string
18 clients int
19 renamed bool
20 wantRemoved bool
21 }{
22 {name: "last ephemeral client removes session", closeOnExit: true, intent: protocol.IntentEphemeral, clients: 1, wantRemoved: true},
23 {name: "option off keeps session", closeOnExit: false, intent: protocol.IntentEphemeral, clients: 1},
24 {name: "named session is never removed", closeOnExit: true, intent: protocol.IntentNew, sessionName: "work", clients: 1},
25 {name: "renamed session is never removed", closeOnExit: true, intent: protocol.IntentEphemeral, clients: 1, renamed: true},
26 {name: "remaining client keeps session", closeOnExit: true, intent: protocol.IntentEphemeral, clients: 2},
27 }
28 for _, tt := range tests {
29 t.Run(tt.name, func(t *testing.T) {
30 pty, release := newBlockingPTY(t)
31 defer release()
32 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
33 updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.ephemeral = domain.EphemeralConfig{CloseOnExit: tt.closeOnExit} })
34
35 tr := &closeTrackingTransport{}
36 sess, ac, err := d.route(helloResumeCapable(tt.intent, tt.sessionName, 0), tr)
37 require.NoError(t, err)
38 for i := 1; i < tt.clients; i++ {
39 _, _, err := d.route(helloResumeCapable(protocol.IntentAttach, sess.nameSnapshot(), 0), &closeTrackingTransport{})
40 require.NoError(t, err)
41 }
42
43 d.clientGone(sess, ac, tr, true)
44 if tt.renamed {
45 require.NoError(t, d.renameSession(sess, "kept"))
46 }
47 d.reapAbandonedEphemeral(sess)
48
49 require.Equal(t, !tt.wantRemoved, d.sessionRegistered(sess))
50 if !tt.wantRemoved {
51 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, false))
52 }
53 d.sessWg.Wait()
54 })
55 }
56}
57
58func TestEphemeralCloseOnExitWaitsForParkExpiry(t *testing.T) {
59 tests := []struct {
60 name string
61 otherClient bool
62 wantRemoved bool
63 }{
64 {name: "expired park removes abandoned session", wantRemoved: true},
65 {name: "expired park keeps session with another client", otherClient: true},
66 }
67 for _, tt := range tests {
68 t.Run(tt.name, func(t *testing.T) {
69 clock := &signalClock{timers: make(chan *signalTimer, 8)}
70 pty, release := newBlockingPTY(t)
71 defer release()
72 d := newTestDaemon(t, newFactory(t, pty), clock)
73
74 tr := &closeTrackingTransport{}
75 sess, ac, err := d.route(helloResumeCapable(protocol.IntentEphemeral, "", 0), tr)
76 require.NoError(t, err)
77 if tt.otherClient {
78 _, _, err := d.route(helloResumeCapable(protocol.IntentAttach, sess.nameSnapshot(), 0), &closeTrackingTransport{})
79 require.NoError(t, err)
80 }
81 token := ac.resumeToken
82
83 // A link loss parks: the session must survive so the client can resume.
84 d.clientGone(sess, ac, tr, false)
85 <-clock.timers
86 d.reapAbandonedEphemeral(sess)
87 require.True(t, d.sessionRegistered(sess), "parked attachment keeps the ephemeral session")
88
89 d.mu.Lock()
90 parked := d.resume.parked[token]
91 d.mu.Unlock()
92 require.NotNil(t, parked)
93 // Expire directly instead of firing the timer, so no watcher
94 // goroutine races the assertion below.
95 d.expireParked(token, parked)
96
97 require.Equal(t, !tt.wantRemoved, d.sessionRegistered(sess))
98 if !tt.wantRemoved {
99 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, false))
100 }
101 d.sessWg.Wait()
102 })
103 }
104}
105
106func TestDetachFrameEphemeralCloseOnExit(t *testing.T) {
107 tests := []struct {
108 name string
109 closeOnExit bool
110 intent uint8
111 sessionName string
112 detach protocol.Detach
113 wantRemoved bool
114 }{
115 {name: "closed client removes session", closeOnExit: true, intent: protocol.IntentEphemeral, detach: protocol.Detach{Closed: true}, wantRemoved: true},
116 {name: "user detach keeps session", closeOnExit: true, intent: protocol.IntentEphemeral, detach: protocol.Detach{}},
117 {name: "option off keeps session", closeOnExit: false, intent: protocol.IntentEphemeral, detach: protocol.Detach{Closed: true}},
118 {name: "named session is kept", closeOnExit: true, intent: protocol.IntentNew, sessionName: "work", detach: protocol.Detach{Closed: true}},
119 }
120 for _, tt := range tests {
121 t.Run(tt.name, func(t *testing.T) {
122 pty, release := newBlockingPTY(t)
123 defer release()
124 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
125 updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.ephemeral = domain.EphemeralConfig{CloseOnExit: tt.closeOnExit} })
126 tr, sends, _ := newConn(t,
127 mustHello(tt.intent, tt.sessionName, domain.Size{Cols: 80, Rows: 24}),
128 mustClientEnvelope(tt.detach),
129 )
130
131 var hg sync.WaitGroup
132 hg.Go(func() { d.handleConn(tr) })
133 awaitFrame(t, sends, "Welcome")
134 hg.Wait()
135
136 if tt.wantRemoved {
137 require.Equal(t, 0, sessionCount(d))
138 } else {
139 require.Equal(t, 1, sessionCount(d))
140 require.NoError(t, d.killSession(firstSession(d), protocol.ReasonSessionKilled, false))
141 }
142 d.sessWg.Wait()
143 d.waitNotifies()
144 })
145 }
146}