1package app
2
3import (
4 "context"
5 "reflect"
6 "testing"
7 "testing/synctest"
8 "time"
9
10 "github.com/bnema/neferwl/internal/adapters/wayland/sessionlock"
11 "github.com/bnema/neferwl/internal/ports"
12)
13
14func hotplugEvent(t *testing.T, events <-chan ports.SecurityBackendEvent) ports.SecurityBackendEvent {
15 t.Helper()
16 select {
17 case event := <-events:
18 return event
19 case <-time.After(2 * time.Second):
20 t.Fatal("missing ordered security event")
21 return nil
22 }
23}
24
25// This switch represents the single display owner's ledger consumption, not
26// an alternate backend implementation. Events come through real outputSet FIFO.
27func hotplugApply(ledger *sessionlock.Readiness, event ports.SecurityBackendEvent) bool {
28 switch v := event.(type) {
29 case ports.SecurityOutputAdded:
30 return ledger.Add(v.Instance)
31 case ports.SecurityOutputRemoved:
32 return ledger.Remove(v.Instance)
33 case ports.SecurityOutputInvalidated:
34 return ledger.Invalidate(v.Generation, v.Instance)
35 case ports.SecurityOutputProof:
36 return ledger.Record(v.Proof)
37 case ports.SecurityBackendBarrier:
38 return v.Err == nil && ledger.BackendBarrier(v.Generation)
39 default:
40 return false
41 }
42}
43
44func TestSecurityHotplugLifetimeAndProofOrdering(t *testing.T) {
45 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
46 defer cancel()
47 events := make(chan ports.SecurityBackendEvent, 8)
48 set := newOutputSet(ctx, nil, outputRoutes{})
49 set.wireSecurity(outputChannels{securityEvents: events})
50 defer func() {
51 if err := set.wait(); err != nil {
52 t.Error(err)
53 }
54 }()
55 const generation = ports.LockGeneration(41)
56 set.setSecurity(ports.SecurityState{Generation: generation, Protected: true})
57 var ledger sessionlock.Readiness
58 if !ledger.Begin(generation) || !ledger.BackendBarrier(generation) || !ledger.CaptureBarrier(generation) {
59 t.Fatal("could not begin display readiness round")
60 }
61 // Native Close success is independently tested in DRM. Here the real
62 // owner callback synchronizes that affirmative result before the backend
63 // sends removal; mere callback exit never emits removal from outputSet.
64 start := func(closeSuccess bool) (ports.OutputInstance, chan []ports.SecurityBackendEvent, <-chan bool) {
65 t.Helper()
66 commands := make(chan []ports.SecurityBackendEvent, 1)
67 closed := make(chan bool, 1)
68 var ownerEvents chan<- ports.SecurityBackendEvent
69 if !set.start(ctx, "DP-1", func(octx context.Context, feed outputFeed) error {
70 security, instance := feed.Security, feed.Instance
71 defer func() { closed <- closeSuccess }()
72 state := <-security
73 proof := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: state.Generation, Instance: instance, Kind: ports.ProtectionProtectedFrame}}
74 select {
75 case ownerEvents <- proof:
76 case <-octx.Done():
77 return nil
78 }
79 for {
80 select {
81 case batch := <-commands:
82 for _, event := range batch {
83 select {
84 case ownerEvents <- event:
85 case <-octx.Done():
86 return nil
87 }
88 }
89 case <-octx.Done():
90 return nil
91 }
92 }
93 }, &ownerEvents) {
94 t.Fatal("start refused")
95 }
96 registered := hotplugEvent(t, events)
97 added, ok := registered.(ports.SecurityOutputAdded)
98 if !ok || added.Output != "DP-1" || added.Instance == 0 || !hotplugApply(&ledger, registered) {
99 t.Fatalf("owner evidence preceded lifetime registration: %+v", registered)
100 }
101 if ledger.Ready() {
102 t.Fatal("new lifetime reused previous physical readiness")
103 }
104 proof := hotplugEvent(t, events)
105 if !hotplugApply(&ledger, proof) || !ledger.Ready() {
106 t.Fatalf("missing exact lifetime proof: %+v", proof)
107 }
108 return added.Instance, commands, closed
109 }
110 old, commands, closed := start(true)
111 // A proof already queued by this owner precedes invalidation, even if the
112 // display reads later. No separate forwarder can replay it after revoke.
113 oldProof := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: generation, Instance: old, Kind: ports.ProtectionProtectedFrame}}
114 invalid := ports.SecurityOutputInvalidated{Generation: generation, Instance: old}
115 commands <- []ports.SecurityBackendEvent{oldProof, invalid}
116 if got := hotplugEvent(t, events); got != oldProof || !hotplugApply(&ledger, got) {
117 t.Fatalf("queued proof lost: %+v", got)
118 }
119 if got := hotplugEvent(t, events); got != invalid || !hotplugApply(&ledger, got) || ledger.Ready() {
120 t.Fatalf("invalidation reordered or ineffective: %+v", got)
121 }
122 commands <- []ports.SecurityBackendEvent{oldProof}
123 if got := hotplugEvent(t, events); got != oldProof || !hotplugApply(&ledger, got) || !ledger.Ready() {
124 t.Fatal("fresh resume proof did not restore readiness")
125 }
126 set.outs["DP-1"].stop()
127 select {
128 case stopped := <-set.stopped:
129 if stopped.instance != old || !set.stoppedCurrent(stopped) {
130 t.Fatalf("wrong stopped lifetime: %+v", stopped)
131 }
132 case <-ctx.Done():
133 t.Fatal("owner did not stop")
134 }
135 if err := set.finish("DP-1"); err != nil {
136 t.Fatal(err)
137 }
138 if !<-closed {
139 t.Fatal("shutdown not affirmative")
140 }
141 select {
142 case got := <-events:
143 t.Fatalf("owner exit forged removal: %+v", got)
144 default:
145 }
146 if !set.securityEvent(ports.SecurityOutputRemoved{Instance: old}) {
147 t.Fatal("confirmed removal refused")
148 }
149 removal := hotplugEvent(t, events)
150 if removal != (ports.SecurityOutputRemoved{Instance: old}) || !hotplugApply(&ledger, removal) {
151 t.Fatalf("wrong removal: %+v", removal)
152 }
153 replacement, _, replacementClosed := start(false)
154 if replacement <= old || set.stoppedCurrent(outputStopped{name: "DP-1", instance: old}) {
155 t.Fatal("same-name replacement reused retired lifetime")
156 }
157 if hotplugApply(&ledger, oldProof) || hotplugApply(&ledger, invalid) {
158 t.Fatal("retired lifetime affected replacement")
159 }
160 stale := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: generation - 1, Instance: replacement, Kind: ports.ProtectionInactiveOutput}}
161 if hotplugApply(&ledger, stale) || !ledger.Ready() {
162 t.Fatal("stale generation overwrote valid replacement proof")
163 }
164 if !ledger.Invalidate(generation, replacement) || ledger.Ready() {
165 t.Fatal("replacement invalidation ineffective")
166 }
167 set.outs["DP-1"].stop()
168 select {
169 case <-set.stopped:
170 case <-ctx.Done():
171 t.Fatal("replacement stop blocked")
172 }
173 if err := set.finish("DP-1"); err != nil {
174 t.Fatal(err)
175 }
176 if <-replacementClosed {
177 t.Fatal("expected failed shutdown")
178 }
179 select {
180 case got := <-events:
181 t.Fatalf("failed shutdown forged removal: %+v", got)
182 default:
183 }
184 if ledger.Ready() {
185 t.Fatal("failed replacement exit erased required unproved lifetime")
186 }
187}
188
189func TestSecurityOwnerFIFOSaturationDrainsOnNormalStop(t *testing.T) {
190 synctest.Test(t, securityOwnerFIFOSaturationDrainsOnNormalStop)
191}
192
193func securityOwnerFIFOSaturationDrainsOnNormalStop(t *testing.T) {
194 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
195 defer cancel()
196 events := make(chan ports.SecurityBackendEvent) // block display after registration
197 set := newOutputSet(ctx, nil, outputRoutes{})
198 set.wireSecurity(outputChannels{securityEvents: events})
199 defer func() {
200 if err := set.wait(); err != nil {
201 t.Error(err)
202 }
203 }()
204 const generation = ports.LockGeneration(7)
205 accepted := make(chan int, 40)
206 startDone := make(chan bool, 1)
207 var ownerEvents chan<- ports.SecurityBackendEvent
208 go func() {
209 startDone <- set.start(ctx, "DP-1", func(octx context.Context, feed outputFeed) error {
210 instance := feed.Instance
211 for i := range 40 {
212 var event ports.SecurityBackendEvent = ports.SecurityOutputInvalidated{Generation: generation, Instance: instance}
213 if i%2 == 0 {
214 event = ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: generation, Instance: instance, Kind: ports.ProtectionProtectedFrame}}
215 }
216 select {
217 case ownerEvents <- event:
218 accepted <- i
219 case <-octx.Done():
220 return nil
221 }
222 }
223 return nil
224 }, &ownerEvents)
225 }()
226 registered := hotplugEvent(t, events).(ports.SecurityOutputAdded)
227 if !<-startDone {
228 t.Fatal("start failed")
229 }
230 if cap(set.outs["DP-1"].securityEvents) != 16 {
231 t.Fatal("owner evidence FIFO is not bounded to 16")
232 }
233 // Forwarder holds one event plus its 16-slot input. The eighteenth send
234 // must remain blocked until the display starts draining.
235 for i := range 17 {
236 select {
237 case got := <-accepted:
238 if got != i {
239 t.Fatalf("acceptance order %d want %d", got, i)
240 }
241 case <-ctx.Done():
242 t.Fatal("FIFO failed to fill")
243 }
244 }
245 synctest.Wait()
246 select {
247 case got := <-accepted:
248 t.Fatalf("full FIFO did not backpressure owner: %d", got)
249 default:
250 }
251 var ledger sessionlock.Readiness
252 ledger.Add(registered.Instance)
253 ledger.Begin(generation)
254 ledger.BackendBarrier(generation)
255 ledger.CaptureBarrier(generation)
256 for i := range 40 {
257 got := hotplugEvent(t, events)
258 var want ports.SecurityBackendEvent = ports.SecurityOutputInvalidated{Generation: generation, Instance: registered.Instance}
259 if i%2 == 0 {
260 want = ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: generation, Instance: registered.Instance, Kind: ports.ProtectionProtectedFrame}}
261 }
262 if !reflect.DeepEqual(got, want) || !hotplugApply(&ledger, got) || ledger.Ready() != (i%2 == 0) {
263 t.Fatalf("evidence lost/duplicated/reordered at %d: %+v", i, got)
264 }
265 }
266 select {
267 case stopped := <-set.stopped:
268 if !set.stoppedCurrent(stopped) {
269 t.Fatal("stop not current")
270 }
271 case <-ctx.Done():
272 t.Fatal("normal owner stop blocked")
273 }
274 if err := set.finish("DP-1"); err != nil {
275 t.Fatal(err)
276 }
277 select {
278 case got := <-events:
279 t.Fatalf("duplicate evidence or removal after normal drain: %+v", got)
280 default:
281 }
282}
283
284// All producer evidence is already buffered when the owner stops normally.
285// finish must not cancel that owner's forwarder or lose its outstanding FIFO.
286func TestSecurityOwnerBufferedEvidenceDrainsAfterFinish(t *testing.T) {
287 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
288 defer cancel()
289 events := make(chan ports.SecurityBackendEvent)
290 set := newOutputSet(ctx, nil, outputRoutes{})
291 set.wireSecurity(outputChannels{securityEvents: events})
292 defer func() {
293 if err := set.wait(); err != nil {
294 t.Error(err)
295 }
296 }()
297 startDone := make(chan bool, 1)
298 var ownerEvents chan<- ports.SecurityBackendEvent
299 go func() {
300 startDone <- set.start(ctx, "DP-1", func(octx context.Context, feed outputFeed) error {
301 instance := feed.Instance
302 for i := range 16 {
303 event := ports.SecurityOutputInvalidated{Generation: ports.LockGeneration(i + 1), Instance: instance}
304 select {
305 case ownerEvents <- event:
306 case <-octx.Done():
307 return nil
308 }
309 }
310 return nil
311 }, &ownerEvents)
312 }()
313 registered := hotplugEvent(t, events).(ports.SecurityOutputAdded)
314 if !<-startDone {
315 t.Fatal("start refused")
316 }
317 select {
318 case stopped := <-set.stopped:
319 if stopped.instance != registered.Instance {
320 t.Fatal("wrong stopped lifetime")
321 }
322 case <-ctx.Done():
323 t.Fatal("buffered owner did not finish")
324 }
325 if err := set.finish("DP-1"); err != nil {
326 t.Fatal(err)
327 }
328 for i := range 16 {
329 want := ports.SecurityOutputInvalidated{Generation: ports.LockGeneration(i + 1), Instance: registered.Instance}
330 if got := hotplugEvent(t, events); got != want {
331 t.Fatalf("buffered stopped-owner evidence %d: %+v", i, got)
332 }
333 }
334 select {
335 case got := <-events:
336 t.Fatalf("duplicate buffered evidence: %+v", got)
337 default:
338 }
339}
340
341func TestSecurityOwnerFIFOSaturationCancelReleasesOwnerAndForwarders(t *testing.T) {
342 synctest.Test(t, securityOwnerFIFOSaturationCancelReleasesOwnerAndForwarders)
343}
344
345func securityOwnerFIFOSaturationCancelReleasesOwnerAndForwarders(t *testing.T) {
346 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
347 defer cancel()
348 events := make(chan ports.SecurityBackendEvent)
349 set := newOutputSet(ctx, nil, outputRoutes{})
350 set.wireSecurity(outputChannels{securityEvents: events})
351 accepted := make(chan int, 40)
352 ownerDone := make(chan struct{})
353 startDone := make(chan bool, 1)
354 var ownerEvents chan<- ports.SecurityBackendEvent
355 go func() {
356 startDone <- set.start(ctx, "DP-1", func(octx context.Context, feed outputFeed) error {
357 instance := feed.Instance
358 defer close(ownerDone)
359 for i := range 40 {
360 select {
361 case ownerEvents <- ports.SecurityOutputInvalidated{Generation: 7, Instance: instance}:
362 accepted <- i
363 case <-octx.Done():
364 return nil
365 }
366 }
367 return nil
368 }, &ownerEvents)
369 }()
370 hotplugEvent(t, events) // registered before producer starts
371 if !<-startDone {
372 t.Fatal("start failed")
373 }
374 for range 17 {
375 select {
376 case <-accepted:
377 case <-ctx.Done():
378 t.Fatal("FIFO never filled")
379 }
380 }
381 synctest.Wait()
382 select {
383 case <-accepted:
384 t.Fatal("producer did not block at finite FIFO")
385 default:
386 }
387 waitDone := make(chan error, 1)
388 go func() { waitDone <- set.wait() }()
389 select {
390 case err := <-waitDone:
391 if err != nil {
392 t.Fatal(err)
393 }
394 case <-ctx.Done():
395 t.Fatal("saturated evidence leaked owner/forwarder on cancellation")
396 }
397 select {
398 case <-ownerDone:
399 default:
400 t.Fatal("wait returned before producer exited")
401 }
402 if len(set.outs) != 0 {
403 t.Fatal("stopped lifetime retained in running set")
404 }
405 // wait includes forwarders.Wait: termination is directly synchronized,
406 // rather than guessed from goroutine counts or arbitrary sleeps.
407}