1package daemon
2
3import (
4 "context"
5 "testing"
6 "time"
7
8 "github.com/stretchr/testify/require"
9
10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/protocol"
12)
13
14// acceptedDeadlineConn is a typed daemon connection that yields one scripted
15// first message, then blocks on its embedded transport, while exposing the
16// optional accepted absolute handshake deadline seam. Ordinary test transports
17// keep the fresh-budget behavior because they do not implement the seam.
18type acceptedDeadlineConn struct {
19 *handshakeBlockingTransport
20 deadline time.Time
21 first protocol.ClientMessage
22}
23
24func (c *acceptedDeadlineConn) ReceiveClient() (protocol.ClientMessage, error) {
25 if c.first != nil {
26 message := c.first
27 c.first = nil
28 return message, nil
29 }
30 return c.handshakeBlockingTransport.ReceiveClient()
31}
32
33func (c *acceptedDeadlineConn) HandshakeDeadline() time.Time { return c.deadline }
34
35func TestAcceptedHandshakeDeadlineSeamResolution(t *testing.T) {
36 plain := newHandshakeBlockingTransport(false)
37 require.True(t, acceptedHandshakeDeadline(plain).IsZero(),
38 "a connection without the optional seam must keep the fresh 15s budget")
39
40 fixed := time.Unix(1_700_000_000, 0)
41 seamed := &acceptedDeadlineConn{
42 handshakeBlockingTransport: newHandshakeBlockingTransport(false),
43 deadline: fixed,
44 }
45 require.Equal(t, fixed, acceptedHandshakeDeadline(seamed))
46}
47
48func TestNewHandshakeContextConsumesAcceptedQueueTime(t *testing.T) {
49 clock := &signalClock{timers: make(chan *signalTimer, 2)}
50 d := newTestDaemon(t, nil, clock)
51
52 // signalClock.Now is the zero time, so a deadline 4s after it leaves only
53 // that much of the accepted budget instead of restarting 15s.
54 ctx, timedOut, finish := d.newHandshakeContext(context.Background(), time.Time{}.Add(4*time.Second))
55 defer finish()
56
57 timer := <-clock.timers
58 require.Equal(t, 4*time.Second, timer.duration, "queue time must be consumed from the accepted budget")
59 require.NoError(t, ctx.Err())
60 select {
61 case <-timedOut:
62 t.Fatal("handshake timed out before the accepted budget elapsed")
63 default:
64 }
65
66 timer.ch <- time.Time{}
67 awaitTestCompletion(t, ctx.Done(), "accepted budget did not cancel the handshake context")
68 awaitTestCompletion(t, timedOut, "accepted budget did not publish the timeout signal")
69}
70
71func TestNewHandshakeContextExpiredAcceptedDeadlineFailsPromptly(t *testing.T) {
72 clock := &signalClock{timers: make(chan *signalTimer, 1)}
73 d := newTestDaemon(t, nil, clock)
74
75 ctx, timedOut, finish := d.newHandshakeContext(context.Background(), time.Time{}.Add(-time.Second))
76 require.ErrorIs(t, ctx.Err(), context.Canceled)
77 select {
78 case <-timedOut:
79 default:
80 t.Fatal("an already-expired accepted deadline did not publish the timeout signal")
81 }
82 select {
83 case timer := <-clock.timers:
84 t.Fatalf("an already-expired accepted deadline started a new %s budget", timer.duration)
85 default:
86 }
87 // finish stays safe to call repeatedly on the immediate-expiry path.
88 finish()
89 finish()
90}
91
92// TestAcceptedHandshakeDeadlineSpansFirstFrameThroughWelcome proves handleConn
93// adopts the accepted absolute deadline as the single budget covering the first
94// ReceiveClient and the blocked Welcome send, so no second 15s budget is
95// started and the accepted budget alone ends the handshake.
96func TestAcceptedHandshakeDeadlineSpansFirstFrameThroughWelcome(t *testing.T) {
97 pty, release := newBlockingPTY(t)
98 defer release()
99 clock := &signalClock{timers: make(chan *signalTimer, 4)}
100 d := newTestDaemon(t, newFactory(t, pty), clock)
101
102 conn := &acceptedDeadlineConn{
103 handshakeBlockingTransport: newHandshakeBlockingTransport(true),
104 deadline: time.Time{}.Add(9 * time.Second),
105 first: protocol.Hello{
106 Version: protocol.Version,
107 Intent: protocol.IntentNew,
108 Name: "accepted-deadline",
109 Size: domain.Size{Cols: 80, Rows: 24},
110 TermEnv: "xterm-256color",
111 },
112 }
113 done := make(chan struct{})
114 go func() {
115 d.handleConn(conn)
116 close(done)
117 }()
118
119 timer := <-clock.timers
120 require.Equal(t, 9*time.Second, timer.duration,
121 "handleConn must adopt the accepted deadline rather than start 15s")
122 <-conn.welcome
123 timer.ch <- time.Time{}
124 awaitTestCompletion(t, done, "accepted handshake did not stop after its budget elapsed")
125 requireClosedHandshakeTransport(t, conn.handshakeBlockingTransport)
126 d.mu.Lock()
127 require.Empty(t, d.sessions, "a budget-expired newly-created handshake must not leave an empty session")
128 require.Empty(t, d.resume.parked)
129 d.mu.Unlock()
130}
131
132// TestExpiredAcceptedHandshakeDeadlineClosesConn proves handleConn fails an
133// already-expired accepted connection promptly: it closes the transport,
134// returns, and never starts a fresh 15s receive budget.
135func TestExpiredAcceptedHandshakeDeadlineClosesConn(t *testing.T) {
136 clock := &signalClock{timers: make(chan *signalTimer, 1)}
137 d := newTestDaemon(t, nil, clock)
138
139 conn := &acceptedDeadlineConn{
140 handshakeBlockingTransport: newHandshakeBlockingTransport(false),
141 deadline: time.Time{}.Add(-time.Second),
142 }
143 done := make(chan struct{})
144 go func() {
145 d.handleConn(conn)
146 close(done)
147 }()
148
149 awaitTestCompletion(t, done, "an expired accepted deadline did not fail promptly")
150 requireClosedHandshakeTransport(t, conn.handshakeBlockingTransport)
151 select {
152 case timer := <-clock.timers:
153 t.Fatalf("an expired accepted deadline started a fresh %s budget", timer.duration)
154 default:
155 }
156 d.mu.Lock()
157 require.Empty(t, d.sessions)
158 require.Empty(t, d.resume.parked)
159 d.mu.Unlock()
160}
161
162// TestHandleHelloAdoptsExpiredAcceptedDeadline proves the direct handleHello
163// entry point honors the same optional seam as handleConn.
164func TestHandleHelloAdoptsExpiredAcceptedDeadline(t *testing.T) {
165 clock := &signalClock{timers: make(chan *signalTimer, 1)}
166 d := newTestDaemon(t, nil, clock)
167
168 conn := &acceptedDeadlineConn{
169 handshakeBlockingTransport: newHandshakeBlockingTransport(false),
170 deadline: time.Time{}.Add(-time.Second),
171 }
172 done := make(chan struct{})
173 go func() {
174 d.handleHello(conn, protocol.Hello{
175 Version: protocol.Version,
176 Intent: protocol.IntentNew,
177 Name: "direct-expired",
178 Size: domain.Size{Cols: 80, Rows: 24},
179 TermEnv: "xterm-256color",
180 })
181 close(done)
182 }()
183
184 awaitTestCompletion(t, done, "handleHello did not fail on an expired accepted deadline")
185 requireClosedHandshakeTransport(t, conn.handshakeBlockingTransport)
186 select {
187 case timer := <-clock.timers:
188 t.Fatalf("handleHello started a fresh %s budget for an expired accepted deadline", timer.duration)
189 default:
190 }
191 d.mu.Lock()
192 require.Empty(t, d.sessions)
193 require.Empty(t, d.resume.parked)
194 d.mu.Unlock()
195}