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