1package headless
2
3import (
4 "context"
5 "errors"
6 "image"
7 "os"
8 "sync/atomic"
9 "testing"
10 "time"
11
12 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
13 "github.com/bnema/neferwl/internal/ports"
14 "github.com/stretchr/testify/mock"
15)
16
17func TestExcludeFenceRetainsReadsAcrossEngageAndOff(t *testing.T) {
18 for _, finish := range []string{"signal", "cancel", "render-error"} {
19 t.Run(finish, func(t *testing.T) {
20 admitted := ports.SecurityState{Generation: 2}
21 protected := ports.SecurityState{Generation: 3, Protected: true}
22 var epoch atomic.Value
23 epoch.Store(admitted)
24 gate := portsmocks.NewMockSessionSecurity(t)
25 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) })
26 fence, signal, err := os.Pipe()
27 if err != nil {
28 t.Fatal(err)
29 }
30 defer signal.Close()
31 r := portsmocks.NewMockRenderer(t)
32 r.EXPECT().Close().Return().Once()
33 rendered := make(chan struct{}, 1)
34 releaseRender := make(chan struct{})
35 failure := errors.New("excluded render failed after submission")
36 r.EXPECT().Render(mock.MatchedBy(func(s ports.Scene) bool { return s.Security == admitted && s.Seq == 0 }), mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
37 rendered <- struct{}{}
38 <-releaseRender
39 if finish == "render-error" {
40 return fence, failure
41 }
42 return fence, nil
43 }).Once()
44 if finish == "signal" {
45 r.EXPECT().Render(mock.MatchedBy(func(s ports.Scene) bool { return s.Security == protected && s.Seq == 0 }), mock.Anything).Return(nil, nil).Once()
46 }
47 // Native capture and Pixels have no expectations; neither is admitted.
48 scenes := make(chan ports.Scene, 2)
49 contents := make(chan ports.SurfaceContent, 1)
50 incoming := make(chan ports.CaptureRequest)
51 presented := make(chan ports.OutputPresented, 8)
52 proofs := make(chan ports.SecurityBackendEvent, 2)
53 replies := make(chan ports.CaptureDone, 2)
54 changes := make(chan ports.SecurityState, 1)
55 ctx, cancel := context.WithCancel(context.Background())
56 defer cancel()
57 done := make(chan error, 1)
58 go func() {
59 done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, SecurityEvents: proofs, Instance: 7, Width: 2, Height: 2, Presented: presented, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, incoming)
60 }()
61 dst := sessionCaptureFile(t)
62 incoming <- ports.CaptureRequest{ID: 99, Exclude: true, Session: 7, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: dst}
63 contents <- ports.SurfaceContent{ID: 1, Seq: 8, SHM: &ports.SHMBuffer{Pool: 1}}
64 scene := sessionScene()
65 scene.Security = admitted
66 scene.Scale = 1
67 scene.OutputWidth = 2
68 scene.OutputHeight = 2
69 scenes <- scene
70 securityReceive(t, rendered)
71 // Prior non-render reports are outside this read lifetime; establish the
72 // cut at excluded GPU submission before exercising transition/off handling.
73 for len(presented) > 0 {
74 <-presented
75 }
76 epoch.Store(protected)
77 scenes <- ports.Scene{Security: protected, Off: true}
78 changes <- protected
79 close(releaseRender)
80 // A bounded observation interval catches the original immediate fence
81 // close/report path without assuming a polling schedule.
82 select {
83 case report := <-presented:
84 t.Fatalf("GPU reads reported before excluded fence: %+v", report)
85 case <-time.After(150 * time.Millisecond):
86 }
87 if _, err := fence.Stat(); err != nil {
88 t.Fatalf("unsignalled excluded read fence closed: %v", err)
89 }
90 select {
91 case proof := <-proofs:
92 t.Fatalf("protection path bypassed pending excluded reads: %+v", proof)
93 default:
94 }
95 if finish == "cancel" {
96 cancel()
97 } else {
98 if _, err := signal.Write([]byte{1}); err != nil {
99 t.Fatal(err)
100 }
101 }
102 if finish == "signal" {
103 securityReceive(t, proofs)
104 cancel()
105 }
106 runErr := securityReceive(t, done)
107 if finish == "render-error" {
108 if !errors.Is(runErr, failure) {
109 t.Fatal(runErr)
110 }
111 } else if runErr != nil {
112 t.Fatal(runErr)
113 }
114 if _, err := fence.Stat(); !errors.Is(err, os.ErrClosed) {
115 t.Fatalf("excluded fence leaked: %v", err)
116 }
117 reply := securityReceive(t, replies)
118 if reply.ID != 99 || reply.Err == nil {
119 t.Fatal(reply)
120 }
121 if _, err := dst.File.Stat(); !errors.Is(err, os.ErrClosed) {
122 t.Fatal("capture destination leaked")
123 }
124 select {
125 case extra := <-replies:
126 t.Fatalf("duplicate capture reply %+v", extra)
127 default:
128 }
129 if finish != "signal" {
130 select {
131 case proof := <-proofs:
132 t.Fatalf("cancel/error falsely proved protection: %+v", proof)
133 default:
134 }
135 select {
136 case report := <-presented:
137 t.Fatalf("cancel/error falsely reported reads: %+v", report)
138 default:
139 }
140 }
141 })
142 }
143}