detached 1e618e6

refactor(outputkit): share capture request bookkeeping and the shown rule between drm and headless

commit
1e618e62d09628513f0a34e03fe435f3b878b547
author
brice · · 2026-10-08 05:47 UTC
parent
a315432
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +238 -116
M internal/adapters/drm/output.go +17 -48
@@ -1044,9 +1044,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10441044 cursorScale := -1.0 // not loaded yet
10451045 var cursorTransform ports.BufferTransform
10461046 frame := 0
1047- var requestStorage [capture.MaxRequests]ports.CaptureRequest
1048- requests := requestStorage[:0]
1047+ var requests capture.Requests
10491048 ctx, cancelCaptures := context.WithCancel(ctx)
1049+ requests.Init(ctx, captured)
10501050 pipeline := capture.NewPipeline(ctx, captured)
10511051 pipeline.Security = o.Security
10521052 if o.NewCaptureRenderer != nil {
@@ -1056,19 +1056,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10561056 defer func() {
10571057 cancelCaptures()
10581058 pipeline.Close(r)
1059- for _, q := range requests {
1060- if q.ID != 0 || q.Dst.File != nil {
1061- capture.Fail(ctx, q, fmt.Errorf("output stopped"), captured)
1062- }
1063- }
1064- for {
1065- select {
1066- case q := <-captures:
1067- capture.Fail(ctx, q, fmt.Errorf("output stopped"), captured)
1068- default:
1069- return
1070- }
1071- }
1059+ requests.Stop(ctx, captures)
10721060 }()
10731061 clk := o.clock
10741062 if clk == nil {
@@ -1119,12 +1107,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
11191107 o.cursor.image, o.cursor.later = false, nil
11201108 }
11211109 }
1122- if o.protected && len(requests) > 0 {
1123- for _, q := range requests {
1124- capture.Fail(ctx, q, fmt.Errorf("session protected"), captured)
1125- }
1126- clear(requests)
1127- requests = requestStorage[:0]
1110+ if o.protected && requests.Len() > 0 {
1111+ requests.FailPending(capture.ErrSessionProtected)
11281112 }
11291113 if o.protected && enabled && !o.frame.pendingCommit() && !o.securityPrepared && !o.protectBackoffActive() {
11301114 if err := o.prepareSecurity(ctx, r); err != nil {
@@ -1200,9 +1184,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
12001184 gapTimer.Stop()
12011185 }
12021186 var holdDue <-chan time.Time
1203- if len(requests) > 0 {
1187+ if requests.Len() > 0 {
12041188 var wait time.Duration
1205- requests, wait = pipeline.Expire(scene, requests, time.Now())
1189+ requests.List, wait = pipeline.Expire(scene, requests.List, time.Now())
12061190 if wait > 0 {
12071191 holdTimer.Reset(wait)
12081192 holdDue = holdTimer.C
@@ -1271,17 +1255,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
12711255 continue
12721256 case q := <-captures:
12731257 o.observeSecurity()
1258+ var refuse error
12741259 if o.protected || !enabled || o.off || o.wantOff {
1275- capture.Fail(ctx, q, fmt.Errorf("output off"), captured)
1276- } else if len(requests) == cap(requests) {
1277- capture.Fail(ctx, q, fmt.Errorf("output capture batch full"), captured)
1278- } else {
1279- q.Since = time.Now()
1280- requests = append(requests, q)
1281- // Its indicator may already be on screen; else it waits for the
1282- // scene that shows it.
1283- dirty = dirty || haveScene && capture.IndicatorShown(scene, q)
1260+ refuse = capture.ErrOutputOff
12841261 }
1262+ dirty = requests.Admit(q, refuse, scene, haveScene) || dirty
12851263 case ack := <-o.seatDisable:
12861264 o.bypassForSeatDisable()
12871265 // The seat is about to be disabled: no frame may select the
@@ -1421,12 +1399,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
14211399 if o.protected && !o.securityPrepared {
14221400 continue
14231401 }
1424- if len(requests) > 0 && (o.protected || !enabled || o.off || o.wantOff) {
1425- for _, q := range requests {
1426- capture.Fail(ctx, q, fmt.Errorf("output off"), captured)
1427- }
1428- clear(requests)
1429- requests = requestStorage[:0]
1402+ if requests.Len() > 0 && (o.protected || !enabled || o.off || o.wantOff) {
1403+ requests.FailPending(capture.ErrOutputOff)
14301404 }
14311405 if !enabled || o.frame.pendingCommit() {
14321406 continue
@@ -1476,20 +1450,15 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
14761450 continue
14771451 }
14781452 start := time.Now()
1479- direct, err := o.submitFrame(ctx, r, scene, drawn, seen, requests, pipeline)
1453+ direct, err := o.submitFrame(ctx, r, scene, drawn, seen, requests.List, pipeline)
14801454 var fatal renderError
14811455 if errors.As(err, &fatal) || errors.Is(err, errSecurityScene) {
1482- // A failed render or epoch rejection did not hand these requests to the worker.
1483- for _, q := range requests {
1484- if capture.Handed(q) {
1485- continue // already owned by the worker, or answered
1486- }
1487- capture.Fail(ctx, q, err, captured)
1488- }
1489- clear(requests) // held ones included: their frame is gone
1456+ // A failed render or epoch rejection did not hand these requests
1457+ // to the worker; held ones go too: their frame is gone.
1458+ requests.FailPending(err)
14901459 }
14911460 // Requests still waiting for their indicator stay; the rest is gone.
1492- requests = capture.Waiting(requests, scene)
1461+ requests.List = capture.Waiting(requests.List, scene)
14931462 if errors.As(err, &fatal) {
14941463 return err
14951464 }
@@ -1044,9 +1044,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1044 cursorScale := -1.0 // not loaded yet1044 cursorScale := -1.0 // not loaded yet
1045 var cursorTransform ports.BufferTransform1045 var cursorTransform ports.BufferTransform
1046 frame := 01046 frame := 0
1047- var requestStorage [capture.MaxRequests]ports.CaptureRequest1047+ var requests capture.Requests
1048- requests := requestStorage[:0]
1049 ctx, cancelCaptures := context.WithCancel(ctx)1048 ctx, cancelCaptures := context.WithCancel(ctx)
1049+ requests.Init(ctx, captured)
1050 pipeline := capture.NewPipeline(ctx, captured)1050 pipeline := capture.NewPipeline(ctx, captured)
1051 pipeline.Security = o.Security1051 pipeline.Security = o.Security
1052 if o.NewCaptureRenderer != nil {1052 if o.NewCaptureRenderer != nil {
@@ -1056,19 +1056,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1056 defer func() {1056 defer func() {
1057 cancelCaptures()1057 cancelCaptures()
1058 pipeline.Close(r)1058 pipeline.Close(r)
1059- for _, q := range requests {1059+ requests.Stop(ctx, captures)
1060- if q.ID != 0 || q.Dst.File != nil {
1061- capture.Fail(ctx, q, fmt.Errorf("output stopped"), captured)
1062- }
1063- }
1064- for {
1065- select {
1066- case q := <-captures:
1067- capture.Fail(ctx, q, fmt.Errorf("output stopped"), captured)
1068- default:
1069- return
1070- }
1071- }
1072 }()1060 }()
1073 clk := o.clock1061 clk := o.clock
1074 if clk == nil {1062 if clk == nil {
@@ -1119,12 +1107,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1119 o.cursor.image, o.cursor.later = false, nil1107 o.cursor.image, o.cursor.later = false, nil
1120 }1108 }
1121 }1109 }
1122- if o.protected && len(requests) > 0 {1110+ if o.protected && requests.Len() > 0 {
1123- for _, q := range requests {1111+ requests.FailPending(capture.ErrSessionProtected)
1124- capture.Fail(ctx, q, fmt.Errorf("session protected"), captured)
1125- }
1126- clear(requests)
1127- requests = requestStorage[:0]
1128 }1112 }
1129 if o.protected && enabled && !o.frame.pendingCommit() && !o.securityPrepared && !o.protectBackoffActive() {1113 if o.protected && enabled && !o.frame.pendingCommit() && !o.securityPrepared && !o.protectBackoffActive() {
1130 if err := o.prepareSecurity(ctx, r); err != nil {1114 if err := o.prepareSecurity(ctx, r); err != nil {
@@ -1200,9 +1184,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1200 gapTimer.Stop()1184 gapTimer.Stop()
1201 }1185 }
1202 var holdDue <-chan time.Time1186 var holdDue <-chan time.Time
1203- if len(requests) > 0 {1187+ if requests.Len() > 0 {
1204 var wait time.Duration1188 var wait time.Duration
1205- requests, wait = pipeline.Expire(scene, requests, time.Now())1189+ requests.List, wait = pipeline.Expire(scene, requests.List, time.Now())
1206 if wait > 0 {1190 if wait > 0 {
1207 holdTimer.Reset(wait)1191 holdTimer.Reset(wait)
1208 holdDue = holdTimer.C1192 holdDue = holdTimer.C
@@ -1271,17 +1255,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1271 continue1255 continue
1272 case q := <-captures:1256 case q := <-captures:
1273 o.observeSecurity()1257 o.observeSecurity()
1258+ var refuse error
1274 if o.protected || !enabled || o.off || o.wantOff {1259 if o.protected || !enabled || o.off || o.wantOff {
1275- capture.Fail(ctx, q, fmt.Errorf("output off"), captured)1260+ refuse = capture.ErrOutputOff
1276- } else if len(requests) == cap(requests) {
1277- capture.Fail(ctx, q, fmt.Errorf("output capture batch full"), captured)
1278- } else {
1279- q.Since = time.Now()
1280- requests = append(requests, q)
1281- // Its indicator may already be on screen; else it waits for the
1282- // scene that shows it.
1283- dirty = dirty || haveScene && capture.IndicatorShown(scene, q)
1284 }1261 }
1262+ dirty = requests.Admit(q, refuse, scene, haveScene) || dirty
1285 case ack := <-o.seatDisable:1263 case ack := <-o.seatDisable:
1286 o.bypassForSeatDisable()1264 o.bypassForSeatDisable()
1287 // The seat is about to be disabled: no frame may select the1265 // The seat is about to be disabled: no frame may select the
@@ -1421,12 +1399,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1421 if o.protected && !o.securityPrepared {1399 if o.protected && !o.securityPrepared {
1422 continue1400 continue
1423 }1401 }
1424- if len(requests) > 0 && (o.protected || !enabled || o.off || o.wantOff) {1402+ if requests.Len() > 0 && (o.protected || !enabled || o.off || o.wantOff) {
1425- for _, q := range requests {1403+ requests.FailPending(capture.ErrOutputOff)
1426- capture.Fail(ctx, q, fmt.Errorf("output off"), captured)
1427- }
1428- clear(requests)
1429- requests = requestStorage[:0]
1430 }1404 }
1431 if !enabled || o.frame.pendingCommit() {1405 if !enabled || o.frame.pendingCommit() {
1432 continue1406 continue
@@ -1476,20 +1450,15 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1476 continue1450 continue
1477 }1451 }
1478 start := time.Now()1452 start := time.Now()
1479- direct, err := o.submitFrame(ctx, r, scene, drawn, seen, requests, pipeline)1453+ direct, err := o.submitFrame(ctx, r, scene, drawn, seen, requests.List, pipeline)
1480 var fatal renderError1454 var fatal renderError
1481 if errors.As(err, &fatal) || errors.Is(err, errSecurityScene) {1455 if errors.As(err, &fatal) || errors.Is(err, errSecurityScene) {
1482- // A failed render or epoch rejection did not hand these requests to the worker.1456+ // A failed render or epoch rejection did not hand these requests
1483- for _, q := range requests {1457+ // to the worker; held ones go too: their frame is gone.
1484- if capture.Handed(q) {1458+ requests.FailPending(err)
1485- continue // already owned by the worker, or answered
1486- }
1487- capture.Fail(ctx, q, err, captured)
1488- }
1489- clear(requests) // held ones included: their frame is gone
1490 }1459 }
1491 // Requests still waiting for their indicator stay; the rest is gone.1460 // Requests still waiting for their indicator stay; the rest is gone.
1492- requests = capture.Waiting(requests, scene)1461+ requests.List = capture.Waiting(requests.List, scene)
1493 if errors.As(err, &fatal) {1462 if errors.As(err, &fatal) {
1494 return err1463 return err
1495 }1464 }
M internal/adapters/drm/report.go +4 -8
@@ -3,6 +3,7 @@ package drm
33 import (
44 "maps"
55
6+ "github.com/bnema/neferwl/internal/adapters/outputkit/surfaces"
67 "github.com/bnema/neferwl/internal/ports"
78 )
89
@@ -28,14 +29,9 @@ func (o *Output) shownBy(s ports.Scene, seen map[ports.WindowID]uint64) map[port
2829 if o.showsScratch == nil {
2930 o.showsScratch = make(map[ports.WindowID]uint64)
3031 }
31- clear(o.showsScratch)
32- for id, seq := range seen {
33- // A closed window drawn from its kept content is not shown to its
34- // client: the empty content has no frame to present.
35- if s.Shows(id) && (o.kept == nil || !o.kept(id)) {
36- o.showsScratch[id] = seq
37- }
38- }
32+ // A closed window drawn from its kept content is not shown to its
33+ // client: the empty content has no frame to present.
34+ surfaces.FillShown(o.showsScratch, seen, s, o.kept)
3935 if !maps.Equal(o.showsSnapshot, o.showsScratch) {
4036 o.showsSnapshot = maps.Clone(o.showsScratch)
4137 }
@@ -3,6 +3,7 @@ package drm
3 import (3 import (
4 "maps"4 "maps"
5 5
6+ "github.com/bnema/neferwl/internal/adapters/outputkit/surfaces"
6 "github.com/bnema/neferwl/internal/ports"7 "github.com/bnema/neferwl/internal/ports"
7 )8 )
8 9
@@ -28,14 +29,9 @@ func (o *Output) shownBy(s ports.Scene, seen map[ports.WindowID]uint64) map[port
28 if o.showsScratch == nil {29 if o.showsScratch == nil {
29 o.showsScratch = make(map[ports.WindowID]uint64)30 o.showsScratch = make(map[ports.WindowID]uint64)
30 }31 }
31- clear(o.showsScratch)32+ // A closed window drawn from its kept content is not shown to its
32- for id, seq := range seen {33+ // client: the empty content has no frame to present.
33- // A closed window drawn from its kept content is not shown to its34+ surfaces.FillShown(o.showsScratch, seen, s, o.kept)
34- // client: the empty content has no frame to present.
35- if s.Shows(id) && (o.kept == nil || !o.kept(id)) {
36- o.showsScratch[id] = seq
37- }
38- }
39 if !maps.Equal(o.showsSnapshot, o.showsScratch) {35 if !maps.Equal(o.showsSnapshot, o.showsScratch) {
40 o.showsSnapshot = maps.Clone(o.showsScratch)36 o.showsSnapshot = maps.Clone(o.showsScratch)
41 }37 }
M internal/adapters/headless/output.go +27 -60
@@ -2,7 +2,6 @@ package headless
22
33 import (
44 "context"
5- "errors"
65 "fmt"
76 "image"
87 "image/png"
@@ -93,9 +92,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
9392 var scene ports.Scene
9493 haveScene, dirty := false, false
9594 frame := 0
96- var requestStorage [capture.MaxRequests]ports.CaptureRequest
97- requests := requestStorage[:0]
95+ var requests capture.Requests
9896 ctx, cancelCaptures := context.WithCancel(ctx)
97+ requests.Init(ctx, opts.Captured)
9998 pipeline := capture.NewPipeline(ctx, opts.Captured)
10099 pipeline.Security = opts.Security
101100 if opts.NewCaptureRenderer != nil {
@@ -106,23 +105,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
106105 pipeline.EndGate(capture.ErrFrameNotPresented)
107106 cancelCaptures()
108107 pipeline.Close(r)
109- for _, q := range requests {
110- if capture.Handed(q) {
111- continue // already answered, or owned by a worker
112- }
113- capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)
114- }
115- for {
116- select {
117- case q, ok := <-incoming:
118- if !ok {
119- return
120- }
121- capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)
122- default:
123- return
124- }
125- }
108+ requests.Stop(ctx, incoming)
126109 }()
127110 var want ports.CursorChange
128111 cursorScale := -1.0 // not loaded yet
@@ -132,16 +115,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
132115 var reports presented.Queue
133116 var security ports.SecurityState
134117 securityKnown := false
135- failRequests := func(reason string) {
118+ failRequests := func(reason error) {
136119 // Whatever this frame had handed to a worker is failed with it.
137- pipeline.EndGate(capture.GateVerdict(errors.New(reason)))
138- for _, q := range requests {
139- if !capture.Handed(q) {
140- capture.Fail(ctx, q, fmt.Errorf("%s", reason), opts.Captured)
141- }
142- }
143- clear(requests)
144- requests = requestStorage[:0]
120+ pipeline.EndGate(capture.GateVerdict(reason))
121+ requests.FailPending(reason)
145122 }
146123 // A transition is its own frame, never a locker-buffer-dependent frame.
147124 // Keep its epoch through the fence wait and report only actual completion.
@@ -194,7 +171,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
194171 }
195172 // Capture reply backpressure must not postpone the first black
196173 // completion/proof. Capture drainage is a separate parent barrier.
197- failRequests("session protected")
174+ failRequests(capture.ErrSessionProtected)
198175 }
199176 }
200177 acceptScene := func(s ports.Scene) {
@@ -245,9 +222,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
245222 // Requests wait for a scene that shows their indicator, at most
246223 // capture.HoldFor (see capture.Hold).
247224 var holdDue <-chan time.Time
248- if len(requests) > 0 {
225+ if requests.Len() > 0 {
249226 var wait time.Duration
250- requests, wait = pipeline.Expire(scene, requests, time.Now())
227+ requests.List, wait = pipeline.Expire(scene, requests.List, time.Now())
251228 if wait > 0 {
252229 holdTimer.Reset(wait)
253230 holdDue = holdTimer.C
@@ -300,25 +277,19 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
300277 }
301278 if err := checkSecurity(); err != nil {
302279 // q has left incoming but is not yet owned by requests/workers.
303- capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)
280+ capture.Fail(ctx, q, capture.ErrOutputStopped, opts.Captured)
304281 if ctx.Err() != nil {
305282 return nil
306283 }
307284 return err
308285 }
286+ var refuse error
309287 if opts.protected() {
310- capture.Fail(ctx, q, fmt.Errorf("session protected"), opts.Captured)
288+ refuse = capture.ErrSessionProtected
311289 } else if scene.Off {
312- capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)
313- } else if len(requests) == cap(requests) {
314- capture.Fail(ctx, q, fmt.Errorf("output capture batch full"), opts.Captured)
315- } else {
316- q.Since = time.Now()
317- requests = append(requests, q)
318- // Its indicator may already be on screen; else it waits for the
319- // scene that shows it.
320- dirty = dirty || haveScene && capture.IndicatorShown(scene, q)
290+ refuse = capture.ErrOutputOff
321291 }
292+ dirty = requests.Admit(q, refuse, scene, haveScene) || dirty
322293 case <-retry:
323294 if err := checkSecurity(); err != nil {
324295 if ctx.Err() != nil {
@@ -419,14 +390,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
419390 return err
420391 }
421392 if opts.protected() {
422- failRequests("session protected")
393+ failRequests(capture.ErrSessionProtected)
423394 }
424- if scene.Off && len(requests) > 0 {
425- for _, q := range requests {
426- capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)
427- }
428- clear(requests)
429- requests = requestStorage[:0]
395+ if scene.Off && requests.Len() > 0 {
396+ requests.FailPending(capture.ErrOutputOff)
430397 }
431398 if !haveScene || !dirty || scene.Off {
432399 // Contents not drawn are still read: report them. An output
@@ -458,7 +425,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
458425 // Requests the scene cannot serve (exclusion of another session, a
459426 // workspace that moved) are failed here; see capture.Pipeline.Split.
460427 // A request whose indicator the scene does not show yet stays held.
461- ready, _ := pipeline.Hold(scene, requests, time.Now())
428+ ready, _ := pipeline.Hold(scene, requests.List, time.Now())
462429 if len(scene.CaptureIndicators) > 0 {
463430 // The captures of this frame are delivered only once its frame,
464431 // with the indicator, is presented (capture.Pipeline.EndGate).
@@ -470,7 +437,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
470437 // A hidden workspace: a child renderer draws it. The display's
471438 // fences do not cover the child, so wait for it before reporting.
472439 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
473- failRequests("security epoch changed")
440+ failRequests(capture.ErrEpochChanged)
474441 continue
475442 }
476443 pipeline.SubmitHidden(scene, drawn, hidden)
@@ -518,7 +485,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
518485 // Exclude requests: the frame without the excluded surfaces is
519486 // drawn first (same renderer, queue order), then the displayed one.
520487 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
521- failRequests("security epoch changed")
488+ failRequests(capture.ErrEpochChanged)
522489 continue
523490 }
524491 xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), drawn)
@@ -539,14 +506,14 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
539506 return fmt.Errorf("render excluded frame: %w", renderErr)
540507 }
541508 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
542- failRequests("security epoch changed")
509+ failRequests(capture.ErrEpochChanged)
543510 continue
544511 }
545512 pipeline.SubmitScoped(scene.Security, r, excluded)
546513 clear(excluded)
547514 }
548515 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
549- failRequests("security epoch changed")
516+ failRequests(capture.ErrEpochChanged)
550517 continue
551518 }
552519 done, err := r.Render(scene, drawn)
@@ -571,13 +538,13 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
571538 // Do not capture or report a frame that crossed a transition during
572539 // Render/fence wait. The next loop establishes the new black proof.
573540 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
574- failRequests("security epoch changed")
541+ failRequests(capture.ErrEpochChanged)
575542 continue
576543 }
577544 pipeline.SubmitScoped(scene.Security, r, normal)
578545 // The indicated frame is presented: release what it gated.
579546 pipeline.EndGate(nil)
580- requests = capture.Waiting(requests, scene)
547+ requests.List = capture.Waiting(requests.List, scene)
581548 frame++
582549 opts.report(&reports, flipInfo(scene, drawn, table.Kept), seen)
583550 if opts.ScreenshotDir != "" && !opts.protected() {
@@ -671,14 +638,14 @@ func (opts Options) flush(q *presented.Queue) {
671638 // flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC
672639 // time (software clock, refresh unknown), so presentation feedback and
673640 // frame callbacks follow headless frames.
674-func flipInfo(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) *ports.FlipInfo {
641+func flipInfo(scene ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) *ports.FlipInfo {
675642 var ts unix.Timespec
676643 _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)
677644 shows := map[ports.WindowID]uint64{}
678- for id, c := range surfaces {
645+ for id, c := range contents {
679646 // A closed window drawn from its kept content is not shown to
680647 // its client.
681- if scene.Shows(id) && !kept(id) {
648+ if surfaces.Shown(scene, id, kept) {
682649 shows[id] = c.Seq
683650 }
684651 }
@@ -2,7 +2,6 @@ package headless
2 2
3 import (3 import (
4 "context"4 "context"
5- "errors"
6 "fmt"5 "fmt"
7 "image"6 "image"
8 "image/png"7 "image/png"
@@ -93,9 +92,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
93 var scene ports.Scene92 var scene ports.Scene
94 haveScene, dirty := false, false93 haveScene, dirty := false, false
95 frame := 094 frame := 0
96- var requestStorage [capture.MaxRequests]ports.CaptureRequest95+ var requests capture.Requests
97- requests := requestStorage[:0]
98 ctx, cancelCaptures := context.WithCancel(ctx)96 ctx, cancelCaptures := context.WithCancel(ctx)
97+ requests.Init(ctx, opts.Captured)
99 pipeline := capture.NewPipeline(ctx, opts.Captured)98 pipeline := capture.NewPipeline(ctx, opts.Captured)
100 pipeline.Security = opts.Security99 pipeline.Security = opts.Security
101 if opts.NewCaptureRenderer != nil {100 if opts.NewCaptureRenderer != nil {
@@ -106,23 +105,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
106 pipeline.EndGate(capture.ErrFrameNotPresented)105 pipeline.EndGate(capture.ErrFrameNotPresented)
107 cancelCaptures()106 cancelCaptures()
108 pipeline.Close(r)107 pipeline.Close(r)
109- for _, q := range requests {108+ requests.Stop(ctx, incoming)
110- if capture.Handed(q) {
111- continue // already answered, or owned by a worker
112- }
113- capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)
114- }
115- for {
116- select {
117- case q, ok := <-incoming:
118- if !ok {
119- return
120- }
121- capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)
122- default:
123- return
124- }
125- }
126 }()109 }()
127 var want ports.CursorChange110 var want ports.CursorChange
128 cursorScale := -1.0 // not loaded yet111 cursorScale := -1.0 // not loaded yet
@@ -132,16 +115,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
132 var reports presented.Queue115 var reports presented.Queue
133 var security ports.SecurityState116 var security ports.SecurityState
134 securityKnown := false117 securityKnown := false
135- failRequests := func(reason string) {118+ failRequests := func(reason error) {
136 // Whatever this frame had handed to a worker is failed with it.119 // Whatever this frame had handed to a worker is failed with it.
137- pipeline.EndGate(capture.GateVerdict(errors.New(reason)))120+ pipeline.EndGate(capture.GateVerdict(reason))
138- for _, q := range requests {121+ requests.FailPending(reason)
139- if !capture.Handed(q) {
140- capture.Fail(ctx, q, fmt.Errorf("%s", reason), opts.Captured)
141- }
142- }
143- clear(requests)
144- requests = requestStorage[:0]
145 }122 }
146 // A transition is its own frame, never a locker-buffer-dependent frame.123 // A transition is its own frame, never a locker-buffer-dependent frame.
147 // Keep its epoch through the fence wait and report only actual completion.124 // Keep its epoch through the fence wait and report only actual completion.
@@ -194,7 +171,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
194 }171 }
195 // Capture reply backpressure must not postpone the first black172 // Capture reply backpressure must not postpone the first black
196 // completion/proof. Capture drainage is a separate parent barrier.173 // completion/proof. Capture drainage is a separate parent barrier.
197- failRequests("session protected")174+ failRequests(capture.ErrSessionProtected)
198 }175 }
199 }176 }
200 acceptScene := func(s ports.Scene) {177 acceptScene := func(s ports.Scene) {
@@ -245,9 +222,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
245 // Requests wait for a scene that shows their indicator, at most222 // Requests wait for a scene that shows their indicator, at most
246 // capture.HoldFor (see capture.Hold).223 // capture.HoldFor (see capture.Hold).
247 var holdDue <-chan time.Time224 var holdDue <-chan time.Time
248- if len(requests) > 0 {225+ if requests.Len() > 0 {
249 var wait time.Duration226 var wait time.Duration
250- requests, wait = pipeline.Expire(scene, requests, time.Now())227+ requests.List, wait = pipeline.Expire(scene, requests.List, time.Now())
251 if wait > 0 {228 if wait > 0 {
252 holdTimer.Reset(wait)229 holdTimer.Reset(wait)
253 holdDue = holdTimer.C230 holdDue = holdTimer.C
@@ -300,25 +277,19 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
300 }277 }
301 if err := checkSecurity(); err != nil {278 if err := checkSecurity(); err != nil {
302 // q has left incoming but is not yet owned by requests/workers.279 // q has left incoming but is not yet owned by requests/workers.
303- capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)280+ capture.Fail(ctx, q, capture.ErrOutputStopped, opts.Captured)
304 if ctx.Err() != nil {281 if ctx.Err() != nil {
305 return nil282 return nil
306 }283 }
307 return err284 return err
308 }285 }
286+ var refuse error
309 if opts.protected() {287 if opts.protected() {
310- capture.Fail(ctx, q, fmt.Errorf("session protected"), opts.Captured)288+ refuse = capture.ErrSessionProtected
311 } else if scene.Off {289 } else if scene.Off {
312- capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)290+ refuse = capture.ErrOutputOff
313- } else if len(requests) == cap(requests) {
314- capture.Fail(ctx, q, fmt.Errorf("output capture batch full"), opts.Captured)
315- } else {
316- q.Since = time.Now()
317- requests = append(requests, q)
318- // Its indicator may already be on screen; else it waits for the
319- // scene that shows it.
320- dirty = dirty || haveScene && capture.IndicatorShown(scene, q)
321 }291 }
292+ dirty = requests.Admit(q, refuse, scene, haveScene) || dirty
322 case <-retry:293 case <-retry:
323 if err := checkSecurity(); err != nil {294 if err := checkSecurity(); err != nil {
324 if ctx.Err() != nil {295 if ctx.Err() != nil {
@@ -419,14 +390,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
419 return err390 return err
420 }391 }
421 if opts.protected() {392 if opts.protected() {
422- failRequests("session protected")393+ failRequests(capture.ErrSessionProtected)
423 }394 }
424- if scene.Off && len(requests) > 0 {395+ if scene.Off && requests.Len() > 0 {
425- for _, q := range requests {396+ requests.FailPending(capture.ErrOutputOff)
426- capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)
427- }
428- clear(requests)
429- requests = requestStorage[:0]
430 }397 }
431 if !haveScene || !dirty || scene.Off {398 if !haveScene || !dirty || scene.Off {
432 // Contents not drawn are still read: report them. An output399 // Contents not drawn are still read: report them. An output
@@ -458,7 +425,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
458 // Requests the scene cannot serve (exclusion of another session, a425 // Requests the scene cannot serve (exclusion of another session, a
459 // workspace that moved) are failed here; see capture.Pipeline.Split.426 // workspace that moved) are failed here; see capture.Pipeline.Split.
460 // A request whose indicator the scene does not show yet stays held.427 // A request whose indicator the scene does not show yet stays held.
461- ready, _ := pipeline.Hold(scene, requests, time.Now())428+ ready, _ := pipeline.Hold(scene, requests.List, time.Now())
462 if len(scene.CaptureIndicators) > 0 {429 if len(scene.CaptureIndicators) > 0 {
463 // The captures of this frame are delivered only once its frame,430 // The captures of this frame are delivered only once its frame,
464 // with the indicator, is presented (capture.Pipeline.EndGate).431 // with the indicator, is presented (capture.Pipeline.EndGate).
@@ -470,7 +437,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
470 // A hidden workspace: a child renderer draws it. The display's437 // A hidden workspace: a child renderer draws it. The display's
471 // fences do not cover the child, so wait for it before reporting.438 // fences do not cover the child, so wait for it before reporting.
472 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {439 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
473- failRequests("security epoch changed")440+ failRequests(capture.ErrEpochChanged)
474 continue441 continue
475 }442 }
476 pipeline.SubmitHidden(scene, drawn, hidden)443 pipeline.SubmitHidden(scene, drawn, hidden)
@@ -518,7 +485,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
518 // Exclude requests: the frame without the excluded surfaces is485 // Exclude requests: the frame without the excluded surfaces is
519 // drawn first (same renderer, queue order), then the displayed one.486 // drawn first (same renderer, queue order), then the displayed one.
520 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {487 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
521- failRequests("security epoch changed")488+ failRequests(capture.ErrEpochChanged)
522 continue489 continue
523 }490 }
524 xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), drawn)491 xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), drawn)
@@ -539,14 +506,14 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
539 return fmt.Errorf("render excluded frame: %w", renderErr)506 return fmt.Errorf("render excluded frame: %w", renderErr)
540 }507 }
541 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {508 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
542- failRequests("security epoch changed")509+ failRequests(capture.ErrEpochChanged)
543 continue510 continue
544 }511 }
545 pipeline.SubmitScoped(scene.Security, r, excluded)512 pipeline.SubmitScoped(scene.Security, r, excluded)
546 clear(excluded)513 clear(excluded)
547 }514 }
548 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {515 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
549- failRequests("security epoch changed")516+ failRequests(capture.ErrEpochChanged)
550 continue517 continue
551 }518 }
552 done, err := r.Render(scene, drawn)519 done, err := r.Render(scene, drawn)
@@ -571,13 +538,13 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
571 // Do not capture or report a frame that crossed a transition during538 // Do not capture or report a frame that crossed a transition during
572 // Render/fence wait. The next loop establishes the new black proof.539 // Render/fence wait. The next loop establishes the new black proof.
573 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {540 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
574- failRequests("security epoch changed")541+ failRequests(capture.ErrEpochChanged)
575 continue542 continue
576 }543 }
577 pipeline.SubmitScoped(scene.Security, r, normal)544 pipeline.SubmitScoped(scene.Security, r, normal)
578 // The indicated frame is presented: release what it gated.545 // The indicated frame is presented: release what it gated.
579 pipeline.EndGate(nil)546 pipeline.EndGate(nil)
580- requests = capture.Waiting(requests, scene)547+ requests.List = capture.Waiting(requests.List, scene)
581 frame++548 frame++
582 opts.report(&reports, flipInfo(scene, drawn, table.Kept), seen)549 opts.report(&reports, flipInfo(scene, drawn, table.Kept), seen)
583 if opts.ScreenshotDir != "" && !opts.protected() {550 if opts.ScreenshotDir != "" && !opts.protected() {
@@ -671,14 +638,14 @@ func (opts Options) flush(q *presented.Queue) {
671 // flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC638 // flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC
672 // time (software clock, refresh unknown), so presentation feedback and639 // time (software clock, refresh unknown), so presentation feedback and
673 // frame callbacks follow headless frames.640 // frame callbacks follow headless frames.
674-func flipInfo(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) *ports.FlipInfo {641+func flipInfo(scene ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) *ports.FlipInfo {
675 var ts unix.Timespec642 var ts unix.Timespec
676 _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)643 _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)
677 shows := map[ports.WindowID]uint64{}644 shows := map[ports.WindowID]uint64{}
678- for id, c := range surfaces {645+ for id, c := range contents {
679 // A closed window drawn from its kept content is not shown to646 // A closed window drawn from its kept content is not shown to
680 // its client.647 // its client.
681- if scene.Shows(id) && !kept(id) {648+ if surfaces.Shown(scene, id, kept) {
682 shows[id] = c.Seq649 shows[id] = c.Seq
683 }650 }
684 }651 }
A internal/adapters/outputkit/capture/requests.go +95 -0
@@ -0,0 +1,95 @@
1+package capture
2+
3+import (
4+ "context"
5+ "errors"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// Reasons an output owner fails a request with.
12+var (
13+ ErrOutputStopped = errors.New("output stopped")
14+ ErrOutputOff = errors.New("output off")
15+ ErrSessionProtected = errors.New("session protected")
16+ ErrBatchFull = errors.New("output capture batch full")
17+ ErrEpochChanged = errors.New("security epoch changed")
18+)
19+
20+// Requests are the capture requests an output owner collected for its next
21+// frame, in fixed storage (MaxRequests): no allocation. Its owner goroutine
22+// is the only user. List is the live slice the pipeline calls take and
23+// return; assign their result back to it.
24+type Requests struct {
25+ storage [MaxRequests]ports.CaptureRequest
26+ List []ports.CaptureRequest
27+ ctx context.Context
28+ replies chan<- ports.CaptureDone
29+}
30+
31+// Init binds the requests to the owner's context and reply channel.
32+func (r *Requests) Init(ctx context.Context, replies chan<- ports.CaptureDone) {
33+ r.List, r.ctx, r.replies = r.storage[:0], ctx, replies
34+}
35+
36+// Len is the number of requests held.
37+func (r *Requests) Len() int { return len(r.List) }
38+
39+// Admit takes q for the next frame, or fails it with refuse when that is not
40+// nil, or when the batch is full. It reports whether q needs a new frame: its
41+// indicator is already on the current scene.
42+func (r *Requests) Admit(q ports.CaptureRequest, refuse error, scene ports.Scene, haveScene bool) (redraw bool) {
43+ if refuse == nil && len(r.List) == cap(r.List) {
44+ refuse = ErrBatchFull
45+ }
46+ if refuse != nil {
47+ Fail(r.ctx, q, refuse, r.replies)
48+ return false
49+ }
50+ q.Since = time.Now()
51+ r.List = append(r.List, q)
52+ // Its indicator may already be on screen; else it waits for the scene
53+ // that shows it.
54+ return haveScene && IndicatorShown(scene, q)
55+}
56+
57+// FailPending fails every request not yet handed to the capture worker and
58+// empties the list.
59+func (r *Requests) FailPending(err error) {
60+ for _, q := range r.List {
61+ if !Handed(q) {
62+ Fail(r.ctx, q, err, r.replies)
63+ }
64+ }
65+ r.Reset()
66+}
67+
68+// Reset empties the list without answering: its requests were answered or
69+// handed over.
70+func (r *Requests) Reset() {
71+ clear(r.List)
72+ r.List = r.storage[:0]
73+}
74+
75+// Stop fails what the owner still holds, then what is queued on incoming,
76+// with ErrOutputStopped. ctx is the context replies are sent under. It
77+// returns once incoming is empty or closed.
78+func (r *Requests) Stop(ctx context.Context, incoming <-chan ports.CaptureRequest) {
79+ for _, q := range r.List {
80+ if !Handed(q) {
81+ Fail(ctx, q, ErrOutputStopped, r.replies)
82+ }
83+ }
84+ for {
85+ select {
86+ case q, ok := <-incoming:
87+ if !ok {
88+ return
89+ }
90+ Fail(ctx, q, ErrOutputStopped, r.replies)
91+ default:
92+ return
93+ }
94+ }
95+}
@@ -0,0 +1,95 @@
1+package capture
2+
3+import (
4+ "context"
5+ "errors"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// Reasons an output owner fails a request with.
12+var (
13+ ErrOutputStopped = errors.New("output stopped")
14+ ErrOutputOff = errors.New("output off")
15+ ErrSessionProtected = errors.New("session protected")
16+ ErrBatchFull = errors.New("output capture batch full")
17+ ErrEpochChanged = errors.New("security epoch changed")
18+)
19+
20+// Requests are the capture requests an output owner collected for its next
21+// frame, in fixed storage (MaxRequests): no allocation. Its owner goroutine
22+// is the only user. List is the live slice the pipeline calls take and
23+// return; assign their result back to it.
24+type Requests struct {
25+ storage [MaxRequests]ports.CaptureRequest
26+ List []ports.CaptureRequest
27+ ctx context.Context
28+ replies chan<- ports.CaptureDone
29+}
30+
31+// Init binds the requests to the owner's context and reply channel.
32+func (r *Requests) Init(ctx context.Context, replies chan<- ports.CaptureDone) {
33+ r.List, r.ctx, r.replies = r.storage[:0], ctx, replies
34+}
35+
36+// Len is the number of requests held.
37+func (r *Requests) Len() int { return len(r.List) }
38+
39+// Admit takes q for the next frame, or fails it with refuse when that is not
40+// nil, or when the batch is full. It reports whether q needs a new frame: its
41+// indicator is already on the current scene.
42+func (r *Requests) Admit(q ports.CaptureRequest, refuse error, scene ports.Scene, haveScene bool) (redraw bool) {
43+ if refuse == nil && len(r.List) == cap(r.List) {
44+ refuse = ErrBatchFull
45+ }
46+ if refuse != nil {
47+ Fail(r.ctx, q, refuse, r.replies)
48+ return false
49+ }
50+ q.Since = time.Now()
51+ r.List = append(r.List, q)
52+ // Its indicator may already be on screen; else it waits for the scene
53+ // that shows it.
54+ return haveScene && IndicatorShown(scene, q)
55+}
56+
57+// FailPending fails every request not yet handed to the capture worker and
58+// empties the list.
59+func (r *Requests) FailPending(err error) {
60+ for _, q := range r.List {
61+ if !Handed(q) {
62+ Fail(r.ctx, q, err, r.replies)
63+ }
64+ }
65+ r.Reset()
66+}
67+
68+// Reset empties the list without answering: its requests were answered or
69+// handed over.
70+func (r *Requests) Reset() {
71+ clear(r.List)
72+ r.List = r.storage[:0]
73+}
74+
75+// Stop fails what the owner still holds, then what is queued on incoming,
76+// with ErrOutputStopped. ctx is the context replies are sent under. It
77+// returns once incoming is empty or closed.
78+func (r *Requests) Stop(ctx context.Context, incoming <-chan ports.CaptureRequest) {
79+ for _, q := range r.List {
80+ if !Handed(q) {
81+ Fail(ctx, q, ErrOutputStopped, r.replies)
82+ }
83+ }
84+ for {
85+ select {
86+ case q, ok := <-incoming:
87+ if !ok {
88+ return
89+ }
90+ Fail(ctx, q, ErrOutputStopped, r.replies)
91+ default:
92+ return
93+ }
94+ }
95+}
A internal/adapters/outputkit/capture/requests_test.go +63 -0
@@ -0,0 +1,63 @@
1+package capture
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestRequestsAdmitRefuseAndFull(t *testing.T) {
12+ replies := make(chan ports.CaptureDone, MaxRequests+2)
13+ var r Requests
14+ r.Init(context.Background(), replies)
15+ scene := ports.Scene{OutputWidth: 10, OutputHeight: 10}
16+
17+ require.False(t, r.Admit(ports.CaptureRequest{ID: 1}, ErrOutputOff, scene, true))
18+ require.ErrorIs(t, (<-replies).Err, ErrOutputOff)
19+ require.Zero(t, r.Len())
20+
21+ // A request without an indicator is ready at once on a known scene.
22+ require.True(t, r.Admit(ports.CaptureRequest{ID: 2}, nil, scene, true))
23+ require.False(t, r.Admit(ports.CaptureRequest{ID: 3}, nil, scene, false), "no scene yet")
24+ require.False(t, r.List[0].Since.IsZero())
25+ for i := r.Len(); i < MaxRequests; i++ {
26+ r.Admit(ports.CaptureRequest{ID: uint64(10 + i)}, nil, scene, true)
27+ }
28+ require.False(t, r.Admit(ports.CaptureRequest{ID: 99}, nil, scene, true))
29+ done := <-replies
30+ require.Equal(t, uint64(99), done.ID)
31+ require.ErrorIs(t, done.Err, ErrBatchFull)
32+ require.Equal(t, MaxRequests, r.Len())
33+}
34+
35+func TestRequestsFailPendingSkipsHanded(t *testing.T) {
36+ replies := make(chan ports.CaptureDone, 4)
37+ var r Requests
38+ r.Init(context.Background(), replies)
39+ r.Admit(ports.CaptureRequest{ID: 1}, nil, ports.Scene{}, false)
40+ r.Admit(ports.CaptureRequest{ID: 2}, nil, ports.Scene{}, false)
41+ r.List[0] = ports.CaptureRequest{} // handed to the worker
42+ r.FailPending(ErrEpochChanged)
43+ require.Zero(t, r.Len())
44+ require.Len(t, replies, 1)
45+ done := <-replies
46+ require.Equal(t, uint64(2), done.ID)
47+ require.ErrorIs(t, done.Err, ErrEpochChanged)
48+}
49+
50+func TestRequestsStopDrainsIncoming(t *testing.T) {
51+ replies := make(chan ports.CaptureDone, 4)
52+ var r Requests
53+ r.Init(context.Background(), replies)
54+ r.Admit(ports.CaptureRequest{ID: 1}, nil, ports.Scene{}, false)
55+ incoming := make(chan ports.CaptureRequest, 2)
56+ incoming <- ports.CaptureRequest{ID: 2}
57+ close(incoming)
58+ r.Stop(context.Background(), incoming)
59+ require.Len(t, replies, 2)
60+ for range 2 {
61+ require.ErrorIs(t, (<-replies).Err, ErrOutputStopped)
62+ }
63+}
@@ -0,0 +1,63 @@
1+package capture
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestRequestsAdmitRefuseAndFull(t *testing.T) {
12+ replies := make(chan ports.CaptureDone, MaxRequests+2)
13+ var r Requests
14+ r.Init(context.Background(), replies)
15+ scene := ports.Scene{OutputWidth: 10, OutputHeight: 10}
16+
17+ require.False(t, r.Admit(ports.CaptureRequest{ID: 1}, ErrOutputOff, scene, true))
18+ require.ErrorIs(t, (<-replies).Err, ErrOutputOff)
19+ require.Zero(t, r.Len())
20+
21+ // A request without an indicator is ready at once on a known scene.
22+ require.True(t, r.Admit(ports.CaptureRequest{ID: 2}, nil, scene, true))
23+ require.False(t, r.Admit(ports.CaptureRequest{ID: 3}, nil, scene, false), "no scene yet")
24+ require.False(t, r.List[0].Since.IsZero())
25+ for i := r.Len(); i < MaxRequests; i++ {
26+ r.Admit(ports.CaptureRequest{ID: uint64(10 + i)}, nil, scene, true)
27+ }
28+ require.False(t, r.Admit(ports.CaptureRequest{ID: 99}, nil, scene, true))
29+ done := <-replies
30+ require.Equal(t, uint64(99), done.ID)
31+ require.ErrorIs(t, done.Err, ErrBatchFull)
32+ require.Equal(t, MaxRequests, r.Len())
33+}
34+
35+func TestRequestsFailPendingSkipsHanded(t *testing.T) {
36+ replies := make(chan ports.CaptureDone, 4)
37+ var r Requests
38+ r.Init(context.Background(), replies)
39+ r.Admit(ports.CaptureRequest{ID: 1}, nil, ports.Scene{}, false)
40+ r.Admit(ports.CaptureRequest{ID: 2}, nil, ports.Scene{}, false)
41+ r.List[0] = ports.CaptureRequest{} // handed to the worker
42+ r.FailPending(ErrEpochChanged)
43+ require.Zero(t, r.Len())
44+ require.Len(t, replies, 1)
45+ done := <-replies
46+ require.Equal(t, uint64(2), done.ID)
47+ require.ErrorIs(t, done.Err, ErrEpochChanged)
48+}
49+
50+func TestRequestsStopDrainsIncoming(t *testing.T) {
51+ replies := make(chan ports.CaptureDone, 4)
52+ var r Requests
53+ r.Init(context.Background(), replies)
54+ r.Admit(ports.CaptureRequest{ID: 1}, nil, ports.Scene{}, false)
55+ incoming := make(chan ports.CaptureRequest, 2)
56+ incoming <- ports.CaptureRequest{ID: 2}
57+ close(incoming)
58+ r.Stop(context.Background(), incoming)
59+ require.Len(t, replies, 2)
60+ for range 2 {
61+ require.ErrorIs(t, (<-replies).Err, ErrOutputStopped)
62+ }
63+}
M internal/adapters/outputkit/surfaces/surfaces.go +18 -0
@@ -98,3 +98,21 @@ func (t *Table) Kept(id ports.WindowID) bool {
9898 _, ok := t.kept[id]
9999 return ok
100100 }
101+
102+// Shown is the presentation rule both output backends report with: id was
103+// shown to its client when scene draws it and its content is not kept. A
104+// nil kept counts nothing as kept.
105+func Shown(scene ports.Scene, id ports.WindowID, kept func(ports.WindowID) bool) bool {
106+ return scene.Shows(id) && (kept == nil || !kept(id))
107+}
108+
109+// FillShown clears dst and fills it with the windows of seen that scene
110+// showed to their client (Shown), at their seen Seq.
111+func FillShown(dst, seen map[ports.WindowID]uint64, scene ports.Scene, kept func(ports.WindowID) bool) {
112+ clear(dst)
113+ for id, seq := range seen {
114+ if Shown(scene, id, kept) {
115+ dst[id] = seq
116+ }
117+ }
118+}
@@ -98,3 +98,21 @@ func (t *Table) Kept(id ports.WindowID) bool {
98 _, ok := t.kept[id]98 _, ok := t.kept[id]
99 return ok99 return ok
100 }100 }
101+
102+// Shown is the presentation rule both output backends report with: id was
103+// shown to its client when scene draws it and its content is not kept. A
104+// nil kept counts nothing as kept.
105+func Shown(scene ports.Scene, id ports.WindowID, kept func(ports.WindowID) bool) bool {
106+ return scene.Shows(id) && (kept == nil || !kept(id))
107+}
108+
109+// FillShown clears dst and fills it with the windows of seen that scene
110+// showed to their client (Shown), at their seen Seq.
111+func FillShown(dst, seen map[ports.WindowID]uint64, scene ports.Scene, kept func(ports.WindowID) bool) {
112+ clear(dst)
113+ for id, seq := range seen {
114+ if Shown(scene, id, kept) {
115+ dst[id] = seq
116+ }
117+ }
118+}
M internal/adapters/outputkit/surfaces/surfaces_test.go +14 -0
@@ -122,3 +122,17 @@ func TestKeptChildrenFencesDroppedOnACopy(t *testing.T) {
122122 t.Fatal("wayland's slice was modified")
123123 }
124124 }
125+
126+// Both backends report a window shown only when the scene draws it and its
127+// content is not kept.
128+func TestFillShown(t *testing.T) {
129+ kept := func(id ports.WindowID) bool { return id == 2 }
130+ dst := map[ports.WindowID]uint64{9: 9}
131+ FillShown(dst, map[ports.WindowID]uint64{1: 3, 2: 4, 3: 5}, shows(1, 2), kept)
132+ if len(dst) != 1 || dst[1] != 3 {
133+ t.Fatalf("shown %v", dst)
134+ }
135+ if !Shown(shows(2), 2, nil) {
136+ t.Fatal("nil kept counts nothing as kept")
137+ }
138+}
@@ -122,3 +122,17 @@ func TestKeptChildrenFencesDroppedOnACopy(t *testing.T) {
122 t.Fatal("wayland's slice was modified")122 t.Fatal("wayland's slice was modified")
123 }123 }
124 }124 }
125+
126+// Both backends report a window shown only when the scene draws it and its
127+// content is not kept.
128+func TestFillShown(t *testing.T) {
129+ kept := func(id ports.WindowID) bool { return id == 2 }
130+ dst := map[ports.WindowID]uint64{9: 9}
131+ FillShown(dst, map[ports.WindowID]uint64{1: 3, 2: 4, 3: 5}, shows(1, 2), kept)
132+ if len(dst) != 1 || dst[1] != 3 {
133+ t.Fatalf("shown %v", dst)
134+ }
135+ if !Shown(shows(2), 2, nil) {
136+ t.Fatal("nil kept counts nothing as kept")
137+ }
138+}

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 23

Tags 10

↑↓ moveenter openesc close