1package daemonmux
2
3import (
4 "errors"
5 "fmt"
6 "sync"
7 "sync/atomic"
8 "testing"
9
10 "github.com/bnema/vev/internal/domain"
11)
12
13// errTerminalSentinel is a stable cause used across tests so error identity
14// can be checked with errors.Is.
15var errTerminalSentinel = errors.New("terminal sentinel")
16
17func terminalDone(s *terminalState) bool {
18 select {
19 case <-s.Done():
20 return true
21 default:
22 return false
23 }
24}
25
26func TestTerminalStatePreTerminalGetters(t *testing.T) {
27 s := newTerminalState()
28
29 if terminalDone(s) {
30 t.Fatal("Done closed before any publication")
31 }
32 if err := s.Err(); err != nil {
33 t.Fatalf("Err() = %v before publication, want nil", err)
34 }
35 if kind := s.FailureKind(); kind != domain.RemoteFailureNone {
36 t.Fatalf("FailureKind() = %v before publication, want none", kind)
37 }
38}
39
40func TestTerminalStateLocalClose(t *testing.T) {
41 s := newTerminalState()
42 s.Close()
43
44 if !terminalDone(s) {
45 t.Fatal("Done not closed after local Close")
46 }
47 if err := s.Err(); err != nil {
48 t.Fatalf("Err() = %v after local Close, want nil", err)
49 }
50 if kind := s.FailureKind(); kind != domain.RemoteFailureNone {
51 t.Fatalf("FailureKind() = %v after local Close, want none", kind)
52 }
53
54 // A second local Close stays orderly, and a later failure must not
55 // overwrite the winning outcome.
56 s.Close()
57 s.Fail(domain.RemoteFailureTimeout, errors.New("late failure"))
58 if err := s.Err(); err != nil {
59 t.Fatalf("Err() = %v after late Fail, want nil", err)
60 }
61 if kind := s.FailureKind(); kind != domain.RemoteFailureNone {
62 t.Fatalf("FailureKind() = %v after late Fail, want none", kind)
63 }
64}
65
66type wrappedError struct{ err error }
67
68func (w wrappedError) Error() string { return w.err.Error() }
69func (w wrappedError) Unwrap() error { return w.err }
70
71func TestTerminalStateFailurePreservesErrorIdentity(t *testing.T) {
72 s := newTerminalState()
73 cause := wrappedError{err: fmt.Errorf("dial daemon: %w", errTerminalSentinel)}
74 s.Fail(domain.RemoteFailureTimeout, cause)
75
76 if !terminalDone(s) {
77 t.Fatal("Done not closed after Fail")
78 }
79 if got := s.Err(); got != cause {
80 t.Fatalf("Err() = %v, want the exact published error value", got)
81 }
82 if !errors.Is(s.Err(), errTerminalSentinel) {
83 t.Fatal("errors.Is lost the wrapped sentinel")
84 }
85 var wrapped wrappedError
86 if !errors.As(s.Err(), &wrapped) {
87 t.Fatal("errors.As lost the wrapped error identity")
88 }
89 if kind := s.FailureKind(); kind != domain.RemoteFailureTimeout {
90 t.Fatalf("FailureKind() = %v, want timeout", kind)
91 }
92
93 // A later orderly Close must not clear the published failure.
94 s.Close()
95 if s.Err() != cause || s.FailureKind() != domain.RemoteFailureTimeout {
96 t.Fatalf("Close overwrote failure: Err()=%v FailureKind()=%v", s.Err(), s.FailureKind())
97 }
98}
99
100// TestTerminalStateNilCauseUsesSentinel proves a failure publisher that
101// supplies no cause still publishes a non-nil Err() carrying the package
102// sentinel, paired with a nonzero valid kind.
103func TestTerminalStateNilCauseUsesSentinel(t *testing.T) {
104 s := newTerminalState()
105 s.Fail(domain.RemoteFailureTimeout, nil)
106
107 if !terminalDone(s) {
108 t.Fatal("Done not closed after Fail with a nil cause")
109 }
110 if s.Err() == nil {
111 t.Fatal("Err() is nil after Fail with a nil cause")
112 }
113 if !errors.Is(s.Err(), ErrTerminalFailure) {
114 t.Fatalf("Err() = %v, want ErrTerminalFailure", s.Err())
115 }
116 if kind := s.FailureKind(); kind != domain.RemoteFailureTimeout {
117 t.Fatalf("FailureKind() = %v, want timeout", kind)
118 }
119 // A failure always pairs a non-nil error with a nonzero valid kind, so a
120 // concurrent reader never observes the inconsistent (nil, nonzero) pair.
121 if (s.Err() == nil) != (s.FailureKind() == domain.RemoteFailureNone) {
122 t.Fatal("failure published an inconsistent (err, kind) pair")
123 }
124
125 // A nil cause with an out-of-range kind still normalizes the kind and
126 // substitutes the sentinel.
127 serialized := newTerminalState()
128 serialized.Fail(domain.RemoteFailureKind(99), nil)
129 if !errors.Is(serialized.Err(), ErrTerminalFailure) {
130 t.Fatalf("Err() = %v, want ErrTerminalFailure", serialized.Err())
131 }
132 if kind := serialized.FailureKind(); kind != domain.RemoteFailureTransport {
133 t.Fatalf("FailureKind() = %v, want transport", kind)
134 }
135}
136
137func TestTerminalStateFailureKindNormalization(t *testing.T) {
138 tests := []struct {
139 name string
140 give domain.RemoteFailureKind
141 want domain.RemoteFailureKind
142 }{
143 {name: "none normalizes to transport", give: domain.RemoteFailureNone, want: domain.RemoteFailureTransport},
144 {name: "out of range normalizes to transport", give: domain.RemoteFailureKind(99), want: domain.RemoteFailureTransport},
145 {name: "transport stays", give: domain.RemoteFailureTransport, want: domain.RemoteFailureTransport},
146 {name: "timeout stays", give: domain.RemoteFailureTimeout, want: domain.RemoteFailureTimeout},
147 {name: "trust stays", give: domain.RemoteFailureTrust, want: domain.RemoteFailureTrust},
148 {name: "authentication stays", give: domain.RemoteFailureAuthentication, want: domain.RemoteFailureAuthentication},
149 {name: "incompatible stays", give: domain.RemoteFailureIncompatible, want: domain.RemoteFailureIncompatible},
150 {name: "invalid response stays", give: domain.RemoteFailureInvalidResponse, want: domain.RemoteFailureInvalidResponse},
151 }
152 for _, tt := range tests {
153 t.Run(tt.name, func(t *testing.T) {
154 s := newTerminalState()
155 s.Fail(tt.give, errors.New("boom"))
156 if got := s.FailureKind(); got != tt.want {
157 t.Fatalf("FailureKind() = %v, want %v", got, tt.want)
158 }
159 if s.Err() == nil {
160 t.Fatal("Err() is nil after Fail")
161 }
162 })
163 }
164}
165
166func TestTerminalStateFirstWinsConcurrentPublishers(t *testing.T) {
167 const publishers = 100
168 validKinds := []domain.RemoteFailureKind{
169 domain.RemoteFailureTransport,
170 domain.RemoteFailureTimeout,
171 domain.RemoteFailureTrust,
172 domain.RemoteFailureAuthentication,
173 domain.RemoteFailureIncompatible,
174 domain.RemoteFailureInvalidResponse,
175 }
176
177 s := newTerminalState()
178 publishedErrs := make([]error, publishers)
179 publishedKinds := make([]domain.RemoteFailureKind, publishers)
180
181 var wg sync.WaitGroup
182 start := make(chan struct{})
183 for i := 0; i < publishers; i++ {
184 publishedErrs[i] = fmt.Errorf("publisher %d: %w", i, errTerminalSentinel)
185 publishedKinds[i] = validKinds[i%len(validKinds)]
186 wg.Add(1)
187 go func(i int) {
188 defer wg.Done()
189 <-start
190 s.Fail(publishedKinds[i], publishedErrs[i])
191 }(i)
192 }
193 close(start)
194 wg.Wait()
195
196 if !terminalDone(s) {
197 t.Fatal("Done not closed after concurrent publishers")
198 }
199 winnerErr := s.Err()
200 winnerKind := s.FailureKind()
201 if winnerErr == nil {
202 t.Fatal("Err() is nil after failure publishers")
203 }
204 if !errors.Is(winnerErr, errTerminalSentinel) {
205 t.Fatal("winner error lost the sentinel identity")
206 }
207
208 matches := 0
209 for i := range publishedErrs {
210 if winnerErr != publishedErrs[i] {
211 continue
212 }
213 matches++
214 if winnerKind != publishedKinds[i] {
215 t.Fatalf("winner error from publisher %d paired with kind %v, want %v", i, winnerKind, publishedKinds[i])
216 }
217 }
218 if matches != 1 {
219 t.Fatalf("exactly one publisher must win, got %d", matches)
220 }
221
222 // Later publications, local or failing, never change the winner.
223 s.Close()
224 s.Fail(domain.RemoteFailureTrust, errors.New("late failure"))
225 if s.Err() != winnerErr || s.FailureKind() != winnerKind {
226 t.Fatalf("terminal values changed: (%v, %v)", s.Err(), s.FailureKind())
227 }
228}
229
230func TestTerminalStateConcurrentGettersAndPublishers(t *testing.T) {
231 const goroutines = 100
232
233 s := newTerminalState()
234 var wg sync.WaitGroup
235 var inconsistent atomic.Int32
236 start := make(chan struct{})
237 for i := 0; i < goroutines; i++ {
238 wg.Add(1)
239 go func(i int) {
240 defer wg.Done()
241 <-start
242 switch i % 4 {
243 case 0:
244 s.Fail(domain.RemoteFailureTransport, fmt.Errorf("failure %d: %w", i, errTerminalSentinel))
245 case 1:
246 s.Close()
247 default:
248 select {
249 case <-s.Done():
250 err := s.Err()
251 kind := s.FailureKind()
252 // Failure pairs a non-nil error with a nonzero valid
253 // kind; an orderly local Close pairs nil with none.
254 if (err == nil) != (kind == domain.RemoteFailureNone) {
255 inconsistent.Add(1)
256 }
257 default:
258 }
259 }
260 }(i)
261 }
262 close(start)
263 wg.Wait()
264
265 if !terminalDone(s) {
266 t.Fatal("Done not closed after concurrent getters and publishers")
267 }
268 if inconsistent.Load() != 0 {
269 t.Fatalf("observed %d inconsistent terminal reads", inconsistent.Load())
270 }
271
272 // Exactly-once close: repeated publication never panics and never changes
273 // the terminal outcome.
274 gotErr := s.Err()
275 gotKind := s.FailureKind()
276 for i := 0; i < 10; i++ {
277 s.Close()
278 s.Fail(domain.RemoteFailureTimeout, errors.New("late failure"))
279 if s.Err() != gotErr || s.FailureKind() != gotKind {
280 t.Fatalf("terminal values changed: (%v, %v) -> (%v, %v)", gotErr, gotKind, s.Err(), s.FailureKind())
281 }
282 }
283}