feat/data-dnd e05cb15

security_hotplug_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 26

Tags 10

↑↓ moveenter openesc close