1package headless
2
3import (
4 "context"
5 "errors"
6 "image"
7 "os"
8 "sync/atomic"
9 "testing"
10
11 "github.com/bnema/neferwl/internal/adapters/capture"
12 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
13 "github.com/bnema/neferwl/internal/ports"
14 "github.com/stretchr/testify/mock"
15)
16
17// Change the gate AFTER the output's post-Render check returns the admitting
18// state. SubmitScoped must close this last gap before native BeginCapture.
19func TestScopedCaptureClosesPostRenderAdmissionGap(t *testing.T) {
20 for _, excluded := range []bool{false, true} {
21 name := "normal"
22 if excluded {
23 name = "excluded"
24 }
25 t.Run(name, func(t *testing.T) {
26 admitted := ports.SecurityState{Generation: 2}
27 protected := ports.SecurityState{Generation: 3, Protected: true}
28 var state atomic.Value
29 state.Store(admitted)
30 var arm atomic.Bool
31 gate := portsmocks.NewMockSessionSecurity(t)
32 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
33 before := state.Load().(ports.SecurityState)
34 if arm.CompareAndSwap(true, false) {
35 state.Store(protected)
36 }
37 return before
38 })
39 r := portsmocks.NewMockRenderer(t)
40 r.EXPECT().Close().Return().Once()
41 frameRendered := make(chan struct{}, 1)
42 proof := make(chan ports.SecurityBackendEvent, 1)
43 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
44 if s.Security == protected {
45 if s.Seq != 0 || len(s.Windows) != 0 {
46 t.Errorf("bad protection frame %+v", s)
47 }
48 return nil, nil
49 }
50 if s.Security != admitted {
51 t.Errorf("wrong render epoch %+v", s)
52 }
53 arm.Store(true)
54 frameRendered <- struct{}{}
55 return nil, nil
56 }).Twice()
57 // BeginCapture has no expectation: any native export fails the test.
58 ctx, cancel := context.WithCancel(context.Background())
59 done := make(chan error, 1)
60 scenes := make(chan ports.Scene, 1)
61 incoming := make(chan ports.CaptureRequest)
62 replies := make(chan ports.CaptureDone, 2)
63 go func() {
64 done <- Run(ctx, Options{Security: gate, SecurityEvents: proof, Instance: 8, Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, incoming)
65 }()
66 dst := sessionCaptureFile(t)
67 incoming <- ports.CaptureRequest{ID: 91, Exclude: excluded, Session: 7, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: dst}
68 scene := ports.Scene{Security: admitted, Seq: 2, Scale: 1, OutputWidth: 2, OutputHeight: 2}
69 if excluded {
70 scene = sessionScene()
71 scene.Security = admitted
72 scene.Scale = 1
73 }
74 scenes <- scene
75 securityReceive(t, frameRendered)
76 result := securityReceive(t, replies)
77 if result.ID != 91 || !errors.Is(result.Err, capture.ErrSecurityState) {
78 t.Fatalf("scoped rejection missing: %+v", result)
79 }
80 if _, err := dst.File.Stat(); !errors.Is(err, os.ErrClosed) {
81 t.Fatalf("destination not closed: %v", err)
82 }
83 securityReceive(t, proof)
84 cancel()
85 if err := securityReceive(t, done); err != nil {
86 t.Fatal(err)
87 }
88 select {
89 case extra := <-replies:
90 t.Fatalf("duplicate reply %+v", extra)
91 default:
92 }
93 })
94 }
95}
96
97func TestTransitionDuringHeadlessRenderWaitRetainsFenceAndRejectsCapture(t *testing.T) {
98 admitted := ports.SecurityState{Generation: 2}
99 locked := ports.SecurityState{Generation: 3, Protected: true}
100 var epoch atomic.Value
101 epoch.Store(admitted)
102 gate := portsmocks.NewMockSessionSecurity(t)
103 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) })
104 fence, signal, err := os.Pipe()
105 if err != nil {
106 t.Fatal(err)
107 }
108 defer signal.Close()
109 r := portsmocks.NewMockRenderer(t)
110 r.EXPECT().Close().Return().Once()
111 rendered := make(chan struct{}, 1)
112 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
113 if s.Security == admitted {
114 epoch.Store(locked)
115 rendered <- struct{}{}
116 return fence, nil
117 }
118 return nil, nil
119 }).Twice()
120 scenes := make(chan ports.Scene, 1)
121 incoming := make(chan ports.CaptureRequest)
122 replies := make(chan ports.CaptureDone, 2)
123 proof := make(chan ports.SecurityBackendEvent, 1)
124 ctx, cancel := context.WithCancel(context.Background())
125 done := make(chan error, 1)
126 go func() {
127 done <- Run(ctx, Options{Security: gate, SecurityEvents: proof, Instance: 8, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, incoming)
128 }()
129 dst := sessionCaptureFile(t)
130 incoming <- ports.CaptureRequest{ID: 92, Dst: dst}
131 scenes <- ports.Scene{Security: admitted, Seq: 2}
132 securityReceive(t, rendered)
133 if _, err := fence.Stat(); err != nil {
134 t.Fatal("pending renderer read fence closed early")
135 }
136 select {
137 case p := <-proof:
138 t.Fatalf("proof on stalled old render %+v", p)
139 default:
140 }
141 if _, err := signal.Write([]byte{1}); err != nil {
142 t.Fatal(err)
143 }
144 result := securityReceive(t, replies)
145 if result.ID != 92 || result.Err == nil {
146 t.Fatal(result)
147 }
148 securityReceive(t, proof)
149 cancel()
150 if err := securityReceive(t, done); err != nil {
151 t.Fatal(err)
152 }
153 if _, err := fence.Stat(); !errors.Is(err, os.ErrClosed) {
154 t.Fatal("completed render fence leaked")
155 }
156 if _, err := dst.File.Stat(); !errors.Is(err, os.ErrClosed) {
157 t.Fatal("rejected capture FD leaked")
158 }
159 select {
160 case extra := <-replies:
161 t.Fatalf("duplicate reply %+v", extra)
162 default:
163 }
164}