1package daemon
2
3import (
4 "testing"
5 "time"
6
7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/protocol"
9 "github.com/stretchr/testify/require"
10)
11
12func clearAttachmentsForTest(sess *session) {
13 for _, ac := range sess.snapshotAttachments() {
14 sess.unregisterAttachment(ac)
15 }
16}
17
18func clearAttachmentsForTestLocked(sess *session) {
19 for _, ac := range sess.snapshotAttachmentsLocked() {
20 sess.unregisterAttachmentLocked(ac)
21 }
22}
23
24func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) {
25 sess := &session{}
26 sess.id = domain.SessionID("local")
27 sess.name = "work"
28 sess.tabs = []*tab{{name: "shell"}}
29 ac := &attachedClient{}
30
31 core := sess.core()
32 require.Same(t, &sess.sessionCore, core)
33 require.Equal(t, sessionCapabilities{}, core.caps, "zero-value local sessions must retain all local capabilities")
34
35 core.mu.Lock()
36 require.False(t, sess.mu.TryLock(), "sessionCore.mu must be the mutex promoted as session.mu")
37 core.registerAttachmentLocked(ac)
38 require.Equal(t, true, sess.attachmentRegisteredLocked(ac))
39 sess.tabs = append(sess.tabs, &tab{name: "logs"})
40 require.Len(t, sess.tabs, 2)
41 core.mu.Unlock()
42
43 require.True(t, sess.mu.TryLock())
44 sess.mu.Unlock()
45}
46
47func TestSessionSnapshotViewUsesPromotedMutexForLocalTabAndRoleState(t *testing.T) {
48 sess := &session{
49 sessionCore: sessionCore{id: domain.SessionID("local"), name: "work"},
50 tabs: []*tab{{stableID: "tab-shell", name: "shell"}},
51 }
52 ac := &attachedClient{}
53
54 // Mutate local-only tab state and shared attachment role state under the
55 // promoted core mutex. snapshotView must observe the same mutex before it
56 // can publish either field.
57 sess.core().mu.Lock()
58 sess.tabs = append(sess.tabs, &tab{stableID: "tab-logs", name: "logs"})
59 sess.registerAttachmentLocked(ac)
60 sess.activateAttachmentViewLocked(ac, 1)
61
62 preCall := make(chan struct{})
63 allowCall := make(chan struct{})
64 snapshotted := make(chan sessionView, 1)
65 go func() {
66 // The barrier places the goroutine immediately before snapshotView, so
67 // the lock is definitely held before the call can begin.
68 close(preCall)
69 <-allowCall
70 snapshotted <- sess.snapshotView(viewOptions{})
71 }()
72 awaitTestCompletion(t, preCall, "snapshot goroutine did not reach the pre-call barrier")
73 close(allowCall)
74
75 // Give the released call a bounded opportunity to acquire the mutex; it
76 // must remain blocked until the owner releases it below.
77 timer := time.NewTimer(50 * time.Millisecond)
78 defer timer.Stop()
79 select {
80 case <-snapshotted:
81 t.Fatal("snapshotView completed while sessionCore.mu guarded local session state")
82 case <-timer.C:
83 }
84 require.Equal(t, true, sess.attachmentRegisteredLocked(ac))
85 sess.core().mu.Unlock()
86
87 view := awaitTestValue(t, snapshotted, "snapshotView did not complete after sessionCore.mu was released")
88 require.True(t, view.attached)
89 require.Equal(t, 2, view.tabCount)
90 require.Equal(t, 0, view.defaultTab)
91 require.Equal(t, 1, testAttachmentTabIndex(sess))
92}
93
94func TestLocalCreateThenKillRemovesLiveRegistryEntry(t *testing.T) {
95 pty, releasePTY := newBlockingPTY(t)
96 defer releasePTY()
97 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
98
99 sess, err := createSessionForTest(d, "0", true, "/tmp", defaultSize, terminalEnv{}, nil)
100 require.NoError(t, err)
101 d.mu.Lock()
102 registered := d.sessions[sess.id]
103 d.mu.Unlock()
104 require.Same(t, sess, registered)
105
106 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, false))
107 d.mu.Lock()
108 _, live := d.sessions[sess.id]
109 d.mu.Unlock()
110 require.False(t, live, "killed local session must not remain in the live registry")
111 d.sessWg.Wait()
112}
113
114func TestAttachmentSessionRegistryUsesExactIdentity(t *testing.T) {
115 d := &Daemon{sessions: make(map[domain.SessionID]*session)}
116 first := &session{}
117 first.id = domain.SessionID("same")
118 second := &session{}
119 second.id = first.id
120
121 require.True(t, d.registerSessionLocked(first))
122 require.False(t, d.registerSessionLocked(first), "duplicate registration must be rejected")
123 require.False(t, d.registerSessionLocked(second), "a different pointer with the same ID must be rejected")
124 require.False(t, d.unregisterSessionLocked(second), "unregister must not remove a replacement pointer")
125 require.Same(t, first, d.sessions[first.id])
126 require.True(t, d.unregisterSessionLocked(first))
127 require.NotContains(t, d.sessions, first.id)
128 require.False(t, d.unregisterSessionLocked(first))
129}
130
131func TestSessionCoreLockOrderUsesImmutableIDs(t *testing.T) {
132 first := &session{}
133 first.id = domain.SessionID("a")
134 second := &session{}
135 second.id = domain.SessionID("z")
136
137 unlock := lockAttachmentSessions(second, first)
138 require.False(t, first.core().mu.TryLock())
139 require.False(t, second.core().mu.TryLock())
140 unlock()
141
142 require.True(t, first.core().mu.TryLock())
143 first.core().mu.Unlock()
144 require.True(t, second.core().mu.TryLock())
145 second.core().mu.Unlock()
146}
147
148func mustLocalSession(t *testing.T, entry *session) *session {
149 t.Helper()
150 require.NotNil(t, entry)
151 return entry
152}