detached 39f7a28

feat(session-lock): add ext-session-lock with DRM output protection (#73)

* feat(session-lock): add ext-session-lock with DRM output protection

* chore(deps): bump wlturbo to v0.5.0

* fix(session-lock): skip unlocked capture control on devices without sync-file export

Lavapipe exports no sync files, so the Vulkan renderer refuses every protocol
capture by design. The end-to-end test's positive control therefore failed in
CI. Add Renderer.CaptureSupported and run the control only where the device
can capture; every protected-capture refusal assertion still runs.
commit
39f7a284d1a81ea0a1a7e3febe6fcd042a9f954a
author
bnema · · 2026-09-30 20:15 UTC
committer
GitHub · · 2026-09-30 20:15 UTC
parent
2b17ac2
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
164 files · +14130 -942
M .hexcheck.yaml +10 -2
@@ -2,7 +2,8 @@ version: 1
22 components:
33 core: { role: core, paths: [internal/core/**] }
44 ports: { role: ports, paths: [internal/ports/**] }
5- adapters: { role: adapter, paths: [internal/adapters/**, internal/logging/**] }
5+ # sessionlock is the display owner's proof ledger, not core placement policy.
6+ adapters: { role: adapter, paths: [internal/adapters/**, internal/logging/**, internal/sessionlock/**] }
67 # app is the composition root: it wires adapters to core, like cmd.
78 entrypoints: { role: entrypoint, paths: [cmd/**, internal/app/**] }
89 generated: { role: ignore, paths: [internal/mocks/**] }
@@ -15,10 +16,17 @@ rules:
1516 mocking:
1617 generatedMockPaths: [internal/mocks/**]
1718 generatedMockNamePatterns: ['Mock{{Interface}}', '{{Interface}}Mock']
19+allow:
20+ - rule: suspicious-business-logic-in-adapter
21+ path: internal/adapters/wayland/seat.go
22+ reason: Seat dispatch resolves live Wayland targets and enforces protected input delivery at the display owner.
23+ - rule: suspicious-business-logic-in-adapter
24+ path: internal/adapters/wayland/server.go
25+ reason: Display command dispatch validates immutable security epochs before converting port messages to wire events.
1826 ruleSettings:
1927 missing-generated-mock-for-port:
2028 # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005).
21- excludePaths: [internal/ports/messages.go]
29+ excludePaths: [internal/ports/messages.go, internal/ports/session_lock_messages.go, internal/ports/session_backend.go]
2230 suspicious-business-logic-in-adapter:
2331 # app is the composition root; starting core and wiring channels is its job.
2432 excludePaths: [internal/app/app.go]
M .mockery.yml +14 -0
@@ -19,6 +19,8 @@ packages:
1919 OutputScaleStore:
2020 Renderer:
2121 Seat:
22+ SessionSecurity:
23+ SessionSecurityController:
2224 Ticker:
2325 Timer:
2426 # Unexported adapter seams: mocks live next to the code, in test files.
@@ -43,11 +45,23 @@ packages:
4345 dir: internal/adapters/wayland
4446 pkgname: wayland
4547 filename: proc_env_mock_test.go
48+ acquirePoller:
49+ config:
50+ dir: internal/adapters/wayland
51+ pkgname: wayland
52+ filename: acquire_poller_mock_test.go
4653 syncobjDevice:
4754 config:
4855 dir: internal/adapters/wayland
4956 pkgname: wayland
5057 filename: syncobj_device_mock_test.go
58+ github.com/bnema/neferwl/internal/app:
59+ interfaces:
60+ securityLeaseCard:
61+ config:
62+ dir: internal/app
63+ pkgname: app
64+ filename: securitylease_mock_test.go
5165 github.com/bnema/neferwl/internal/adapters/statefile:
5266 interfaces:
5367 procTree:
M README.md +1 -0
@@ -110,6 +110,7 @@ The file has one `key = value` per line, and `#` starts a comment. A missing fil
110110
111111 - [Configuration](docs/config.md): every key, action and default bind, and HDR.
112112 - [Desktop integration](docs/desktop.md): running commands, clipboard, input methods, X11 apps, idle and screen off, bars, and the state file for scripts.
113+- [Session locking](docs/session-lock.md): external lockers, output confirmation, owner recovery and security limits.
113114 - [Headless mode](docs/headless.md): run without a screen, take screenshots, play input scripts and test input methods.
114115 - [Performance](docs/performance.md): how to profile, the allocation guards, and reference numbers.
115116
M docs/desktop.md +5 -1
@@ -68,6 +68,10 @@ xwayland = off # no X11 display
6868
6969 Do not start xwayland-satellite yourself with `startup`. Changing `xwayland` takes effect at the next start.
7070
71+## Session locking
72+
73+External lockers use `ext-session-lock-v1`. NeferWL isolates desktop input and captures, covers every output, and stays protected if the locker dies. Authentication belongs to the locker; screen-off and a visual cover do not replace locking. See [Session locking](session-lock.md) for confirmation, recovery and security limits.
74+
7175 ## Idle and screen off
7276
7377 NeferWL supports `ext_idle_notifier_v1` and `zwlr_output_power_management_v1`, so [swayidle](https://github.com/swaywm/swayidle) and [wlopm](https://git.sr.ht/~leon_plickat/wlopm) turn the screens off after a delay. A window that inhibits idle, such as a video player or a game, keeps them on.
@@ -88,7 +92,7 @@ exec wlopm --off '*'
8892
8993 NeferWL supports `ext_workspace_manager_v1` for bars such as Waybar 0.13+ (`ext/workspaces`) and ironbar. Bars receive workspace updates and can switch workspaces without polling.
9094
91-NeferWL also supports `zwlr_foreign_toplevel_manager_v1`. Taskbars can list, focus, close and fullscreen windows, and notification daemons such as Dunst can detect fullscreen windows. Every client can see window titles and app IDs.
95+NeferWL also supports `zwlr_foreign_toplevel_manager_v1`. Taskbars can list, focus, close and fullscreen windows, and notification daemons such as Dunst can detect fullscreen windows. While unlocked, clients can see window titles and app IDs. Workspace and window inventory updates are withheld during session protection.
9296
9397 ## State for scripts
9498
M docs/headless.md +1 -1
@@ -15,7 +15,7 @@ The compositor draws a red border around the visible recording target. An explic
1515
1616 Offscreen frame callbacks keep clients running, but no physical presentation feedback is invented for hidden windows. The child renderer and its readback storage retire when unused and idle, are recreated for session or frame-size changes, and close with the output. Clean capture can require two compositions to preserve the displayed z-order.
1717
18-`/tmp/neferwl-shots/latest.png` shows the current frame, including the recording border and HUD. `--timeout 5s` stops NeferWL after 5 seconds.
18+`/tmp/neferwl-shots/latest.png` shows the current unlocked frame, including the recording border and HUD. During session protection, protocol captures are refused and debug PNG files stop updating; an existing file still contains an earlier unlocked frame. See [Session locking](session-lock.md). `--timeout 5s` stops NeferWL after 5 seconds.
1919
2020 `--input <path>`, or `--input -` for stdin, plays an input script. One command per line:
2121
A docs/session-lock.md +35 -0
@@ -0,0 +1,35 @@
1+# Session locking
2+
3+NeferWL supports `ext-session-lock-v1` with an external Wayland locker. The locker owns authentication and requests unlock after it succeeds; NeferWL owns input isolation and output protection. A fullscreen window, layer-shell cover or fade is not a session lock.
4+
5+## Protection
6+
7+NeferWL accepts one live lock owner. From acceptance, it refuses desktop input, binds, activation, virtual-keyboard injection, input-method grabs, clipboard requests and capture admission. Queued desktop input from an earlier transition is discarded rather than relabelled. Protected scenes contain only an opaque compositor-owned black background and the owner's validated lock surfaces. Direct scanout, overlays, tearing and hardware cursors are disabled while protected.
8+
9+The locker receives `locked` only after every registered output lifetime has affirmative evidence of a protected frame or actual inactivity, the backend has revoked outstanding DRM leases, and admitted prelock captures have finished writing their destination buffers. Queued rendering, a timeout, renderer failure and loss of DRM master are not protection evidence. A lease that cannot be revoked keeps `locked` withheld.
10+
11+Lock surfaces receive exact logical dimensions through configure/acknowledge. Outputs without a mapped lock surface remain black. An output added during protection starts safe-first; stale frames or proofs from an earlier lock or connector lifetime cannot authorize it. Resume and recovery require fresh output protection rather than replaying a desktop framebuffer.
12+
13+## Owner failure and unlock
14+
15+Destroying a lock surface, disconnecting the locker or encountering its protocol error does not unlock. NeferWL keeps protection and falls back to black. A new ordinary locker can take over after the previous owner disappears; a second live owner is refused.
16+
17+Only the current owner, after `locked`, may send `unlock_and_destroy`. A locker exiting afterward must perform a Wayland roundtrip before closing its connection. Closing before the server processes unlock can be treated as owner death, which keeps the session protected. Input held across a transition is cleared or quarantined until released, so password-held keys do not enter the desktop after unlock.
18+
19+Returning activity may wake powered-off outputs but never unlocks. There is no emergency unlock key or timeout-to-unlock. VT switching remains available; `Ctrl+Alt+Backspace` and the `--debug=input-keys` key log are suppressed while protected.
20+
21+## Boundaries and limits
22+
23+- Password/PIN verification belongs to the locker. NeferWL does not implement PAM, store credentials, or authenticate the protocol's owner.
24+- A same-user process that controls the session or a legitimate lock owner is outside this isolation boundary. Root, hostile kernel/driver code and another active VT are not isolated by the compositor.
25+- Clipboard pipes already handed to clients before acquisition cannot be recalled. Later receive/set requests are denied, including requests on old offer objects. Information a client received before locking cannot be erased from that client.
26+- Captures admitted before acquisition may complete; `locked` waits for their copy/FD-write completion even if the requesting protocol object has been destroyed. New captures, including private recording and hidden-workspace capture, are denied.
27+- The script state file is not a security API and is not scrubbed by locking. Its existing file permissions and same-user access boundary remain unchanged.
28+- Native KMS calls and compositor GPU work can stall. Stalls never produce a successful protection acknowledgement; bounded lock latency is not guaranteed. Protected shutdown never restores saved desktop pixels, but disabling an output is best effort if KMS authority is lost.
29+- Refused or transient protected KMS commits keep the output registered and dark, and retry with a 50 ms to 2 s backoff. An output stops, and the locker loses it, only when the renderer fails to clear it or no image format at all is accepted. If the driver keeps refusing to disable an output, its previous content can stay lit; `locked` is still withheld.
30+
31+## Verification
32+
33+Headless integration tests exercise two outputs, bufferless acquisition, locker input isolation, capture refusal, owner death/takeover and synchronized unlock. Headless inactivity means the software output is off; there is no physical display. Protected headless runs do not update debug PNG files or read back protected content; an existing PNG remains an earlier unlocked image, not a live protected screenshot.
34+
35+KMS tests use generated mocks to verify detachment, black clearing, fence ordering, failure handling and shutdown. They do not prove physical behavior on every GPU. Multi-monitor hotplug, DPMS, VT/resume and suspend require separately authorized isolated hardware testing before making hardware-specific guarantees.
M examples/testime/main.go +16 -0
@@ -14,6 +14,8 @@ import (
1414
1515 "github.com/bnema/purego-libwayland/protocol/inputmethod"
1616 "github.com/bnema/wlturbo"
17+ clientcore "github.com/bnema/wlturbo/protocol/core"
18+ clientime "github.com/bnema/wlturbo/protocol/inputmethod"
1719 )
1820
1921 func main() {
@@ -40,6 +42,12 @@ type inputMethod struct {
4042 serve bool // an activation applied by done, not yet served
4143 }
4244
45+// EventSignature delegates to the generated client schema, including events
46+// this example intentionally ignores and their descriptor ownership.
47+func (*inputMethod) EventSignature(op uint16) (string, bool) {
48+ return (&clientime.InputMethod{}).EventSignature(op)
49+}
50+
4351 func (m *inputMethod) Dispatch(e *wlturbo.Event) {
4452 switch uint32(e.Opcode) {
4553 case inputmethod.ZwpInputMethodV2EventActivate:
@@ -65,6 +73,10 @@ type ignored struct{ wlturbo.BaseProxy }
6573
6674 func (*ignored) Dispatch(*wlturbo.Event) {}
6775
76+func (*ignored) EventSignature(op uint16) (string, bool) {
77+ return (&clientcore.Seat{}).EventSignature(op)
78+}
79+
6880 func (m *inputMethod) send(op uint32, args ...any) error {
6981 return m.c.SendRequest(m.ID(), uint16(op), args...)
7082 }
@@ -92,6 +104,8 @@ func run(preedit, commit string, delay time.Duration, count int) error {
92104 // wlturbo fails on events for unregistered objects: the seat sends some.
93105 seatProxy := &ignored{}
94106 seatProxy.SetID(seat)
107+ seatProxy.SetContext(c.Context())
108+ seatProxy.SetVersion(1)
95109 c.Context().Register(seatProxy)
96110 manager, err := bind("zwp_input_method_manager_v2")
97111 if err != nil {
@@ -99,6 +113,8 @@ func run(preedit, commit string, delay time.Duration, count int) error {
99113 }
100114 m := &inputMethod{c: c}
101115 m.SetID(c.AllocateID())
116+ m.SetContext(c.Context())
117+ m.SetVersion(1)
102118 c.Context().Register(m)
103119 if err := c.SendRequest(manager, uint16(inputmethod.ZwpInputMethodManagerV2RequestGetInputMethod), seat, m.ID()); err != nil {
104120 return err
M go.mod +1 -1
@@ -6,7 +6,7 @@ require (
66 github.com/bnema/purego v0.13.0-bnema.1
77 github.com/bnema/purego-libwayland v0.7.0
88 github.com/bnema/purego-vulkan v0.6.0
9- github.com/bnema/wlturbo v0.2.0
9+ github.com/bnema/wlturbo v0.5.0
1010 github.com/bnema/zerowrap v1.4.1
1111 github.com/rs/zerolog v1.35.1
1212 github.com/stretchr/testify v1.12.1
M go.sum +2 -2 generated file: collapsed
M internal/adapters/capture/offscreen.go +16 -4
@@ -346,6 +346,9 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports
346346 switch {
347347 case len(reqs) == 0:
348348 return
349+ case !p.captureAllowed(s.Security):
350+ fail(ErrSecurityState, reqs)
351+ return
349352 case s.Off:
350353 fail(errors.New("output off"), reqs)
351354 return
@@ -391,6 +394,7 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports
391394 return
392395 }
393396 o.r, o.p, o.w, o.h, o.session = r, NewPipeline(p.ctx, p.replies), w, h, s.Capture.Session
397+ o.p.Security = p.Security
394398 }
395399 // Request regions are physical pixels of the output; TargetRect is
396400 // relative to the workspace viewport, so it cannot place them. The child
@@ -417,13 +421,21 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports
417421 child := *cs
418422 child.Capture = &ports.SceneCapture{Excluded: s.Capture.Excluded}
419423 child = o.clean.scene(child)
420- done, err := o.r.Render(child, surfaces)
421- if err != nil {
422- fail(fmt.Errorf("render workspace: %w", err), reqs)
424+ if !p.captureAllowed(s.Security) {
425+ fail(ErrSecurityState, reqs)
423426 return
424427 }
428+ done, err := o.r.Render(child, surfaces)
429+ // This device's reads are independent of the display. Even a rejected
430+ // capture retains its render fence/ceilings until GPU completion.
425431 if done != nil {
426432 o.track(done, child, surfaces)
427433 }
428- o.p.Submit(o.r, reqs)
434+ if err != nil {
435+ fail(fmt.Errorf("render workspace: %w", err), reqs)
436+ return
437+ }
438+ // Immutable parent scene epoch, not the cleaner's rewritten child scene.
439+ // SubmitScoped rechecks immediately before native BeginCapture.
440+ o.p.SubmitScoped(s.Security, o.r, reqs)
429441 }
M internal/adapters/capture/pipeline.go +15 -3
@@ -2,6 +2,7 @@ package capture
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "sync"
78 "time"
@@ -15,6 +16,9 @@ const MaxRequests = 8
1516
1617 const pipelineSlots = 2
1718
19+// ErrSecurityState rejects capture admission across an epoch or while protected.
20+var ErrSecurityState = errors.New("capture security epoch changed or session protected")
21+
1822 // batch remains owned by the worker until it is returned through completed.
1923 // Both batches and their request storage are reused for the output's lifetime.
2024 type batch struct {
@@ -27,6 +31,8 @@ type batch struct {
2731 // Pipeline moves fence waiting and SHM copies off the renderer's owner.
2832 // Submit, Recycle and Close belong to that owner; one worker handles all jobs.
2933 type Pipeline struct {
34+ // Set before owner use; no mutation or worker access to this dependency.
35+ Security ports.SessionSecurity
3036 ctx context.Context
3137 cancel context.CancelFunc
3238 replies chan<- ports.CaptureDone
@@ -55,9 +61,9 @@ func (p *Pipeline) Recycle(b *batch, r ports.Renderer) {
5561 *b = batch{}
5662 }
5763
58-// Submit consumes all requests. Saturation fails explicitly instead of
59-// waiting, growing a queue, or starting another worker.
60-func (p *Pipeline) Submit(r ports.Renderer, requests []ports.CaptureRequest) {
64+// SubmitScoped consumes requests with explicit immutable scene admission. The
65+// final gate read precedes BeginCapture, not the render that produced pixels.
66+func (p *Pipeline) SubmitScoped(state ports.SecurityState, r ports.Renderer, requests []ports.CaptureRequest) {
6167 if len(requests) == 0 {
6268 return
6369 }
@@ -75,6 +81,8 @@ func (p *Pipeline) Submit(r ports.Renderer, requests []ports.CaptureRequest) {
7581 var err error
7682 if b == nil {
7783 err = fmt.Errorf("capture pipeline full or stopped")
84+ } else if !p.captureAllowed(state) {
85+ err = ErrSecurityState
7886 } else {
7987 b.frame, err = r.BeginCapture()
8088 if err == nil && b.frame == nil {
@@ -94,6 +102,10 @@ func (p *Pipeline) Submit(r ports.Renderer, requests []ports.CaptureRequest) {
94102 p.jobs <- b
95103 }
96104
105+func (p *Pipeline) captureAllowed(state ports.SecurityState) bool {
106+ return p.Security == nil || !state.Protected && p.Security.Snapshot() == state
107+}
108+
97109 func (p *Pipeline) reclaim(r ports.Renderer) {
98110 for {
99111 select {
M internal/adapters/capture/pipeline_fence_test.go +2 -2
@@ -40,7 +40,7 @@ func TestPipelineWaitsForFenceBeforeRead(t *testing.T) {
4040 replies := make(chan ports.CaptureDone, 1)
4141 p := NewPipeline(context.Background(), replies)
4242 q := pipelineRequest(t, 1)
43- p.Submit(r, []ports.CaptureRequest{q})
43+ p.SubmitScoped(ports.SecurityState{}, r, []ports.CaptureRequest{q})
4444 // Longer than one syncfile poll slice: the worker has polled at least once.
4545 select {
4646 case <-entered:
@@ -79,7 +79,7 @@ func TestPipelinePropagatesReadError(t *testing.T) {
7979 replies := make(chan ports.CaptureDone, 1)
8080 p := NewPipeline(context.Background(), replies)
8181 q := pipelineRequest(t, 1)
82- p.Submit(r, []ports.CaptureRequest{q})
82+ p.SubmitScoped(ports.SecurityState{}, r, []ports.CaptureRequest{q})
8383 done := awaitCapture(t, replies)
8484 if !errors.Is(done.Err, boom) || done.ID != q.ID {
8585 t.Fatalf("result %+v", done)
M internal/adapters/capture/pipeline_test.go +5 -5
@@ -53,7 +53,7 @@ func TestPipelineCopiesOffOwnerAndRecycles(t *testing.T) {
5353 replies := make(chan ports.CaptureDone, 1)
5454 p := NewPipeline(context.Background(), replies)
5555 q := pipelineRequest(t, 1)
56- p.Submit(r, []ports.CaptureRequest{q})
56+ p.SubmitScoped(ports.SecurityState{}, r, []ports.CaptureRequest{q})
5757 select {
5858 case <-entered:
5959 case <-time.After(time.Second):
@@ -92,10 +92,10 @@ func TestPipelineSaturationAndShutdownWithoutGPUCompletion(t *testing.T) {
9292 frame.EXPECT().Done().Return(read).Maybe()
9393 r.EXPECT().BeginCapture().Return(frame, nil).Once()
9494 r.EXPECT().EndCapture(frame).Return().Once()
95- p.Submit(r, []ports.CaptureRequest{pipelineRequest(t, id)})
95+ p.SubmitScoped(ports.SecurityState{}, r, []ports.CaptureRequest{pipelineRequest(t, id)})
9696 }
9797 q := pipelineRequest(t, 3)
98- p.Submit(r, []ports.CaptureRequest{q})
98+ p.SubmitScoped(ports.SecurityState{}, r, []ports.CaptureRequest{q})
9999 if done := awaitCapture(t, replies); done.ID != 3 || done.Err == nil {
100100 t.Fatalf("saturation result %+v", done)
101101 }
@@ -120,7 +120,7 @@ func TestPipelineBeginFailureClosesRequest(t *testing.T) {
120120 replies := make(chan ports.CaptureDone, 1)
121121 p := NewPipeline(context.Background(), replies)
122122 q := pipelineRequest(t, 1)
123- p.Submit(r, []ports.CaptureRequest{q})
123+ p.SubmitScoped(ports.SecurityState{}, r, []ports.CaptureRequest{q})
124124 if done := awaitCapture(t, replies); !errors.Is(done.Err, boom) {
125125 t.Fatalf("result %+v", done)
126126 }
@@ -138,7 +138,7 @@ func TestPipelineRejectsOversizedBatch(t *testing.T) {
138138 for i := range requests {
139139 requests[i] = pipelineRequest(t, uint64(i))
140140 }
141- p.Submit(r, requests)
141+ p.SubmitScoped(ports.SecurityState{}, r, requests)
142142 for range requests {
143143 if done := awaitCapture(t, replies); done.Err == nil {
144144 t.Fatal("oversized batch accepted")
A internal/adapters/capture/security_test.go +92 -0
@@ -0,0 +1,92 @@
1+package capture
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "image"
8+ "os"
9+ "sync/atomic"
10+ "testing"
11+
12+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
13+ "github.com/bnema/neferwl/internal/ports"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+func TestSubmitScopedRejectsChangedAndProtectedEpoch(t *testing.T) {
19+ for _, state := range []ports.SecurityState{{Generation: 4}, {Generation: 3, Protected: true}} {
20+ t.Run(fmt.Sprintf("generation-%d-protected-%v", state.Generation, state.Protected), func(t *testing.T) {
21+ replies := make(chan ports.CaptureDone, 2)
22+ p := NewPipeline(context.Background(), replies)
23+ security := portsmocks.NewMockSessionSecurity(t)
24+ security.EXPECT().Snapshot().Return(state)
25+ p.Security = security
26+ r := portsmocks.NewMockRenderer(t)
27+ q := pipelineRequest(t, 1)
28+ file := q.Dst.File
29+ p.SubmitScoped(ports.SecurityState{Generation: 3}, r, []ports.CaptureRequest{q})
30+ p.Close(r)
31+ require.Len(t, replies, 1)
32+ require.ErrorIs(t, (<-replies).Err, ErrSecurityState)
33+ r.AssertNotCalled(t, "BeginCapture")
34+ _, err := file.Stat()
35+ require.ErrorIs(t, err, os.ErrClosed)
36+ })
37+ }
38+}
39+
40+func TestSubmitHiddenEngageDuringRenderRejectsCopyRetainsReads(t *testing.T) {
41+ replies := make(chan ports.CaptureDone, 4)
42+ p := NewPipeline(context.Background(), replies)
43+ security := portsmocks.NewMockSessionSecurity(t)
44+ var engaged atomic.Bool
45+ security.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
46+ if engaged.Load() {
47+ return ports.SecurityState{Generation: 1, Protected: true}
48+ }
49+ return ports.SecurityState{}
50+ })
51+ p.Security = security
52+ child := portsmocks.NewMockRenderer(t)
53+ f, w, err := os.Pipe()
54+ require.NoError(t, err)
55+ t.Cleanup(func() { f.Close(); w.Close() })
56+ child.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
57+ engaged.Store(true)
58+ return f, nil
59+ }).Once()
60+ child.EXPECT().Close().Return().Once()
61+ p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil })
62+ s := hiddenScene()
63+ s.CaptureScene.Windows = []ports.SceneWindow{{ID: 5}}
64+ surfaces := map[ports.WindowID]ports.SurfaceContent{5: {ID: 5, Seq: 8}}
65+ reqs := []ports.CaptureRequest{hiddenRequest(t, 1, image.Rect(0, 0, 200, 100)), hiddenRequest(t, 2, image.Rect(0, 0, 200, 100))}
66+ files := []*os.File{reqs[0].Dst.File, reqs[1].Dst.File}
67+ p.SubmitHidden(s, surfaces, reqs)
68+ child.AssertNotCalled(t, "BeginCapture")
69+ require.True(t, p.HiddenReading(), "rejected native capture still owns GPU reads")
70+ seen, reads, limited := p.CapHiddenSeen(map[ports.WindowID]uint64{5: 9})
71+ require.True(t, limited)
72+ require.Equal(t, uint64(8), seen[5])
73+ require.Equal(t, uint64(8), reads[5])
74+ for range 2 {
75+ reply := awaitCapture(t, replies)
76+ require.ErrorIs(t, reply.Err, ErrSecurityState)
77+ }
78+ for _, file := range files {
79+ _, err := file.Stat()
80+ require.ErrorIs(t, err, os.ErrClosed)
81+ }
82+ require.Empty(t, replies, "exactly one failure reply for each request credit")
83+ _, err = f.Stat()
84+ require.NoError(t, err, "render fence stays owned until signalling")
85+ _, err = w.Write([]byte{1})
86+ require.NoError(t, err)
87+ require.False(t, p.HiddenReading())
88+ _, err = f.Stat()
89+ require.True(t, errors.Is(err, os.ErrClosed), "signalled child fence closes")
90+ p.Close(nil)
91+ require.Empty(t, replies, "closing rejected pipeline creates no duplicate reply")
92+}
A internal/adapters/drm/amdgpu_rules_test.go +413 -0
@@ -0,0 +1,413 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "sync/atomic"
8+ "testing"
9+ "time"
10+
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+ "github.com/stretchr/testify/mock"
14+ "golang.org/x/sys/unix"
15+)
16+
17+// Connector HDR property IDs and values of the amdgpu rule tests.
18+const (
19+ pHDRMeta, pColorspace, pMaxBPC = 20, 21, 22
20+ colorspaceBT2020 = 9
21+)
22+
23+// Blob lifetime is part of the model: attach feeds createBlob/destroyBlob
24+// mocks into the live set.
25+type amdgpuModel struct {
26+ t *testing.T
27+ mu sync.Mutex
28+ live map[uint32]bool
29+ next uint32
30+ rule func(*atomicReq, uint32) error
31+}
32+
33+// newAmdgpuModel starts from a lit desktop whose mode blob 99 is live.
34+func newAmdgpuModel(t *testing.T) *amdgpuModel {
35+ t.Helper()
36+ m := &amdgpuModel{t: t, live: map[uint32]bool{99: true}, next: 100}
37+ m.rule = amdgpuRuleWith(t, m.isLive)
38+ return m
39+}
40+
41+func (m *amdgpuModel) isLive(blob uint32) bool {
42+ m.mu.Lock()
43+ defer m.mu.Unlock()
44+ return m.live[blob]
45+}
46+
47+// attach makes k create ids from 100 up and destroy only live blobs. With
48+// strict ids, each is expected to be destroyed exactly once and no other
49+// destroy is allowed; without, any live blob may be destroyed.
50+func (m *amdgpuModel) attach(k *mockkms, strict ...uint32) {
51+ k.EXPECT().createBlob(mock.Anything).RunAndReturn(func([]byte) (uint32, error) {
52+ m.mu.Lock()
53+ defer m.mu.Unlock()
54+ id := m.next
55+ m.next++
56+ m.live[id] = true
57+ return id, nil
58+ }).Maybe()
59+ destroy := func(id uint32) error {
60+ m.mu.Lock()
61+ defer m.mu.Unlock()
62+ if !m.live[id] {
63+ m.t.Errorf("amdgpu: destroyBlob(%d) of an unknown or destroyed blob", id)
64+ return unix.ENOENT
65+ }
66+ delete(m.live, id)
67+ return nil
68+ }
69+ if len(strict) == 0 {
70+ k.EXPECT().destroyBlob(mock.Anything).RunAndReturn(destroy).Maybe()
71+ }
72+ for _, id := range strict {
73+ k.EXPECT().destroyBlob(id).RunAndReturn(destroy).Once()
74+ }
75+}
76+
77+// amdgpuRule returns a commit rule encoding the atomic_check constraints of
78+// amdgpu_dm that real hardware applied to the protected requests (EINVAL on
79+// kernel 7.2, RX/DP+HDMI). The rule tracks the committed state, starting from
80+// a lit desktop: CRTC enabled and active, primary/cursor/overlay/stray
81+// planes attached, connector routed.
82+//
83+// - a CRTC with MODE_ID set (enabled) needs its primary plane attached,
84+// even with ACTIVE=0 (drm_atomic_helper_check_crtc_primary_plane);
85+// - MODE_ID, connector CRTC_ID and ACTIVE agree: an active or routed CRTC
86+// is enabled, an enabled one is routed;
87+// - a plane's FB_ID and CRTC_ID are both set or both clear, and only on an
88+// enabled CRTC;
89+// - the active primary plane covers the whole mode (no partial src/dst);
90+// - HDR_OUTPUT_METADATA needs the BT.2020 Colorspace;
91+// - MODE_ID must name a live blob, and a change of MODE_ID, ACTIVE or the
92+// connector CRTC_ID is a modeset that needs the ALLOW_MODESET flag.
93+//
94+// amdgpuRule has no blob model: any MODE_ID is live.
95+func amdgpuRule(t *testing.T) func(*atomicReq, uint32) error {
96+ t.Helper()
97+ return amdgpuRuleWith(t, func(uint32) bool { return true })
98+}
99+
100+func amdgpuRuleWith(t *testing.T, live func(uint32) bool) func(*atomicReq, uint32) error {
101+ t.Helper()
102+ state := map[[2]uint32]uint64{
103+ {tCrtc, pMode}: 99, {tCrtc, pActive}: 1, {tConn, pConnCrtc}: tCrtc,
104+ }
105+ for _, p := range []uint32{tPrimary, tCursor, tOverlay, 53} {
106+ state[[2]uint32{p, pFB}], state[[2]uint32{p, pCrtcID}] = 70, tCrtc
107+ }
108+ return func(req *atomicReq, flags uint32) error {
109+ next := make(map[[2]uint32]uint64, len(state))
110+ for k, v := range state {
111+ next[k] = v
112+ }
113+ for i, obj := range req.objs {
114+ for _, p := range req.props[i] {
115+ next[[2]uint32{obj, p.prop}] = p.val
116+ }
117+ }
118+ get := func(obj, prop uint32) uint64 { return next[[2]uint32{obj, prop}] }
119+ if mode := get(tCrtc, pMode); mode != 0 && !live(uint32(mode)) {
120+ t.Logf("amdgpu: MODE_ID %d names an unknown or destroyed blob", mode)
121+ return unix.EINVAL
122+ }
123+ for _, k := range [...][2]uint32{{tCrtc, pMode}, {tCrtc, pActive}, {tConn, pConnCrtc}} {
124+ if next[k] != state[k] && flags&atomicAllowModes == 0 {
125+ t.Logf("amdgpu: modeset change of %v without ALLOW_MODESET", k)
126+ return unix.EINVAL
127+ }
128+ }
129+ enabled, active := get(tCrtc, pMode) != 0, get(tCrtc, pActive) == 1
130+ if active && !enabled || (get(tConn, pConnCrtc) != 0) != enabled {
131+ t.Logf("amdgpu: ACTIVE/MODE_ID/connector routing disagree: %v", next)
132+ return unix.EINVAL
133+ }
134+ for _, p := range []uint32{tPrimary, tCursor, tOverlay, 53} {
135+ fb, crtc := get(p, pFB), get(p, pCrtcID)
136+ if (fb != 0) != (crtc != 0) || crtc != 0 && (!enabled || crtc != tCrtc) {
137+ t.Logf("amdgpu: plane %d fb=%d crtc=%d enabled=%v", p, fb, crtc, enabled)
138+ return unix.EINVAL
139+ }
140+ }
141+ if enabled && get(tPrimary, pFB) == 0 {
142+ t.Log("amdgpu: enabled CRTC without primary plane")
143+ return unix.EINVAL
144+ }
145+ if active {
146+ for _, c := range [...]struct {
147+ prop uint32
148+ want uint64
149+ }{{10, 0}, {11, 0}, {12, 200 << 16}, {13, 100 << 16}, {14, 0}, {15, 0}, {16, 200}, {17, 100}} {
150+ if get(tPrimary, c.prop) != c.want {
151+ t.Logf("amdgpu: primary prop %d = %d, want full-screen %d", c.prop, get(tPrimary, c.prop), c.want)
152+ return unix.EINVAL
153+ }
154+ }
155+ }
156+ if get(tConn, pHDRMeta) != 0 && get(tConn, pColorspace) != colorspaceBT2020 {
157+ t.Log("amdgpu: HDR metadata without BT.2020 colorspace")
158+ return unix.EINVAL
159+ }
160+ if flags&atomicTestOnly == 0 {
161+ state = next
162+ }
163+ return nil
164+ }
165+}
166+
167+func enableTestHDR(o *Output) {
168+ o.hdrOn, o.hdrBlob = true, 55
169+ o.hdrProps = connectorHDRProps{Metadata: pHDRMeta, Colorspace: pColorspace, MaxBPC: pMaxBPC, BT2020Value: colorspaceBT2020, MaxBPCValue: 8, HasDefault: true}
170+}
171+
172+func TestAmdgpuRuleRejectsWhatHardwareRejected(t *testing.T) {
173+ for name, build := range map[string]func(*atomicReq){
174+ // The shape that failed on hardware: inactive, still enabled, no planes.
175+ "enabled CRTC without primary": func(r *atomicReq) {
176+ r.set(tCrtc, pActive, 0)
177+ for _, p := range []uint32{tPrimary, tCursor, tOverlay, 53} {
178+ r.set(p, pFB, 0)
179+ r.set(p, pCrtcID, 0)
180+ }
181+ },
182+ "plane on disabled CRTC": func(r *atomicReq) {
183+ r.set(tCrtc, pActive, 0)
184+ r.set(tCrtc, pMode, 0)
185+ r.set(tConn, pConnCrtc, 0)
186+ },
187+ "connector routed to disabled CRTC": func(r *atomicReq) {
188+ r.set(tCrtc, pActive, 0)
189+ r.set(tCrtc, pMode, 0)
190+ for _, p := range []uint32{tPrimary, tCursor, tOverlay, 53} {
191+ r.set(p, pFB, 0)
192+ r.set(p, pCrtcID, 0)
193+ }
194+ },
195+ "cursor with CRTC but no FB": func(r *atomicReq) { r.set(tCursor, pFB, 0) },
196+ "partial primary": func(r *atomicReq) { r.set(tPrimary, 16, 100) },
197+ "unknown MODE_ID": func(r *atomicReq) { r.set(tCrtc, pMode, 123) },
198+ "HDR metadata without colorspace": func(r *atomicReq) {
199+ r.set(tConn, pHDRMeta, 55)
200+ },
201+ } {
202+ t.Run(name, func(t *testing.T) {
203+ req := &atomicReq{}
204+ build(req)
205+ if err := amdgpuRule(t)(req, atomicAllowModes); err == nil {
206+ t.Fatal("rule accepted a request amdgpu refuses")
207+ }
208+ })
209+ }
210+}
211+
212+// Every protected disable/activation request must pass the amdgpu rules, with
213+// HDR on and off, and leave the state dark then black-only.
214+func TestProtectedRequestsAcceptedByAmdgpuRules(t *testing.T) {
215+ for _, hdr := range []bool{false, true} {
216+ name := map[bool]string{false: "sdr", true: "hdr"}[hdr]
217+ t.Run(name, func(t *testing.T) {
218+ m := newAmdgpuModel(t)
219+ o, k, commits, _ := testOutputRules(t, m.rule)
220+ m.attach(k, 99)
221+ if hdr {
222+ enableTestHDR(o)
223+ }
224+ o.modeBlob = 99
225+ o.overlay = &plane{id: tOverlay, props: planeProps}
226+ o.stray = []*plane{o.overlay, {id: 53, props: planeProps}}
227+ k.EXPECT().objProps(uint32(53), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, tCrtc}}, nil)
228+ r := portsmocks.NewMockRenderer(t)
229+ r.EXPECT().UseTarget(0).Return().Once()
230+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
231+ if err := o.protectedModeset(context.Background(), r); err != nil {
232+ t.Fatalf("amdgpu refused the protected transaction: %v", err)
233+ }
234+ if len(*commits) != 2 {
235+ t.Fatalf("commits %d", len(*commits))
236+ }
237+ act := (*commits)[1]
238+ if fb, _ := act.req.value(tPrimary, pFB); fb != 71 && fb != 70 {
239+ t.Fatalf("activated fb %d", fb)
240+ }
241+ if crtc, ok := act.req.value(tCursor, pCrtcID); !ok || crtc != 0 {
242+ t.Fatalf("cursor not explicitly detached: %d %v", crtc, ok)
243+ }
244+ if fb, ok := act.req.value(tCursor, pFB); !ok || fb != 0 {
245+ t.Fatalf("cursor fb not explicitly detached: %d %v", fb, ok)
246+ }
247+ if o.off || !o.protected {
248+ t.Fatalf("off=%v protected=%v", o.off, o.protected)
249+ }
250+ // The desktop mode blob is destroyed once by the disable; the
251+ // activation's blob is the only live one and o.modeBlob owns it.
252+ if o.modeBlob != 100 || !m.isLive(100) || m.isLive(99) {
253+ t.Fatalf("modeBlob %d live100=%v live99=%v", o.modeBlob, m.isLive(100), m.isLive(99))
254+ }
255+ })
256+ }
257+}
258+
259+func TestAmdgpuRuleRejectsModesetWithoutAllowModes(t *testing.T) {
260+ for name, build := range map[string]func(*atomicReq){
261+ "MODE_ID": func(r *atomicReq) { r.set(tCrtc, pMode, 99) },
262+ "ACTIVE": func(r *atomicReq) { r.set(tCrtc, pActive, 0) },
263+ "connector CRTC_ID": func(r *atomicReq) { r.set(tConn, pConnCrtc, 0) },
264+ } {
265+ t.Run(name, func(t *testing.T) {
266+ m := newAmdgpuModel(t)
267+ m.live[98] = true
268+ req := &atomicReq{}
269+ build(req)
270+ if name == "MODE_ID" {
271+ req.set(tCrtc, pMode, 98)
272+ }
273+ if err := m.rule(req, 0); err == nil {
274+ t.Fatal("rule accepted a modeset change without ALLOW_MODESET")
275+ }
276+ })
277+ }
278+}
279+
280+func TestAmdgpuRuleRejectsDestroyedModeBlob(t *testing.T) {
281+ m := newAmdgpuModel(t)
282+ req := &atomicReq{}
283+ req.set(tCrtc, pMode, 99)
284+ for _, c := range [...]struct{ prop, val uint32 }{{12, 200 << 16}, {13, 100 << 16}, {16, 200}, {17, 100}} {
285+ req.set(tPrimary, c.prop, uint64(c.val))
286+ }
287+ if err := m.rule(req, atomicAllowModes); err != nil {
288+ t.Fatalf("live blob rejected: %v", err)
289+ }
290+ m.mu.Lock()
291+ delete(m.live, 99)
292+ m.mu.Unlock()
293+ if err := m.rule(req, atomicAllowModes); err == nil {
294+ t.Fatal("rule accepted a destroyed MODE_ID blob")
295+ }
296+}
297+
298+func TestProtectedResumeAcceptedByAmdgpuRules(t *testing.T) {
299+ m := newAmdgpuModel(t)
300+ o, k, commits, _ := testOutputRules(t, m.rule)
301+ m.attach(k)
302+ o.modeBlob = 99
303+ o.protected = true
304+ o.overlay = &plane{id: tOverlay, props: planeProps}
305+ o.stray = []*plane{o.overlay, {id: 53, props: planeProps}}
306+ k.EXPECT().objProps(uint32(53), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, tCrtc}}, nil)
307+ if err := o.modeset(); err != nil { // resume while protected: dark, not stale
308+ t.Fatalf("amdgpu refused protected inactive modeset: %v", err)
309+ }
310+ if !o.off || len(*commits) != 1 {
311+ t.Fatalf("off=%v commits=%d", o.off, len(*commits))
312+ }
313+ if v, ok := (*commits)[0].req.value(tCrtc, pMode); !ok || v != 0 {
314+ t.Fatal("protected disable left the CRTC enabled")
315+ }
316+}
317+
318+// The legacy request is refused by the rule; the output stays registered and
319+// protected, retries with bounded backoff and never reports proof.
320+func TestProtectedRefusalKeepsOutputProtectedAndBacksOff(t *testing.T) {
321+ o, k, commits, _ := testOutputRules(t, func(r *atomicReq, flags uint32) error {
322+ if v, ok := r.value(tCrtc, pActive); ok && v == 1 {
323+ return unix.EINVAL
324+ }
325+ return nil
326+ })
327+ k.EXPECT().createBlob(mock.Anything).Return(100, nil)
328+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
329+ r := portsmocks.NewMockRenderer(t)
330+ r.EXPECT().UseTarget(0).Return().Once()
331+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
332+ err := o.protectedModeset(context.Background(), r)
333+ if err == nil || !retryableProtected(err) || !errorsIsEINVAL(err) {
334+ t.Fatalf("refusal result %v", err)
335+ }
336+ if !o.protected || !o.off || o.back != 0 || len(*commits) != 2 {
337+ t.Fatalf("protected=%v off=%v back=%d commits=%d", o.protected, o.off, o.back, len(*commits))
338+ }
339+ var enabled = true
340+ if !o.commitFailed(err, &enabled) || !enabled || !o.protectBackoffActive() {
341+ t.Fatal("refusal not retryable with backoff")
342+ }
343+ d := time.Duration(0)
344+ for range 10 {
345+ d = nextProtectBackoff(d)
346+ }
347+ if d != protectRetryMax || nextProtectBackoff(0) != protectRetryMin {
348+ t.Fatalf("backoff not bounded: %v", d)
349+ }
350+}
351+
352+func errorsIsEINVAL(err error) bool { return err != nil && errors.Is(err, unix.EINVAL) }
353+
354+// Run: activation refused twice with EINVAL. The output keeps running, stays
355+// dark, and only the later accepted black activation produces proof.
356+func TestRunProtectedActivationRetriesWithoutStoppingOrEarlyProof(t *testing.T) {
357+ var activations, refusals atomic.Int32
358+ o, k, _, _ := testOutputRules(t, func(r *atomicReq, flags uint32) error {
359+ if v, ok := r.value(tCrtc, pActive); ok && v == 1 && flags&atomicTestOnly == 0 {
360+ activations.Add(1)
361+ if refusals.Add(1) <= 2 {
362+ return unix.EINVAL
363+ }
364+ }
365+ return nil
366+ })
367+ o.cursor = nil
368+ var state atomic.Uint64
369+ state.Store(3)
370+ securityGate(t, o, &state)
371+ events := make(chan ports.SecurityBackendEvent, 32)
372+ o.SecurityEvents = events
373+ r := portsmocks.NewMockRenderer(t)
374+ buf := func() ports.DMABuf {
375+ f, w := protectionFence(t, false)
376+ w.Close()
377+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
378+ }
379+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
380+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
381+ r.EXPECT().UseTarget(mock.Anything).Return()
382+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
383+ r.EXPECT().Close().Return().Once()
384+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil)
385+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
386+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
387+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
388+ defer cancel()
389+ done := make(chan error, 1)
390+ go func() {
391+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, nil, nil, nil, make(chan ports.OutputPresented, 8), nil, nil)
392+ }()
393+ for {
394+ select {
395+ case ev := <-events:
396+ if _, ok := ev.(ports.SecurityOutputProof); !ok {
397+ continue
398+ }
399+ if n := activations.Load(); n < 3 {
400+ t.Fatalf("proof after %d activation attempts; refused ones are no evidence", n)
401+ }
402+ cancel()
403+ if err := <-done; err != nil {
404+ t.Fatalf("output stopped: %v", err)
405+ }
406+ return
407+ case err := <-done:
408+ t.Fatalf("output stopped on refused protected commit: %v", err)
409+ case <-ctx.Done():
410+ t.Fatal("no proof after retries")
411+ }
412+ }
413+}
M internal/adapters/drm/capture_session_test.go +1 -1
@@ -295,7 +295,7 @@ func TestHiddenWorkspaceChildKeepsDirectScanout(t *testing.T) {
295295 pipeline.Close(display)
296296 }
297297
298-// GO-002: child holds do not expire, so a finished child read must lift its
298+// Child holds do not expire, so a finished child read must lift its
299299 // hold even while the display's own commit never completes. The display's
300300 // Seen stays at the last safe value and the output is not stopped.
301301 func TestRunChildHoldLiftedWhileDisplayCommitStalls(t *testing.T) {
M internal/adapters/drm/frame_lifecycle.go +1 -0
@@ -26,6 +26,7 @@ type frameLifecycle struct {
2626
2727 // pendingFrame is what the pending frame commit shows.
2828 type pendingFrame struct {
29+ security ports.SecurityState // admission epoch checked again at KMS boundary
2930 frame bool
3031 queued uint64
3132 zeroCopy ports.WindowID // window shown without composition
M internal/adapters/drm/frame_submit.go +49 -4
@@ -23,9 +23,15 @@ func (e renderError) Unwrap() error { return e.err }
2323 // without the overlay; a renderError stops the output; other errors are
2424 // commit errors for commitFailed.
2525 func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, seen map[ports.WindowID]uint64, requests []ports.CaptureRequest, pipeline *capture.Pipeline) (direct bool, err error) {
26+ if !o.sceneCurrent(scene) {
27+ return false, errSecurityScene
28+ }
29+ if o.protected {
30+ return false, o.submitProtectedFrame(ctx, r, scene, surfaces, seen)
31+ }
2632 // shows is what the frame puts on screen: presentation feedback of
2733 // windows it does not draw is discarded, not presented.
28- f := pendingFrame{shows: o.shownBy(scene, seen)}
34+ f := pendingFrame{security: scene.Security, shows: o.shownBy(scene, seen)}
2935 o.wantContent(scene, surfaces)
3036 // Clean requests that cannot be served (their session is not the
3137 // scene's) are failed here, never answered from the displayed frame.
@@ -33,6 +39,9 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
3339 pipeline.Retire(scene)
3440 hiddenClean := len(clean) > 0 && scene.CaptureScene != nil
3541 if hiddenClean {
42+ if !o.sceneCurrent(scene) || o.protected {
43+ return false, errSecurityScene
44+ }
3645 // A hidden workspace: the child renderer draws it, whatever the display
3746 // does (scanout included). Failures answer the requests and never stop
3847 // the output.
@@ -50,9 +59,13 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
5059 // force composition: scanout and the overlay plane would skip them. The
5160 // hidden-workspace child needs none, so it keeps direct scanout.
5261 decision := o.decideFrame(scene, surfaces, len(normal) > 0 || len(clean) > 0 || capture.BorderVisible(scene))
62+ if !o.sceneCurrent(scene) || o.protected {
63+ decision.overlay.close()
64+ return false, errSecurityScene
65+ }
5366 if decision.fb != 0 {
5467 c := decision.content
55- if direct, err = o.commitScanoutRect(decision.fb, c, pendingFrame{shows: o.directShownBy(c.ID, seen[c.ID])}, decision.rect); direct {
68+ if direct, err = o.commitScanoutRect(decision.fb, c, pendingFrame{security: scene.Security, shows: o.directShownBy(c.ID, seen[c.ID])}, decision.rect); direct {
5669 return true, err
5770 }
5871 decision = o.composeFrame(scene, surfaces)
@@ -65,12 +78,28 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
6578 // Cost: a second composition on frames with a clean request.
6679 cdone, cerr := r.Render(pipeline.CleanScene(composed), surfaces)
6780 if cerr != nil {
81+ o.holdRead(cdone)
82+ if cdone != nil {
83+ cdone.Close()
84+ }
85+ ov.close()
6886 return false, renderError{cerr}
6987 }
88+ // A clean composition is not capture admission. Engage may have
89+ // happened while Render submitted GPU work; retain its client reads
90+ // and leave the request credit with the caller on rejection.
91+ if !o.sceneCurrent(scene) || o.protected {
92+ o.holdRead(cdone)
93+ if cdone != nil {
94+ cdone.Close()
95+ }
96+ ov.close()
97+ return false, errSecurityScene
98+ }
7099 if cdone != nil {
71100 cdone.Close()
72101 }
73- pipeline.Submit(r, clean)
102+ pipeline.SubmitScoped(scene.Security, r, clean)
74103 // Ownership moved to the worker (or an immediate failure reply).
75104 clear(clean)
76105 }
@@ -90,14 +119,30 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
90119 return false, renderError{rerr}
91120 }
92121 }
122+ if !o.sceneCurrent(scene) || o.protected {
123+ o.holdRead(done)
124+ if done != nil {
125+ done.Close()
126+ }
127+ ov.close()
128+ return false, errSecurityScene
129+ }
93130 if len(normal) > 0 {
94131 // Scanout and the overlay plane were skipped for this frame.
95132 o.log.Debug().Str("connector", o.conn.name).Int("captures", len(normal)).Msg("capture frame composed")
96- pipeline.Submit(r, normal)
133+ pipeline.SubmitScoped(scene.Security, r, normal)
97134 // Ownership moved to the worker (or an immediate failure reply).
98135 // A later commit panic must not fail the same requests again.
99136 clear(normal)
100137 }
138+ if !o.sceneCurrent(scene) || o.protected {
139+ o.holdRead(done)
140+ if done != nil {
141+ done.Close()
142+ }
143+ ov.close()
144+ return false, errSecurityScene
145+ }
101146 // The overlay buffer is on screen like a scanned-out one: it is
102147 // reported shown, so it is not released under the plane.
103148 f.queued, f.zeroCopy, f.composed = ov.buf, ov.id, true
M internal/adapters/drm/output.go +324 -34
@@ -26,6 +26,17 @@ import (
2626 // it with EBUSY (it retries soon), and an event that never comes is
2727 // bounded (stuckTimeout, then a modeset).
2828 type Output struct {
29+ // Set before Run. The backend owns instance registration and wake routing.
30+ Security ports.SessionSecurity
31+ SecurityChanges <-chan ports.SecurityState
32+ Instance ports.OutputInstance
33+ SecurityEvents chan<- ports.SecurityBackendEvent
34+ securityState ports.SecurityState
35+ securityPrepared bool
36+ securityInvalid bool
37+ inactiveOnClose bool // affirmative terminal KMS result, owned by Close
38+ runContext context.Context // bounds compositor clear waits during Run startup
39+
2940 k kms
3041 flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents
3142 crtc uint32
@@ -130,6 +141,14 @@ type Output struct {
130141 // off: the CRTC is inactive for it. Every modeset (resume, recovery)
131142 // follows wantOff, so a display turned off never lights up.
132143 wantOff, off bool
144+ // protected is an output-owned latch. Ordinary modesets must not expose
145+ // a previous desktop target while it is set; only protectedModeset may
146+ // activate a target after clearing it and waiting for the GPU.
147+ protected bool
148+ // protectBackoff is the current retry delay after a refused protected
149+ // commit; protectNotBefore the earliest next attempt (zero: none).
150+ protectBackoff time.Duration
151+ protectNotBefore time.Time
133152 // kind is how the images were made.
134153 kind imageKind
135154 // formats receives the direct scanout formats after each modeset;
@@ -383,12 +402,58 @@ func (o *Output) primaryRectProps(req *atomicReq, fb uint32, rect planeRect) {
383402 // other plane on the CRTC; needed at start and after every VT resume.
384403 // It blocks until the kernel applied it.
385404 func (o *Output) modeset() error {
405+ o.observeSecurity()
406+ return o.modesetImage(o.fbs[1-o.back], !o.wantOff && !o.protected)
407+}
408+
409+// modesetImage is called with an active protected image only by
410+// protectedModeset, after its clear fence has signalled.
411+func (o *Output) modesetImage(fb uint32, active bool) error {
412+ if o.protected && o.frame.pendingCommit() {
413+ return errProtectionPending
414+ }
415+ if o.protected && !active {
416+ if err := o.disableProtected(); err != nil {
417+ return err
418+ }
419+ o.frame.resetAfterModeset()
420+ o.planeRect = fullPlaneRect(o.Width(), o.Height())
421+ o.signalReady()
422+ return nil
423+ }
386424 blob, err := o.modeBlobFor(o.mode)
387425 if err != nil {
426+ if o.protected {
427+ return protectedCommitError{err}
428+ }
388429 return err
389430 }
390- if err := o.k.commit(o.modesetReq(blob, !o.wantOff), atomicAllowModes, 0); err != nil {
431+ var req *atomicReq
432+ if o.protected {
433+ req = o.modesetBaseReq(blob, active)
434+ if err := o.detachProtectedPlanes(req); err != nil {
435+ _ = o.k.destroyBlob(blob)
436+ return protectedCommitError{err}
437+ }
438+ if active {
439+ o.primaryProps(req, fb)
440+ }
441+ } else {
442+ req = o.modesetReq(blob, active)
443+ }
444+ // Do not admit a desktop-front modeset after an engage observed while
445+ // constructing the request. Protected activation only contains black.
446+ wasProtected := o.protected
447+ o.observeSecurity()
448+ if active && !wasProtected && o.protected {
449+ _ = o.k.destroyBlob(blob)
450+ return errSecurityScene
451+ }
452+ if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
391453 _ = o.k.destroyBlob(blob)
454+ if o.protected {
455+ return protectedCommitError{fmt.Errorf("modeset: %w", err)}
456+ }
392457 return fmt.Errorf("modeset: %w", err)
393458 }
394459 // Only now is nothing of ours pending or on screen: on failure the
@@ -403,7 +468,7 @@ func (o *Output) modeset() error {
403468 _ = o.k.destroyBlob(o.modeBlob)
404469 }
405470 o.modeBlob = blob
406- o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, o.wantOff
471+ o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, !active
407472 // The gap and the traced flip interval belonged to the old state.
408473 o.flipGapUntil, o.lastFlipAt = time.Time{}, 0
409474 o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]
@@ -413,16 +478,22 @@ func (o *Output) modeset() error {
413478 o.cursor.screen, o.cursor.flying = 0, false
414479 }
415480 o.log.Info().Str("connector", o.conn.name).Bool("off", o.off).Msg("modeset")
416- if !o.off {
417- // An inactive CRTC tells nothing about the cursor plane.
481+ if !o.off && !o.protected {
482+ // An inactive CRTC tells nothing about the cursor plane. Protected
483+ // activation keeps it detached; do not test desktop cursor images.
418484 o.testCursor()
419485 }
420486 o.sendFormats()
487+ o.signalReady()
488+ return nil
489+}
490+
491+// signalReady reports the first successful modeset once.
492+func (o *Output) signalReady() {
421493 if o.ready != nil && !o.readySent {
422494 o.ready <- nil
423495 o.readySent = true
424496 }
425- return nil
426497 }
427498
428499 // powerOff turns the display off: the CRTC goes inactive and keeps its
@@ -513,9 +584,8 @@ func (o *Output) cursorOff(err error) {
513584 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("cursor plane refused; hardware cursor off")
514585 }
515586
516-// modesetReq is the modeset of the front image with mode blob; active
517-// false keeps the display off (output power).
518-func (o *Output) modesetReq(blob uint32, active bool) *atomicReq {
587+// modesetBaseReq sets the CRTC and connector without attaching any plane.
588+func (o *Output) modesetBaseReq(blob uint32, active bool) *atomicReq {
519589 req := &atomicReq{}
520590 req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(blob))
521591 req.set(o.crtc, o.crtcProps["ACTIVE"], boolValue(active))
@@ -523,6 +593,13 @@ func (o *Output) modesetReq(blob uint32, active bool) *atomicReq {
523593 req.set(o.conn.id, o.contentProp, o.contentValues[0])
524594 req.set(o.conn.id, o.connCrtc, uint64(o.crtc))
525595 o.hdrConnectorProps(req, o.hdrOn)
596+ return req
597+}
598+
599+// modesetReq is the unlocked modeset of the front image with mode blob;
600+// active false keeps the display off (output power).
601+func (o *Output) modesetReq(blob uint32, active bool) *atomicReq {
602+ req := o.modesetBaseReq(blob, active)
526603 o.primaryProps(req, o.fbs[1-o.back])
527604 if o.cursor != nil {
528605 o.cursor.props(req, o.crtc, cursorState{})
@@ -634,10 +711,17 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p
634711 }
635712
636713 func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame, ov overlayWin, rect planeRect) error {
714+ o.observeSecurity()
715+ if o.Security != nil && (f.security != o.securityState || o.protected && !o.securityPrepared) {
716+ return errSecurityScene
717+ }
637718 cur := cursorState{}
638- if o.cursor != nil {
719+ if o.cursor != nil && !o.protected {
639720 cur = o.cursor.desired()
640721 }
722+ if o.protected {
723+ async, vrr, ov = false, false, overlayWin{}
724+ }
641725 if async && (vrr != o.vrrOn || o.cursor != nil && cur != o.cursor.applied || fence != nil && !o.asyncFence || ov.buf != o.overlayOn || rect != o.planeRect || o.contentWanted != o.contentValue) {
642726 async = false
643727 }
@@ -660,6 +744,12 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool,
660744 if fence != nil {
661745 req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd()))
662746 }
747+ // Admission linearizes at this last defensive snapshot. An already
748+ // admitted native ioctl can complete, but precedes the black proof.
749+ o.observeSecurity()
750+ if o.Security != nil && (f.security != o.securityState || o.protected && !o.securityPrepared) {
751+ return errSecurityScene
752+ }
663753 // The kernel takes its own reference on the fence.
664754 if err := o.k.commit(req, flags, o.frame.userData(userFrame)); err != nil {
665755 if !async && cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() {
@@ -735,6 +825,10 @@ func (o *Output) cursorRefused() bool {
735825
736826 // commitState commits a cursor or VRR change without a new frame.
737827 func (o *Output) commitState(vrr bool) error {
828+ o.observeSecurity()
829+ if o.protected {
830+ return errSecurityScene
831+ }
738832 req := &atomicReq{}
739833 cur := cursorState{}
740834 // A move back to the applied state holds nothing.
@@ -757,6 +851,10 @@ func (o *Output) commitState(vrr bool) error {
757851 if len(req.objs) == 0 {
758852 return nil
759853 }
854+ o.observeSecurity()
855+ if o.protected {
856+ return errSecurityScene
857+ }
760858 if err := o.k.commit(req, atomicNonblock|flipEventFlag, o.frame.userData(userState)); err != nil {
761859 if o.overlayConflict(err, o.overlayOn) {
762860 // The next frame is composed without the overlay.
@@ -864,9 +962,13 @@ func (o *Output) setAsync(on bool) {
864962 }
865963 }
866964
867-// Close restores the CRTC state found at startup, when it had a mode, and
868-// frees buffers.
965+// Close frees buffers and disables scanout. Without a security gate it
966+// retains legacy restoration of the CRTC state found at startup; a wired
967+// gate never admits saved desktop pixels, even if its snapshot is unlocked.
869968 func (o *Output) Close() {
969+ o.inactiveOnClose = false
970+ // Close may run after Run stopped, without receiving the engage wake.
971+ o.observeSecurity()
870972 req := &atomicReq{}
871973 if o.cursor != nil {
872974 o.cursor.props(req, o.crtc, cursorState{})
@@ -878,7 +980,7 @@ func (o *Output) Close() {
878980 var blob uint32
879981 p := o.primary
880982 restored := false
881- if s.crtcID != 0 && s.modeValid != 0 && s.fbID != 0 {
983+ if o.Security == nil && !o.protected && s.crtcID != 0 && s.modeValid != 0 && s.fbID != 0 {
882984 if b, err := o.modeBlobFor(s.mode); err == nil {
883985 blob, restored = b, true
884986 req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(b))
@@ -904,6 +1006,16 @@ func (o *Output) Close() {
9041006 req.set(p.id, p.prop("CRTC_ID"), 0)
9051007 req.set(o.conn.id, o.connCrtc, 0)
9061008 }
1009+ // Only the validated detachment path can certify terminal inactivity.
1010+ // Legacy nil-gate restoration/disable remains best effort, not evidence.
1011+ detached := false
1012+ if o.protected || o.Security != nil {
1013+ if err := o.detachProtectedPlanes(req); err != nil {
1014+ o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("protected close plane detachment")
1015+ } else {
1016+ detached = o.primary != nil && o.crtc != 0 && o.crtcProps["ACTIVE"] != 0
1017+ }
1018+ }
9071019 // A live metadata blob means an HDR modeset may still be on screen, even
9081020 // after a failed SDR fallback (hdrOn is then false): it is freed only once
9091021 // an SDR modeset succeeds.
@@ -916,8 +1028,12 @@ func (o *Output) Close() {
9161028 ev = o.log.Warn()
9171029 }
9181030 ev.Err(err).Str("connector", o.conn.name).Bool("hdr", hdrShown).Msg("restore crtc")
919- } else if hdrShown {
920- o.log.Info().Str("connector", o.conn.name).Msg("HDR10 off")
1031+ } else {
1032+ active, hasActive := req.value(o.crtc, o.crtcProps["ACTIVE"])
1033+ o.inactiveOnClose = !restored && detached && hasActive && active == 0
1034+ if hdrShown {
1035+ o.log.Info().Str("connector", o.conn.name).Msg("HDR10 off")
1036+ }
9211037 }
9221038 if blob != 0 {
9231039 _ = o.k.destroyBlob(blob)
@@ -940,6 +1056,12 @@ func (o *Output) Close() {
9401056 o.dropClientFBs(time.Now(), true)
9411057 }
9421058
1059+// InactiveOnClose reports affirmative terminal inactivity, not cached power
1060+// state. Call only after Close's owner has finished and a done channel (or
1061+// equivalent owner-completion synchronization) has been observed. It is not
1062+// safe to read concurrently with the output owner; no shared mutation is added.
1063+func (o *Output) InactiveOnClose() bool { return o.inactiveOnClose }
1064+
9431065 // Run renders scenes and commits them until ctx ends. active reports seat
9441066 // enable/disable. What the output shows and read is reported on presented.
9451067 func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone) (runErr error) {
@@ -956,6 +1078,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
9561078 panic(p)
9571079 }
9581080 }()
1081+ o.runContext = ctx
1082+ o.observeSecurity()
9591083 r, err := newRenderer(o.Width(), o.Height())
9601084 if err != nil {
9611085 return fmt.Errorf("create renderer: %w", err)
@@ -978,9 +1102,15 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
9781102 // While switched away they are made on the first enable.
9791103 if enabled {
9801104 if err := o.showImages(r, imagesDriver, nil); err != nil {
981- return err
1105+ // While protected, a recoverable KMS failure keeps the output
1106+ // registered and dark; the loop retries with bounded backoff.
1107+ if !o.protected || !o.commitFailed(err, &enabled) {
1108+ return err
1109+ }
1110+ }
1111+ if !o.protected {
1112+ o.probeAsync(r)
9821113 }
983- o.probeAsync(r)
9841114 }
9851115 surfaces := make(map[ports.WindowID]ports.SurfaceContent)
9861116 var scene ports.Scene
@@ -992,6 +1122,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
9921122 requests := requestStorage[:0]
9931123 ctx, cancelCaptures := context.WithCancel(ctx)
9941124 pipeline := capture.NewPipeline(ctx, captured)
1125+ pipeline.Security = o.Security
9951126 if o.NewCaptureRenderer != nil {
9961127 pipeline.EnableOffscreen(o.NewCaptureRenderer)
9971128 o.capHidden = pipeline.CapHiddenSeen
@@ -1024,6 +1155,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10241155 retryTimer := time.NewTimer(time.Hour)
10251156 vrrTimer := time.NewTimer(time.Hour)
10261157 cursorTimer := time.NewTimer(time.Hour)
1158+ protectTimer := time.NewTimer(time.Hour)
10271159 o.frame.startTimer()
10281160 gapTimer := time.NewTimer(time.Hour)
10291161 gapTimer.Stop()
@@ -1031,6 +1163,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10311163 retryTimer.Stop()
10321164 vrrTimer.Stop()
10331165 cursorTimer.Stop()
1166+ protectTimer.Stop()
1167+ defer protectTimer.Stop()
10341168 defer retryTimer.Stop()
10351169 defer vrrTimer.Stop()
10361170 defer cursorTimer.Stop()
@@ -1044,7 +1178,42 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10441178 if o.cursor != nil {
10451179 cursorWake = o.cursor.wake
10461180 }
1181+ securityChanges := o.SecurityChanges
10471182 for {
1183+ if o.observeSecurity() || haveScene && !o.sceneCurrent(scene) {
1184+ scene, haveScene, dirty = ports.Scene{}, false, false
1185+ want, cursorScale = ports.CursorChange{}, -1
1186+ if o.cursor != nil {
1187+ o.cursor.image, o.cursor.later = false, nil
1188+ }
1189+ }
1190+ if o.protected && len(requests) > 0 {
1191+ for _, q := range requests {
1192+ capture.Fail(ctx, q, fmt.Errorf("session protected"), captured)
1193+ }
1194+ clear(requests)
1195+ requests = requestStorage[:0]
1196+ }
1197+ if o.protected && enabled && !o.frame.pendingCommit() && !o.securityPrepared && !o.protectBackoffActive() {
1198+ if err := o.prepareSecurity(ctx, r); err != nil {
1199+ if ctx.Err() != nil {
1200+ return nil
1201+ }
1202+ if !o.commitFailed(err, &enabled) {
1203+ return err
1204+ }
1205+ }
1206+ // Recheck immediately: a takeover during the clear needs its own
1207+ // transaction, not another scene or wake to make progress.
1208+ if enabled && !o.frame.pendingCommit() && !o.securityPrepared && !o.protectBackoffActive() {
1209+ continue
1210+ }
1211+ }
1212+ if o.securityInvalid {
1213+ if err := o.sendSecurityInvalid(ctx); err != nil {
1214+ return nil
1215+ }
1216+ }
10481217 readWait := false
10491218 if reportDirty || o.capped {
10501219 published := false
@@ -1084,7 +1253,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10841253 }
10851254 // A held cursor move commits alone if no frame comes in time.
10861255 var cursorDue <-chan time.Time
1087- if enabled && o.cursorHeld && !o.frame.pendingCommit() {
1256+ if enabled && !o.protected && o.cursorHeld && !o.frame.pendingCommit() {
10881257 cursorTimer.Reset(max(0, cursorMinInterval-time.Since(o.lastFrame)))
10891258 cursorDue = cursorTimer.C
10901259 } else {
@@ -1098,17 +1267,37 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10981267 } else {
10991268 gapTimer.Stop()
11001269 }
1270+ // A refused protected commit is retried after a bounded backoff.
1271+ var protectDue <-chan time.Time
1272+ if o.protected && enabled && !o.securityPrepared && o.protectBackoffActive() {
1273+ protectTimer.Reset(max(0, time.Until(o.protectNotBefore)))
1274+ protectDue = protectTimer.C
1275+ } else {
1276+ protectTimer.Stop()
1277+ }
11011278 // A commit whose event never comes must not stop the output.
11021279 stuck := o.frame.wait(enabled)
11031280 stateDirty := false
11041281 select {
1282+ case _, ok := <-securityChanges:
1283+ if !ok {
1284+ securityChanges = nil
1285+ }
1286+ continue // gate is authoritative, not the queued wake payload
11051287 case <-retry:
11061288 continue
1289+ case <-protectDue:
1290+ continue
11071291 case <-stuck:
1292+ o.observeSecurity()
11081293 if !o.frame.deadlineReached(time.Now()) {
11091294 continue // waits for a fence
11101295 }
11111296 if o.expire() {
1297+ if o.protected {
1298+ o.invalidateSecurity()
1299+ continue // fresh black transaction at the top of the loop
1300+ }
11121301 if err := o.modeset(); err != nil {
11131302 if !o.commitFailed(err, &enabled) {
11141303 return fmt.Errorf("recovery modeset: %w", err)
@@ -1135,7 +1324,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
11351324 pipeline.Retire(scene)
11361325 continue
11371326 case q := <-captures:
1138- if !enabled || o.off || o.wantOff {
1327+ o.observeSecurity()
1328+ if o.protected || !enabled || o.off || o.wantOff {
11391329 capture.Fail(ctx, q, fmt.Errorf("output off"), captured)
11401330 } else if len(requests) == cap(requests) {
11411331 capture.Fail(ctx, q, fmt.Errorf("output capture batch full"), captured)
@@ -1144,22 +1334,37 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
11441334 dirty = haveScene
11451335 }
11461336 case on := <-active:
1337+ o.observeSecurity()
1338+ if o.protected {
1339+ o.invalidateSecurity()
1340+ enabled = on
1341+ if on && o.fbs[0] == 0 {
1342+ if err := o.showImages(r, imagesDriver, nil); err != nil {
1343+ if !o.commitFailed(err, &enabled) {
1344+ return err
1345+ }
1346+ }
1347+ }
1348+ continue
1349+ }
11471350 // Modeset on every enable: a fast disable+enable can coalesce to one true.
11481351 if on {
1352+ enabled = true // this enable may supersede a previous master loss
11491353 var err error
11501354 if o.fbs[0] == 0 {
11511355 err = o.showImages(r, imagesDriver, nil)
11521356 } else {
11531357 err = o.modeset()
11541358 }
1155- if lostMaster(err) {
1156- // The seat took DRM master back: wait for the next enable.
1157- o.log.Warn().Err(err).Msg("resume")
1158- continue
1159- }
11601359 if err != nil {
1161- o.log.Error().Err(err).Msg("resume")
1162- return err
1360+ if !o.commitFailed(err, &enabled) {
1361+ return err
1362+ }
1363+ continue // epoch retry or wait disabled for master reacquisition
1364+ }
1365+ o.observeSecurity()
1366+ if o.protected {
1367+ continue
11631368 }
11641369 o.probeAsync(r)
11651370 dirty = haveScene
@@ -1168,18 +1373,29 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
11681373 enabled = on
11691374 o.log.Info().Bool("enabled", on).Msg("output")
11701375 case ev := <-o.flipped:
1376+ o.observeSecurity()
11711377 if !o.completed(ev, seen) {
11721378 continue // stale event from before a modeset
11731379 }
11741380 stateDirty = true
1175- if o.cursor != nil {
1381+ if o.cursor != nil && !o.protected {
11761382 // An image that waited for a free slot loads now.
11771383 if _, err := o.cursor.flushLater(r); err != nil {
11781384 o.log.Warn().Err(err).Msg("cursor")
11791385 }
11801386 }
11811387 case s := <-scenes:
1182- if o.cursor != nil && loadCursor != nil && s.Scale != cursorScale {
1388+ o.observeSecurity()
1389+ if !o.sceneCurrent(s) {
1390+ continue
1391+ }
1392+ if o.protected {
1393+ s.CaptureScene = nil
1394+ if o.wantOff != s.Off {
1395+ o.invalidateSecurity()
1396+ }
1397+ }
1398+ if !o.protected && o.cursor != nil && loadCursor != nil && s.Scale != cursorScale {
11831399 cursorScale = s.Scale
11841400 o.setCursor(r, loadCursor, want, s.Scale)
11851401 stateDirty = true
@@ -1187,6 +1403,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
11871403 scene, haveScene, dirty = s, true, true
11881404 o.wantOff = s.Off
11891405 case c := <-cursor:
1406+ o.observeSecurity()
1407+ if o.protected {
1408+ continue
1409+ }
11901410 want = c
11911411 // Before the first scene the scale is unknown: loaded then.
11921412 if o.cursor != nil && loadCursor != nil && cursorScale > 0 {
@@ -1221,7 +1441,17 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
12211441 }
12221442 ev.Msg("stats")
12231443 }
1224- if len(requests) > 0 && (!enabled || o.off || o.wantOff) {
1444+ o.observeSecurity()
1445+ if haveScene && !o.sceneCurrent(scene) {
1446+ continue
1447+ }
1448+ if o.protected && o.wantOff != o.off {
1449+ o.invalidateSecurity()
1450+ }
1451+ if o.protected && !o.securityPrepared {
1452+ continue
1453+ }
1454+ if len(requests) > 0 && (o.protected || !enabled || o.off || o.wantOff) {
12251455 for _, q := range requests {
12261456 capture.Fail(ctx, q, fmt.Errorf("output off"), captured)
12271457 }
@@ -1248,8 +1478,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
12481478 continue
12491479 }
12501480 if !dirty || !haveScene {
1251- if stateDirty {
1481+ if stateDirty && !o.protected {
12521482 err := o.commitState(o.stateVRR())
1483+ if errors.Is(err, errSecurityScene) {
1484+ continue
1485+ }
12531486 if errors.Is(err, errOverlayDropped) {
12541487 dirty = haveScene
12551488 continue
@@ -1266,11 +1499,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
12661499 }
12671500 o.flipGapUntil = time.Time{}
12681501 }
1502+ if !o.sceneCurrent(scene) {
1503+ continue
1504+ }
12691505 start := time.Now()
12701506 direct, err := o.submitFrame(ctx, r, scene, surfaces, seen, requests, pipeline)
12711507 var fatal renderError
1272- if errors.As(err, &fatal) {
1273- // A failed render did not hand these requests to the worker.
1508+ if errors.As(err, &fatal) || errors.Is(err, errSecurityScene) {
1509+ // A failed render or epoch rejection did not hand these requests to the worker.
12741510 for _, q := range requests {
12751511 if capture.Handed(q) {
12761512 continue // already owned by the worker, or answered
@@ -1284,6 +1520,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
12841520 return err
12851521 }
12861522 if err != nil {
1523+ if errors.Is(err, errSecurityScene) {
1524+ continue
1525+ }
12871526 if errors.Is(err, errOverlayDropped) {
12881527 dirty = true
12891528 continue
@@ -1307,13 +1546,29 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
13071546 // lost mid-switch (the seat disable follows).
13081547 func (o *Output) commitFailed(err error, enabled *bool) bool {
13091548 switch {
1549+ case errors.Is(err, errSecurityScene), errors.Is(err, errProtectionPending):
1550+ return true // retry at the owner boundary, never certify stale work
13101551 case errors.Is(err, unix.EBUSY):
13111552 // Any CRTC event can unblock a refusal; absent one, retry on time.
13121553 if o.frame.busy(time.Now()) {
13131554 o.log.Info().Err(err).Str("connector", o.conn.name).Msg("commit busy")
13141555 }
1556+ if o.protected && !o.securityPrepared {
1557+ o.deferProtection() // bounded: a busy protected attempt never spins
1558+ }
1559+ return true
1560+ case retryableProtected(err):
1561+ // Protection stays latched and the output stays registered, dark.
1562+ // Never stop the output: that would remove it from Wayland while the
1563+ // session stays locked. Retry later; no proof is sent meanwhile.
1564+ o.deferProtection()
1565+ o.log.Warn().Err(err).Str("connector", o.conn.name).Dur("retry_in", o.protectBackoff).Msg("protected commit refused; output stays protected, retrying")
13151566 return true
13161567 case lostMaster(err):
1568+ o.observeSecurity()
1569+ if o.protected {
1570+ o.invalidateSecurity()
1571+ }
13171572 o.log.Warn().Err(err).Msg("commit refused")
13181573 *enabled = false
13191574 return true
@@ -1370,7 +1625,8 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
13701625
13711626 // setCursor loads a cursor for scale into the cursor images.
13721627 func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64) {
1373- if o.cursor.off {
1628+ o.observeSecurity()
1629+ if o.protected || o.cursor.off {
13741630 return
13751631 }
13761632 img, err := load(c, scale, o.cursor.Limit())
@@ -1486,6 +1742,21 @@ func (o *Output) showImages(r ports.Renderer, kind imageKind, cause error) error
14861742 r.SetHDR(float64(o.hdrSettings.SDRBrightness))
14871743 err = o.showImageKind(r, imagesDriver, nil)
14881744 }
1745+ var pe protectedCommitError
1746+ if errors.As(err, &pe) {
1747+ // A protected KMS failure is not an HDR failure: images exist and
1748+ // the owner retries the protected modeset. Never fall back to SDR
1749+ // or latch hdrFailed for it.
1750+ if created {
1751+ if oldBlob != 0 {
1752+ _ = o.k.destroyBlob(o.hdrBlob)
1753+ o.hdrBlob = oldBlob
1754+ } else {
1755+ o.hdrBlobData = meta
1756+ }
1757+ }
1758+ return err
1759+ }
14891760 if err == nil {
14901761 if created {
14911762 o.hdrBlobData = meta
@@ -1530,7 +1801,19 @@ func (o *Output) showImageKind(r ports.Renderer, kind imageKind, cause error) er
15301801 }
15311802 o.kind = got
15321803 if err = o.testModeset(); err == nil {
1533- return o.modeset()
1804+ err = o.modeset()
1805+ if err != nil && o.protected && !errors.Is(err, errSecurityScene) && !errors.Is(err, errProtectionPending) && !errors.As(err, new(protectedCommitError)) {
1806+ err = protectedCommitError{err}
1807+ }
1808+ // A protected failure is not an image refusal: never fall back to
1809+ // another image kind or SDR for it. The caller hands it to the
1810+ // bounded retry (commitFailed).
1811+ return err
1812+ }
1813+ if o.protected && !refused(err) {
1814+ // EBUSY, lost master or a blob failure of the test: the images
1815+ // are fine; retry the protected modeset later.
1816+ return protectedCommitError{err}
15341817 }
15351818 if !refused(err) || got == imagesLinear {
15361819 o.freeImages()
@@ -1558,6 +1841,9 @@ func lostMaster(err error) bool {
15581841 func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error) (imageKind, error) {
15591842 err := cause
15601843 for ; kind <= imagesLinear; kind++ {
1844+ if o.runContext != nil && o.runContext.Err() != nil {
1845+ return kind, o.runContext.Err()
1846+ }
15611847 mods := []uint64(nil)
15621848 if o.hdrOn {
15631849 for _, f := range o.primary.formats {
@@ -1611,7 +1897,11 @@ func (o *Output) exportImages(r ports.Renderer, mods []uint64) error {
16111897 var done *os.File
16121898 if done, err = r.Render(ports.Scene{Background: "#000000"}, nil); done != nil {
16131899 // The modeset is a blocking commit: wait for the clear.
1614- err = errors.Join(err, syncfile.Wait(context.Background(), done))
1900+ waitCtx := o.runContext
1901+ if waitCtx == nil {
1902+ waitCtx = context.Background()
1903+ }
1904+ err = errors.Join(err, syncfile.Wait(waitCtx, done))
16151905 done.Close()
16161906 }
16171907 }
M internal/adapters/drm/output_test.go +11 -0
@@ -44,6 +44,12 @@ func testOutput(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec) {
4444 // testOutputMu is testOutput with the mutex that guards the recorded
4545 // commits (Run commits on its goroutine).
4646 func testOutputMu(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec, *sync.Mutex) {
47+ return testOutputRules(t, nil, errs...)
48+}
49+
50+// testOutputRules is testOutputMu with an optional driver rule: a non-nil
51+// rule sees every commit (under the commit mutex) and may refuse it.
52+func testOutputRules(t *testing.T, rule func(*atomicReq, uint32) error, errs ...error) (*Output, *mockkms, *[]commitRec, *sync.Mutex) {
4753 k := newMockkms(t)
4854 var commits []commitRec
4955 commitMu := &sync.Mutex{}
@@ -56,6 +62,11 @@ func testOutputMu(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec,
5662 c.req.props = append(c.req.props, append([]propValue(nil), ps...))
5763 }
5864 commits = append(commits, c)
65+ if rule != nil {
66+ if err := rule(r, flags); err != nil {
67+ return err
68+ }
69+ }
5970 if len(errs) > 0 {
6071 err := errs[0]
6172 errs = errs[1:]
A internal/adapters/drm/protection.go +206 -0
@@ -0,0 +1,206 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/adapters/syncfile"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "golang.org/x/sys/unix"
12+)
13+
14+// errProtectionPending asks the output owner to retry after the outstanding
15+// commit ends. In particular, never queue a blocking disable behind a client
16+// acquire fence: that fence need not signal in bounded time.
17+var errProtectionPending = errors.New("output protection: commit pending")
18+
19+// detachProtectedPlanes detaches every plane owned by this output, and stray
20+// planes currently on its CRTC (not planes belonging to another output). A
21+// failed read is not evidence that a stray plane is detached.
22+func (o *Output) detachProtectedPlanes(req *atomicReq) error {
23+ var err error
24+ owned := make(map[uint32]bool)
25+ detach := func(p *plane) {
26+ if p == nil {
27+ return
28+ }
29+ // atomicReq.set silently skips zero property IDs. Partial cached
30+ // metadata therefore cannot prove detachment of a previous desktop.
31+ if p.id == 0 || p.prop("FB_ID") == 0 || p.prop("CRTC_ID") == 0 {
32+ err = errors.Join(err, fmt.Errorf("protected plane %d: incomplete detachment properties", p.id))
33+ return
34+ }
35+ req.set(p.id, p.prop("FB_ID"), 0)
36+ req.set(p.id, p.prop("CRTC_ID"), 0)
37+ }
38+ detachOwned := func(p *plane) {
39+ if p != nil {
40+ owned[p.id] = true
41+ detach(p)
42+ }
43+ }
44+ detachOwned(o.primary)
45+ if o.cursor != nil {
46+ detachOwned(o.cursor.plane)
47+ }
48+ detachOwned(o.overlay)
49+ for _, p := range o.stray {
50+ // pickOverlay leaves the owned overlay in stray. Do not turn a
51+ // redundant query failure into refusal of known-owned detachment.
52+ if owned[p.id] {
53+ continue
54+ }
55+ props, readErr := o.k.objProps(p.id, objPlane)
56+ if readErr != nil {
57+ err = errors.Join(err, fmt.Errorf("read protected plane %d: %w", p.id, readErr))
58+ continue
59+ }
60+ crtc, ok := props["CRTC_ID"]
61+ if !ok {
62+ err = errors.Join(err, fmt.Errorf("protected plane %d: missing CRTC_ID", p.id))
63+ continue
64+ }
65+ if crtc[1] == uint64(o.crtc) {
66+ detach(p)
67+ }
68+ }
69+ return err
70+}
71+
72+// disableProtected is the atomic disable of a protected output. It is a full
73+// disable: ACTIVE=0, MODE_ID=0, connector CRTC_ID=0, every plane detached, VRR
74+// and HDR connector state off. amdgpu (amdgpu_dm, via
75+// drm_atomic_helper_check_crtc_primary_plane) rejects with EINVAL a CRTC that
76+// stays enabled (MODE_ID set) without its primary plane, even with ACTIVE=0,
77+// so "inactive but every plane detached" is not a valid request. The other
78+// valid shape, ACTIVE=0 with the primary still attached, would keep a desktop
79+// framebuffer referenced by the CRTC until a later activation replaced it. A
80+// full disable leaves nothing of the desktop attached and needs no blob; the
81+// activation that follows is a full modeset either way. It is the same
82+// request as Close's disable, which this hardware accepts.
83+func (o *Output) disableProtected() error {
84+ req := &atomicReq{}
85+ req.set(o.crtc, o.crtcProps["ACTIVE"], 0)
86+ req.set(o.crtc, o.crtcProps["MODE_ID"], 0)
87+ req.set(o.crtc, o.vrrProp, 0)
88+ req.set(o.conn.id, o.connCrtc, 0)
89+ o.hdrConnectorProps(req, false)
90+ if err := o.detachProtectedPlanes(req); err != nil {
91+ return protectedCommitError{err}
92+ }
93+ if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
94+ return protectedCommitError{fmt.Errorf("disable protected output: %w", err)}
95+ }
96+ if o.modeBlob != 0 {
97+ _ = o.k.destroyBlob(o.modeBlob) // MODE_ID is 0 now: nothing references it
98+ o.modeBlob = 0
99+ }
100+ o.off, o.vrrOn, o.vrrGame, o.overlayOn = true, false, false, 0
101+ o.sendFormats()
102+ o.shown, o.queued = 0, 0
103+ if o.cursor != nil {
104+ o.cursor.applied = cursorState{}
105+ o.cursor.screen, o.cursor.flying = 0, false
106+ }
107+ return nil
108+}
109+
110+// protectedCommitError marks a recoverable failure of the protected disable or
111+// activation: a refused KMS commit, unverifiable plane state, a mode blob or a
112+// clear fence that failed. The output owner keeps the output, stays protected
113+// and retries with bounded backoff instead of stopping (see commitFailed). A
114+// renderer error is not wrapped: it stays fatal.
115+type protectedCommitError struct{ error }
116+
117+func (e protectedCommitError) Unwrap() error { return e.error }
118+
119+// retryableProtected reports a refused protected KMS commit worth retrying
120+// later. Busy and lost-master errors keep their own handling.
121+func retryableProtected(err error) bool {
122+ var pe protectedCommitError
123+ return errors.As(err, &pe) && !lostMaster(err) && !errors.Is(err, unix.EBUSY)
124+}
125+
126+const (
127+ protectRetryMin = 50 * time.Millisecond
128+ protectRetryMax = 2 * time.Second
129+)
130+
131+// deferProtection schedules the next protected attempt after a refusal.
132+func (o *Output) deferProtection() {
133+ o.protectBackoff = nextProtectBackoff(o.protectBackoff)
134+ o.protectNotBefore = time.Now().Add(o.protectBackoff)
135+}
136+
137+// protectBackoffActive reports a pending retry delay.
138+func (o *Output) protectBackoffActive() bool {
139+ return !o.protectNotBefore.IsZero() && time.Now().Before(o.protectNotBefore)
140+}
141+
142+// protectSucceeded clears the retry state.
143+func (o *Output) protectSucceeded() { o.protectBackoff, o.protectNotBefore = 0, time.Time{} }
144+
145+// nextProtectBackoff doubles the delay, bounded by protectRetryMax.
146+func nextProtectBackoff(d time.Duration) time.Duration {
147+ if d < protectRetryMin {
148+ return protectRetryMin
149+ }
150+ return min(2*d, protectRetryMax)
151+}
152+
153+// protectedModeset is an internal output-owner primitive, not session-lock
154+// readiness or a protocol entry point. It latches protection even on failure.
155+// First stop scanout with a blocking full disable, then clear an unscanned
156+// target and wait for the clear to complete before activating that exact FB.
157+func (o *Output) protectedModeset(ctx context.Context, r ports.Renderer) error {
158+ o.protected = true
159+ if o.frame.pendingCommit() {
160+ return errProtectionPending
161+ }
162+ if err := ctx.Err(); err != nil {
163+ return err
164+ }
165+ if err := o.disableProtected(); err != nil {
166+ return err
167+ }
168+ // Do not dropRead here: an earlier render whose KMS commit failed may
169+ // still read client buffers. KMS detachment is not a GPU completion fence.
170+ if err := ctx.Err(); err != nil {
171+ return err
172+ }
173+ r.UseTarget(o.back)
174+ fence, err := r.Render(ports.Scene{Seq: 0, Background: "#000000"}, nil)
175+ if err != nil {
176+ if fence != nil {
177+ fence.Close()
178+ }
179+ return fmt.Errorf("clear protected output: %w", err)
180+ }
181+ if fence != nil {
182+ err := syncfile.Wait(ctx, fence)
183+ fence.Close()
184+ if err != nil {
185+ err = fmt.Errorf("wait protected clear: %w", err)
186+ if ctx.Err() != nil {
187+ return err
188+ }
189+ return protectedCommitError{err}
190+ }
191+ }
192+ if err := ctx.Err(); err != nil {
193+ return err
194+ }
195+ if o.wantOff {
196+ o.protectSucceeded()
197+ o.signalReady()
198+ return nil // the blocking disable is the proof; do not activate or flip
199+ }
200+ if err := o.modesetImage(o.fbs[o.back], true); err != nil {
201+ return err
202+ }
203+ o.back = 1 - o.back
204+ o.protectSucceeded()
205+ return nil
206+}
A internal/adapters/drm/protection_close_test.go +26 -0
@@ -0,0 +1,26 @@
1+package drm
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/mock"
7+ "golang.org/x/sys/unix"
8+)
9+
10+func TestProtectedCloseUnreadableStrayStillDisablesWithoutRestore(t *testing.T) {
11+ o, k, commits := testOutput(t)
12+ o.protected = true
13+ o.saved = modeCrtc{crtcID: tCrtc, fbID: 123, modeValid: 1, mode: o.mode}
14+ o.stray = []*plane{{id: 53, props: planeProps}, {id: 54, props: planeProps}}
15+ k.EXPECT().objProps(uint32(53), uint32(objPlane)).Return(nil, unix.EACCES).Once()
16+ k.EXPECT().objProps(uint32(54), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, tCrtc}}, nil).Once()
17+ k.EXPECT().rmFB(mock.Anything).Return(nil)
18+ o.Close() // best effort, no readiness or protection ACK
19+ if len(*commits) != 1 || !o.protected {
20+ t.Fatal("query failure skipped disable or dropped protection")
21+ }
22+ checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor, 54)
23+ if _, ok := (*commits)[0].req.value(53, pFB); ok {
24+ t.Fatal("claimed ownership of unreadable stray")
25+ }
26+}
A internal/adapters/drm/protection_fence_test.go +96 -0
@@ -0,0 +1,96 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "testing"
8+ "time"
9+
10+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11+ "github.com/bnema/neferwl/internal/ports"
12+ "github.com/stretchr/testify/mock"
13+)
14+
15+func TestProtectedModesetWaitsForClearFence(t *testing.T) {
16+ o, k, commits, mu := testOutputMu(t)
17+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
18+ r := portsmocks.NewMockRenderer(t)
19+ f, w := protectionFence(t, false)
20+ rendered := make(chan struct{})
21+ r.EXPECT().UseTarget(0).Return().Once()
22+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
23+ close(rendered)
24+ return f, nil
25+ }).Once()
26+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
27+ defer cancel()
28+ done := make(chan error, 1)
29+ go func() { done <- o.protectedModeset(ctx, r) }()
30+ <-rendered
31+ // The fence remains unreadable; activation must not be queued behind it.
32+ select {
33+ case err := <-done:
34+ t.Fatalf("returned before clear fence signalled: %v", err)
35+ case <-time.After(20 * time.Millisecond):
36+ }
37+ mu.Lock()
38+ n := len(*commits)
39+ mu.Unlock()
40+ if n != 1 {
41+ t.Errorf("committed activation before fence signalled: %d commits", n)
42+ }
43+ if _, err := w.Write([]byte{1}); err != nil {
44+ t.Fatal(err)
45+ }
46+ if err := <-done; err != nil {
47+ t.Fatal(err)
48+ }
49+ if len(*commits) != 2 || o.back != 1 || o.off {
50+ t.Fatal("signalled clear did not activate")
51+ }
52+}
53+
54+func TestProtectedModesetPreservesUncommittedGPUReads(t *testing.T) {
55+ for _, fail := range []bool{false, true} {
56+ t.Run(map[bool]string{false: "success", true: "clear-failure"}[fail], func(t *testing.T) {
57+ o, k, _ := testOutput(t)
58+ clientFence, _ := protectionFence(t, false)
59+ o.holdRead(clientFence)
60+ owned := o.readFences[0]
61+ r := portsmocks.NewMockRenderer(t)
62+ r.EXPECT().UseTarget(0).Return().Once()
63+ clearFence, _ := protectionFence(t, true)
64+ var renderErr error
65+ if fail {
66+ renderErr = errors.New("clear failed")
67+ } else {
68+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
69+ }
70+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(clearFence, renderErr).Once()
71+ err := o.protectedModeset(context.Background(), r)
72+ if (err != nil) != fail {
73+ t.Fatalf("clear result: %v", err)
74+ }
75+ if o.readDone() || len(o.readFences) != 1 || o.readFences[0] != owned {
76+ t.Fatal("KMS detachment released an uncommitted GPU read")
77+ }
78+ if _, err := owned.Stat(); err != nil {
79+ t.Fatalf("owned client read fence closed: %v", err)
80+ }
81+ o.dropRead() // test cleanup only
82+ })
83+ }
84+}
85+
86+func TestProtectedCloseCommitFailureDoesNotRestoreDesktop(t *testing.T) {
87+ o, k, commits := testOutput(t, errors.New("no DRM master"))
88+ o.protected = true
89+ o.saved = modeCrtc{crtcID: tCrtc, fbID: 123, modeValid: 1, mode: o.mode}
90+ k.EXPECT().rmFB(mock.Anything).Return(nil)
91+ o.Close()
92+ if len(*commits) != 1 || !o.protected {
93+ t.Fatal("failed close dropped protection or retried restore")
94+ }
95+ checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor)
96+}
A internal/adapters/drm/protection_retry_test.go +293 -0
@@ -0,0 +1,293 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "sync/atomic"
8+ "testing"
9+ "time"
10+
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+ "github.com/stretchr/testify/mock"
14+ "golang.org/x/sys/unix"
15+)
16+
17+// runProtectedStartup runs o with the session already protected. k and r are
18+// set up with permissive renderer/KMS expectations; inject holds the failure
19+// under test.
20+func runProtectedStartup(t *testing.T, o *Output, k *mockkms, renders func() (*os.File, error)) (<-chan ports.SecurityBackendEvent, <-chan error, context.CancelFunc) {
21+ t.Helper()
22+ o.cursor = nil
23+ var state atomic.Uint64
24+ state.Store(3)
25+ securityGate(t, o, &state)
26+ events := make(chan ports.SecurityBackendEvent, 32)
27+ o.SecurityEvents = events
28+ r := portsmocks.NewMockRenderer(t)
29+ buf := func() ports.DMABuf {
30+ f, w := protectionFence(t, false)
31+ w.Close()
32+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
33+ }
34+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
35+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
36+ r.EXPECT().UseTarget(mock.Anything).Return().Maybe()
37+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
38+ return renders()
39+ }).Maybe()
40+ r.EXPECT().Close().Return().Once()
41+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Maybe()
42+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
43+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
44+ done := make(chan error, 1)
45+ go func() {
46+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, nil, nil, nil, make(chan ports.OutputPresented, 8), nil, nil)
47+ }()
48+ return events, done, cancel
49+}
50+
51+func waitProof(t *testing.T, events <-chan ports.SecurityBackendEvent, done <-chan error) {
52+ t.Helper()
53+ timeout := time.After(4 * time.Second)
54+ for {
55+ select {
56+ case ev := <-events:
57+ if _, ok := ev.(ports.SecurityOutputProof); ok {
58+ return
59+ }
60+ case err := <-done:
61+ t.Fatalf("output stopped while protected: %v", err)
62+ case <-timeout:
63+ t.Fatal("no proof after retry")
64+ }
65+ }
66+}
67+
68+// A protected startup whose first KMS attempt is refused (EBUSY,
69+// or a swallowed EINVAL refusal) keeps the output, backs off from that first
70+// refusal, and proves only after the accepted retry.
71+func TestProtectedStartupRefusalRetriesWithBackoffFromFirstRefusal(t *testing.T) {
72+ for _, errno := range []error{unix.EBUSY, unix.EINVAL} {
73+ t.Run(errno.Error(), func(t *testing.T) {
74+ // Commits: test-only modeset, then the blocking disable (refused).
75+ o, k, commits, mu := testOutputMu(t, nil, errno)
76+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Maybe()
77+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
78+ events, done, cancel := runProtectedStartup(t, o, k, func() (*os.File, error) { return nil, nil })
79+ defer cancel()
80+ waitProof(t, events, done)
81+ cancel()
82+ if err := <-done; err != nil {
83+ t.Fatalf("output stopped: %v", err)
84+ }
85+ mu.Lock()
86+ defer mu.Unlock()
87+ var real []commitRec
88+ for _, c := range *commits {
89+ if c.flags&atomicTestOnly == 0 {
90+ real = append(real, c)
91+ }
92+ }
93+ if len(real) < 3 {
94+ t.Fatalf("real commits %d", len(real))
95+ }
96+ if gap := real[1].at.Sub(real[0].at); gap < protectRetryMin {
97+ t.Fatalf("retry after %v of the first refusal, want >= %v", gap, protectRetryMin)
98+ }
99+ })
100+ }
101+}
102+
103+// Lost master at protected startup keeps the output running, dark,
104+// without proof; it follows the lost-master invalidate path.
105+func TestProtectedStartupLostMasterStaysRunningWithoutProof(t *testing.T) {
106+ for _, errno := range []error{unix.EACCES, unix.EPERM} {
107+ t.Run(errno.Error(), func(t *testing.T) {
108+ o, k, _, _ := testOutputMu(t, nil, errno)
109+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Maybe()
110+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
111+ events, done, cancel := runProtectedStartup(t, o, k, func() (*os.File, error) { return nil, nil })
112+ defer cancel()
113+ select {
114+ case err := <-done:
115+ t.Fatalf("output stopped on lost master: %v", err)
116+ case ev := <-events:
117+ if _, ok := ev.(ports.SecurityOutputProof); ok {
118+ t.Fatal("proof after lost master")
119+ }
120+ case <-time.After(300 * time.Millisecond):
121+ }
122+ cancel()
123+ if err := <-done; err != nil {
124+ t.Fatalf("output stopped: %v", err)
125+ }
126+ if o.securityPrepared || !o.protected {
127+ t.Fatalf("prepared=%v protected=%v", o.securityPrepared, o.protected)
128+ }
129+ })
130+ }
131+}
132+
133+// Startup failures of detachment, mode blob or clear fence keep
134+// the output and recover through the bounded retry; proof only after success.
135+func TestProtectedStartupRecoverableFailuresRetry(t *testing.T) {
136+ for _, stage := range []string{"detach", "blob", "fence"} {
137+ t.Run(stage, func(t *testing.T) {
138+ o, k, _, _ := testOutputMu(t)
139+ var failures, blobs, objs, renders atomic.Int32
140+ switch stage {
141+ case "detach":
142+ o.stray = []*plane{{id: 53, props: planeProps}}
143+ k.EXPECT().objProps(uint32(53), uint32(objPlane)).RunAndReturn(func(uint32, uint32) (map[string][2]uint64, error) {
144+ // 1: test modeset request (error ignored); 2: first detach.
145+ if objs.Add(1) == 2 {
146+ failures.Add(1)
147+ return nil, unix.EIO
148+ }
149+ return map[string][2]uint64{"CRTC_ID": {pCrtcID, 0}}, nil
150+ }).Maybe()
151+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Maybe()
152+ case "blob":
153+ k.EXPECT().createBlob(mock.Anything).RunAndReturn(func([]byte) (uint32, error) {
154+ if blobs.Add(1) == 2 { // 1: test modeset; 2: activation
155+ failures.Add(1)
156+ return 0, unix.ENOMEM
157+ }
158+ return 99, nil
159+ }).Maybe()
160+ case "fence":
161+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Maybe()
162+ }
163+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
164+ events, done, cancel := runProtectedStartup(t, o, k, func() (*os.File, error) {
165+ // Renders 1-2 initialise the images; 3 is the first protected clear.
166+ if stage != "fence" || renders.Add(1) != 3 {
167+ return nil, nil
168+ }
169+ failures.Add(1)
170+ f, w := protectionFence(t, false)
171+ w.Close() // HUP without POLLIN is an error, not completion
172+ return f, nil
173+ })
174+ defer cancel()
175+ waitProof(t, events, done)
176+ if failures.Load() == 0 {
177+ t.Fatal("failure was not injected before proof")
178+ }
179+ cancel()
180+ if err := <-done; err != nil {
181+ t.Fatalf("output stopped: %v", err)
182+ }
183+ })
184+ }
185+}
186+
187+// Recoverable preparation failures are retryable protected errors
188+// handled by commitFailed with backoff; a renderer error stays fatal.
189+func TestProtectedPreparationFailuresRetryableRenderFatal(t *testing.T) {
190+ boom := errors.New("render broke")
191+ for _, stage := range []string{"stray-read", "stray-missing", "blob", "fence", "render"} {
192+ t.Run(stage, func(t *testing.T) {
193+ o, k, _ := testOutput(t)
194+ r := portsmocks.NewMockRenderer(t)
195+ switch stage {
196+ case "stray-read", "stray-missing":
197+ o.stray = []*plane{{id: 53, props: planeProps}}
198+ var err error
199+ props := map[string][2]uint64{}
200+ if stage == "stray-read" {
201+ err = unix.EIO
202+ }
203+ k.EXPECT().objProps(uint32(53), uint32(objPlane)).Return(props, err).Once()
204+ case "blob":
205+ k.EXPECT().createBlob(mock.Anything).Return(0, unix.ENOMEM).Once()
206+ r.EXPECT().UseTarget(0).Return().Once()
207+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
208+ case "fence":
209+ f, w := protectionFence(t, false)
210+ w.Close()
211+ r.EXPECT().UseTarget(0).Return().Once()
212+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(f, nil).Once()
213+ case "render":
214+ r.EXPECT().UseTarget(0).Return().Once()
215+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, boom).Once()
216+ }
217+ o.protected = true
218+ err := o.protectedModeset(context.Background(), r)
219+ if err == nil {
220+ t.Fatal("failure reported success")
221+ }
222+ enabled := true
223+ handled := o.commitFailed(err, &enabled)
224+ if stage == "render" {
225+ if retryableProtected(err) || handled {
226+ t.Fatalf("renderer error must stay fatal: retryable=%v handled=%v", retryableProtected(err), handled)
227+ }
228+ return
229+ }
230+ if !retryableProtected(err) || !handled || !enabled || !o.protectBackoffActive() || o.securityPrepared {
231+ t.Fatalf("not retried with backoff: %v", err)
232+ }
233+ })
234+ }
235+}
236+
237+// A protected EBUSY/lost-master/detach error from showImages is a
238+// protected KMS error: it never triggers the HDR->SDR fallback or hdrFailed.
239+func TestProtectedShowImagesHDRKeepsHDRForKMSErrors(t *testing.T) {
240+ for _, errno := range []error{unix.EBUSY, unix.EACCES, unix.EPERM, unix.EINVAL, nil} {
241+ name := "detach"
242+ if errno != nil {
243+ name = errno.Error()
244+ }
245+ t.Run(name, func(t *testing.T) {
246+ var errs []error
247+ if errno != nil {
248+ errs = []error{nil, errno} // HDR test commit, then the disable
249+ }
250+ o, k, _ := testOutput(t, errs...)
251+ o.cursor = nil
252+ o.protected = true
253+ o.hdr = hdrCapability{Capable: true}
254+ o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: 203}
255+ o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
256+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 0}}
257+ if errno == nil {
258+ o.primary.props = map[string]uint32{"CRTC_ID": pCrtcID} // incomplete: detach fails
259+ }
260+ r := portsmocks.NewMockRenderer(t)
261+ buf := func() ports.DMABuf {
262+ f, w := protectionFence(t, false)
263+ w.Close()
264+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
265+ }
266+ r.EXPECT().SetHDR(float64(203)).Return().Once()
267+ r.EXPECT().UseTarget(mock.Anything).Return().Maybe()
268+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Maybe()
269+ r.EXPECT().ExportTargets(2, []uint64{0}).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
270+ k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil).Maybe()
271+ k.EXPECT().createBlob(mock.Anything).Return(321, nil).Maybe()
272+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
273+ err := o.showImages(r, imagesDriver, nil)
274+ if err == nil {
275+ t.Fatal("protected KMS failure reported success")
276+ }
277+ enabled := true
278+ lost := errno != nil && lostMaster(errno)
279+ if !o.commitFailed(err, &enabled) {
280+ t.Fatalf("protected failure not handled: %v", err)
281+ }
282+ if o.hdrFailed || !o.hdrOn {
283+ t.Fatalf("HDR fell back: failed=%v on=%v", o.hdrFailed, o.hdrOn)
284+ }
285+ if lost == enabled {
286+ t.Fatalf("lost master=%v enabled=%v", lost, enabled)
287+ }
288+ if !o.protected || o.securityPrepared {
289+ t.Fatal("protection dropped or certified")
290+ }
291+ })
292+ }
293+}
A internal/adapters/drm/protection_review_test.go +130 -0
@@ -0,0 +1,130 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "maps"
7+ "os"
8+ "testing"
9+
10+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11+ "github.com/bnema/neferwl/internal/ports"
12+ "github.com/stretchr/testify/mock"
13+ "golang.org/x/sys/unix"
14+)
15+
16+func TestProtectedModesetPartialPlaneMetadataFailsClosed(t *testing.T) {
17+ for _, role := range []string{"primary", "cursor", "overlay", "stray"} {
18+ for _, missing := range []string{"FB_ID", "CRTC_ID"} {
19+ t.Run(role+"/"+missing, func(t *testing.T) {
20+ o, k, commits := testOutput(t)
21+ props := maps.Clone(planeProps)
22+ props[missing] = 0
23+ p := &plane{id: tOverlay, props: props, crtc: tCrtc}
24+ switch role {
25+ case "primary":
26+ o.primary.props = props
27+ case "cursor":
28+ o.cursor.plane.props = props
29+ case "overlay":
30+ o.overlay = p
31+ o.stray = []*plane{p} // pickOverlay keeps the plane in stray.
32+ case "stray":
33+ o.stray = []*plane{p} // old master's overlay may still be here.
34+ k.EXPECT().objProps(uint32(tOverlay), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, tCrtc}}, nil).Once()
35+ }
36+ r := portsmocks.NewMockRenderer(t) // no GPU calls permitted
37+ if err := o.protectedModeset(context.Background(), r); err == nil {
38+ t.Fatal("partial metadata reported protection success")
39+ }
40+ if !o.protected || len(*commits) != 0 || o.off || o.back != 0 {
41+ t.Fatal("unverified detachment committed, flipped or claimed off")
42+ }
43+ })
44+ }
45+ }
46+}
47+
48+func TestProtectedModesetSkipsOwnedStrayDuplicates(t *testing.T) {
49+ o, k, commits := testOutput(t)
50+ o.overlay = &plane{id: tOverlay, props: planeProps}
51+ o.stray = []*plane{o.overlay, o.primary, o.cursor.plane}
52+ // Even if a fresh query would fail, cached owned-plane IDs suffice.
53+ // No owned plane may be queried, during disable or activation.
54+ k.EXPECT().objProps(mock.Anything, uint32(objPlane)).Return(nil, unix.EACCES).Maybe()
55+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
56+ r := portsmocks.NewMockRenderer(t)
57+ r.EXPECT().UseTarget(0).Return().Once()
58+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
59+ if err := o.protectedModeset(context.Background(), r); err != nil {
60+ t.Fatal(err)
61+ }
62+ k.AssertNotCalled(t, "objProps", mock.Anything, mock.Anything)
63+ if len(*commits) != 2 {
64+ t.Fatalf("commits %d", len(*commits))
65+ }
66+ checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor, tOverlay)
67+}
68+
69+func TestProtectedModesetDisableSendsInactiveFormats(t *testing.T) {
70+ for _, stage := range []string{"disable-failure", "clear-failure", "cancel", "off", "activate"} {
71+ t.Run(stage, func(t *testing.T) {
72+ var failures []error
73+ if stage == "disable-failure" {
74+ failures = []error{unix.EACCES}
75+ }
76+ o, k, _ := testOutput(t, failures...)
77+ o.device, o.hdrOn = 42, true
78+ feedback := make(chan ports.OutputFormats, 2)
79+ o.formats = feedback
80+ r := portsmocks.NewMockRenderer(t)
81+ ctx, cancel := context.WithCancel(context.Background())
82+ defer cancel()
83+ if stage != "disable-failure" {
84+ r.EXPECT().UseTarget(0).Return().Once()
85+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
86+ // Disable feedback must precede GPU writes, not just return.
87+ select {
88+ case f := <-feedback:
89+ if f.Output != o.conn.name || f.Device != 42 || len(f.Formats) != 0 || f.HDR != nil || !o.off {
90+ t.Fatalf("not inactive feedback: %+v off=%v", f, o.off)
91+ }
92+ default:
93+ t.Fatal("successful disable sent no inactive feedback")
94+ }
95+ if stage == "clear-failure" {
96+ return nil, errors.New("clear failed")
97+ }
98+ if stage == "cancel" {
99+ cancel()
100+ }
101+ return nil, nil
102+ }).Once()
103+ }
104+ if stage == "off" {
105+ o.wantOff = true
106+ }
107+ if stage == "activate" {
108+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
109+ }
110+ err := o.protectedModeset(ctx, r)
111+ wantErr := stage == "disable-failure" || stage == "clear-failure" || stage == "cancel"
112+ if (err != nil) != wantErr {
113+ t.Fatalf("result %v", err)
114+ }
115+ if stage == "activate" {
116+ select {
117+ case f := <-feedback:
118+ if f.HDR == nil || o.off {
119+ t.Fatalf("activation did not send active feedback: %+v", f)
120+ }
121+ default:
122+ t.Fatal("missing active feedback after activation")
123+ }
124+ }
125+ if len(feedback) != 0 {
126+ t.Fatal("unexpected feedback without a successful state transition")
127+ }
128+ })
129+ }
130+}
A internal/adapters/drm/protection_test.go +289 -0
@@ -0,0 +1,289 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "testing"
8+ "time"
9+
10+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11+ "github.com/bnema/neferwl/internal/ports"
12+ "github.com/stretchr/testify/mock"
13+ "golang.org/x/sys/unix"
14+)
15+
16+func protectionFence(t *testing.T, signalled bool) (*os.File, *os.File) {
17+ t.Helper()
18+ f, w, err := os.Pipe()
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ t.Cleanup(func() { f.Close(); w.Close() })
23+ if signalled {
24+ if _, err := w.Write([]byte{1}); err != nil {
25+ t.Fatal(err)
26+ }
27+ }
28+ return f, w
29+}
30+
31+func checkProtectedDisable(t *testing.T, c commitRec, planes ...uint32) {
32+ t.Helper()
33+ if c.flags != atomicAllowModes || c.user != 0 {
34+ t.Fatalf("not blocking modeset: flags=%#x user=%d", c.flags, c.user)
35+ }
36+ if active, ok := c.req.value(tCrtc, pActive); !ok || active != 0 {
37+ t.Fatalf("CRTC still active: %d %v", active, ok)
38+ }
39+ // Full disable: no CRTC enabled without its primary plane (amdgpu rule).
40+ if v, ok := c.req.value(tCrtc, pMode); !ok || v != 0 {
41+ t.Fatalf("CRTC still enabled: MODE_ID %d %v", v, ok)
42+ }
43+ if v, ok := c.req.value(tConn, pConnCrtc); !ok || v != 0 {
44+ t.Fatalf("connector still routed: CRTC_ID %d %v", v, ok)
45+ }
46+ for _, p := range planes {
47+ for _, prop := range []uint32{pFB, pCrtcID} {
48+ if v, ok := c.req.value(p, prop); !ok || v != 0 {
49+ t.Fatalf("plane %d prop %d still attached: %d %v", p, prop, v, ok)
50+ }
51+ }
52+ }
53+}
54+
55+// Adapted from the parent's draft: detach, clear, wait, then activate only
56+// the cleared target, with no nonblocking commit or desktop cursor test.
57+func TestProtectedModesetClearsBeforeActivation(t *testing.T) {
58+ o, k, commits := testOutput(t)
59+ o.back = 1 // exercise the other target, not just startup's back=0
60+ o.overlay = &plane{id: tOverlay, props: planeProps}
61+ o.stray = []*plane{o.overlay, {id: 53, props: planeProps}, {id: 54, props: planeProps}}
62+ k.EXPECT().objProps(uint32(53), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, tCrtc}}, nil)
63+ k.EXPECT().objProps(uint32(54), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, 99}}, nil)
64+ r := portsmocks.NewMockRenderer(t)
65+ r.EXPECT().UseTarget(1).Return().Once()
66+ f, _ := protectionFence(t, true)
67+ // Blob creation is part of activation and must occur after fence close.
68+ k.EXPECT().createBlob(mock.Anything).RunAndReturn(func([]byte) (uint32, error) {
69+ if _, err := f.Stat(); !errors.Is(err, os.ErrClosed) {
70+ t.Fatalf("activation before clear fence close: %v", err)
71+ }
72+ return 99, nil
73+ }).Once()
74+ r.EXPECT().Render(ports.Scene{Seq: 0, Background: "#000000"}, map[ports.WindowID]ports.SurfaceContent(nil)).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
75+ if len(*commits) != 1 {
76+ t.Fatalf("clear before disable: %d commits", len(*commits))
77+ }
78+ checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor, tOverlay, 53)
79+ if _, ok := (*commits)[0].req.value(54, pFB); ok {
80+ t.Fatal("detached another output's plane")
81+ }
82+ return f, nil
83+ }).Once()
84+ if err := o.protectedModeset(context.Background(), r); err != nil {
85+ t.Fatal(err)
86+ }
87+ if len(*commits) != 2 {
88+ t.Fatalf("commits %d", len(*commits))
89+ }
90+ last := (*commits)[1]
91+ if last.flags != atomicAllowModes || last.user != 0 {
92+ t.Fatal("activation was not blocking")
93+ }
94+ if fb, _ := last.req.value(tPrimary, pFB); fb != 71 {
95+ t.Fatalf("activated fb %d, want cleared 71", fb)
96+ }
97+ if active, _ := last.req.value(tCrtc, pActive); active != 1 {
98+ t.Fatal("not activated")
99+ }
100+ if o.back != 0 || !o.protected || o.off {
101+ t.Fatalf("back=%d protected=%v off=%v", o.back, o.protected, o.off)
102+ }
103+ if _, err := f.Stat(); !errors.Is(err, os.ErrClosed) {
104+ t.Fatalf("fence not closed: %v", err)
105+ }
106+}
107+
108+func TestProtectedModesetFailureNeverActivates(t *testing.T) {
109+ for _, stage := range []string{"disable", "render", "fence", "blob", "activate"} {
110+ t.Run(stage, func(t *testing.T) {
111+ boom := errors.New("refused")
112+ var failures []error
113+ if stage == "disable" {
114+ failures = []error{unix.EACCES}
115+ }
116+ if stage == "activate" {
117+ failures = []error{nil, unix.EPERM}
118+ }
119+ o, k, commits := testOutput(t, failures...)
120+ r := portsmocks.NewMockRenderer(t)
121+ var fence *os.File
122+ if stage != "disable" {
123+ r.EXPECT().UseTarget(0).Return().Once()
124+ f, w := protectionFence(t, stage != "fence")
125+ fence = f
126+ if stage == "fence" {
127+ w.Close() // HUP without POLLIN is not fence completion.
128+ }
129+ var renderErr error
130+ if stage == "render" {
131+ renderErr = boom
132+ }
133+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(fence, renderErr).Once()
134+ }
135+ if stage == "activate" {
136+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
137+ k.EXPECT().destroyBlob(uint32(99)).Return(nil).Once()
138+ }
139+ if stage == "blob" {
140+ k.EXPECT().createBlob(mock.Anything).Return(0, boom).Once()
141+ }
142+ if err := o.protectedModeset(context.Background(), r); err == nil {
143+ t.Fatal("failure reported success")
144+ }
145+ if !o.protected || o.back != 0 {
146+ t.Fatal("failure dropped protection or flipped target")
147+ }
148+ if stage != "activate" && len(*commits) != 1 {
149+ t.Fatalf("activated after %s failure", stage)
150+ }
151+ if stage != "disable" && !o.off {
152+ t.Fatal("disabled output considered active")
153+ }
154+ if fence != nil {
155+ if _, err := fence.Stat(); !errors.Is(err, os.ErrClosed) {
156+ t.Fatalf("fence not closed: %v", err)
157+ }
158+ }
159+ })
160+ }
161+}
162+
163+func TestProtectedResumeWithoutClearStaysInactive(t *testing.T) {
164+ o, _, commits := testOutput(t)
165+ o.protected = true
166+ if err := o.modeset(); err != nil {
167+ t.Fatal(err)
168+ }
169+ checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor)
170+ if !o.off {
171+ t.Fatal("resume exposed stale target")
172+ }
173+}
174+
175+func TestProtectedCloseNeverRestoresSavedDesktop(t *testing.T) {
176+ o, k, commits := testOutput(t)
177+ o.protected = true
178+ o.saved = modeCrtc{crtcID: tCrtc, fbID: 123, modeValid: 1, mode: o.mode}
179+ o.overlay = &plane{id: tOverlay, props: planeProps}
180+ o.stray = []*plane{o.overlay, {id: 53, props: planeProps}, {id: 54, props: planeProps}}
181+ k.EXPECT().objProps(uint32(53), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, tCrtc}}, nil).Once()
182+ k.EXPECT().objProps(uint32(54), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, 99}}, nil).Once()
183+ k.EXPECT().rmFB(mock.Anything).Return(nil)
184+ o.Close()
185+ if len(*commits) != 1 {
186+ t.Fatalf("commits %d", len(*commits))
187+ }
188+ checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor, tOverlay, 53)
189+ if _, ok := (*commits)[0].req.value(54, pFB); ok {
190+ t.Fatal("close detached another output's plane")
191+ }
192+}
193+
194+func TestProtectedModesetPendingDoesNotWaitForClientFence(t *testing.T) {
195+ o, _, commits := testOutput(t)
196+ f, _ := protectionFence(t, false)
197+ o.frame.begin(pendingFrame{fences: []*os.File{f}}, time.Now())
198+ r := portsmocks.NewMockRenderer(t)
199+ for _, call := range []func() error{
200+ func() error { return o.protectedModeset(context.Background(), r) },
201+ o.modeset,
202+ } {
203+ if err := call(); !errors.Is(err, errProtectionPending) {
204+ t.Fatalf("pending result: %v", err)
205+ }
206+ }
207+ if !o.protected || len(*commits) != 0 || !o.frame.pendingCommit() {
208+ t.Fatal("pending protection touched KMS or dropped pending state")
209+ }
210+ if _, err := f.Stat(); err != nil {
211+ t.Fatalf("client fence ownership dropped: %v", err)
212+ }
213+}
214+
215+func TestProtectedModesetCanceled(t *testing.T) {
216+ for _, before := range []bool{true, false} {
217+ t.Run(map[bool]string{true: "before-disable", false: "during-wait"}[before], func(t *testing.T) {
218+ o, _, commits := testOutput(t)
219+ r := portsmocks.NewMockRenderer(t)
220+ ctx, cancel := context.WithCancel(context.Background())
221+ defer cancel()
222+ var fence *os.File
223+ if before {
224+ cancel()
225+ } else {
226+ fence, _ = protectionFence(t, false)
227+ r.EXPECT().UseTarget(0).Return().Once()
228+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
229+ cancel()
230+ return fence, nil
231+ }).Once()
232+ }
233+ if err := o.protectedModeset(ctx, r); !errors.Is(err, context.Canceled) {
234+ t.Fatalf("cancel result: %v", err)
235+ }
236+ want := 1
237+ if before {
238+ want = 0
239+ }
240+ if len(*commits) != want || !o.protected || o.back != 0 {
241+ t.Fatal("cancel activated or dropped latch")
242+ }
243+ if fence != nil {
244+ if !o.off {
245+ t.Fatal("canceled clear left output marked active")
246+ }
247+ if _, err := fence.Stat(); !errors.Is(err, os.ErrClosed) {
248+ t.Fatalf("cancel did not close fence: %v", err)
249+ }
250+ }
251+ })
252+ }
253+}
254+
255+func TestProtectedModesetPreservesPowerOff(t *testing.T) {
256+ o, _, commits := testOutput(t)
257+ o.wantOff = true
258+ r := portsmocks.NewMockRenderer(t)
259+ r.EXPECT().UseTarget(0).Return().Once()
260+ f, _ := protectionFence(t, true)
261+ r.EXPECT().Render(ports.Scene{Seq: 0, Background: "#000000"}, map[ports.WindowID]ports.SurfaceContent(nil)).Return(f, nil).Once()
262+ if err := o.protectedModeset(context.Background(), r); err != nil {
263+ t.Fatal(err)
264+ }
265+ if len(*commits) != 1 || !o.wantOff || !o.off || !o.protected || o.back != 0 {
266+ t.Fatal("off proof activated output, flipped target or changed power intent")
267+ }
268+ checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor)
269+}
270+
271+func TestProtectedModesetUnreadableStrayFailsClosed(t *testing.T) {
272+ for _, missing := range []bool{false, true} {
273+ t.Run(map[bool]string{false: "read-error", true: "missing-property"}[missing], func(t *testing.T) {
274+ o, k, commits := testOutput(t)
275+ o.stray = []*plane{{id: tOverlay, props: planeProps}}
276+ var err error
277+ if !missing {
278+ err = unix.EACCES
279+ }
280+ k.EXPECT().objProps(uint32(tOverlay), uint32(objPlane)).Return(map[string][2]uint64{}, err).Once()
281+ if err := o.protectedModeset(context.Background(), portsmocks.NewMockRenderer(t)); err == nil {
282+ t.Fatal("unverified stray plane reported success")
283+ }
284+ if !o.protected || len(*commits) != 0 {
285+ t.Fatal("unverified detachment touched KMS or dropped latch")
286+ }
287+ })
288+ }
289+}
A internal/adapters/drm/security.go +149 -0
@@ -0,0 +1,149 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+var errSecurityScene = errors.New("scene security epoch changed or output unprepared")
12+
13+// observeSecurity is output-owner only. The channel wakes; the coherent gate
14+// decides admission, including shutdown paths that never received the wake.
15+func (o *Output) observeSecurity() bool {
16+ if o.Security == nil {
17+ return false
18+ }
19+ state := o.Security.Snapshot()
20+ if state == o.securityState {
21+ return false
22+ }
23+ o.securityState = state
24+ o.securityPrepared = false
25+ o.protected = state.Protected
26+ o.protectSucceeded() // a new epoch gets an immediate first attempt
27+ if o.cursor != nil {
28+ o.cursor.image, o.cursor.later = false, nil
29+ }
30+ return true
31+}
32+
33+func (o *Output) sceneCurrent(scene ports.Scene) bool {
34+ o.observeSecurity()
35+ return o.Security == nil || scene.Security == o.securityState
36+}
37+
38+func (o *Output) invalidateSecurity() {
39+ o.securityPrepared = false
40+ if o.protected {
41+ o.securityInvalid = true
42+ }
43+}
44+
45+func (o *Output) securityEvent(ctx context.Context, event ports.SecurityBackendEvent) error {
46+ if o.SecurityEvents == nil || o.Instance == 0 {
47+ return nil
48+ }
49+ select {
50+ case o.SecurityEvents <- event:
51+ return nil
52+ case <-ctx.Done():
53+ return ctx.Err()
54+ }
55+}
56+
57+func (o *Output) sendSecurityInvalid(ctx context.Context) error {
58+ if !o.securityInvalid {
59+ return nil
60+ }
61+ state := o.securityState
62+ if err := o.securityEvent(ctx, ports.SecurityOutputInvalidated{Generation: state.Generation, Instance: o.Instance}); err != nil {
63+ return err
64+ }
65+ o.securityInvalid = false
66+ return nil
67+}
68+
69+func (o *Output) prepareSecurity(ctx context.Context, r ports.Renderer) error {
70+ o.observeSecurity()
71+ if !o.protected {
72+ return nil
73+ }
74+ if err := o.sendSecurityInvalid(ctx); err != nil {
75+ return err
76+ }
77+ state := o.securityState
78+ if err := o.protectedModeset(ctx, r); err != nil {
79+ return err
80+ }
81+ // A gate transition during the blocking clear cannot certify a new epoch.
82+ if o.observeSecurity() || o.securityState != state {
83+ return nil
84+ }
85+ o.securityPrepared = true
86+ kind := ports.ProtectionProtectedFrame
87+ if o.off {
88+ kind = ports.ProtectionInactiveOutput
89+ }
90+ // Separate non-merging channel; presentation feedback is not evidence.
91+ // Backpressure may outlive an epoch. Revalidate on each bounded send
92+ // attempt; the ledger also rejects a transition racing the final send.
93+ if o.SecurityEvents == nil || o.Instance == 0 {
94+ return nil
95+ }
96+ timer := time.NewTimer(time.Millisecond)
97+ defer timer.Stop()
98+ proof := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: state.Generation, Instance: o.Instance, Kind: kind}}
99+ for {
100+ if o.observeSecurity() || o.securityState != state {
101+ return nil
102+ }
103+ select {
104+ case o.SecurityEvents <- proof:
105+ return nil
106+ case <-ctx.Done():
107+ return ctx.Err()
108+ case <-timer.C:
109+ timer.Reset(time.Millisecond)
110+ }
111+ }
112+}
113+
114+// Protected frames never enter client-fence KMS paths or capture pipelines.
115+// Locker content is composed only after black preparation for this epoch.
116+func (o *Output) submitProtectedFrame(ctx context.Context, r ports.Renderer, scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, seen map[ports.WindowID]uint64) error {
117+ if !o.securityPrepared || !o.sceneCurrent(scene) {
118+ return errSecurityScene
119+ }
120+ scene.CaptureScene, scene.Capture = nil, nil
121+ r.UseTarget(o.back)
122+ done, err := r.Render(scene, surfaces)
123+ if err != nil {
124+ if done != nil {
125+ done.Close()
126+ }
127+ return renderError{err}
128+ }
129+ // A transition while rendering must not put the obsolete scene on KMS.
130+ if !o.sceneCurrent(scene) || !o.protected || !o.securityPrepared {
131+ o.holdRead(done)
132+ if done != nil {
133+ done.Close()
134+ }
135+ return errSecurityScene
136+ }
137+ f := pendingFrame{security: scene.Security, frame: true, composed: true, shows: o.shownBy(scene, seen)}
138+ err = o.commitWith(o.fbs[o.back], done, false, false, f, overlayWin{})
139+ if err != nil {
140+ o.holdRead(done)
141+ }
142+ if done != nil {
143+ done.Close()
144+ }
145+ if err == nil {
146+ o.back = 1 - o.back
147+ }
148+ return err
149+}
A internal/adapters/drm/security_capture_test.go +102 -0
@@ -0,0 +1,102 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "sync/atomic"
8+ "testing"
9+
10+ "github.com/bnema/neferwl/internal/adapters/capture"
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+ "github.com/stretchr/testify/mock"
14+)
15+
16+func TestSecurityEngageDuringCleanRenderRejectsCaptureTransfer(t *testing.T) {
17+ o, _, commits := testOutput(t)
18+ o.cursor = nil
19+ var state atomic.Uint64
20+ securityGate(t, o, &state)
21+ r := portsmocks.NewMockRenderer(t)
22+ f, _ := protectionFence(t, false)
23+ r.EXPECT().UseTarget(0).Return().Once()
24+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
25+ state.Store(3)
26+ return f, nil
27+ }).Once()
28+ replies := make(chan ports.CaptureDone, 2)
29+ pipeline := capture.NewPipeline(context.Background(), replies)
30+ requests := []ports.CaptureRequest{sessionRequest(t, 1, true), sessionRequest(t, 2, false)}
31+ files := []*os.File{requests[0].Dst.File, requests[1].Dst.File}
32+ _, err := o.submitFrame(context.Background(), r, drmSessionScene(), nil, nil, requests, pipeline)
33+ if !errors.Is(err, errSecurityScene) || len(*commits) != 0 {
34+ t.Fatalf("capture admission result: %v", err)
35+ }
36+ if len(o.readFences) != 1 || o.readDone() {
37+ t.Fatal("rejected clean render dropped GPU reads")
38+ }
39+ if _, err := f.Stat(); !errors.Is(err, os.ErrClosed) {
40+ t.Fatal("returned clean fence not closed")
41+ }
42+ // Match Run's remaining-credit rejection: nothing was transferred to the
43+ // worker, each request is failed once, and every owned FD is closed.
44+ for _, q := range requests {
45+ if capture.Handed(q) {
46+ t.Fatal("obsolete capture transferred credit")
47+ }
48+ capture.Fail(context.Background(), q, err, replies)
49+ }
50+ pipeline.Close(r)
51+ r.AssertNotCalled(t, "BeginCapture")
52+ if len(replies) != 2 {
53+ t.Fatalf("capture replies %d", len(replies))
54+ }
55+ for range 2 {
56+ if reply := <-replies; !errors.Is(reply.Err, errSecurityScene) {
57+ t.Fatalf("reply %+v", reply)
58+ }
59+ }
60+ for _, file := range files {
61+ if _, err := file.Stat(); !errors.Is(err, os.ErrClosed) {
62+ t.Fatal("capture FD not closed")
63+ }
64+ }
65+ o.dropRead()
66+}
67+
68+func TestSecurityHiddenAdmissionRechecksBeforeNativeStart(t *testing.T) {
69+ o, _, _ := testOutput(t)
70+ o.cursor = nil
71+ security := portsmocks.NewMockSessionSecurity(t)
72+ security.EXPECT().Snapshot().Return(ports.SecurityState{}).Once()
73+ security.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
74+ o.Security = security
75+ r := portsmocks.NewMockRenderer(t)
76+ child := portsmocks.NewMockRenderer(t)
77+ replies := make(chan ports.CaptureDone, 1)
78+ pipeline := capture.NewPipeline(context.Background(), replies)
79+ pipeline.EnableOffscreen(func(int, int) (ports.Renderer, error) {
80+ t.Fatal("obsolete hidden capture created child")
81+ return child, nil
82+ })
83+ s := drmSessionScene()
84+ s.CaptureScene = &ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}
85+ requests := []ports.CaptureRequest{sessionRequest(t, 1, true)}
86+ file := requests[0].Dst.File
87+ _, err := o.submitFrame(context.Background(), r, s, nil, nil, requests, pipeline)
88+ if !errors.Is(err, errSecurityScene) || capture.Handed(requests[0]) {
89+ t.Fatalf("hidden admission result %v", err)
90+ }
91+ capture.Fail(context.Background(), requests[0], err, replies)
92+ pipeline.Close(r)
93+ if len(replies) != 1 || (<-replies).ID != 1 {
94+ t.Fatal("hidden request credit not failed once")
95+ }
96+ if _, err := file.Stat(); !errors.Is(err, os.ErrClosed) {
97+ t.Fatal("hidden FD not closed")
98+ }
99+ r.AssertNotCalled(t, "BeginCapture")
100+ child.AssertNotCalled(t, "Render", mock.Anything, mock.Anything)
101+ child.AssertNotCalled(t, "BeginCapture")
102+}
A internal/adapters/drm/security_close_test.go +74 -0
@@ -0,0 +1,74 @@
1+package drm
2+
3+import (
4+ "maps"
5+ "sync/atomic"
6+ "testing"
7+
8+ "github.com/stretchr/testify/mock"
9+ "golang.org/x/sys/unix"
10+)
11+
12+func TestInactiveOnCloseRequiresSuccessfulValidatedDisable(t *testing.T) {
13+ for _, stage := range []string{"success", "commit-failure", "query-failure", "partial-primary", "partial-cursor", "partial-overlay", "partial-stray", "missing-active"} {
14+ t.Run(stage, func(t *testing.T) {
15+ var failures []error
16+ if stage == "commit-failure" {
17+ failures = []error{unix.EACCES}
18+ }
19+ o, k, commits := testOutput(t, failures...)
20+ var state atomic.Uint64
21+ securityGate(t, o, &state) // production gate, even unlocked: disable
22+ o.off = true // stale cached state is never terminal evidence
23+ o.inactiveOnClose = true // reset any earlier result at entry
24+ o.saved = modeCrtc{crtcID: tCrtc, fbID: 123, modeValid: 1, mode: o.mode}
25+ o.overlay = &plane{id: tOverlay, props: planeProps}
26+ stray := &plane{id: 53, props: planeProps}
27+ o.stray = []*plane{o.overlay, stray}
28+ var readErr error
29+ if stage == "query-failure" {
30+ readErr = unix.EACCES
31+ }
32+ k.EXPECT().objProps(uint32(53), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, tCrtc}}, readErr).Once()
33+ partial := func(p *plane) { p.props = maps.Clone(planeProps); p.props["FB_ID"] = 0 }
34+ switch stage {
35+ case "partial-primary":
36+ partial(o.primary)
37+ case "partial-cursor":
38+ partial(o.cursor.plane)
39+ case "partial-overlay":
40+ partial(o.overlay)
41+ case "partial-stray":
42+ partial(stray)
43+ case "missing-active":
44+ o.crtcProps = maps.Clone(o.crtcProps)
45+ o.crtcProps["ACTIVE"] = 0
46+ }
47+ k.EXPECT().rmFB(mock.Anything).Return(nil)
48+ done := make(chan struct{})
49+ go func() { o.Close(); close(done) }()
50+ <-done // accessor is only read after owner-completion synchronization
51+ if got := o.InactiveOnClose(); got != (stage == "success") {
52+ t.Fatalf("inactiveOnClose=%v stage=%s", got, stage)
53+ }
54+ if len(*commits) != 1 {
55+ t.Fatalf("commits %d", len(*commits))
56+ }
57+ if stage == "success" {
58+ checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor, tOverlay, 53)
59+ }
60+ })
61+ }
62+}
63+
64+func TestInactiveOnCloseSavedRestoreNeverCertifiesRemoval(t *testing.T) {
65+ o, k, _ := testOutput(t)
66+ o.saved = modeCrtc{crtcID: tCrtc, fbID: 123, modeValid: 1, mode: o.mode}
67+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
68+ k.EXPECT().destroyBlob(uint32(99)).Return(nil).Once()
69+ k.EXPECT().rmFB(mock.Anything).Return(nil)
70+ o.Close()
71+ if o.InactiveOnClose() {
72+ t.Fatal("legacy saved desktop restoration certified inactivity")
73+ }
74+}
A internal/adapters/drm/security_commit_test.go +22 -0
@@ -0,0 +1,22 @@
1+package drm
2+
3+import (
4+ "errors"
5+ "sync/atomic"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+func TestSecurityFinalCommitSnapshotRejectsEngage(t *testing.T) {
12+ o, _, commits := testOutput(t)
13+ var state atomic.Uint64
14+ securityGate(t, o, &state)
15+ // The frame was admitted in unlocked generation zero; before the final
16+ // commit boundary a lock engages. No client fence is handed to KMS.
17+ state.Store(3)
18+ err := o.commitWith(70, nil, true, true, pendingFrame{security: ports.SecurityState{}}, overlayWin{})
19+ if !errors.Is(err, errSecurityScene) || len(*commits) != 0 || !o.protected {
20+ t.Fatalf("obsolete commit admitted: %v commits=%d", err, len(*commits))
21+ }
22+}
A internal/adapters/drm/security_recovery_test.go +131 -0
@@ -0,0 +1,131 @@
1+package drm
2+
3+import (
4+ "context"
5+ "os"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11+ "github.com/bnema/neferwl/internal/ports"
12+ "github.com/stretchr/testify/mock"
13+)
14+
15+// A missing flip event is recoverable only after the real acquire fence
16+// signals. Recovery revokes earlier proof before a fresh blocking black proof.
17+func TestSecurityStuckRecoveryInvalidatesBeforeFreshProof(t *testing.T) {
18+ o, k, commits, mu := testOutputMu(t)
19+ o.cursor, o.tearing = nil, false
20+ o.frame.stuckAfter = 40 * time.Millisecond
21+ o.flipped = make(chan flipEvent) // the event is intentionally lost
22+ var state atomic.Uint64
23+ state.Store(3)
24+ securityGate(t, o, &state)
25+ events := make(chan ports.SecurityBackendEvent, 8)
26+ o.SecurityEvents = events
27+ r := portsmocks.NewMockRenderer(t)
28+ buf := func() ports.DMABuf {
29+ f, w := protectionFence(t, false)
30+ w.Close()
31+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
32+ }
33+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
34+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
35+ r.EXPECT().UseTarget(mock.Anything).Return()
36+ fence, signal := protectionFence(t, false)
37+ var renderedOnce atomic.Bool
38+ rendered := make(chan struct{}, 1)
39+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
40+ if s.Seq == 12 {
41+ if renderedOnce.CompareAndSwap(false, true) {
42+ select {
43+ case rendered <- struct{}{}:
44+ default:
45+ }
46+ return fence, nil
47+ }
48+ return nil, nil
49+ }
50+ if s.Background != "#000000" || s.Seq != 0 || len(s.Windows) != 0 {
51+ t.Error("recovery did not compose exact black")
52+ }
53+ return nil, nil
54+ })
55+ r.EXPECT().Close().Return().Once()
56+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil)
57+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
58+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
59+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
60+ defer cancel()
61+ scenes := make(chan ports.Scene, 1)
62+ done := make(chan error, 1)
63+ go func() {
64+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, scenes, nil, nil, make(chan ports.OutputPresented, 8), nil, nil)
65+ }()
66+ receive := func() ports.SecurityBackendEvent {
67+ t.Helper()
68+ select {
69+ case event := <-events:
70+ return event
71+ case <-ctx.Done():
72+ t.Fatal("missing recovery security event")
73+ return nil
74+ }
75+ }
76+ initial := receive()
77+ if p, ok := initial.(ports.SecurityOutputProof); !ok || p.Proof != (ports.OutputProtection{Generation: 1, Instance: 77, Kind: ports.ProtectionProtectedFrame}) {
78+ t.Fatalf("initial proof %+v", initial)
79+ }
80+ scenes <- ports.Scene{Seq: 12, Security: ports.SecurityState{Generation: 1, Protected: true}}
81+ select {
82+ case <-rendered:
83+ case <-ctx.Done():
84+ t.Fatal("protected frame not submitted")
85+ }
86+ waitFor(t, func() bool {
87+ mu.Lock()
88+ defer mu.Unlock()
89+ for _, c := range *commits {
90+ if c.user&3 == userFrame {
91+ return true
92+ }
93+ }
94+ return false
95+ })
96+ select {
97+ case event := <-events:
98+ t.Fatalf("unsignalled acquire fence forged recovery evidence: %+v", event)
99+ case <-time.After(3 * o.frame.stuckAfter):
100+ }
101+ if _, err := signal.Write([]byte{1}); err != nil {
102+ t.Fatal(err)
103+ }
104+ if event := receive(); event != (ports.SecurityOutputInvalidated{Generation: 1, Instance: 77}) {
105+ t.Fatalf("fresh proof preceded recovery invalidation: %+v", event)
106+ }
107+ if event := receive(); event != initial {
108+ t.Fatalf("fresh recovery proof changed lifetime/epoch: %+v", event)
109+ }
110+ cancel()
111+ if err := <-done; err != nil {
112+ t.Fatal(err)
113+ }
114+ mu.Lock()
115+ defer mu.Unlock()
116+ var frameIndex int
117+ for i, c := range *commits {
118+ if c.user&3 == userFrame {
119+ frameIndex = i
120+ break
121+ }
122+ }
123+ if frameIndex == 0 || len(*commits) < frameIndex+3 {
124+ t.Fatal("no actual blocking black recovery transaction")
125+ }
126+ checkProtectedDisable(t, (*commits)[frameIndex+1], tPrimary)
127+ activate := (*commits)[frameIndex+2]
128+ if active, ok := activate.req.value(tCrtc, pActive); !ok || active != 1 || activate.flags != atomicAllowModes {
129+ t.Fatal("recovery proof did not follow blocking activation")
130+ }
131+}
A internal/adapters/drm/security_resume_test.go +96 -0
@@ -0,0 +1,96 @@
1+package drm
2+
3+import (
4+ "context"
5+ "os"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11+ "github.com/bnema/neferwl/internal/ports"
12+ "github.com/stretchr/testify/mock"
13+ "golang.org/x/sys/unix"
14+)
15+
16+func TestSecurityUnlockedResumeUsesOwnerRetry(t *testing.T) {
17+ for _, stage := range []string{"engage-during-request", "master-loss"} {
18+ t.Run(stage, func(t *testing.T) {
19+ var failures []error
20+ if stage == "master-loss" {
21+ failures = []error{unix.EACCES}
22+ }
23+ o, k, commits, mu := testOutputMu(t, failures...)
24+ o.cursor, o.tearing = nil, false
25+ var state atomic.Uint64
26+ securityGate(t, o, &state)
27+ events := make(chan ports.SecurityBackendEvent, 4)
28+ o.SecurityEvents = events
29+ active := make(chan bool, 2)
30+ active <- false // retain existing images; first modeset is resume
31+ wake := make(chan ports.SecurityState, 1)
32+ o.SecurityChanges = wake
33+ r := portsmocks.NewMockRenderer(t)
34+ r.EXPECT().UseTarget(0).Return().Once()
35+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
36+ if s.Seq != 0 || s.Background != "#000000" || len(s.Windows) != 0 {
37+ t.Fatal("resume rendered desktop")
38+ }
39+ return nil, nil
40+ }).Once()
41+ r.EXPECT().Close().Return().Once()
42+ var blobs atomic.Int32
43+ k.EXPECT().createBlob(mock.Anything).RunAndReturn(func([]byte) (uint32, error) {
44+ if blobs.Add(1) == 1 && stage == "engage-during-request" {
45+ state.Store(3)
46+ }
47+ return 99, nil
48+ })
49+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
50+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
51+ defer cancel()
52+ done := make(chan error, 1)
53+ go func() {
54+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, nil, nil, nil)
55+ }()
56+ active <- true
57+ if stage == "master-loss" {
58+ waitFor(t, func() bool { mu.Lock(); defer mu.Unlock(); return len(*commits) == 1 })
59+ state.Store(3)
60+ wake <- ports.SecurityState{}
61+ select {
62+ case ev := <-events:
63+ if _, ok := ev.(ports.SecurityOutputProof); ok {
64+ t.Fatal("master loss forged proof")
65+ }
66+ case <-time.After(20 * time.Millisecond):
67+ }
68+ select {
69+ case err := <-done:
70+ t.Fatalf("resume stopped owner: %v", err)
71+ default:
72+ }
73+ active <- true // only real seat reacquisition permits preparation
74+ }
75+ for {
76+ select {
77+ case ev := <-events:
78+ if p, ok := ev.(ports.SecurityOutputProof); ok {
79+ if p.Proof.Generation != 1 || p.Proof.Instance != 77 {
80+ t.Fatalf("proof %+v", p)
81+ }
82+ cancel()
83+ if err := <-done; err != nil {
84+ t.Fatal(err)
85+ }
86+ return
87+ }
88+ case err := <-done:
89+ t.Fatalf("resume ended before fresh proof: %v", err)
90+ case <-ctx.Done():
91+ t.Fatal("missing fresh resume proof")
92+ }
93+ }
94+ })
95+ }
96+}
A internal/adapters/drm/security_run_test.go +131 -0
@@ -0,0 +1,131 @@
1+package drm
2+
3+import (
4+ "context"
5+ "os"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11+ "github.com/bnema/neferwl/internal/ports"
12+ "github.com/stretchr/testify/mock"
13+)
14+
15+func TestSecurityRunWakePendingResumeAndOff(t *testing.T) {
16+ o, k, commits, mu := testOutputMu(t)
17+ o.cursor, o.tearing = nil, false
18+ var state atomic.Uint64
19+ securityGate(t, o, &state)
20+ events := make(chan ports.SecurityBackendEvent, 8)
21+ o.SecurityEvents = events
22+ wake := make(chan ports.SecurityState, 1)
23+ o.SecurityChanges = wake
24+ active := make(chan bool, 2)
25+ flips := make(chan flipEvent, 2)
26+ o.flipped = flips
27+ scenes := make(chan ports.Scene, 2)
28+ r := portsmocks.NewMockRenderer(t)
29+ buf := func() ports.DMABuf {
30+ f, w := protectionFence(t, false)
31+ w.Close()
32+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
33+ }
34+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
35+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
36+ r.EXPECT().UseTarget(mock.Anything).Return()
37+ black := make(chan struct{}, 8)
38+ desktop := make(chan struct{}, 2)
39+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
40+ if s.Seq == 99 {
41+ desktop <- struct{}{}
42+ } else {
43+ black <- struct{}{}
44+ }
45+ return nil, nil
46+ })
47+ r.EXPECT().Close().Return().Once()
48+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil)
49+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
50+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
51+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
52+ defer cancel()
53+ done := make(chan error, 1)
54+ go func() {
55+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, scenes, nil, nil, make(chan ports.OutputPresented, 8), nil, nil)
56+ }()
57+ scenes <- ports.Scene{Seq: 99, Scale: 1}
58+ select {
59+ case <-desktop:
60+ case <-ctx.Done():
61+ t.Fatal("no desktop frame")
62+ }
63+ var frame commitRec
64+ waitFor(t, func() bool {
65+ mu.Lock()
66+ defer mu.Unlock()
67+ for _, c := range *commits {
68+ if c.user&3 == userFrame {
69+ frame = c
70+ return true
71+ }
72+ }
73+ return false
74+ })
75+ state.Store(3)
76+ wake <- ports.SecurityState{} // stale payload: read gate, not payload
77+ select {
78+ case ev := <-events:
79+ t.Fatalf("proof while old frame pending: %+v", ev)
80+ case <-time.After(20 * time.Millisecond):
81+ }
82+ flips <- eventOf(frame)
83+ proof := func(kind ports.ProtectionKind) {
84+ t.Helper()
85+ for {
86+ select {
87+ case ev := <-events:
88+ if p, ok := ev.(ports.SecurityOutputProof); ok {
89+ if p.Proof != (ports.OutputProtection{Generation: 1, Instance: 77, Kind: kind}) {
90+ t.Fatalf("proof %+v", p)
91+ }
92+ return
93+ }
94+ case <-ctx.Done():
95+ t.Fatal("missing security proof")
96+ }
97+ }
98+ }
99+ proof(ports.ProtectionProtectedFrame)
100+ scenes <- ports.Scene{Seq: 99} // held desktop scene cannot be replayed
101+ select {
102+ case <-desktop:
103+ t.Fatal("stale desktop rendered under protection")
104+ case <-time.After(20 * time.Millisecond):
105+ }
106+ active <- false
107+ select {
108+ case ev := <-events:
109+ if _, ok := ev.(ports.SecurityOutputInvalidated); !ok {
110+ t.Fatalf("disable did not invalidate before resume: %+v", ev)
111+ }
112+ case <-ctx.Done():
113+ t.Fatal("missing disable invalidation")
114+ }
115+ active <- true
116+ select {
117+ case ev := <-events:
118+ if _, ok := ev.(ports.SecurityOutputInvalidated); !ok {
119+ t.Fatalf("resume proof preceded invalidation: %+v", ev)
120+ }
121+ case <-ctx.Done():
122+ t.Fatal("missing resume invalidation")
123+ }
124+ proof(ports.ProtectionProtectedFrame) // reacquire requires a fresh black transaction
125+ scenes <- ports.Scene{Security: ports.SecurityState{Generation: 1, Protected: true}, Off: true}
126+ proof(ports.ProtectionInactiveOutput)
127+ cancel()
128+ if err := <-done; err != nil {
129+ t.Fatal(err)
130+ }
131+}
A internal/adapters/drm/security_startup_test.go +127 -0
@@ -0,0 +1,127 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "sync/atomic"
8+ "testing"
9+ "time"
10+
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+ "github.com/stretchr/testify/mock"
14+)
15+
16+func TestSecurityProtectedStartupBlackProofAndCaptureRejection(t *testing.T) {
17+ o, k, commits, mu := testOutputMu(t)
18+ o.cursor = nil
19+ var state atomic.Uint64
20+ state.Store(3)
21+ securityGate(t, o, &state)
22+ events := make(chan ports.SecurityBackendEvent, 2)
23+ o.SecurityEvents = events
24+ r := portsmocks.NewMockRenderer(t)
25+ buf := func() ports.DMABuf {
26+ f, w := protectionFence(t, false)
27+ w.Close()
28+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
29+ }
30+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
31+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
32+ r.EXPECT().UseTarget(mock.Anything).Return()
33+ r.EXPECT().Render(ports.Scene{Background: "#000000"}, map[ports.WindowID]ports.SurfaceContent(nil)).Return(nil, nil).Times(3)
34+ r.EXPECT().Close().Return().Once()
35+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil)
36+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
37+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
38+ captures := make(chan ports.CaptureRequest, 1)
39+ captured := make(chan ports.CaptureDone, 1)
40+ scenes := make(chan ports.Scene, 1)
41+ scenes <- ports.Scene{Seq: 99} // never render stale desktop after startup
42+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
43+ defer cancel()
44+ done := make(chan error, 1)
45+ go func() {
46+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, scenes, nil, nil, nil, captures, captured)
47+ }()
48+ select {
49+ case ev := <-events:
50+ if p, ok := ev.(ports.SecurityOutputProof); !ok || p.Proof.Generation != 1 || p.Proof.Instance != 77 || p.Proof.Kind != ports.ProtectionProtectedFrame {
51+ t.Fatalf("proof %+v", ev)
52+ }
53+ case <-ctx.Done():
54+ t.Fatal("no startup black proof")
55+ }
56+ mu.Lock()
57+ for i, c := range *commits {
58+ if c.flags&atomicTestOnly != 0 {
59+ continue
60+ }
61+ active, _ := c.req.value(tCrtc, pActive)
62+ if active != 0 && i != len(*commits)-1 {
63+ t.Error("startup exposed an active target before protected transaction")
64+ }
65+ }
66+ mu.Unlock()
67+ f, w := protectionFence(t, false)
68+ w.Close()
69+ captures <- ports.CaptureRequest{ID: 9, Dst: ports.SHMBuffer{File: f}}
70+ select {
71+ case reply := <-captured:
72+ if reply.ID != 9 || reply.Err == nil {
73+ t.Fatalf("capture result %+v", reply)
74+ }
75+ case <-ctx.Done():
76+ t.Fatal("protected capture not failed")
77+ }
78+ cancel()
79+ if err := <-done; err != nil {
80+ t.Fatal(err)
81+ }
82+}
83+
84+func TestSecurityStartupClearWaitUsesRunContext(t *testing.T) {
85+ o, k, _ := testOutput(t)
86+ o.cursor = nil
87+ var state atomic.Uint64
88+ state.Store(3)
89+ securityGate(t, o, &state)
90+ r := portsmocks.NewMockRenderer(t)
91+ buf := func() ports.DMABuf {
92+ f, w := protectionFence(t, false)
93+ w.Close()
94+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
95+ }
96+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
97+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
98+ r.EXPECT().ExportTargets(0, []uint64(nil)).Return(nil, nil).Once()
99+ r.EXPECT().UseTarget(0).Return().Once()
100+ fence, _ := protectionFence(t, false)
101+ ctx, cancel := context.WithCancel(context.Background())
102+ defer cancel()
103+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
104+ cancel()
105+ return fence, nil
106+ }).Once()
107+ r.EXPECT().Close().Return().Once()
108+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil)
109+ k.EXPECT().rmFB(mock.Anything).Return(nil)
110+ err := o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, nil, nil, nil, nil, nil, nil)
111+ if !errors.Is(err, context.Canceled) {
112+ t.Fatalf("startup cancellation: %v", err)
113+ }
114+ if _, err := fence.Stat(); !errors.Is(err, os.ErrClosed) {
115+ t.Fatal("startup fence leaked")
116+ }
117+}
118+
119+func TestSecurityWiredUnlockedCloseNeverRestoresSavedFB(t *testing.T) {
120+ o, k, commits := testOutput(t)
121+ var state atomic.Uint64
122+ securityGate(t, o, &state)
123+ o.saved = modeCrtc{crtcID: tCrtc, fbID: 123, modeValid: 1, mode: o.mode}
124+ k.EXPECT().rmFB(mock.Anything).Return(nil)
125+ o.Close() // no protected snapshot needed: saved restoration is never admitted
126+ checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor)
127+}
A internal/adapters/drm/security_test.go +170 -0
@@ -0,0 +1,170 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "sync/atomic"
8+ "testing"
9+ "time"
10+
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+ "github.com/stretchr/testify/mock"
14+ "golang.org/x/sys/unix"
15+)
16+
17+// A generated mock through the real ports interface, using a coherent atomic
18+// test state so transitions can race with the output owner without data races.
19+func securityGate(t *testing.T, o *Output, state *atomic.Uint64) {
20+ k := portsmocks.NewMockSessionSecurity(t)
21+ k.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
22+ v := state.Load()
23+ return ports.SecurityState{Generation: ports.LockGeneration(v >> 1), Protected: v&1 != 0}
24+ })
25+ o.Security = k
26+ o.Instance = 77
27+ o.SecurityEvents = make(chan ports.SecurityBackendEvent, 8)
28+}
29+
30+func TestSecurityPreparationProofAndFailure(t *testing.T) {
31+ for _, stage := range []string{"success", "off", "disable", "render", "pending", "changed"} {
32+ t.Run(stage, func(t *testing.T) {
33+ var failures []error
34+ if stage == "disable" {
35+ failures = []error{unix.EACCES}
36+ }
37+ o, k, commits := testOutput(t, failures...)
38+ var state atomic.Uint64
39+ state.Store(3)
40+ securityGate(t, o, &state)
41+ events := make(chan ports.SecurityBackendEvent, 8)
42+ o.SecurityEvents = events
43+ r := portsmocks.NewMockRenderer(t)
44+ if stage == "pending" {
45+ f, _ := protectionFence(t, false)
46+ o.frame.begin(pendingFrame{fences: []*os.File{f}}, time.Now())
47+ } else if stage != "disable" {
48+ r.EXPECT().UseTarget(0).Return().Once()
49+ r.EXPECT().Render(ports.Scene{Background: "#000000"}, map[ports.WindowID]ports.SurfaceContent(nil)).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
50+ if stage == "changed" {
51+ state.Store(5)
52+ }
53+ if stage == "render" {
54+ return nil, errors.New("render refused")
55+ }
56+ return nil, nil
57+ }).Once()
58+ if stage != "off" && stage != "render" {
59+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
60+ }
61+ }
62+ o.wantOff = stage == "off"
63+ err := o.prepareSecurity(context.Background(), r)
64+ wantErr := stage == "disable" || stage == "render" || stage == "pending"
65+ if (err != nil) != wantErr {
66+ t.Fatalf("result %v", err)
67+ }
68+ if stage == "success" || stage == "off" {
69+ select {
70+ case ev := <-events:
71+ proof, ok := ev.(ports.SecurityOutputProof)
72+ kind := ports.ProtectionProtectedFrame
73+ if stage == "off" {
74+ kind = ports.ProtectionInactiveOutput
75+ }
76+ if !ok || proof.Proof != (ports.OutputProtection{Generation: 1, Instance: 77, Kind: kind}) {
77+ t.Fatalf("proof %+v", ev)
78+ }
79+ default:
80+ t.Fatal("missing proof")
81+ }
82+ } else if len(events) != 0 || o.securityPrepared {
83+ t.Fatal("failure or changed epoch certified protection")
84+ }
85+ if stage == "pending" && len(*commits) != 0 {
86+ t.Fatal("pending client fence queued blocking KMS")
87+ }
88+ })
89+ }
90+}
91+
92+func TestSecurityRejectsStaleAcquireReleaseScenes(t *testing.T) {
93+ o, _, commits := testOutput(t)
94+ var state atomic.Uint64
95+ securityGate(t, o, &state)
96+ r := portsmocks.NewMockRenderer(t)
97+ state.Store(3)
98+ if _, err := o.submitFrame(context.Background(), r, ports.Scene{}, nil, nil, nil, nil); !errors.Is(err, errSecurityScene) {
99+ t.Fatalf("old desktop admitted: %v", err)
100+ }
101+ state.Store(4)
102+ if _, err := o.submitFrame(context.Background(), r, ports.Scene{Security: ports.SecurityState{Generation: 1, Protected: true}}, nil, nil, nil, nil); !errors.Is(err, errSecurityScene) {
103+ t.Fatalf("old lock admitted on release: %v", err)
104+ }
105+ if len(*commits) != 0 {
106+ t.Fatal("stale epoch committed")
107+ }
108+}
109+
110+func TestSecurityProtectedCompositionDetachesCursorAndOverlay(t *testing.T) {
111+ o, _, commits := testOutput(t)
112+ var state atomic.Uint64
113+ state.Store(3)
114+ securityGate(t, o, &state)
115+ o.observeSecurity()
116+ o.securityPrepared = true
117+ o.cursor.image = true
118+ o.cursor.Move(8, 8)
119+ o.overlay = &plane{id: tOverlay, props: planeProps}
120+ s, c := fullscreenScene()
121+ s.Security = ports.SecurityState{Generation: 1, Protected: true}
122+ s.CaptureScene = &ports.Scene{}
123+ r := portsmocks.NewMockRenderer(t)
124+ r.EXPECT().UseTarget(0).Return().Once()
125+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(scene ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
126+ if scene.CaptureScene != nil {
127+ t.Fatal("hidden capture crossed protected path")
128+ }
129+ return nil, nil
130+ }).Once()
131+ direct, err := o.submitFrame(context.Background(), r, s, c, nil, nil, nil)
132+ if err != nil || direct {
133+ t.Fatalf("direct=%v err=%v", direct, err)
134+ }
135+ last := (*commits)[0]
136+ if last.flags&flipAsyncFlag != 0 {
137+ t.Fatal("protected tearing")
138+ }
139+ for _, plane := range []uint32{tCursor, tOverlay} {
140+ if fb, _ := last.req.value(plane, pFB); fb != 0 {
141+ t.Fatalf("desktop plane %d attached", plane)
142+ }
143+ }
144+}
145+
146+func TestSecurityCloseReadsGateAfterOwnerStopped(t *testing.T) {
147+ o, k, commits := testOutput(t)
148+ var state atomic.Uint64
149+ securityGate(t, o, &state)
150+ o.saved = modeCrtc{crtcID: tCrtc, fbID: 123, modeValid: 1, mode: o.mode}
151+ state.Store(3) // no wake or owner loop observes it
152+ k.EXPECT().rmFB(mock.Anything).Return(nil)
153+ o.Close()
154+ checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor)
155+}
156+
157+func TestSecurityCommitStateReadsGateBeforeCursorCommit(t *testing.T) {
158+ o, _, commits := testOutput(t)
159+ var state atomic.Uint64
160+ securityGate(t, o, &state)
161+ o.cursor.image = true
162+ o.cursor.Move(10, 10)
163+ state.Store(3)
164+ if err := o.commitState(true); !errors.Is(err, errSecurityScene) {
165+ t.Fatalf("cursor result: %v", err)
166+ }
167+ if len(*commits) != 0 || !o.protected {
168+ t.Fatal("cursor committed before owner observed lock")
169+ }
170+}
A internal/adapters/drm/security_transition_test.go +90 -0
@@ -0,0 +1,90 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "sync/atomic"
8+ "testing"
9+ "time"
10+
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+ "github.com/stretchr/testify/mock"
14+ "golang.org/x/sys/unix"
15+)
16+
17+func TestSecurityTransitionDuringDesktopRenderPreservesReadFence(t *testing.T) {
18+ o, _, commits := testOutput(t)
19+ o.cursor, o.scanout = nil, false
20+ var state atomic.Uint64
21+ securityGate(t, o, &state)
22+ r := portsmocks.NewMockRenderer(t)
23+ f, _ := protectionFence(t, false)
24+ r.EXPECT().UseTarget(0).Return().Once()
25+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
26+ state.Store(3)
27+ return f, nil
28+ }).Once()
29+ _, err := o.submitFrame(context.Background(), r, ports.Scene{}, nil, nil, nil, nil)
30+ if !errors.Is(err, errSecurityScene) || len(*commits) != 0 || o.readDone() {
31+ t.Fatalf("old rendered frame committed or read fence dropped: %v", err)
32+ }
33+ o.dropRead()
34+}
35+
36+func TestSecurityMasterLossInvalidatesWithoutProof(t *testing.T) {
37+ o, _, _ := testOutput(t)
38+ var state atomic.Uint64
39+ state.Store(3)
40+ securityGate(t, o, &state)
41+ events := make(chan ports.SecurityBackendEvent, 2)
42+ o.SecurityEvents = events
43+ o.observeSecurity()
44+ o.securityPrepared = true
45+ enabled := true
46+ if !o.commitFailed(unix.EACCES, &enabled) || enabled || o.securityPrepared {
47+ t.Fatal("master loss retained proof")
48+ }
49+ if err := o.sendSecurityInvalid(context.Background()); err != nil {
50+ t.Fatal(err)
51+ }
52+ ev := <-events
53+ if invalid, ok := ev.(ports.SecurityOutputInvalidated); !ok || invalid.Generation != 1 || invalid.Instance != 77 {
54+ t.Fatalf("invalid event %+v", ev)
55+ }
56+ if len(events) != 0 {
57+ t.Fatal("master loss forged inactive proof")
58+ }
59+}
60+
61+func TestSecurityProofBackpressureRechecksEpoch(t *testing.T) {
62+ o, k, _ := testOutput(t)
63+ var state atomic.Uint64
64+ state.Store(3)
65+ securityGate(t, o, &state)
66+ events := make(chan ports.SecurityBackendEvent) // no consumer until transition
67+ o.SecurityEvents = events
68+ r := portsmocks.NewMockRenderer(t)
69+ cleared := make(chan struct{})
70+ r.EXPECT().UseTarget(0).Return().Once()
71+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
72+ k.EXPECT().createBlob(mock.Anything).RunAndReturn(func([]byte) (uint32, error) { close(cleared); return 99, nil }).Once()
73+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
74+ defer cancel()
75+ done := make(chan error, 1)
76+ go func() { done <- o.prepareSecurity(ctx, r) }()
77+ <-cleared
78+ state.Store(5)
79+ if err := <-done; err != nil {
80+ t.Fatal(err)
81+ }
82+ select {
83+ case ev := <-events:
84+ t.Fatalf("obsolete proof emitted: %+v", ev)
85+ default:
86+ }
87+ if o.securityPrepared {
88+ t.Fatal("obsolete proof retained")
89+ }
90+}
M internal/adapters/headless/capture_session_test.go +1 -1
@@ -249,7 +249,7 @@ func TestRenderErrorDoesNotAnswerHandedRequestsAgain(t *testing.T) {
249249 require.Equal(t, []uint64{1}, ids, "request 1 answered once by its worker, no pseudo request 0")
250250 }
251251
252-// GO-003: an unchanged child-hold publication reuses its immutable snapshots;
252+// An unchanged child-hold publication reuses its immutable snapshots;
253253 // a change clones, and earlier reports keep their maps.
254254 func TestHoldSnapshotsCloneOnlyOnChange(t *testing.T) {
255255 var h holdSnapshots
M internal/adapters/headless/cursor.go +5 -1
@@ -37,9 +37,13 @@ func (c *Cursor) set(img ports.CursorImage) {
3737
3838 // draw blends the premultiplied cursor over dst with its hotspot at (x, y).
3939 func (c *Cursor) draw(dst *image.RGBA) {
40+ c.drawSecure(dst, nil)
41+}
42+
43+func (c *Cursor) drawSecure(dst *image.RGBA, security ports.SessionSecurity) {
4044 c.mu.Lock()
4145 defer c.mu.Unlock()
42- if c.hidden {
46+ if c.hidden || security != nil && security.Snapshot().Protected {
4347 return
4448 }
4549 img := c.img
M internal/adapters/headless/output.go +262 -20
@@ -19,6 +19,12 @@ import (
1919 )
2020
2121 type Options struct {
22+ // Security is the defensive gate. SecurityChanges wakes an idle owner;
23+ // the snapshot, not the queued notification payload, is authoritative.
24+ Security ports.SessionSecurity
25+ SecurityChanges <-chan ports.SecurityState
26+ Instance ports.OutputInstance
27+ SecurityEvents chan<- ports.SecurityBackendEvent
2228 // Cursor, when set, is drawn into screenshots with the image from
2329 // LoadCursor at the scene scale.
2430 Cursor *Cursor
@@ -83,6 +89,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
8389 requests := requestStorage[:0]
8490 ctx, cancelCaptures := context.WithCancel(ctx)
8591 pipeline := capture.NewPipeline(ctx, opts.Captured)
92+ pipeline.Security = opts.Security
8693 if opts.NewCaptureRenderer != nil {
8794 pipeline.EnableOffscreen(opts.NewCaptureRenderer)
8895 }
@@ -97,7 +104,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
97104 }
98105 for {
99106 select {
100- case q := <-incoming:
107+ case q, ok := <-incoming:
108+ if !ok {
109+ return
110+ }
101111 capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)
102112 default:
103113 return
@@ -110,6 +120,77 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
110120 var holds holdSnapshots
111121 // pending is a report the channel could not take, retried soon.
112122 var pending *ports.OutputPresented
123+ var security ports.SecurityState
124+ securityKnown := false
125+ failRequests := func(reason string) {
126+ for _, q := range requests {
127+ if !capture.Handed(q) {
128+ capture.Fail(ctx, q, fmt.Errorf("%s", reason), opts.Captured)
129+ }
130+ }
131+ clear(requests)
132+ requests = requestStorage[:0]
133+ }
134+ // A transition is its own frame, never a locker-buffer-dependent frame.
135+ // Keep its epoch through the fence wait and report only actual completion.
136+ checkSecurity := func() error {
137+ if opts.Security == nil {
138+ return nil
139+ }
140+ for {
141+ next := opts.Security.Snapshot()
142+ if securityKnown && next == security {
143+ return nil
144+ }
145+ // No hardware scanout exists: an already applied Off scene is
146+ // confirmed software inactivity, independent of its old epoch.
147+ inactive := haveScene && scene.Off
148+ security, securityKnown = next, true
149+ pending = nil
150+ haveScene, dirty = false, false
151+ if !security.Protected {
152+ continue
153+ }
154+ black := ports.Scene{Security: security, Output: opts.Name, OutputWidth: opts.Width, OutputHeight: opts.Height, Scale: 1, Background: "#000000"}
155+ black.Off = inactive
156+ scene = black
157+ kind := ports.ProtectionInactiveOutput
158+ if !inactive {
159+ kind = ports.ProtectionProtectedFrame
160+ done, err := r.Render(black, nil)
161+ if err != nil {
162+ return fmt.Errorf("render protection: %w", err)
163+ }
164+ if done != nil {
165+ err = syncfile.Wait(ctx, done)
166+ _ = done.Close()
167+ if err != nil {
168+ return fmt.Errorf("protection fence: %w", err)
169+ }
170+ }
171+ }
172+ if opts.Security.Snapshot() != security {
173+ continue
174+ }
175+ if opts.SecurityEvents != nil {
176+ proof := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: security.Generation, Instance: opts.Instance, Kind: kind}}
177+ select {
178+ case opts.SecurityEvents <- proof:
179+ case <-ctx.Done():
180+ return ctx.Err()
181+ }
182+ }
183+ // Capture reply backpressure must not postpone the first black
184+ // completion/proof. Capture drainage is a separate parent barrier.
185+ failRequests("session protected")
186+ }
187+ }
188+ acceptScene := func(s ports.Scene) {
189+ if opts.Security != nil && (s.Security != security || s.Security != opts.Security.Snapshot()) {
190+ return
191+ }
192+ scene, haveScene, dirty = s, true, true
193+ }
113194 update := func(c ports.SurfaceContent) {
114195 seen[c.ID] = max(seen[c.ID], c.Seq)
115196 dirty = dirty || capture.Shows(scene, c.ID)
@@ -129,6 +210,12 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
129210 if ctx.Err() != nil {
130211 return nil
131212 }
213+ if err := checkSecurity(); err != nil {
214+ if ctx.Err() != nil {
215+ return nil
216+ }
217+ return err
218+ }
132219 var retry <-chan time.Time
133220 if pending != nil {
134221 retry = time.After(time.Millisecond)
@@ -136,15 +223,54 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
136223 select {
137224 case <-ctx.Done():
138225 return nil
226+ case _, ok := <-opts.SecurityChanges:
227+ if !ok {
228+ opts.SecurityChanges = nil
229+ }
230+ if err := checkSecurity(); err != nil {
231+ if ctx.Err() != nil {
232+ return nil
233+ }
234+ return err
235+ }
236+ continue
139237 case b := <-pipeline.Completed():
238+ // The completed token has left the pipeline's queue. Return its
239+ // lease before any transition path can error or block.
140240 pipeline.Recycle(b, r)
241+ if err := checkSecurity(); err != nil {
242+ if ctx.Err() != nil {
243+ return nil
244+ }
245+ return err
246+ }
141247 continue
142248 case b := <-pipeline.HiddenCompleted():
143249 pipeline.RecycleHidden(b)
250+ if err := checkSecurity(); err != nil {
251+ if ctx.Err() != nil {
252+ return nil
253+ }
254+ return err
255+ }
144256 pipeline.Retire(scene)
145257 continue
146- case q := <-incoming:
147- if scene.Off {
258+ case q, ok := <-incoming:
259+ if !ok {
260+ incoming = nil
261+ continue
262+ }
263+ if err := checkSecurity(); err != nil {
264+ // q has left incoming but is not yet owned by requests/workers.
265+ capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)
266+ if ctx.Err() != nil {
267+ return nil
268+ }
269+ return err
270+ }
271+ if opts.protected() {
272+ capture.Fail(ctx, q, fmt.Errorf("session protected"), opts.Captured)
273+ } else if scene.Off {
148274 capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)
149275 } else if len(requests) == cap(requests) {
150276 capture.Fail(ctx, q, fmt.Errorf("output capture batch full"), opts.Captured)
@@ -153,9 +279,23 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
153279 dirty = haveScene
154280 }
155281 case <-retry:
156- pending = opts.send(pending)
282+ if err := checkSecurity(); err != nil {
283+ if ctx.Err() != nil {
284+ return nil
285+ }
286+ return err
287+ }
288+ if pending != nil {
289+ pending = opts.send(pending)
290+ }
157291 continue
158292 case <-trim.C():
293+ if err := checkSecurity(); err != nil {
294+ if ctx.Err() != nil {
295+ return nil
296+ }
297+ return err
298+ }
159299 // An idle output renders nothing: free what windows that left
160300 // it held, and the child of an ended session.
161301 pipeline.Retire(scene)
@@ -168,14 +308,36 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
168308 scenes = nil
169309 continue
170310 }
171- scene, haveScene, dirty = s, true, true
311+ if err := checkSecurity(); err != nil {
312+ if ctx.Err() != nil {
313+ return nil
314+ }
315+ return err
316+ }
317+ acceptScene(s)
172318 case c, ok := <-contents:
173319 if !ok {
174320 contents = nil
175321 continue
176322 }
323+ if err := checkSecurity(); err != nil {
324+ if ctx.Err() != nil {
325+ return nil
326+ }
327+ return err
328+ }
177329 update(c)
178- case c := <-cursor:
330+ case c, ok := <-cursor:
331+ if !ok {
332+ cursor = nil
333+ continue
334+ }
335+ if err := checkSecurity(); err != nil {
336+ if ctx.Err() != nil {
337+ return nil
338+ }
339+ return err
340+ }
179341 // The cursor is only drawn into screenshots: no new frame.
180342 want, cursorScale = c, -1
181343 }
@@ -187,18 +349,39 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
187349 if !ok {
188350 scenes = nil
189351 } else {
190- scene, haveScene, dirty = s, true, true
352+ if err := checkSecurity(); err != nil {
353+ if ctx.Err() != nil {
354+ return nil
355+ }
356+ return err
357+ }
358+ acceptScene(s)
191359 }
192360 case c, ok := <-contents:
193361 if !ok {
194362 contents = nil
195363 } else {
364+ if err := checkSecurity(); err != nil {
365+ if ctx.Err() != nil {
366+ return nil
367+ }
368+ return err
369+ }
196370 update(c)
197371 }
198372 default:
199373 break drain
200374 }
201375 }
376+ if err := checkSecurity(); err != nil {
377+ if ctx.Err() != nil {
378+ return nil
379+ }
380+ return err
381+ }
382+ if opts.protected() {
383+ failRequests("session protected")
384+ }
202385 if scene.Off && len(requests) > 0 {
203386 for _, q := range requests {
204387 capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)
@@ -213,14 +396,25 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
213396 continue
214397 }
215398 dirty = false
216- if opts.Cursor != nil && opts.LoadCursor != nil && scene.Scale != cursorScale {
399+ if !opts.protected() && opts.Cursor != nil && opts.LoadCursor != nil && scene.Scale != cursorScale {
217400 cursorScale = scene.Scale
218401 if img, err := opts.LoadCursor(want, scene.Scale, 256); err == nil {
219- opts.Cursor.set(img)
402+ if !opts.protected() {
403+ opts.Cursor.set(img)
404+ }
220405 } else {
221406 opts.Log.Warn().Err(err).Msg("cursor")
222407 }
223408 }
409+ if err := checkSecurity(); err != nil {
410+ if ctx.Err() != nil {
411+ return nil
412+ }
413+ return err
414+ }
415+ if !haveScene {
416+ continue
417+ }
224418 start := time.Now()
225419 // Clean requests of a session the scene does not carry are failed
226420 // here. The others draw the clean frame first (same renderer, queue
@@ -230,6 +424,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
230424 if len(clean) > 0 && scene.CaptureScene != nil {
231425 // A hidden workspace: a child renderer draws it. The display's
232426 // fences do not cover the child, so wait for it before reporting.
427+ if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
428+ failRequests("security epoch changed")
429+ continue
430+ }
233431 pipeline.SubmitHidden(scene, surfaces, clean)
234432 clear(clean)
235433 // A slow child may outlive Wayland's stale-report timeout even
@@ -252,16 +450,38 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
252450 return fmt.Errorf("workspace frame fence: %w", err)
253451 }
254452 } else if len(clean) > 0 {
255- cdone, err := r.Render(pipeline.CleanScene(scene), surfaces)
256- if err != nil {
257- return fmt.Errorf("render clean frame: %w", err)
453+ if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
454+ failRequests("security epoch changed")
455+ continue
258456 }
457+ cdone, renderErr := r.Render(pipeline.CleanScene(scene), surfaces)
259458 if cdone != nil {
260- cdone.Close()
459+ // Clean composition reads client buffers even when the displayed
460+ // frame is skipped by a gate/off transition. Own and wait its
461+ // fence before any Seen report or protection check can proceed.
462+ waitErr := syncfile.Wait(ctx, cdone)
463+ _ = cdone.Close()
464+ if waitErr != nil {
465+ if ctx.Err() != nil {
466+ return nil
467+ }
468+ return fmt.Errorf("clean frame fence: %w", waitErr)
469+ }
470+ }
471+ if renderErr != nil {
472+ return fmt.Errorf("render clean frame: %w", renderErr)
473+ }
474+ if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
475+ failRequests("security epoch changed")
476+ continue
261477 }
262- pipeline.Submit(r, clean)
478+ pipeline.SubmitScoped(scene.Security, r, clean)
263479 clear(clean)
264480 }
481+ if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
482+ failRequests("security epoch changed")
483+ continue
484+ }
265485 done, err := r.Render(scene, surfaces)
266486 if err != nil {
267487 return fmt.Errorf("render frame: %w", err)
@@ -281,24 +501,36 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
281501 if len(normal) > 0 {
282502 opts.Log.Debug().Str("output", opts.Name).Int("captures", len(normal)).Msg("capture frame composed")
283503 }
284- pipeline.Submit(r, normal)
504+ // Do not capture or report a frame that crossed a transition during
505+ // Render/fence wait. The next loop establishes the new black proof.
506+ if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
507+ failRequests("security epoch changed")
508+ continue
509+ }
510+ pipeline.SubmitScoped(scene.Security, r, normal)
285511 clear(requests)
286512 requests = requestStorage[:0]
287513 frame++
288514 pending = opts.flipped(pending, seen, scene, surfaces)
289- if opts.ScreenshotDir != "" {
515+ if opts.ScreenshotDir != "" && !opts.protected() {
290516 shot := r.Pixels()
517+ if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
518+ continue
519+ }
291520 if shot == nil {
292521 opts.Log.Debug().Str("component", "headless").Str("output", opts.Name).Int("frame", frame).Msg("screenshot readback unavailable; frame skipped")
293522 continue
294523 }
295524 if opts.Cursor != nil {
296- opts.Cursor.draw(shot)
525+ opts.Cursor.drawSecure(shot, opts.Security)
526+ }
527+ if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
528+ continue
297529 }
298- if err := writePNG(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), shot); err != nil {
530+ if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), shot, opts.Security, scene.Security); err != nil {
299531 return fmt.Errorf("screenshot: %w", err)
300532 }
301- if err := writePNG(filepath.Join(opts.ScreenshotDir, "latest.png"), shot); err != nil {
533+ if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security); err != nil {
302534 return fmt.Errorf("latest screenshot: %w", err)
303535 }
304536 }
@@ -324,7 +556,14 @@ func (h *holdSnapshots) report(output string, seen, reads map[ports.WindowID]uin
324556 return ports.OutputPresented{Output: output, Seen: h.seen, ChildReads: h.reads}
325557 }
326558
327-func writePNG(path string, img *image.RGBA) error {
559+func (opts Options) protected() bool {
560+ return opts.Security != nil && opts.Security.Snapshot().Protected
561+}
562+
563+func writePNGSecure(path string, img *image.RGBA, security ports.SessionSecurity, state ports.SecurityState) error {
564+ if security != nil && (state.Protected || security.Snapshot() != state) {
565+ return nil
566+ }
328567 f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png")
329568 if err != nil {
330569 return err
@@ -337,6 +576,9 @@ func writePNG(path string, img *image.RGBA) error {
337576 if err := f.Close(); err != nil {
338577 return err
339578 }
579+ if security != nil && security.Snapshot() != state {
580+ return nil
581+ }
340582 return os.Rename(f.Name(), path)
341583 }
342584
M internal/adapters/headless/output_test.go +1 -1
@@ -193,7 +193,7 @@ func TestWritePNGAtomic(t *testing.T) {
193193 img := image.NewRGBA(image.Rect(0, 0, 1, 1))
194194 for _, name := range []string{"frame-000001.png", "latest.png"} {
195195 path := filepath.Join(dir, name)
196- if err := writePNG(path, img); err != nil {
196+ if err := writePNGSecure(path, img, nil, ports.SecurityState{}); err != nil {
197197 t.Fatal(err)
198198 }
199199 f, err := os.Open(path)
A internal/adapters/headless/security_capture_test.go +164 -0
@@ -0,0 +1,164 @@
1+package headless
2+
3+import (
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.
19+func TestScopedCaptureClosesPostRenderAdmissionGap(t *testing.T) {
20+ for _, clean := range []bool{false, true} {
21+ name := "normal"
22+ if clean {
23+ name = "clean"
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, Clean: clean, 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 clean {
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+
97+func 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+}
A internal/adapters/headless/security_clean_fence_test.go +143 -0
@@ -0,0 +1,143 @@
1+package headless
2+
3+import (
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+
17+func TestCleanFenceRetainsReadsAcrossEngageAndOff(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("clean 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, Clean: 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 clean 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 clean fence: %+v", report)
85+ case <-time.After(150 * time.Millisecond):
86+ }
87+ if _, err := fence.Stat(); err != nil {
88+ t.Fatalf("unsignalled clean read fence closed: %v", err)
89+ }
90+ select {
91+ case proof := <-proofs:
92+ t.Fatalf("protection path bypassed pending clean 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("clean 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+}
A internal/adapters/headless/security_test.go +370 -0
@@ -0,0 +1,370 @@
1+package headless
2+
3+import (
4+ "context"
5+ "errors"
6+ "image"
7+ "os"
8+ "path/filepath"
9+ "sync/atomic"
10+ "testing"
11+ "time"
12+
13+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
14+ "github.com/bnema/neferwl/internal/ports"
15+ "github.com/stretchr/testify/mock"
16+)
17+
18+func securityReceive[T any](t *testing.T, ch <-chan T) T {
19+ t.Helper()
20+ select {
21+ case v := <-ch:
22+ return v
23+ case <-time.After(time.Second):
24+ t.Fatal("timed out")
25+ var z T
26+ return z
27+ }
28+}
29+
30+func TestSecurityInitialBlackFenceBeforeLockerAndNoReadback(t *testing.T) {
31+ state := ports.SecurityState{Generation: 7, Protected: true}
32+ gate := portsmocks.NewMockSessionSecurity(t)
33+ gate.EXPECT().Snapshot().Return(state)
34+ r := portsmocks.NewMockRenderer(t)
35+ r.EXPECT().Close().Return().Once()
36+ fence, signal, err := os.Pipe()
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ defer signal.Close()
41+ rendered := make(chan ports.Scene, 4)
42+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, c map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
43+ if s.Seq == 0 {
44+ if s.Background != "#000000" || s.Security != state || len(s.Windows) != 0 || len(s.Layers) != 0 || s.Capture != nil || s.CaptureScene != nil || c != nil {
45+ t.Errorf("unsafe initial black: %+v surfaces %v", s, c)
46+ }
47+ rendered <- s
48+ return fence, nil
49+ }
50+ if s.Security != state || s.Seq != 2 {
51+ t.Errorf("stale desktop rendered: %+v", s)
52+ }
53+ rendered <- s
54+ return nil, nil
55+ }).Twice()
56+ // No Pixels/BeginCapture expectation: either call fails this test.
57+ scenes := make(chan ports.Scene, 3)
58+ scenes <- ports.Scene{Seq: 1, Windows: []ports.SceneWindow{{ID: 1}}}
59+ scenes <- ports.Scene{Security: state, Seq: 2, Background: "#000000", Windows: []ports.SceneWindow{{ID: 10, Fullscreen: true, Rect: ports.Rect{W: 2, H: 2}}}}
60+ contents := make(chan ports.SurfaceContent, 1)
61+ acquire, acquireSignal, err := os.Pipe()
62+ if err != nil {
63+ t.Fatal(err)
64+ }
65+ defer acquire.Close()
66+ defer acquireSignal.Close()
67+ contents <- ports.SurfaceContent{ID: 10, SHM: &ports.SHMBuffer{Pool: 10}, Acquire: acquire}
68+ proof := make(chan ports.SecurityBackendEvent, 4)
69+ replies := make(chan ports.CaptureDone, 4)
70+ requests := make(chan ports.CaptureRequest, 2)
71+ dst, err := os.CreateTemp(t.TempDir(), "capture")
72+ if err != nil {
73+ t.Fatal(err)
74+ }
75+ requests <- ports.CaptureRequest{ID: 44, Dst: ports.SHMBuffer{File: dst}}
76+ ctx, cancel := context.WithCancel(context.Background())
77+ done := make(chan error, 1)
78+ dir := t.TempDir()
79+ go func() {
80+ done <- Run(ctx, Options{Security: gate, Instance: 9, SecurityEvents: proof, Width: 2, Height: 2, Name: "H", ScreenshotDir: dir, Cursor: &Cursor{}, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, requests)
81+ }()
82+ t.Cleanup(func() {
83+ cancel()
84+ if err := securityReceive(t, done); err != nil {
85+ t.Error(err)
86+ }
87+ })
88+ securityReceive(t, rendered)
89+ select {
90+ case p := <-proof:
91+ t.Fatalf("proof before fence: %+v", p)
92+ default:
93+ }
94+ if _, err := signal.Write([]byte{1}); err != nil {
95+ t.Fatal(err)
96+ }
97+ p := securityReceive(t, proof).(ports.SecurityOutputProof)
98+ if p.Proof != (ports.OutputProtection{Generation: 7, Instance: 9, Kind: ports.ProtectionProtectedFrame}) {
99+ t.Fatal(p)
100+ }
101+ securityReceive(t, rendered)
102+ result := securityReceive(t, replies)
103+ if result.ID != 44 || result.Err == nil {
104+ t.Fatal(result)
105+ }
106+ if _, err := dst.Stat(); err == nil {
107+ t.Fatal("capture destination not closed")
108+ }
109+ if err := dst.Close(); err == nil {
110+ t.Fatal("destination was still open")
111+ }
112+ select {
113+ case result := <-replies:
114+ t.Fatalf("duplicate failure: %+v", result)
115+ default:
116+ }
117+ files, err := os.ReadDir(dir)
118+ if err != nil || len(files) != 0 {
119+ t.Fatalf("protected screenshot files: %v %v", files, err)
120+ }
121+}
122+
123+func TestSecurityChangesWakeIdleAndRejectQueuedDesktop(t *testing.T) {
124+ var state atomic.Value
125+ state.Store(ports.SecurityState{})
126+ gate := portsmocks.NewMockSessionSecurity(t)
127+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state.Load().(ports.SecurityState) })
128+ r := portsmocks.NewMockRenderer(t)
129+ r.EXPECT().Close().Return().Once()
130+ rendered := make(chan ports.Scene, 4)
131+ desktopFinished := make(chan struct{})
132+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, c map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
133+ if s.Seq != 0 {
134+ rendered <- s
135+ <-desktopFinished
136+ return nil, nil
137+ }
138+ if len(s.Windows) != 0 || c != nil || !s.Security.Protected {
139+ t.Errorf("bad transition black %+v", s)
140+ }
141+ rendered <- s
142+ return nil, nil
143+ }).Twice()
144+ scenes := make(chan ports.Scene, 2)
145+ scenes <- ports.Scene{Seq: 1, Windows: []ports.SceneWindow{{ID: 1}}}
146+ changes := make(chan ports.SecurityState, 1)
147+ proofs := make(chan ports.SecurityBackendEvent, 2)
148+ presented := make(chan ports.OutputPresented, 4)
149+ ctx, cancel := context.WithCancel(context.Background())
150+ done := make(chan error, 1)
151+ go func() {
152+ done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, Instance: 3, SecurityEvents: proofs, Presented: presented, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil)
153+ }()
154+ t.Cleanup(func() {
155+ cancel()
156+ if err := securityReceive(t, done); err != nil {
157+ t.Error(err)
158+ }
159+ })
160+ securityReceive(t, rendered)
161+ locked := ports.SecurityState{Generation: 1, Protected: true}
162+ state.Store(locked)
163+ scenes <- ports.Scene{Seq: 99, Windows: []ports.SceneWindow{{ID: 1}}}
164+ changes <- locked
165+ close(desktopFinished)
166+ s := securityReceive(t, rendered)
167+ if s.Security != locked || s.Seq != 0 {
168+ t.Fatal(s)
169+ }
170+ securityReceive(t, proofs)
171+ select {
172+ case p := <-presented:
173+ if p.Flip != nil {
174+ t.Fatalf("stale render reported: %+v", p)
175+ }
176+ default:
177+ }
178+}
179+
180+func TestSecurityDedicatedChangeWakesWithoutScene(t *testing.T) {
181+ var state atomic.Value
182+ state.Store(ports.SecurityState{})
183+ gate := portsmocks.NewMockSessionSecurity(t)
184+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state.Load().(ports.SecurityState) })
185+ r := portsmocks.NewMockRenderer(t)
186+ r.EXPECT().Close().Return().Once()
187+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
188+ changes := make(chan ports.SecurityState, 1)
189+ proof := make(chan ports.SecurityBackendEvent, 1)
190+ ctx, cancel := context.WithCancel(context.Background())
191+ done := make(chan error, 1)
192+ go func() {
193+ done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, Instance: 5, SecurityEvents: proof, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil)
194+ }()
195+ locked := ports.SecurityState{Generation: 2, Protected: true}
196+ state.Store(locked)
197+ changes <- locked
198+ securityReceive(t, proof)
199+ cancel()
200+ if err := securityReceive(t, done); err != nil {
201+ t.Fatal(err)
202+ }
203+}
204+
205+func TestSecurityCursorAndPNGSuppressed(t *testing.T) {
206+ gate := portsmocks.NewMockSessionSecurity(t)
207+ state := ports.SecurityState{Generation: 1, Protected: true}
208+ gate.EXPECT().Snapshot().Return(state)
209+ c := &Cursor{}
210+ c.set(ports.CursorImage{W: 1, H: 1, Pixels: []byte{0, 0, 255, 255}})
211+ c.Move(0, 0)
212+ img := image.NewRGBA(image.Rect(0, 0, 1, 1))
213+ c.drawSecure(img, gate)
214+ if img.Pix[0] != 0 {
215+ t.Fatal("protected cursor drawn")
216+ }
217+ path := filepath.Join(t.TempDir(), "shot.png")
218+ if err := writePNGSecure(path, img, gate, ports.SecurityState{}); err != nil {
219+ t.Fatal(err)
220+ }
221+ if _, err := os.Stat(path); !os.IsNotExist(err) {
222+ t.Fatal("stale screenshot delivered")
223+ }
224+}
225+
226+func TestSecurityQueuedCaptureFailedOnceAndProofCancellation(t *testing.T) {
227+ var state atomic.Value
228+ state.Store(ports.SecurityState{})
229+ gate := portsmocks.NewMockSessionSecurity(t)
230+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state.Load().(ports.SecurityState) })
231+ r := portsmocks.NewMockRenderer(t)
232+ r.EXPECT().Close().Return().Once()
233+ black := make(chan struct{})
234+ r.EXPECT().Render(mock.Anything, mock.Anything).Run(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) { close(black) }).Return(nil, nil).Once()
235+ incoming := make(chan ports.CaptureRequest)
236+ replies := make(chan ports.CaptureDone, 2)
237+ changes := make(chan ports.SecurityState, 1)
238+ // No receiver: proof delivery must wait rather than drop, and cancel.
239+ proofs := make(chan ports.SecurityBackendEvent)
240+ ctx, cancel := context.WithCancel(context.Background())
241+ done := make(chan error, 1)
242+ go func() {
243+ done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, SecurityEvents: proofs, Instance: 4, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, incoming)
244+ }()
245+ file, err := os.CreateTemp(t.TempDir(), "queued")
246+ if err != nil {
247+ t.Fatal(err)
248+ }
249+ select {
250+ case incoming <- ports.CaptureRequest{ID: 100, Dst: ports.SHMBuffer{File: file}}:
251+ case <-time.After(time.Second):
252+ t.Fatal("capture not admitted")
253+ }
254+ locked := ports.SecurityState{Generation: 3, Protected: true}
255+ state.Store(locked)
256+ changes <- locked
257+ securityReceive(t, black)
258+ cancel()
259+ if err := securityReceive(t, done); err != nil {
260+ t.Fatal(err)
261+ }
262+ result := securityReceive(t, replies)
263+ if result.ID != 100 || result.Err == nil {
264+ t.Fatal(result)
265+ }
266+ if _, err := file.Stat(); err == nil {
267+ t.Fatal("queued FD leaked")
268+ }
269+ select {
270+ case result := <-replies:
271+ t.Fatalf("duplicate queued reply: %+v", result)
272+ default:
273+ }
274+}
275+
276+func TestSecurityOffOutputProofDoesNotRender(t *testing.T) {
277+ var state atomic.Value
278+ state.Store(ports.SecurityState{})
279+ gate := portsmocks.NewMockSessionSecurity(t)
280+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state.Load().(ports.SecurityState) })
281+ r := portsmocks.NewMockRenderer(t)
282+ r.EXPECT().Close().Return().Once()
283+ scenes := make(chan ports.Scene, 1)
284+ scenes <- ports.Scene{Off: true}
285+ presented := make(chan ports.OutputPresented, 1)
286+ changes := make(chan ports.SecurityState, 1)
287+ proofs := make(chan ports.SecurityBackendEvent, 1)
288+ ctx, cancel := context.WithCancel(context.Background())
289+ done := make(chan error, 1)
290+ go func() {
291+ done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, SecurityEvents: proofs, Instance: 12, Presented: presented, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil)
292+ }()
293+ securityReceive(t, presented) // Off has actually been applied by the owner.
294+ locked := ports.SecurityState{Generation: 4, Protected: true}
295+ state.Store(locked)
296+ changes <- locked
297+ proof := securityReceive(t, proofs).(ports.SecurityOutputProof)
298+ if proof.Proof.Kind != ports.ProtectionInactiveOutput || proof.Proof.Generation != 4 || proof.Proof.Instance != 12 {
299+ t.Fatal(proof)
300+ }
301+ cancel()
302+ if err := securityReceive(t, done); err != nil {
303+ t.Fatal(err)
304+ }
305+}
306+
307+func TestSecurityCompletionLeaseRecycledBeforeProtectionRenderFailure(t *testing.T) {
308+ var state atomic.Value
309+ state.Store(ports.SecurityState{})
310+ gate := portsmocks.NewMockSessionSecurity(t)
311+ var submitted atomic.Bool
312+ ownerWaiting := make(chan struct{})
313+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
314+ v := state.Load().(ports.SecurityState)
315+ if submitted.CompareAndSwap(true, false) {
316+ close(ownerWaiting)
317+ }
318+ return v
319+ })
320+ r := portsmocks.NewMockRenderer(t)
321+ frame := portsmocks.NewMockCaptureFrame(t)
322+ frame.EXPECT().Done().Return(nil)
323+ frame.EXPECT().Read(mock.Anything, mock.Anything, 8).RunAndReturn(func(image.Rectangle, []byte, int) error {
324+ <-ownerWaiting // owner has checked the old gate and is selecting
325+ state.Store(ports.SecurityState{Generation: 1, Protected: true})
326+ return nil
327+ }).Once()
328+ r.EXPECT().BeginCapture().Run(func() { submitted.Store(true) }).Return(frame, nil).Once()
329+ var recycled atomic.Bool
330+ r.EXPECT().EndCapture(frame).Run(func(ports.CaptureFrame) { recycled.Store(true) }).Return().Once()
331+ failure := errors.New("protection render failed")
332+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
333+ if s.Seq == 0 {
334+ if !recycled.Load() {
335+ t.Error("pulled completion lease not recycled before transition")
336+ }
337+ return nil, failure
338+ }
339+ return nil, nil
340+ }).Twice()
341+ r.EXPECT().Close().Return().Once()
342+ incoming := make(chan ports.CaptureRequest)
343+ scenes := make(chan ports.Scene)
344+ replies := make(chan ports.CaptureDone, 1)
345+ ctx, cancel := context.WithCancel(context.Background())
346+ defer cancel()
347+ done := make(chan error, 1)
348+ go func() {
349+ done <- Run(ctx, Options{Security: gate, Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, incoming)
350+ }()
351+ file, err := os.CreateTemp(t.TempDir(), "capture")
352+ if err != nil {
353+ t.Fatal(err)
354+ }
355+ if err := file.Truncate(16); err != nil {
356+ t.Fatal(err)
357+ }
358+ incoming <- ports.CaptureRequest{ID: 77, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: ports.SHMBuffer{File: file}}
359+ scenes <- ports.Scene{Seq: 1, Scale: 1}
360+ securityReceive(t, replies)
361+ if err := securityReceive(t, done); !errors.Is(err, failure) {
362+ t.Fatal(err)
363+ }
364+ if !recycled.Load() {
365+ t.Fatal("capture lease leaked")
366+ }
367+ // Read arguments contain an mmap slice that capture has now unmapped;
368+ // don't let testify format that expired borrowed memory at cleanup.
369+ frame.Calls = nil
370+}
M internal/adapters/headlessinput/input.go +56 -9
@@ -20,10 +20,53 @@ import (
2020 // software cursor of the output under them (physical position on it), using
2121 // the latest layout from layouts.
2222 func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script <-chan string, input chan<- ports.InputEvent, layouts <-chan ports.Layout, moveCursor func(output string, x, y float64), log zerowrap.Logger) error {
23+ return RunSecure(ctx, km, keymaps, script, input, layouts, moveCursor, log, nil)
24+}
25+
26+// RunSecure snapshots the protection epoch before each event enters the input
27+// queue. Scripts are a headless test transport, never an unlock authority.
28+func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script <-chan string, input chan<- ports.InputEvent, layouts <-chan ports.Layout, moveCursor func(output string, x, y float64), log zerowrap.Logger, security ports.SessionSecurity) error {
29+ snapshot := func() ports.SecurityState {
30+ if security != nil {
31+ return security.Snapshot()
32+ }
33+ return ports.SecurityState{}
34+ }
35+ stamp := func(ev ports.InputEvent, state ports.SecurityState) ports.InputEvent {
36+ if security == nil {
37+ return ev
38+ }
39+ return ports.SecurityInput{State: state, Event: ev}
40+ }
41+ // Script payloads can contain credentials even when parsing fails. Keep a
42+ // useful generic warning while protected, never input-derived fields.
43+ warnInput := func(field, value, message string) {
44+ entry := log.Warn()
45+ if snapshot().Protected {
46+ entry.Msg("invalid script input")
47+ return
48+ }
49+ entry.Str(field, value).Msg(message)
50+ }
2351 var layout ports.Layout
2452 defer func() { km.Close() }()
2553 emit := func(code uint32, down bool) error {
26- ev := km.Key(code, down, msec(monotonic()))
54+ state := snapshot()
55+ var key ports.KeyEvent
56+ if security != nil {
57+ var deliver bool
58+ var err error
59+ key, deliver, err = km.KeySecure(code, down, msec(monotonic()), state)
60+ if err != nil {
61+ return err
62+ }
63+ if !deliver {
64+ return nil
65+ }
66+ } else {
67+ key = km.Key(code, down, msec(monotonic()))
68+ }
69+ ev := stamp(key, state)
2770 select {
2871 case <-ctx.Done():
2972 return ctx.Err()
@@ -41,7 +84,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
4184 stroke := func(name string, mods []uint32) error {
4285 code, shift, ok := km.KeycodeFor(name)
4386 if !ok {
44- log.Warn().Str("key", name).Msg("unknown key")
87+ warnInput("key", name, "unknown key")
4588 return nil
4689 }
4790 if shift {
@@ -79,6 +122,9 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
79122 case <-ctx.Done():
80123 return ctx.Err()
81124 case next := <-keymaps:
125+ if security != nil {
126+ next.QuarantineFrom(km)
127+ }
82128 km.Close()
83129 km = next
84130 log.Info().Msg("keymap replaced")
@@ -89,6 +135,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
89135 }
90136 fields := strings.Fields(line)
91137 sendPointer := func(ev ports.InputEvent) error {
138+ ev = stamp(ev, snapshot())
92139 select {
93140 case <-ctx.Done():
94141 return ctx.Err()
@@ -122,7 +169,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
122169 continue
123170 }
124171 }
125- log.Warn().Str("line", line).Msg("invalid move")
172+ warnInput("line", line, "invalid move")
126173 continue
127174 }
128175 if len(fields) > 0 && (fields[0] == "swipe" || fields[0] == "swipe4") {
@@ -132,7 +179,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
132179 d, ok = dirs[fields[1]]
133180 }
134181 if !ok {
135- log.Warn().Str("line", line).Msg("invalid swipe")
182+ warnInput("line", line, "invalid swipe")
136183 continue
137184 }
138185 fingers := 3
@@ -153,7 +200,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
153200 }
154201 code := buttonCode(name)
155202 if len(fields) > 2 || (len(fields) != 2 && fields[0] != "click") || code == 0 {
156- log.Warn().Str("line", line).Msg("invalid button")
203+ warnInput("line", line, "invalid button")
157204 continue
158205 }
159206 if fields[0] != "up" {
@@ -176,7 +223,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
176223 name = string(r)
177224 }
178225 if name == "" {
179- log.Warn().Str("rune", string(r)).Msg("unknown rune")
226+ warnInput("rune", string(r), "unknown rune")
180227 continue
181228 }
182229 if err := stroke(name, nil); err != nil {
@@ -202,7 +249,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
202249 }
203250 }
204251 if !valid {
205- log.Warn().Str("combo", line).Msg("unknown modifier")
252+ warnInput("combo", line, "unknown modifier")
206253 continue
207254 }
208255 if err := stroke(parts[len(parts)-1], mods); err != nil {
@@ -211,7 +258,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
211258 case strings.HasPrefix(line, "sleep "):
212259 d, err := time.ParseDuration(strings.TrimPrefix(line, "sleep "))
213260 if err != nil || d < 0 {
214- log.Warn().Str("line", line).Msg("invalid sleep")
261+ warnInput("line", line, "invalid sleep")
215262 continue
216263 }
217264 timer := time.NewTimer(d)
@@ -222,7 +269,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
222269 case <-timer.C:
223270 }
224271 default:
225- log.Warn().Str("line", line).Msg("unknown script line")
272+ warnInput("line", line, "unknown script line")
226273 }
227274 }
228275 }
A internal/adapters/headlessinput/security_test.go +270 -0
@@ -0,0 +1,270 @@
1+package headlessinput
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "strings"
8+ "sync"
9+ "sync/atomic"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/neferwl/internal/adapters/xkb"
14+ "github.com/bnema/neferwl/internal/logging"
15+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
16+ "github.com/bnema/neferwl/internal/ports"
17+ "github.com/bnema/zerowrap"
18+)
19+
20+func TestSecureScriptSnapshotsEveryInputKind(t *testing.T) {
21+ km, err := xkb.New(xkb.RMLVO{Layout: "us"})
22+ if err != nil {
23+ t.Skip(err)
24+ }
25+ script := make(chan string, 4)
26+ for _, line := range []string{"key A", "move 10 20", "click", "swipe left"} {
27+ script <- line
28+ }
29+ close(script)
30+ input := make(chan ports.InputEvent, 32)
31+ security := portsmocks.NewMockSessionSecurity(t)
32+ state := ports.SecurityState{Generation: 7, Protected: true}
33+ security.EXPECT().Snapshot().Return(state)
34+ if err := RunSecure(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input"), security); err != nil {
35+ t.Fatal(err)
36+ }
37+ kinds := map[string]bool{}
38+ for len(input) > 0 {
39+ raw := <-input
40+ ev, ok := raw.(ports.SecurityInput)
41+ if !ok || ev.State != state {
42+ t.Fatalf("unstamped event %T %+v", raw, raw)
43+ }
44+ switch ev.Event.(type) {
45+ case ports.KeyEvent:
46+ kinds["key"] = true
47+ case ports.PointerMotion:
48+ kinds["motion"] = true
49+ case ports.PointerButton:
50+ kinds["button"] = true
51+ case ports.SwipeBegin:
52+ kinds["begin"] = true
53+ case ports.SwipeUpdate:
54+ kinds["update"] = true
55+ case ports.SwipeEnd:
56+ kinds["end"] = true
57+ default:
58+ t.Fatalf("unexpected input %T", ev.Event)
59+ }
60+ }
61+ if len(kinds) != 6 {
62+ t.Fatalf("input kinds %v", kinds)
63+ }
64+}
65+
66+// securityLogBuffer is a real synchronized io.Writer, not a logging double.
67+type securityLogBuffer struct {
68+ mu sync.Mutex
69+ buf bytes.Buffer
70+}
71+
72+func (b *securityLogBuffer) Write(p []byte) (int, error) {
73+ b.mu.Lock()
74+ defer b.mu.Unlock()
75+ return b.buf.Write(p)
76+}
77+
78+func (b *securityLogBuffer) String() string {
79+ b.mu.Lock()
80+ defer b.mu.Unlock()
81+ return b.buf.String()
82+}
83+
84+func TestSecureScriptDiagnosticsRedactProtectedPayloads(t *testing.T) {
85+ lines := []string{
86+ "type 🔐", "key credential-secret", "key credential-modifier+A",
87+ "move credential-coordinate 2", "swipe credential-direction",
88+ "click credential-button", "sleep credential-duration", "credential-full-command",
89+ }
90+ for _, protected := range []bool{false, true} {
91+ name := "unlocked"
92+ if protected {
93+ name = "protected"
94+ }
95+ t.Run(name, func(t *testing.T) {
96+ km, err := xkb.New(xkb.RMLVO{Layout: "us"})
97+ if err != nil {
98+ t.Skip(err)
99+ }
100+ security := portsmocks.NewMockSessionSecurity(t)
101+ security.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: protected}).Times(len(lines))
102+ var buf securityLogBuffer
103+ log := zerowrap.Logger{Logger: zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf}).With().Str("component", "input").Logger()}
104+ script := make(chan string, len(lines))
105+ for _, line := range lines {
106+ script <- line
107+ }
108+ close(script)
109+ if err := RunSecure(context.Background(), km, nil, script, make(chan ports.InputEvent, 32), nil, nil, log, security); err != nil {
110+ t.Fatal(err)
111+ }
112+ output := buf.String()
113+ entries := strings.Split(strings.TrimSpace(output), "\n")
114+ if len(entries) != len(lines) {
115+ t.Fatalf("diagnostics missing: %s", output)
116+ }
117+ if protected {
118+ for _, secret := range []string{"🔐", "credential", "type 🔐", "key credential-secret", "credential-full-command"} {
119+ if strings.Contains(output, secret) {
120+ t.Fatalf("protected credential %q logged: %s", secret, output)
121+ }
122+ }
123+ for _, entry := range entries {
124+ var fields map[string]any
125+ if err := json.Unmarshal([]byte(entry), &fields); err != nil {
126+ t.Fatal(err)
127+ }
128+ if fields["message"] != "invalid script input" {
129+ t.Fatalf("input-derived diagnostic: %s", entry)
130+ }
131+ for _, field := range []string{"rune", "key", "combo", "line"} {
132+ if _, exists := fields[field]; exists {
133+ t.Fatalf("protected field %s: %s", field, entry)
134+ }
135+ }
136+ }
137+ } else {
138+ for _, value := range []string{"🔐", "credential-secret", "credential-full-command", "unknown rune", "unknown key", "invalid move"} {
139+ if !strings.Contains(output, value) {
140+ t.Fatalf("unlocked diagnostic %q changed: %s", value, output)
141+ }
142+ }
143+ }
144+ })
145+ }
146+}
147+
148+func TestSecureScriptSnapshotsBeforeKeyTranslation(t *testing.T) {
149+ km, err := xkb.New(xkb.RMLVO{Layout: "us"})
150+ if err != nil {
151+ t.Skip(err)
152+ }
153+ security := portsmocks.NewMockSessionSecurity(t)
154+ state := ports.SecurityState{Generation: 7, Protected: true}
155+ security.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
156+ // The first emitted event presses Shift. If snapshot happened after
157+ // km.Key, the live keymap would already contain the Shift modifier.
158+ if ev := km.Key(30, false, 0); ev.Mods&ports.ModShift != 0 {
159+ t.Error("snapshot taken after key translation updated Shift")
160+ }
161+ return state
162+ }).Once()
163+ security.EXPECT().Snapshot().Return(state).Times(3)
164+ script := make(chan string, 1)
165+ script <- "key Shift+A"
166+ close(script)
167+ input := make(chan ports.InputEvent, 4)
168+ if err := RunSecure(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input"), security); err != nil {
169+ t.Fatal(err)
170+ }
171+ if len(input) != 4 {
172+ t.Fatalf("events=%d", len(input))
173+ }
174+ first := (<-input).(ports.SecurityInput)
175+ if first.State != state || first.Event.(ports.KeyEvent).Keycode != 42 {
176+ t.Fatalf("first event %#v", first)
177+ }
178+}
179+
180+func TestSecureScriptBlockedSendPreservesProductionSnapshot(t *testing.T) {
181+ for _, line := range []string{"key a", "move 1 2"} {
182+ t.Run(line, func(t *testing.T) {
183+ km, err := xkb.New(xkb.RMLVO{Layout: "us"})
184+ if err != nil {
185+ t.Skip(err)
186+ }
187+ original := ports.SecurityState{Generation: 1, Protected: true}
188+ changed := ports.SecurityState{Generation: 2}
189+ security := portsmocks.NewMockSessionSecurity(t)
190+ var current atomic.Pointer[ports.SecurityState]
191+ current.Store(&original)
192+ produced := make(chan struct{})
193+ security.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
194+ state := *current.Load()
195+ close(produced)
196+ return state
197+ }).Once()
198+ if line == "key a" {
199+ security.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return *current.Load() }).Once()
200+ }
201+ script := make(chan string, 1)
202+ script <- line
203+ close(script)
204+ input := make(chan ports.InputEvent) // no receiver until gate changes
205+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
206+ defer cancel()
207+ done := make(chan error, 1)
208+ go func() { done <- RunSecure(ctx, km, nil, script, input, nil, nil, logging.For(ctx, "input"), security) }()
209+ select {
210+ case <-produced:
211+ case <-ctx.Done():
212+ t.Fatal("no production snapshot")
213+ }
214+ current.Store(&changed)
215+ select {
216+ case raw := <-input:
217+ if ev, ok := raw.(ports.SecurityInput); !ok || ev.State != original {
218+ t.Fatalf("blocked event restamped: %#v", raw)
219+ }
220+ case <-ctx.Done():
221+ t.Fatal("blocked event not delivered")
222+ }
223+ // The release of a key held across the transition is quarantined.
224+ // It must not be delivered into the new epoch.
225+
226+ select {
227+ case err := <-done:
228+ if err != nil {
229+ t.Fatal(err)
230+ }
231+ case <-ctx.Done():
232+ t.Fatal("script did not stop")
233+ }
234+ })
235+ }
236+}
237+
238+func TestSecureScriptQuarantinesModifierAcrossTransition(t *testing.T) {
239+ km, err := xkb.New(xkb.RMLVO{Layout: "us"})
240+ if err != nil {
241+ t.Fatal(err)
242+ }
243+ initial := ports.SecurityState{}
244+ locked := ports.SecurityState{Generation: 1, Protected: true}
245+ security := portsmocks.NewMockSessionSecurity(t)
246+ security.EXPECT().Snapshot().Return(initial).Once() // Super down
247+ security.EXPECT().Snapshot().Return(locked).Times(5) // q down/up, held Super up, fresh q down/up
248+ script := make(chan string, 2)
249+ script <- "key Super+q"
250+ script <- "key q"
251+ close(script)
252+ input := make(chan ports.InputEvent, 8)
253+ if err := RunSecure(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input"), security); err != nil {
254+ t.Fatal(err)
255+ }
256+ if len(input) != 5 {
257+ t.Fatalf("quarantined release replayed: %d events", len(input))
258+ }
259+ first := (<-input).(ports.SecurityInput)
260+ if first.State != initial || first.Event.(ports.KeyEvent).Keycode != 125 {
261+ t.Fatalf("first event %#v", first)
262+ }
263+ for len(input) > 0 {
264+ ev := (<-input).(ports.SecurityInput)
265+ key := ev.Event.(ports.KeyEvent)
266+ if ev.State != locked || key.Keycode != 16 || key.Mods != 0 || key.State != (ports.ModState{}) {
267+ t.Fatalf("script password inherited modifiers: %#v", ev)
268+ }
269+ }
270+}
M internal/adapters/launcher/launcher.go +21 -2
@@ -16,8 +16,10 @@ import (
1616 )
1717
1818 type Launcher struct {
19- env []string
20- log zerowrap.Logger
19+ // Security is the optional defensive admission gate. Set before Run.
20+ Security ports.SessionSecurity
21+ env []string
22+ log zerowrap.Logger
2123 }
2224
2325 func New(env []string, log zerowrap.Logger) *Launcher {
@@ -64,6 +66,9 @@ func (l *Launcher) Run(ctx context.Context, reqs <-chan ports.SpawnRequest) erro
6466 if !ok {
6567 return nil
6668 }
69+ if !l.admitted(req) {
70+ continue
71+ }
6772 if len(req.Argv) == 0 {
6873 l.log.Warn().Msg("empty spawn argv")
6974 continue
@@ -79,6 +84,12 @@ func (l *Launcher) Run(ctx context.Context, reqs <-chan ports.SpawnRequest) erro
7984 cmd.Stdout = nil
8085 cmd.Stderr = nil
8186 cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
87+ // Path lookup and environment construction may race a transition.
88+ // Recheck at the native process-start boundary using the original
89+ // owner epoch, never relabel a delayed request.
90+ if !l.admitted(req) {
91+ continue
92+ }
8293 if err := cmd.Start(); err != nil {
8394 l.log.Warn().Err(err).Str("binary", req.Argv[0]).Msg("spawn failed")
8495 continue
@@ -89,6 +100,14 @@ func (l *Launcher) Run(ctx context.Context, reqs <-chan ports.SpawnRequest) erro
89100 }
90101 }
91102
103+func (l *Launcher) admitted(req ports.SpawnRequest) bool {
104+ if l.Security == nil {
105+ return true
106+ }
107+ state := l.Security.Snapshot()
108+ return !state.Protected && req.Security == state
109+}
110+
92111 func childLookPath(name string, env []string) (string, error) {
93112 if strings.Contains(name, "/") {
94113 return name, nil
A internal/adapters/launcher/security_test.go +58 -0
@@ -0,0 +1,58 @@
1+package launcher
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
10+ "github.com/bnema/neferwl/internal/logging"
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+)
14+
15+func TestSpawnAdmissionRequiresCurrentUnlockedEpoch(t *testing.T) {
16+ var gate sessionsecurity.Gate
17+ l := New(nil, logging.For(context.Background(), "launcher"))
18+ l.Security = &gate
19+ if !l.admitted(ports.SpawnRequest{}) {
20+ t.Fatal("initial unlocked request refused")
21+ }
22+ protected, err := gate.Engage()
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ if l.admitted(ports.SpawnRequest{}) || l.admitted(ports.SpawnRequest{Security: protected}) {
27+ t.Fatal("protected spawn admitted")
28+ }
29+ unlocked, err := gate.Release(protected.Generation)
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ if l.admitted(ports.SpawnRequest{}) || l.admitted(ports.SpawnRequest{Security: protected}) {
34+ t.Fatal("old spawn admitted after release")
35+ }
36+ if !l.admitted(ports.SpawnRequest{Security: unlocked}) {
37+ t.Fatal("current spawn refused")
38+ }
39+}
40+
41+func TestSpawnRechecksBeforeProcessStart(t *testing.T) {
42+ ctx := context.Background()
43+ security := portsmocks.NewMockSessionSecurity(t)
44+ security.EXPECT().Snapshot().Return(ports.SecurityState{}).Once()
45+ security.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true}).Once()
46+ l := New([]string{"PATH=/bin"}, logging.For(ctx, "launcher"))
47+ l.Security = security
48+ result := filepath.Join(t.TempDir(), "must-not-exist")
49+ requests := make(chan ports.SpawnRequest, 1)
50+ requests <- ports.SpawnRequest{Argv: []string{"touch", result}}
51+ close(requests)
52+ if err := l.Run(ctx, requests); err != nil {
53+ t.Fatal(err)
54+ }
55+ if _, err := os.Stat(result); !os.IsNotExist(err) {
56+ t.Fatalf("stale native spawn ran: %v", err)
57+ }
58+}
M internal/adapters/libinput/forward.go +19 -3
@@ -42,16 +42,20 @@ func newForwarder(log zerowrap.Logger) *forwarder {
4242 // push queues ev without blocking.
4343 func (f *forwarder) push(ev ports.InputEvent) {
4444 f.mu.Lock()
45- if m, ok := ev.(ports.PointerMotion); ok {
45+ if m, state, wrapped, ok := queuedMotion(ev); ok {
4646 f.stats.Motions++
4747 if n := len(f.queue); n > 0 {
48- if prev, tail := f.queue[n-1].(ports.PointerMotion); tail {
48+ if prev, prevState, prevWrapped, tail := queuedMotion(f.queue[n-1]); tail && wrapped == prevWrapped && state == prevState {
4949 // Relative deltas add up: games read them, not the position.
5050 m.DX += prev.DX
5151 m.DY += prev.DY
5252 m.UnaccelDX += prev.UnaccelDX
5353 m.UnaccelDY += prev.UnaccelDY
54- f.queue[n-1] = m
54+ if wrapped {
55+ f.queue[n-1] = ports.SecurityInput{State: state, Event: m}
56+ } else {
57+ f.queue[n-1] = m
58+ }
5559 f.stats.Coalesced++
5660 f.mu.Unlock()
5761 return
@@ -70,6 +74,18 @@ func (f *forwarder) push(ev ports.InputEvent) {
7074 }
7175 }
7276
77+// queuedMotion exposes only motion, retaining its exact production epoch.
78+// Raw and stamped events never coalesce with each other or across transitions.
79+func queuedMotion(ev ports.InputEvent) (ports.PointerMotion, ports.SecurityState, bool, bool) {
80+ var state ports.SecurityState
81+ wrapped := false
82+ if secure, ok := ev.(ports.SecurityInput); ok {
83+ state, ev, wrapped = secure.State, secure.Event, true
84+ }
85+ m, ok := ev.(ports.PointerMotion)
86+ return m, state, wrapped, ok
87+}
88+
7389 func (f *forwarder) pop() (ports.InputEvent, bool) {
7490 f.mu.Lock()
7591 defer f.mu.Unlock()
M internal/adapters/libinput/forward_test.go +36 -0
@@ -139,3 +139,39 @@ func (l *lockedBuffer) String() string {
139139 defer l.mu.Unlock()
140140 return l.b.String()
141141 }
142+
143+func TestForwarderSecurityMotionEpochs(t *testing.T) {
144+ states := []ports.SecurityState{
145+ {Generation: 1}, {Generation: 2, Protected: true}, {Generation: 3},
146+ {Generation: 3, Protected: true}, // exact state, not just generation
147+ }
148+ f := newForwarder(zerowrap.Default())
149+ for _, state := range states {
150+ for i := range 1000 {
151+ f.push(ports.SecurityInput{State: state, Event: ports.PointerMotion{X: float64(i), DX: 0.5, DY: 2, UnaccelDX: 3, UnaccelDY: 4}})
152+ }
153+ }
154+ // An unwrapped motion must not absorb or be absorbed by wrapped motion.
155+ f.push(ports.PointerMotion{X: 5})
156+ f.push(ports.SecurityInput{State: states[len(states)-1], Event: ports.PointerMotion{X: 6}})
157+ if len(f.queue) != len(states)+2 {
158+ t.Fatalf("wrong epoch coalescing: %d entries", len(f.queue))
159+ }
160+ for _, state := range states {
161+ ev, ok := f.pop()
162+ want := ports.SecurityInput{State: state, Event: ports.PointerMotion{X: 999, DX: 500, DY: 2000, UnaccelDX: 3000, UnaccelDY: 4000}}
163+ if !ok || ev != want {
164+ t.Fatalf("lost epoch/deltas: got %#v, want %#v", ev, want)
165+ }
166+ }
167+ if ev, _ := f.pop(); ev != (ports.PointerMotion{X: 5}) {
168+ t.Fatalf("raw motion changed: %#v", ev)
169+ }
170+ if ev, _ := f.pop(); ev != (ports.SecurityInput{State: states[len(states)-1], Event: ports.PointerMotion{X: 6}}) {
171+ t.Fatalf("wrapped tail changed: %#v", ev)
172+ }
173+ s := f.take()
174+ if s.Motions != 4002 || s.Coalesced != 3996 {
175+ t.Fatalf("stats %+v", s)
176+ }
177+}
M internal/adapters/libinput/libinput.go +70 -16
@@ -184,6 +184,10 @@ func goString(p *byte) string {
184184
185185 // Options configures the input adapter.
186186 type Options struct {
187+ // Security gates credential logging and emergency quit and stamps input
188+ // before forwarding. Nil preserves raw unlocked behavior; core remains
189+ // responsible for input admission.
190+ Security ports.SessionSecurity
187191 Seat ports.Seat
188192 SeatName string
189193 Keymap *xkb.Keymap
@@ -266,6 +270,9 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
266270 }
267271 opts.Log.Info().Bool("active", on).Msg("input")
268272 case km := <-opts.Keymaps:
273+ if opts.Security != nil {
274+ km.QuarantineFrom(opts.Keymap)
275+ }
269276 opts.Keymap.Close()
270277 opts.Keymap = km
271278 opts.Log.Info().Msg("keymap replaced")
@@ -418,7 +425,28 @@ func msec(v uint64) uint32 { return uint32(v / 1000) }
418425 // translate converts one libinput event. Timestamps are the device's
419426 // (CLOCK_MONOTONIC), so clients measure speeds (kinetic scrolling) on
420427 // when events happened, not when they were read.
428+// It snapshots admission before translating and queueing any input kind.
421429 func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.InputEvent, error) {
430+ state := securitySnapshot(opts.Security)
431+ out, err := translateEvent(ev, opts, p, in, state)
432+ return secureInput(out, opts.Security, state), err
433+}
434+
435+func securitySnapshot(security ports.SessionSecurity) ports.SecurityState {
436+ if security != nil {
437+ return security.Snapshot()
438+ }
439+ return ports.SecurityState{}
440+}
441+
442+func secureInput(ev ports.InputEvent, security ports.SessionSecurity, state ports.SecurityState) ports.InputEvent {
443+ if ev == nil || security == nil {
444+ return ev
445+ }
446+ return ports.SecurityInput{State: state, Event: ev}
447+}
448+
449+func translateEvent(ev uintptr, opts Options, p *pointer, in *inputState, state ports.SecurityState) (ports.InputEvent, error) {
422450 log := opts.Log
423451 switch eventType(ev) {
424452 case evDeviceAdded:
@@ -449,19 +477,7 @@ func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.Inpu
449477 case evKeyboardKey:
450478 k := keyboardEvent(ev)
451479 code, pressed := keyboardKey(k), keyboardState(k) == 1
452- ke := opts.Keymap.Key(code, pressed, msec(keyboardUsec(k)))
453- if opts.LogKeys {
454- log.Debug().Uint32("code", code).Str("keysym", ke.Keysym).Bool("pressed", pressed).Uint8("mods", uint8(ke.Mods)).Msg("key")
455- }
456- switch action, vt := hotkey(ke); action {
457- case hotkeyQuit:
458- log.Warn().Str("reason", "emergency-key").Msg("quit")
459- return nil, ErrEmergencyQuit
460- case hotkeyVT:
461- opts.Seat.SwitchVT(vt)
462- return nil, nil
463- }
464- return ke, nil
480+ return translateKeyboard(code, pressed, msec(keyboardUsec(k)), opts, state)
465481 case evPointerMotion:
466482 pe := pointerEvent(ev)
467483 m := p.move(pointerDX(pe), pointerDY(pe))
@@ -516,6 +532,41 @@ func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.Inpu
516532 return nil, nil
517533 }
518534
535+// translateKeyboard applies the production epoch before native translation.
536+// Quarantined events never reach logging, reserved hotkeys or core.
537+func translateKeyboard(code uint32, pressed bool, timeMsec uint32, opts Options, state ports.SecurityState) (ports.InputEvent, error) {
538+ var ke ports.KeyEvent
539+ if opts.Security != nil {
540+ var deliver bool
541+ var err error
542+ ke, deliver, err = opts.Keymap.KeySecure(code, pressed, timeMsec, state)
543+ if err != nil || !deliver {
544+ return nil, err
545+ }
546+ } else {
547+ ke = opts.Keymap.Key(code, pressed, timeMsec)
548+ }
549+ return translateKey(ke, opts, state)
550+}
551+
552+// translateKey applies the keyboard policy without native calls. One coherent
553+// snapshot governs both logging and reserved hotkeys; protected keys still go
554+// to core for the locker. VT switching is allowed and does not release the lock.
555+func translateKey(ke ports.KeyEvent, opts Options, security ports.SecurityState) (ports.InputEvent, error) {
556+ if opts.LogKeys && !security.Protected {
557+ opts.Log.Debug().Str("component", "input").Uint32("code", ke.Keycode).Str("keysym", ke.Keysym).Bool("pressed", ke.Pressed).Uint8("mods", uint8(ke.Mods)).Msg("key")
558+ }
559+ switch action, vt := hotkey(ke, security.Protected); action {
560+ case hotkeyQuit:
561+ opts.Log.Warn().Str("component", "input").Str("reason", "emergency-key").Msg("quit")
562+ return nil, ErrEmergencyQuit
563+ case hotkeyVT:
564+ opts.Seat.SwitchVT(vt)
565+ return nil, nil
566+ }
567+ return ke, nil
568+}
569+
519570 type hotkeyAction int
520571
521572 const (
@@ -524,8 +575,9 @@ const (
524575 hotkeyVT
525576 )
526577
527-// hotkey detects compositor-reserved keys on press: Ctrl+Alt+Backspace and Ctrl+Alt+F1..F12.
528-func hotkey(ke ports.KeyEvent) (hotkeyAction, int) {
578+// hotkey detects compositor-reserved keys on press: Ctrl+Alt+Backspace and
579+// Ctrl+Alt+F1..F12. Protection disables emergency quit, but not VT switching.
580+func hotkey(ke ports.KeyEvent, protected bool) (hotkeyAction, int) {
529581 if !ke.Pressed {
530582 return hotkeyNone, 0
531583 }
@@ -540,7 +592,9 @@ func hotkey(ke ports.KeyEvent) (hotkeyAction, int) {
540592 }
541593 switch {
542594 case ke.Keysym == "BackSpace" || ke.Keycode == 14:
543- return hotkeyQuit, 0
595+ if !protected {
596+ return hotkeyQuit, 0
597+ }
544598 case ke.Keycode >= 59 && ke.Keycode <= 68: // F1..F10
545599 return hotkeyVT, int(ke.Keycode) - 58
546600 case ke.Keycode == 87 || ke.Keycode == 88: // F11, F12
M internal/adapters/libinput/libinput_test.go +165 -3
@@ -1,10 +1,16 @@
11 package libinput
22
33 import (
4+ "bytes"
5+ "errors"
6+ "strings"
47 "testing"
58 "time"
69
10+ "github.com/bnema/neferwl/internal/adapters/xkb"
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
712 "github.com/bnema/neferwl/internal/ports"
13+ "github.com/bnema/zerowrap"
814 )
915
1016 func TestHotkey(t *testing.T) {
@@ -23,9 +29,15 @@ func TestHotkey(t *testing.T) {
2329 {"ctrl alt f12 raw", ports.KeyEvent{Keysym: "F12", Keycode: 88, Mods: ca, Pressed: true}, hotkeyVT, 12},
2430 {"plain f2", ports.KeyEvent{Keysym: "F2", Keycode: 60, Pressed: true}, hotkeyNone, 0},
2531 } {
26- a, vt := hotkey(tc.ev)
27- if a != tc.action || vt != tc.vt {
28- t.Errorf("%s: got %v %d", tc.name, a, vt)
32+ for _, protected := range []bool{false, true} {
33+ want := tc.action
34+ if protected && want == hotkeyQuit {
35+ want = hotkeyNone
36+ }
37+ a, vt := hotkey(tc.ev, protected)
38+ if a != want || vt != tc.vt {
39+ t.Errorf("%s protected=%v: got %v %d", tc.name, protected, a, vt)
40+ }
2941 }
3042 }
3143 }
@@ -175,3 +187,153 @@ func TestPointerConstraints(t *testing.T) {
175187 t.Fatal(p.x, p.y)
176188 }
177189 }
190+
191+func TestProtectedKeyboardPolicy(t *testing.T) {
192+ ca := ports.ModCtrl | ports.ModAlt
193+ for _, key := range []ports.KeyEvent{
194+ {Keysym: "credential-secret", Base: "raw-secret", Keycode: 424242, Pressed: true},
195+ {Keysym: "credential-secret", Keycode: 424242},
196+ {Keysym: "BackSpace", Mods: ca, Pressed: true},
197+ {Keysym: "raw-secret", Keycode: 14, Mods: ca, Pressed: true},
198+ } {
199+ t.Run(key.Keysym, func(t *testing.T) {
200+ var buf bytes.Buffer
201+ security := portsmocks.NewMockSessionSecurity(t)
202+ state := ports.SecurityState{Generation: 7, Protected: true}
203+ security.EXPECT().Snapshot().Return(state).Once()
204+ opts := Options{Security: security, LogKeys: true, Log: zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf})}
205+ produced := securitySnapshot(opts.Security)
206+ ev, err := translateKey(key, opts, produced)
207+ if err != nil || errors.Is(err, ErrEmergencyQuit) || ev != key {
208+ t.Fatalf("protected key lost/quit: event=%#v err=%v", ev, err)
209+ }
210+ if got := secureInput(ev, opts.Security, produced); got != (ports.SecurityInput{State: state, Event: key}) {
211+ t.Fatalf("wrong production epoch: %#v", got)
212+ }
213+ if buf.Len() != 0 {
214+ t.Fatalf("protected key logged identifiers: %s", &buf)
215+ }
216+ })
217+ }
218+}
219+
220+func TestKeyboardPolicyUnlockedAndVT(t *testing.T) {
221+ ca := ports.ModCtrl | ports.ModAlt
222+ for _, protected := range []bool{false, true} {
223+ t.Run(map[bool]string{false: "unlocked", true: "protected"}[protected], func(t *testing.T) {
224+ var buf bytes.Buffer
225+ security := portsmocks.NewMockSessionSecurity(t)
226+ state := ports.SecurityState{Generation: 9, Protected: protected}
227+ security.EXPECT().Snapshot().Return(state).Once()
228+ seat := portsmocks.NewMockSeat(t)
229+ seat.EXPECT().SwitchVT(2).Once()
230+ opts := Options{Security: security, Seat: seat, LogKeys: true, Log: zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf})}
231+ ev, err := translateKey(ports.KeyEvent{Keysym: "XF86Switch_VT_2", Keycode: 60, Mods: ca, Pressed: true}, opts, securitySnapshot(opts.Security))
232+ if ev != nil || err != nil {
233+ t.Fatalf("VT not consumed: %#v %v", ev, err)
234+ }
235+ if protected && buf.Len() != 0 {
236+ t.Fatalf("protected VT key logged: %s", &buf)
237+ }
238+ if !protected && !strings.Contains(buf.String(), "XF86Switch_VT_2") {
239+ t.Fatalf("unlocked debug logging changed: %s", &buf)
240+ }
241+ })
242+ }
243+ var buf bytes.Buffer
244+ opts := Options{LogKeys: true, Log: zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf})}
245+ key := ports.KeyEvent{Keysym: "q", Keycode: 16, Pressed: true}
246+ state := securitySnapshot(nil)
247+ ev, err := translateKey(key, opts, state)
248+ if err != nil || secureInput(ev, nil, state) != key || !strings.Contains(buf.String(), `"keysym":"q"`) {
249+ t.Fatalf("nil gate changed unlocked behavior: %#v %v %s", ev, err, &buf)
250+ }
251+ ev, err = translateKey(ports.KeyEvent{Keysym: "BackSpace", Mods: ca, Pressed: true}, opts, state)
252+ if ev != nil || !errors.Is(err, ErrEmergencyQuit) || !strings.Contains(buf.String(), "emergency-key") {
253+ t.Fatalf("unlocked emergency quit changed: %#v %v %s", ev, err, &buf)
254+ }
255+}
256+
257+func TestSecurityInputAllKindsProductionEpoch(t *testing.T) {
258+ security := portsmocks.NewMockSessionSecurity(t)
259+ old := ports.SecurityState{Generation: 11, Protected: true}
260+ newState := ports.SecurityState{Generation: 12}
261+ events := []ports.InputEvent{
262+ ports.KeyEvent{Keysym: "secret", Pressed: true}, ports.PointerMotion{X: 1},
263+ ports.PointerButton{Button: 272}, ports.PointerAxis{},
264+ ports.SwipeBegin{}, ports.SwipeUpdate{}, ports.SwipeEnd{},
265+ }
266+ security.EXPECT().Snapshot().Return(old).Times(len(events))
267+ security.EXPECT().Snapshot().Return(newState).Once()
268+ f := newForwarder(zerowrap.Default())
269+ for _, ev := range events {
270+ f.push(secureInput(ev, security, securitySnapshot(security)))
271+ }
272+ if securitySnapshot(security) != newState {
273+ t.Fatal("did not transition")
274+ }
275+ for _, want := range events {
276+ got, ok := f.pop()
277+ if !ok || got != (ports.SecurityInput{State: old, Event: want}) {
278+ t.Fatalf("queued event changed epoch: %#v", got)
279+ }
280+ }
281+ if secureInput(nil, security, old) != nil {
282+ t.Fatal("nil translated event was wrapped")
283+ }
284+}
285+
286+func TestKeyboardProducerQuarantinesBeforeHotkeysAndLogging(t *testing.T) {
287+ km, err := xkb.New(xkb.RMLVO{Layout: "us"})
288+ if err != nil {
289+ t.Fatal(err)
290+ }
291+ defer km.Close()
292+ initial := ports.SecurityState{}
293+ locked := ports.SecurityState{Generation: 1, Protected: true}
294+ unlocked := ports.SecurityState{Generation: 2}
295+ security := portsmocks.NewMockSessionSecurity(t)
296+ security.EXPECT().Snapshot().Return(initial).Times(3)
297+ security.EXPECT().Snapshot().Return(locked).Times(4)
298+ security.EXPECT().Snapshot().Return(unlocked).Times(5)
299+ var buf bytes.Buffer
300+ opts := Options{Keymap: km, Security: security, LogKeys: true, Log: zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf})}
301+ produce := func(code uint32, down bool) ports.InputEvent {
302+ t.Helper()
303+ state := securitySnapshot(opts.Security)
304+ ev, err := translateKeyboard(code, down, 123, opts, state)
305+ if err != nil {
306+ t.Fatal(err)
307+ }
308+ return secureInput(ev, opts.Security, state)
309+ }
310+ for _, code := range []uint32{125, 29, 56} {
311+ produce(code, true)
312+ }
313+ buf.Reset()
314+ for _, code := range []uint32{125, 29, 56} {
315+ if ev := produce(code, true); ev != nil {
316+ t.Fatalf("inherited repeat delivered: %#v", ev)
317+ }
318+ }
319+ // Ctrl+Alt held before lock must not activate emergency quit, even on a
320+ // fresh Backspace. Super must not contaminate the credential either.
321+ if ev := produce(14, true).(ports.SecurityInput).Event.(ports.KeyEvent); ev.Mods != 0 || ev.State != (ports.ModState{}) {
322+ t.Fatalf("protected fresh key inherited native state: %+v", ev)
323+ }
324+ if buf.Len() != 0 {
325+ t.Fatalf("protected keyboard logged: %s", &buf)
326+ }
327+ if ev := produce(16, true).(ports.SecurityInput); ev.State != unlocked || ev.Event.(ports.KeyEvent).Mods != 0 || ev.Event.(ports.KeyEvent).State != (ports.ModState{}) {
328+ t.Fatalf("unlocked q inherited state: %#v", ev)
329+ }
330+ buf.Reset()
331+ for _, code := range []uint32{14, 56, 29, 125} {
332+ if ev := produce(code, false); ev != nil {
333+ t.Fatalf("inherited release delivered: %#v", ev)
334+ }
335+ }
336+ if buf.Len() != 0 {
337+ t.Fatalf("quarantined release logged: %s", &buf)
338+ }
339+}
A internal/adapters/sessionsecurity/security.go +62 -0
@@ -0,0 +1,62 @@
1+// Package sessionsecurity supplies a coherent defensive session gate to
2+// boundaries outside the Wayland display goroutine. It owns no window state.
3+package sessionsecurity
4+
5+import (
6+ "errors"
7+ "math"
8+ "sync/atomic"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+var (
14+ ErrGenerationExhausted = errors.New("session security generation exhausted")
15+ ErrStaleRelease = errors.New("session security release does not own protection")
16+)
17+
18+// Gate's zero value is an unlocked session. The packed atomic prevents readers
19+// from observing a generation from one transition and protection from another.
20+// Only the display owner calls Engage and Release.
21+type Gate struct{ state atomic.Uint64 }
22+
23+func unpack(state uint64) ports.SecurityState {
24+ return ports.SecurityState{Generation: ports.LockGeneration(state >> 1), Protected: state&1 != 0}
25+}
26+
27+func (g *Gate) Snapshot() ports.SecurityState { return unpack(g.state.Load()) }
28+
29+// Engage creates a new protected generation, including orphan-owner takeover.
30+func (g *Gate) Engage() (ports.SecurityState, error) {
31+ for {
32+ old := g.state.Load()
33+ if old>>1 == math.MaxUint64>>1 {
34+ return unpack(old), ErrGenerationExhausted
35+ }
36+ next := ((old>>1)+1)<<1 | 1
37+ if g.state.CompareAndSwap(old, next) {
38+ return unpack(next), nil
39+ }
40+ }
41+}
42+
43+func (g *Gate) Release(expected ports.LockGeneration) (ports.SecurityState, error) {
44+ for {
45+ old := g.state.Load()
46+ state := unpack(old)
47+ if !state.Protected || state.Generation != expected {
48+ return state, ErrStaleRelease
49+ }
50+ // Reserve the final generation for protection. Publishing it unlocked
51+ // would make every later Engage fail without being able to protect.
52+ if old>>1 >= (math.MaxUint64>>1)-1 {
53+ return state, ErrGenerationExhausted
54+ }
55+ next := ((old >> 1) + 1) << 1
56+ if g.state.CompareAndSwap(old, next) {
57+ return unpack(next), nil
58+ }
59+ }
60+}
61+
62+var _ ports.SessionSecurityController = (*Gate)(nil)
A internal/adapters/sessionsecurity/security_test.go +107 -0
@@ -0,0 +1,107 @@
1+package sessionsecurity
2+
3+import (
4+ "errors"
5+ "math"
6+ "sync"
7+ "testing"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+func TestGateTransitionOwnership(t *testing.T) {
13+ var g Gate
14+ if got := g.Snapshot(); got != (ports.SecurityState{}) {
15+ t.Fatalf("zero state %v", got)
16+ }
17+ first, err := g.Engage()
18+ if err != nil || !first.Protected || first.Generation != 1 {
19+ t.Fatalf("engage %v %v", first, err)
20+ }
21+ second, err := g.Engage()
22+ if err != nil || !second.Protected || second.Generation != 2 {
23+ t.Fatalf("takeover %v %v", second, err)
24+ }
25+ if _, err := g.Release(first.Generation); !errors.Is(err, ErrStaleRelease) {
26+ t.Fatalf("old owner released: %v", err)
27+ }
28+ if got := g.Snapshot(); got != second {
29+ t.Fatalf("stale release changed gate: %v", got)
30+ }
31+ released, err := g.Release(second.Generation)
32+ if err != nil || released.Protected || released.Generation != 3 {
33+ t.Fatalf("release %v %v", released, err)
34+ }
35+ if _, err := g.Release(second.Generation); !errors.Is(err, ErrStaleRelease) {
36+ t.Fatalf("duplicate release: %v", err)
37+ }
38+}
39+
40+func TestGateExhaustionStaysProtected(t *testing.T) {
41+ var g Gate
42+ g.state.Store(math.MaxUint64)
43+ before := g.Snapshot()
44+ if _, err := g.Engage(); !errors.Is(err, ErrGenerationExhausted) {
45+ t.Fatalf("engage: %v", err)
46+ }
47+ if _, err := g.Release(before.Generation); !errors.Is(err, ErrGenerationExhausted) {
48+ t.Fatalf("release: %v", err)
49+ }
50+ if g.Snapshot() != before {
51+ t.Fatal("exhaustion changed protection")
52+ }
53+}
54+
55+func TestGatePenultimateReleaseStaysProtected(t *testing.T) {
56+ var g Gate
57+ g.state.Store(math.MaxUint64 - 2)
58+ before := g.Snapshot()
59+ if _, err := g.Release(before.Generation); !errors.Is(err, ErrGenerationExhausted) {
60+ t.Fatalf("penultimate release: %v", err)
61+ }
62+ if g.Snapshot() != before || !g.Snapshot().Protected {
63+ t.Fatal("release exhausted generation while unprotected")
64+ }
65+ next, err := g.Engage()
66+ if err != nil || !next.Protected || next.Generation != before.Generation+1 {
67+ t.Fatalf("final engagement: %v %v", next, err)
68+ }
69+ if _, err := g.Release(next.Generation); !errors.Is(err, ErrGenerationExhausted) {
70+ t.Fatalf("final release: %v", err)
71+ }
72+}
73+
74+func TestGateSnapshotCoherence(t *testing.T) {
75+ var g Gate
76+ var readers sync.WaitGroup
77+ stop := make(chan struct{})
78+ for range 4 {
79+ readers.Go(func() {
80+ for {
81+ select {
82+ case <-stop:
83+ return
84+ default:
85+ }
86+ s := g.Snapshot()
87+ // This test alternates engage/release without takeovers:
88+ // odd generations protected, even generations unlocked.
89+ if s.Protected != (s.Generation&1 != 0) {
90+ t.Errorf("torn state %v", s)
91+ return
92+ }
93+ }
94+ })
95+ }
96+ for range 1000 {
97+ s, err := g.Engage()
98+ if err != nil {
99+ t.Fatal(err)
100+ }
101+ if _, err := g.Release(s.Generation); err != nil {
102+ t.Fatal(err)
103+ }
104+ }
105+ close(stop)
106+ readers.Wait()
107+}
M internal/adapters/vulkan/capture.go +6 -0
@@ -377,6 +377,12 @@ func (r *Renderer) freeCapture() (*captureSlot, error) {
377377 return nil, nil
378378 }
379379
380+// CaptureSupported reports whether BeginCapture can work on this device: it
381+// needs sync-file export, which software devices such as lavapipe lack.
382+func (r *Renderer) CaptureSupported() bool {
383+ return r.dd != nil && r.syncFD && r.dd.HasGetSemaphoreFdKHR()
384+}
385+
380386 // BeginCapture submits a GPU copy of the last frame and leases its slot.
381387 // The lease is valid until EndCapture and no longer: the same object
382388 // serves the slot's next lease.
A internal/adapters/wayland/acquire_failure_test.go +255 -0
@@ -0,0 +1,255 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "math"
6+ "os"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/bnema/purego-libwayland/protocol/wayland"
12+ "github.com/stretchr/testify/mock"
13+ "golang.org/x/sys/unix"
14+)
15+
16+func TestExplicitAcquireWatchFailureDiscardsCommit(t *testing.T) {
17+ for _, failure := range []string{"signalled", "eventfd"} {
18+ t.Run(failure, func(t *testing.T) {
19+ h := newSyncHarness(t)
20+ h.releases = map[uint32]*syncReleaseProxy{}
21+ // Order setup after import and reset on the display owner so no
22+ // concurrent device call can read the generated expectations.
23+ if err := h.c.Roundtrip(); err != nil {
24+ t.Fatal(err)
25+ }
26+ h.s.display.Do(func() { h.dev.ExpectedCalls = nil })
27+ h.dev.EXPECT().fdToHandle(mock.Anything).Return(7, nil).Maybe()
28+ h.dev.EXPECT().destroy(mock.Anything).Return(nil).Maybe()
29+ if failure == "signalled" {
30+ h.dev.EXPECT().signalled(uint32(7), uint64(101)).Return(false, unix.EIO).Once()
31+ } else {
32+ h.dev.EXPECT().signalled(uint32(7), uint64(101)).Return(false, nil).Once()
33+ h.dev.EXPECT().eventfd(uint32(7), uint64(101), mock.Anything).Return(unix.EIO).Once()
34+ }
35+ drainContents(h.contents)
36+ buffer := h.dmabuf()
37+ h.commit(buffer, 101, 102)
38+ if got, ok := h.content(50 * time.Millisecond); ok {
39+ t.Fatalf("untracked acquire published %+v", got)
40+ }
41+ h.s.display.Do(func() {
42+ surf := h.s.windows[h.win].xdg.surface
43+ if len(surf.queue) != 0 || surf.hold.acquire != nil {
44+ t.Error("failed acquire retained queued or rendered fence")
45+ }
46+ for _, tl := range h.s.timelines {
47+ if tl.uses != 0 {
48+ t.Errorf("timeline uses=%d", tl.uses)
49+ }
50+ }
51+ })
52+ if len(h.signalled()) != 0 {
53+ t.Fatal("unsafe release point signalled")
54+ }
55+ select {
56+ case <-h.releases[buffer].released:
57+ t.Fatal("explicit buffer release event")
58+ default:
59+ }
60+ })
61+ }
62+}
63+
64+func TestImplicitAcquireWatchFailureDiscardsCommit(t *testing.T) {
65+ for _, failure := range []string{"poll", "hangup"} {
66+ t.Run(failure, func(t *testing.T) {
67+ h := newSyncHarness(t)
68+ h.releases = map[uint32]*syncReleaseProxy{}
69+ drainContents(h.contents)
70+ buffer := h.dmabuf()
71+ var plane *os.File
72+ var writer *os.File
73+ if failure == "poll" {
74+ plane = os.NewFile(uintptr(math.MaxInt32), "invalid")
75+ } else {
76+ var err error
77+ plane, writer, err = os.Pipe()
78+ if err != nil {
79+ t.Fatal(err)
80+ }
81+ defer plane.Close()
82+ }
83+ // Real pollable descriptors exercise observation errors without replacing
84+ // watch/poll functions or constructing a handwritten device double.
85+ if err := h.c.Roundtrip(); err != nil {
86+ t.Fatal(err)
87+ }
88+ h.s.display.Do(func() {
89+ var buf *dmabufBuffer
90+ var resource *wayland.Buffer
91+ for r, b := range h.s.buffers {
92+ if r.ID() == buffer {
93+ buf = b.(*dmabufBuffer)
94+ resource = wayland.WrapBuffer(r)
95+ }
96+ }
97+ if buf == nil {
98+ t.Error("buffer missing")
99+ return
100+ }
101+ original := buf.buf.Planes[0].File
102+ defer func() { buf.buf.Planes[0].File = original }()
103+ buf.buf.Planes[0].File = plane
104+ surf := h.s.windows[h.win].xdg.surface
105+ surf.Attach(surf.wl, resource, 0, 0)
106+ surf.Commit(surf.wl)
107+ })
108+ if writer != nil {
109+ writer.Close()
110+ }
111+ if got, ok := h.content(100 * time.Millisecond); ok {
112+ t.Fatalf("failed implicit acquire published %+v", got)
113+ }
114+ h.s.display.Do(func() {
115+ if len(h.s.windows[h.win].xdg.surface.queue) != 0 {
116+ t.Error("failed implicit acquire left queue blocked")
117+ }
118+ })
119+ })
120+ }
121+}
122+
123+func TestAcquirePollTerminalFailureRetiresActiveAndAdded(t *testing.T) {
124+ poller := newMockacquirePoller(t)
125+ wake := make(chan struct{}, 1)
126+ sw, err := newSyncWaiter(func() { wake <- struct{}{} })
127+ if err != nil {
128+ t.Fatal(err)
129+ }
130+ sw.poller = poller
131+ defer sw.close()
132+ fd, err := unix.Eventfd(0, unix.EFD_CLOEXEC)
133+ if err != nil {
134+ t.Fatal(err)
135+ }
136+ active := sw.add(fd)
137+ var added *syncWait
138+ poller.EXPECT().poll(mock.Anything, 100).RunAndReturn(func(fds []unix.PollFd, _ int) (int, error) { fds[0].Revents = unix.POLLIN; return 1, nil }).Once()
139+ poller.EXPECT().poll(mock.Anything, 100).RunAndReturn(func([]unix.PollFd, int) (int, error) {
140+ fd, err := unix.Eventfd(0, unix.EFD_CLOEXEC)
141+ if err != nil {
142+ t.Error(err)
143+ return -1, err
144+ }
145+ added = sw.add(fd)
146+ return -1, unix.EIO
147+ }).Once()
148+ sw.run(context.Background())
149+ select {
150+ case <-wake:
151+ default:
152+ t.Fatal("terminal failure did not wake owner")
153+ }
154+ for _, w := range []*syncWait{active, added} {
155+ if w == nil || !sw.fired(w) || !w.failed {
156+ t.Fatal("wait stranded after terminal poll failure")
157+ }
158+ }
159+ if !sw.stopped() {
160+ t.Fatal("waiter not terminal")
161+ }
162+ dev := newMocksyncobjDevice(t) // no device access permitted after terminal failure
163+ if w, err := sw.watch(syncPoint{tl: &timeline{dev: dev, handle: 7}, point: 1}); err == nil || !w.failed {
164+ t.Fatal("new explicit watch accepted")
165+ }
166+ var waits [4]*syncWait
167+ if err := sw.watchImplicit(&ports.DMABuf{}, &waits); err == nil {
168+ t.Fatal("new implicit watch accepted")
169+ }
170+}
171+
172+func TestFailedRootRetiresEveryChildPredecessorGraph(t *testing.T) {
173+ h := newSyncHarness(t)
174+ sw := h.s.syncWait
175+ root := &surface{server: h.s}
176+ child := &surface{server: h.s}
177+ a, b := &surface{server: h.s}, &surface{server: h.s}
178+ tl := &timeline{dev: h.dev, handle: 7, alive: true, uses: 4}
179+ var waits [2]*syncWait
180+ var graphs [2]*update
181+ h.s.display.Do(func() {
182+ for i, surf := range []*surface{a, b} {
183+ fd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
184+ if err != nil {
185+ t.Error(err)
186+ return
187+ }
188+ waits[i] = sw.add(fd)
189+ surf.next.sync = &commitSync{acquire: syncPoint{tl: tl, point: uint64(21 + i*2)}, release: syncPoint{tl: tl, point: uint64(22 + i*2)}, wait: waits[i]}
190+ surf.queueUpdate()
191+ graphs[i] = surf.queue[len(surf.queue)-1]
192+ graphs[i].refs++ // pin retired state for assertions
193+ }
194+ child.queueUpdate()
195+ child.queueUpdate()
196+ one, two := child.queue[0], child.queue[1]
197+ one.deps = []*update{graphs[0]}
198+ graphs[0].refs++
199+ two.deps = []*update{graphs[1]}
200+ graphs[1].refs++
201+ root.queueUpdate()
202+ u := root.queue[0]
203+ u.deps = []*update{two}
204+ two.refs++
205+ u.failed = true
206+ child.queueUpdate()
207+ later := child.queue[2]
208+ u.applyGraph()
209+ if len(child.queue) != 1 || child.queue[0] != later || len(a.queue) != 0 || len(b.queue) != 0 || tl.uses != 0 {
210+ t.Errorf("retirement stranded prefix graphs: uses=%d child=%d a=%d b=%d", tl.uses, len(child.queue), len(a.queue), len(b.queue))
211+ }
212+ for i, g := range graphs {
213+ if !g.retired || g.refs != 1 || !waits[i].cancelled.Load() {
214+ t.Error("grandchild not safely retired")
215+ }
216+ g.releaseRef()
217+ }
218+ child.dropQueue()
219+ })
220+ if len(h.signalled()) != 0 {
221+ t.Fatal("release signalled before acquire")
222+ }
223+}
224+
225+func TestAcquireExportFailurePreservesCurrentContent(t *testing.T) {
226+ h := newSyncHarness(t)
227+ h.releases = map[uint32]*syncReleaseProxy{}
228+ if err := h.c.Roundtrip(); err != nil {
229+ t.Fatal(err)
230+ }
231+ h.s.display.Do(func() { h.dev.ExpectedCalls = nil })
232+ h.dev.EXPECT().destroy(mock.Anything).Return(nil).Maybe()
233+ h.dev.EXPECT().signalled(uint32(7), uint64(111)).Return(true, nil).Once()
234+ h.dev.EXPECT().exportSyncFile(uint32(7), uint64(111)).Return(nil, unix.EIO).Once()
235+ h.dev.EXPECT().signal(uint32(7), uint64(112)).Return(nil).Once() // observed acquire: safe discarded release
236+ drainContents(h.contents)
237+ buffer := h.dmabuf()
238+ var before ports.SurfaceContent
239+ h.s.display.Do(func() { before = h.s.windows[h.win].xdg.surface.content })
240+ h.commit(buffer, 111, 112)
241+ if got, ok := h.content(50 * time.Millisecond); ok {
242+ t.Fatalf("export failure published %+v", got)
243+ }
244+ h.s.display.Do(func() {
245+ surf := h.s.windows[h.win].xdg.surface
246+ if surf.content.Seq != before.Seq || surf.content.DMABuf != before.DMABuf || surf.hold.acquire != nil || len(surf.queue) != 0 {
247+ t.Error("export failure changed current content")
248+ }
249+ for _, tl := range h.s.timelines {
250+ if tl.uses != 0 {
251+ t.Error("export failure pinned timeline")
252+ }
253+ }
254+ })
255+}
A internal/adapters/wayland/acquire_poller_mock_test.go +112 -0
@@ -0,0 +1,112 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package wayland
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+ "golang.org/x/sys/unix"
10+)
11+
12+// newMockacquirePoller creates a new instance of mockacquirePoller. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func newMockacquirePoller(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *mockacquirePoller {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
22+ mock := &mockacquirePoller{}
23+ mock.Mock.Test(t)
24+
25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
31+
32+ return mock
33+}
34+
35+// mockacquirePoller is an autogenerated mock type for the acquirePoller type
36+type mockacquirePoller struct {
37+ mock.Mock
38+}
39+
40+type mockacquirePoller_Expecter struct {
41+ mock *mock.Mock
42+}
43+
44+func (_m *mockacquirePoller) EXPECT() *mockacquirePoller_Expecter {
45+ return &mockacquirePoller_Expecter{mock: &_m.Mock}
46+}
47+
48+// poll provides a mock function for the type mockacquirePoller
49+func (_mock *mockacquirePoller) poll(pollFds []unix.PollFd, n int) (int, error) {
50+ ret := _mock.Called(pollFds, n)
51+
52+ if len(ret) == 0 {
53+ panic("no return value specified for poll")
54+ }
55+
56+ var r0 int
57+ var r1 error
58+ if returnFunc, ok := ret.Get(0).(func([]unix.PollFd, int) (int, error)); ok {
59+ return returnFunc(pollFds, n)
60+ }
61+ if returnFunc, ok := ret.Get(0).(func([]unix.PollFd, int) int); ok {
62+ r0 = returnFunc(pollFds, n)
63+ } else {
64+ r0 = ret.Get(0).(int)
65+ }
66+ if returnFunc, ok := ret.Get(1).(func([]unix.PollFd, int) error); ok {
67+ r1 = returnFunc(pollFds, n)
68+ } else {
69+ r1 = ret.Error(1)
70+ }
71+ return r0, r1
72+}
73+
74+// mockacquirePoller_poll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'poll'
75+type mockacquirePoller_poll_Call struct {
76+ *mock.Call
77+}
78+
79+// poll is a helper method to define mock.On call
80+// - pollFds []unix.PollFd
81+// - n int
82+func (_e *mockacquirePoller_Expecter) poll(pollFds any, n any) *mockacquirePoller_poll_Call {
83+ return &mockacquirePoller_poll_Call{Call: _e.mock.On("poll", pollFds, n)}
84+}
85+
86+func (_c *mockacquirePoller_poll_Call) Run(run func(pollFds []unix.PollFd, n int)) *mockacquirePoller_poll_Call {
87+ _c.Call.Run(func(args mock.Arguments) {
88+ var arg0 []unix.PollFd
89+ if args[0] != nil {
90+ arg0 = args[0].([]unix.PollFd)
91+ }
92+ var arg1 int
93+ if args[1] != nil {
94+ arg1 = args[1].(int)
95+ }
96+ run(
97+ arg0,
98+ arg1,
99+ )
100+ })
101+ return _c
102+}
103+
104+func (_c *mockacquirePoller_poll_Call) Return(n1 int, err error) *mockacquirePoller_poll_Call {
105+ _c.Call.Return(n1, err)
106+ return _c
107+}
108+
109+func (_c *mockacquirePoller_poll_Call) RunAndReturn(run func(pollFds []unix.PollFd, n int) (int, error)) *mockacquirePoller_poll_Call {
110+ _c.Call.Return(run)
111+ return _c
112+}
M internal/adapters/wayland/activation.go +3 -1
@@ -55,6 +55,8 @@ func (a activation) Activate(_ *xdgactivation.ActivationV1, token string, surf *
5555 state := s.surfaceOf(surf)
5656 reason := ""
5757 switch {
58+ case s.protected():
59+ reason = "session_protected"
5860 case !ok:
5961 reason = "unknown_token"
6062 case time.Since(t.at) > tokenLifetime:
@@ -105,7 +107,7 @@ func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) {
105107 fresh := time.Since(s.seat.pressAt) <= tokenLifetime && !s.seat.pressAt.Before(s.seat.focusAt)
106108 valid := s.focusClient() == c || (t.hasSerial && t.serial == s.seat.press && s.seat.pressClient == c && fresh)
107109 token := rand.Text()
108- if valid {
110+ if valid && !s.protected() {
109111 s.pruneTokens()
110112 s.tokens[token] = activationToken{client: c, at: time.Now()}
111113 }
M internal/adapters/wayland/activation_test.go +1 -1
@@ -24,7 +24,7 @@ func newToken(t *testing.T, c *wlturbo.Display, manager uint32, serialSeat ...ui
2424 id := c.AllocateID()
2525 p := &tokenProxy{tokens: make(chan string, 1)}
2626 p.SetID(id)
27- c.Context().Register(p)
27+ registerWireProxy(c, p)
2828 requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestGetActivationToken, id)
2929 if len(serialSeat) == 2 {
3030 requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestSetSerial, serialSeat[0], serialSeat[1])
M internal/adapters/wayland/capture_admission_test.go +2 -2
@@ -79,7 +79,7 @@ func (h *admissionHarness) client(t *testing.T) *admissionClient {
7979 if !ok {
8080 t.Fatal("output missing")
8181 }
82- out, err := c.Registry().BindID(g.Name, g.Interface, 4)
82+ out, err := bindWireID(c, g.Name, g.Interface, 4)
8383 if err != nil {
8484 t.Fatal(err)
8585 }
@@ -93,7 +93,7 @@ func (a *admissionClient) newFrame() (uint32, *captureEvents) {
9393 frame := a.c.AllocateID()
9494 ev := &captureEvents{events: make(chan uint16, 8)}
9595 ev.SetID(frame)
96- a.c.Context().Register(ev)
96+ registerWireProxy(a.c, ev)
9797 return frame, ev
9898 }
9999
M internal/adapters/wayland/capture_session.go +3 -3
@@ -121,7 +121,7 @@ type captureManager struct {
121121 // update sends the workspaces that are new or changed, and the ones gone.
122122 // Untrusted peers get no inventory.
123123 func (m *captureManager) update(snapshot ports.Workspaces) {
124- if !m.trusted || !m.res.Alive() {
124+ if m.s.protected() || !m.trusted || !m.res.Alive() {
125125 return
126126 }
127127 now := map[uint64]privateWorkspace{}
@@ -176,7 +176,7 @@ func (m *captureManager) BeginSession(r *capturesession.NeferwlCaptureManagerV1,
176176 stop := func(reason capturesession.NeferwlCaptureSessionV1StopReason) { res.SendStopped(uint32(reason)) }
177177 o := s.outputOf(out)
178178 switch {
179- case !m.trusted:
179+ case s.protected() || !m.trusted:
180180 s.log.Info().Str("reason", string(ports.CaptureReasonUnauthorized)).Msg("capture session refused")
181181 stop(capturesession.NeferwlCaptureSessionV1StopReasonUnauthorized)
182182 return
@@ -221,7 +221,7 @@ func (m *captureManager) AttachSurface(r *capturesession.NeferwlCaptureManagerV1
221221 // the same check): nothing else can attach.
222222 ps := s.private.session
223223 switch {
224- case !m.trusted: // the UID of this connection, checked once at bind
224+ case s.protected() || !m.trusted: // DISPLAY protection and bind-time UID
225225 att.fail(capturesession.NeferwlCaptureLayerV1FailureUnauthorized)
226226 return
227227 case ps == nil || subtle.ConstantTimeCompare([]byte(token), []byte(ps.token)) != 1:
M internal/adapters/wayland/capture_session_test.go +8 -8
@@ -133,14 +133,14 @@ func (h *privateHarness) client(t *testing.T) *privateClient {
133133 if !ok {
134134 t.Fatal("output missing")
135135 }
136- out, err := c.Registry().BindID(g.Name, g.Interface, 4)
136+ out, err := bindWireID(c, g.Name, g.Interface, 4)
137137 if err != nil {
138138 t.Fatal(err)
139139 }
140140 registerProtocol(t, c, out)
141141 pc := &privateClient{c: c, output: out, manager: bindProtocol(t, c, "neferwl_capture_manager_v1"), mgr: &managerEvents{msgs: make(chan sessionMsg, 32)}}
142142 pc.mgr.SetID(pc.manager)
143- c.Context().Register(pc.mgr)
143+ registerWireProxy(c, pc.mgr)
144144 return pc
145145 }
146146
@@ -150,7 +150,7 @@ func (pc *privateClient) begin(t *testing.T, x, y, w, h int32, workspace uint64,
150150 id := pc.c.AllocateID()
151151 ev := &sessionEvents{msgs: make(chan sessionMsg, 32)}
152152 ev.SetID(id)
153- pc.c.Context().Register(ev)
153+ registerWireProxy(pc.c, ev)
154154 requestProtocol(t, pc.c, pc.manager, capturesession.NeferwlCaptureManagerV1RequestBeginSession, id, pc.output, x, y, w, h, uint32(workspace>>32), uint32(workspace), record)
155155 if err := pc.c.Roundtrip(); err != nil {
156156 t.Fatal(err)
@@ -348,7 +348,7 @@ func (hd *hud) attachSurface(t *testing.T, token string, surface uint32) chan se
348348 id := hd.c.AllocateID()
349349 ev := &attachEvents{msgs: make(chan sessionMsg, 8)}
350350 ev.SetID(id)
351- hd.c.Context().Register(ev)
351+ registerWireProxy(hd.c, ev)
352352 requestProtocol(t, hd.c, hd.manager, capturesession.NeferwlCaptureManagerV1RequestAttachSurface, id, token, surface)
353353 if err := hd.c.Roundtrip(); err != nil {
354354 t.Fatal(err)
@@ -452,7 +452,7 @@ func mapLayer(t *testing.T, hd *hud) {
452452 requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit)
453453 cfg := &layerProxy{configured: make(chan [3]uint32, 4)}
454454 cfg.SetID(hd.layer)
455- hd.c.Context().Register(cfg)
455+ registerWireProxy(hd.c, cfg)
456456 if err := hd.c.Roundtrip(); err != nil {
457457 t.Fatal(err)
458458 }
@@ -480,7 +480,7 @@ func TestPrivateCaptureStopClosesLayers(t *testing.T) {
480480 closed := make(chan sessionMsg, 4)
481481 lp := &layerClosedProxy{msgs: closed}
482482 lp.SetID(hd.layer)
483- hd.c.Context().Register(lp)
483+ registerWireProxy(hd.c, lp)
484484 confirmed := hd.attach(t, token)
485485 layer := nextEvent[ports.CaptureSessionLayer](t, h.events)
486486 h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 2, Layers: []ports.WindowID{layer.Layer}})
@@ -611,7 +611,7 @@ func captureOnce(t *testing.T, h *privateHarness, pc *privateClient) (*captureEv
611611 frame := pc.c.AllocateID()
612612 ev := &captureEvents{events: make(chan uint16, 16)}
613613 ev.SetID(frame)
614- pc.c.Context().Register(ev)
614+ registerWireProxy(pc.c, ev)
615615 requestProtocol(t, pc.c, mgr, wlr.ZwlrScreencopyManagerV1RequestCaptureOutput, frame, int32(0), pc.output)
616616 requestProtocol(t, pc.c, frame, wlr.ZwlrScreencopyFrameV1RequestCopy, buf)
617617 if err := pc.c.Roundtrip(); err != nil {
@@ -761,7 +761,7 @@ func TestPrivateCaptureLayerMovedBelowTop(t *testing.T) {
761761 closed := make(chan sessionMsg, 4)
762762 lp := &layerClosedProxy{msgs: closed}
763763 lp.SetID(hd.layer)
764- hd.c.Context().Register(lp)
764+ registerWireProxy(hd.c, lp)
765765 requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetLayer, uint32(ports.LayerBottom))
766766 requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit)
767767 if err := hd.c.Roundtrip(); err != nil {
M internal/adapters/wayland/clipboard.go +22 -3
@@ -15,6 +15,11 @@ import (
1515 // both without focus. Like wlroots, any client may bind it. Pasting hands the reader's pipe to the source client:
1616 // the data never goes through neferwl. Drag and drop is not implemented.
1717
18+// protected is a defensive DISPLAY admission check, not lock ownership.
19+func (s *Server) protected() bool {
20+ return s.security != nil && s.security.Snapshot().Protected
21+}
22+
1823 const (
1924 selClipboard = iota
2025 selPrimary
@@ -89,6 +94,12 @@ func (s *Server) focusedClient() (server.Client, bool) {
8994 // setSelection makes src the selection of kind and announces it. A
9095 // replaced source is cancelled.
9196 func (s *Server) setSelection(kind int, src *clipSource) {
97+ if s.protected() {
98+ if src != nil && src != s.selections[kind] && src.alive() {
99+ src.cancel()
100+ }
101+ return
102+ }
92103 old := s.selections[kind]
93104 if old == src {
94105 return
@@ -109,6 +120,9 @@ func (s *Server) announceAll(kind int) {
109120
110121 // announce sends the selection of kind to the devices matching want.
111122 func (s *Server) announce(kind int, want func(*clipDevice) bool) {
123+ if s.protected() {
124+ return
125+ }
112126 src := s.selections[kind]
113127 if src != nil && !src.alive() {
114128 src, s.selections[kind] = nil, nil
@@ -142,6 +156,9 @@ func (s *Server) focusSelections() {
142156 // addClipDevice tracks a device and sends it the selections it may see.
143157 func (s *Server) addClipDevice(d *clipDevice) {
144158 s.clipDevices = append(s.clipDevices, d)
159+ if s.protected() {
160+ return
161+ }
145162 c, ok := s.focusedClient()
146163 if !d.control && (!ok || d.client != c) {
147164 return
@@ -170,7 +187,7 @@ func (s *Server) sourceGone(src *clipSource) {
170187 // focused one. Any client holding an offer may read it.
171188 func (s *Server) maySet(c server.Client) bool {
172189 f, ok := s.focusedClient()
173- return ok && f == c
190+ return !s.protected() && ok && f == c
174191 }
175192
176193 // setFocused sets a selection from a focus-bound device. A used source is
@@ -193,10 +210,12 @@ func (s *Server) setFocused(c server.Client, kind int, src *clipSource) {
193210 }
194211
195212 // receive hands the reader's pipe to the source if it is still the
196-// selection; the fd is always closed here (the event dups it).
213+// selection; the fd is always closed here (the event dups it). Protection
214+// rejects even previously announced offers. Transfers accepted before protection
215+// are client-to-client: their already handed-off pipes cannot be revoked here.
197216 func (s *Server) receive(kind int, src *clipSource, mime string, fd int) {
198217 defer unix.Close(fd)
199- if s.selections[kind] != src || !src.alive() {
218+ if s.protected() || s.selections[kind] != src || !src.alive() {
200219 return
201220 }
202221 for _, m := range src.mimes {
M internal/adapters/wayland/clipboard_test.go +9 -9
@@ -52,7 +52,7 @@ func (p *sourceEvents) Dispatch(e *wlturbo.Event) {
5252 func registerOffer(c *wlturbo.Display, id uint32) {
5353 p := &protocolProxy{}
5454 p.SetID(id)
55- c.Context().Register(p)
55+ registerWireProxy(c, p)
5656 }
5757
5858 // clipClient is a client with a toplevel, a data device and a pointer to
@@ -70,13 +70,13 @@ func newClipClient(t *testing.T, s *Server, dir string, events chan ports.Client
7070 seat := bindProtocol(t, c, "wl_seat")
7171 registerProtocol(t, c, seat)
7272 g, _ := c.Registry().FindGlobal("wl_data_device_manager")
73- manager, err := c.Registry().BindID(g.Name, g.Interface, 3)
73+ manager, err := bindWireID(c, g.Name, g.Interface, 3)
7474 if err != nil {
7575 t.Fatal(err)
7676 }
7777 device := &clipEvents{c: c, offers: make(chan uint32, 8)}
7878 device.SetID(c.AllocateID())
79- c.Context().Register(device)
79+ registerWireProxy(c, device)
8080 requestProtocol(t, c, manager, wayland.DataDeviceManagerRequestGetDataDevice, device.ID(), seat)
8181 w := toplevelMapper(t, c, events)()
8282 return &clipClient{c: c, manager: manager, device: device, window: w.ID}
@@ -100,7 +100,7 @@ func (cc *clipClient) copy(t *testing.T) *sourceEvents {
100100 t.Helper()
101101 src := &sourceEvents{sends: make(chan int, 4), cancelled: make(chan struct{}, 4)}
102102 src.SetID(cc.c.AllocateID())
103- cc.c.Context().Register(src)
103+ registerWireProxy(cc.c, src)
104104 requestProtocol(t, cc.c, cc.manager, wayland.DataDeviceManagerRequestCreateDataSource, src.ID())
105105 requestProtocol(t, cc.c, src.ID(), wayland.DataSourceRequestOffer, "text/plain")
106106 requestProtocol(t, cc.c, cc.device.ID(), wayland.DataDeviceRequestSetSelection, src.ID(), uint32(0))
@@ -161,7 +161,7 @@ func TestClipboardFollowsFocus(t *testing.T) {
161161 t.Fatal(err)
162162 }
163163 defer unix.Close(fds[0])
164- if err := b.c.SendRequestWithFDs(offer, uint16(wayland.DataOfferRequestReceive), []int{fds[1]}, "text/plain"); err != nil {
164+ if err := wireRequest(b.c, offer, uint16(wayland.DataOfferRequestReceive), []int{fds[1]}, "text/plain"); err != nil {
165165 t.Fatal(err)
166166 }
167167 _ = unix.Close(fds[1])
@@ -208,7 +208,7 @@ func TestDataControlSeesSelection(t *testing.T) {
208208 offers := make(chan uint32, 8)
209209 dev := &controlEvents{c: m, offers: offers}
210210 dev.SetID(m.AllocateID())
211- m.Context().Register(dev)
211+ registerWireProxy(m, dev)
212212 requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), seat)
213213 next := func() uint32 {
214214 t.Helper()
@@ -294,7 +294,7 @@ func TestDataControlUsedSource(t *testing.T) {
294294 manager := bindProtocol(t, m, "ext_data_control_manager_v1")
295295 dev := &controlEvents{c: m, offers: make(chan uint32, 8)}
296296 dev.SetID(m.AllocateID())
297- m.Context().Register(dev)
297+ registerWireProxy(m, dev)
298298 requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), seat)
299299 src := m.AllocateID()
300300 registerProtocol(t, m, src)
@@ -330,7 +330,7 @@ func TestPrimarySelectionFromManager(t *testing.T) {
330330 registerProtocol(t, a.c, seat)
331331 pdev := &primaryEvents{c: a.c, offers: make(chan uint32, 8)}
332332 pdev.SetID(a.c.AllocateID())
333- a.c.Context().Register(pdev)
333+ registerWireProxy(a.c, pdev)
334334 requestProtocol(t, a.c, pm, primaryselection.ZwpPrimarySelectionDeviceManagerV1RequestGetDevice, pdev.ID(), seat)
335335 a.focus(t, commands)
336336
@@ -340,7 +340,7 @@ func TestPrimarySelectionFromManager(t *testing.T) {
340340 manager := bindProtocol(t, m, "ext_data_control_manager_v1")
341341 dev := &controlEvents{c: m, offers: make(chan uint32, 8)}
342342 dev.SetID(m.AllocateID())
343- m.Context().Register(dev)
343+ registerWireProxy(m, dev)
344344 requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), mseat)
345345 src := m.AllocateID()
346346 registerProtocol(t, m, src)
M internal/adapters/wayland/colormanagement_errors_test.go +1 -1
@@ -72,7 +72,7 @@ func TestColorSurfaceErrors(t *testing.T) {
7272 global = name
7373 }
7474 }
75- out, err := c.Registry().BindID(global, "wl_output", 4)
75+ out, err := bindWireID(c, global, "wl_output", 4)
7676 if err != nil {
7777 t.Fatal(err)
7878 }
M internal/adapters/wayland/colormanagement_test.go +2 -2
@@ -31,7 +31,7 @@ func (p *colorEvent) Dispatch(e *wlturbo.Event) {
3131 func watchColor(c *wlturbo.Display, id uint32) *colorEvent {
3232 p := &colorEvent{}
3333 p.SetID(id)
34- c.Context().Register(p)
34+ registerWireProxy(c, p)
3535 return p
3636 }
3737 func colorServer(t *testing.T) (*Server, *wlturbo.Display, chan ports.OutputFormats) {
@@ -205,7 +205,7 @@ func TestColorOutputAndFeedback(t *testing.T) {
205205 name = id
206206 }
207207 }
208- out, err := c.Registry().BindID(name, "wl_output", 4)
208+ out, err := bindWireID(c, name, "wl_output", 4)
209209 if err != nil {
210210 t.Fatal(err)
211211 }
M internal/adapters/wayland/commit_hotpath_bench_test.go +8 -5
@@ -31,7 +31,10 @@ func BenchmarkProtocolQueuedCommit(b *testing.B) {
3131 if err != nil {
3232 b.Fatal(err)
3333 }
34- defer c.Close()
34+ defer func() {
35+ _ = c.Close()
36+ forgetWireClient(c)
37+ }()
3538 if err := c.Roundtrip(); err != nil {
3639 b.Fatal(err)
3740 }
@@ -40,14 +43,14 @@ func BenchmarkProtocolQueuedCommit(b *testing.B) {
4043 if !ok {
4144 b.Fatalf("missing %s", name)
4245 }
43- id, err := c.Registry().BindID(g.Name, g.Interface, 1)
46+ id, err := bindWireID(c, g.Name, g.Interface, 1)
4447 if err != nil {
4548 b.Fatal(err)
4649 }
4750 return id
4851 }
4952 send := func(id, op uint32, args ...any) {
50- if err := c.SendRequest(id, uint16(op), args...); err != nil {
53+ if err := wireRequest(c, id, uint16(op), nil, args...); err != nil {
5154 b.Fatal(err)
5255 }
5356 }
@@ -56,7 +59,7 @@ func BenchmarkProtocolQueuedCommit(b *testing.B) {
5659 register := func(id uint32) {
5760 p := &protocolProxy{}
5861 p.SetID(id)
59- c.Context().Register(p)
62+ registerWireProxy(c, p)
6063 }
6164 register(shm)
6265 surf := c.AllocateID()
@@ -80,7 +83,7 @@ func BenchmarkProtocolQueuedCommit(b *testing.B) {
8083 pool, buf := c.AllocateID(), c.AllocateID()
8184 register(pool)
8285 register(buf)
83- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
86+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
8487 b.Fatal(err)
8588 }
8689 send(pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(0))
M internal/adapters/wayland/constraints.go +11 -4
@@ -52,7 +52,7 @@ type pointerWarp struct{ server *Server }
5252 func (pointerWarp) Destroy(*pointerwarp.WpPointerWarpV1) {}
5353 func (h pointerWarp) WarpPointer(r *pointerwarp.WpPointerWarpV1, surf *wayland.Surface, p *wayland.Pointer, x, y server.Fixed, serial uint32) {
5454 s := h.server
55- if surf == nil || p == nil || p.Client() != r.Client() {
55+ if s.protected() || surf == nil || p == nil || p.Client() != r.Client() {
5656 return
5757 }
5858 if enter, ok := s.seat.enters[p.Resource]; !ok || enter != serial {
@@ -75,7 +75,7 @@ func (relativeHandler) Destroy(*relativepointer.ZwpRelativePointerV1) {}
7575 // pointers. Absolute devices have no deltas and send nothing.
7676 // It reports whether any event was sent.
7777 func (s *Server) relativeMotion(w *window, c ports.PointerMotionTo) bool {
78- if c.DX == 0 && c.DY == 0 && c.UnaccelDX == 0 && c.UnaccelDY == 0 {
78+ if s.protected() || c.DX == 0 && c.DY == 0 && c.UnaccelDX == 0 && c.UnaccelDY == 0 {
7979 return false
8080 }
8181 if !w.mapped || !w.xdg.resource.Resource.Alive() {
@@ -142,7 +142,7 @@ func (m constraintManager) ConfinePointer(r *pointerconstraints.ZwpPointerConstr
142142 // create validates the request and registers the constraint on the surface.
143143 func (m constraintManager) create(r *pointerconstraints.ZwpPointerConstraintsV1, surf *wayland.Surface, reg *wayland.Region, lifetime uint32) *constraint {
144144 s := m.server
145- if surf == nil {
145+ if s.protected() || surf == nil {
146146 return nil
147147 }
148148 state := s.surfaces[surf.Resource]
@@ -263,6 +263,10 @@ func (s *Server) deactivateConstraint() {
263263 // updateConstraint activates the constraint of the window holding both
264264 // pointer and keyboard focus, and deactivates any other.
265265 func (s *Server) updateConstraint() {
266+ if s.protected() {
267+ s.deactivateConstraint()
268+ return
269+ }
266270 var want *constraint
267271 if w := s.windows[s.seat.pointerFocus]; w != nil && w.mapped && s.seat.pointerFocus == s.seat.focused {
268272 if c := s.constraints[w.xdg.surface]; c != nil && !c.defunct && (c.active || c.contains(w)) {
@@ -288,6 +292,9 @@ func (s *Server) updateConstraint() {
288292
289293 // emitConstraint tells core about the active constraint, window-local.
290294 func (s *Server) emitConstraint(c *constraint) {
295+ if s.protected() {
296+ return
297+ }
291298 x := c.surface.xdg
292299 if x == nil || x.window == nil {
293300 return
@@ -353,5 +360,5 @@ func (c *constraint) contains(w *window) bool {
353360
354361 // locked reports whether the window's pointer is locked.
355362 func (s *Server) locked(w *window) bool {
356- return s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.xdg.surface
363+ return !s.protected() && s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.xdg.surface
357364 }
M internal/adapters/wayland/constraints_test.go +3 -3
@@ -39,7 +39,7 @@ func logProxy(t *testing.T, c *wlturbo.Display, id uint32) chan []float64 {
3939 t.Helper()
4040 p := &eventLog{events: make(chan []float64, 64)}
4141 p.SetID(id)
42- c.Context().Register(p)
42+ registerWireProxy(c, p)
4343 return p.events
4444 }
4545
@@ -80,7 +80,7 @@ func TestPointerWarp(t *testing.T) {
8080 pointer := c.AllocateID()
8181 p := &eventLog{events: make(chan []float64, 64), pointer: true}
8282 p.SetID(pointer)
83- c.Context().Register(p)
83+ registerWireProxy(c, p)
8484 requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
8585 w, surf, xdgID := surfaceMapper(t, c, events)()
8686 requestProtocol(t, c, xdgID, xdgshell.SurfaceRequestSetWindowGeometry, int32(4), int32(6), int32(50), int32(50))
@@ -100,7 +100,7 @@ func TestPointerWarp(t *testing.T) {
100100 pointer2 := c.AllocateID()
101101 p2 := &eventLog{events: make(chan []float64, 64), pointer: true}
102102 p2.SetID(pointer2)
103- c.Context().Register(p2)
103+ registerWireProxy(c, p2)
104104 requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer2)
105105 requestProtocol(t, c, warp, pointerwarp.WpPointerWarpV1RequestWarpPointer, surf, pointer2, wlturbo.Fixed(10*256), wlturbo.Fixed(10*256), serial)
106106 // A stale serial is ignored; the enter serial moves the pointer.
M internal/adapters/wayland/cursor.go +6 -3
@@ -59,7 +59,7 @@ func (d cursorShapeDevice) SetShape(r *cursorshape.WpCursorShapeDeviceV1, _ uint
5959 return
6060 }
6161 s := d.server
62- if !s.hasPointerFocus(r.Client()) {
62+ if s.protected() || !s.hasPointerFocus(r.Client()) {
6363 return
6464 }
6565 s.cursorSurface = nil
@@ -70,6 +70,9 @@ type pointer struct{ server *Server }
7070
7171 func (p pointer) SetCursor(r *wayland.Pointer, _ uint32, surf *wayland.Surface, hotX, hotY int32) {
7272 s := p.server
73+ if s.protected() {
74+ return
75+ }
7376 var state *surface
7477 if surf != nil {
7578 state = s.surfaces[surf.Resource]
@@ -137,7 +140,7 @@ func (s *surface) cursorImage() ports.CursorChange {
137140
138141 // setCursor queues a cursor change; only the latest is delivered.
139142 func (s *Server) setCursor(c ports.CursorChange) {
140- if s.channels.Cursors == nil {
143+ if s.protected() || s.channels.Cursors == nil {
141144 return
142145 }
143146 s.cursorMu.Lock()
@@ -163,7 +166,7 @@ func (s *Server) forwardCursors(ctx context.Context) {
163166 c, ok := s.cursorLatest, s.cursorQueued
164167 s.cursorQueued = false
165168 s.cursorMu.Unlock()
166- if !ok {
169+ if !ok || s.protected() {
167170 continue
168171 }
169172 select {
M internal/adapters/wayland/cursor_test.go +1 -1
@@ -147,7 +147,7 @@ func TestSetCursorSurface(t *testing.T) {
147147 pool, buf := c.AllocateID(), c.AllocateID()
148148 registerProtocol(t, c, pool)
149149 registerProtocol(t, c, buf)
150- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(12)); err != nil {
150+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(12)); err != nil {
151151 t.Fatal(err)
152152 }
153153 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(2), int32(1), int32(12), uint32(wayland.ShmFormatArgb8888))
M internal/adapters/wayland/damage_test.go +2 -2
@@ -26,7 +26,7 @@ func TestDamageHistory(t *testing.T) {
2626 serials := make(chan uint32, 8)
2727 xp := &configureProxy{serial: serials}
2828 xp.SetID(xdg)
29- c.Context().Register(xp)
29+ registerWireProxy(c, xp)
3030 registerProtocol(t, c, top)
3131 requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
3232 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
@@ -44,7 +44,7 @@ func TestDamageHistory(t *testing.T) {
4444 }
4545 pool := c.AllocateID()
4646 registerProtocol(t, c, pool)
47- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(2*16*16*4)); err != nil {
47+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(2*16*16*4)); err != nil {
4848 t.Fatal(err)
4949 }
5050 bufs := [2]uint32{c.AllocateID(), c.AllocateID()}
M internal/adapters/wayland/decoration_test.go +1 -1
@@ -31,7 +31,7 @@ func TestKDEDecorationAcknowledgesMode(t *testing.T) {
3131 deco := c.AllocateID()
3232 proxy := &kdeModeProxy{modes: make(chan uint32, 8)}
3333 proxy.SetID(deco)
34- c.Context().Register(proxy)
34+ registerWireProxy(c, proxy)
3535 requestProtocol(t, c, mgr, kdedecoration.OrgKdeKwinServerDecorationManagerRequestCreate, deco, surf)
3636 requestProtocol(t, c, deco, kdedecoration.OrgKdeKwinServerDecorationRequestRequestMode, uint32(kdedecoration.OrgKdeKwinServerDecorationModeClient))
3737 // An undefined mode gets no answer.
M internal/adapters/wayland/dmabuf_test.go +8 -8
@@ -66,7 +66,7 @@ func TestDMABufParams(t *testing.T) {
6666 if !ok || g.Version != dmabufVersion {
6767 t.Fatalf("global %+v", g)
6868 }
69- dm, err := c.Registry().BindID(g.Name, g.Interface, dmabufVersion)
69+ dm, err := bindWireID(c, g.Name, g.Interface, dmabufVersion)
7070 if err != nil {
7171 t.Fatal(err)
7272 }
@@ -83,8 +83,8 @@ func TestDMABufParams(t *testing.T) {
8383 requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, p)
8484 proxy := &paramsProxy{result: make(chan uint16, 2)}
8585 proxy.SetID(p)
86- c.Context().Register(proxy)
87- if err := c.SendRequestWithFDs(p, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, uint32(0), uint32(0), uint32(256), uint32(0), uint32(0)); err != nil {
86+ registerWireProxy(c, proxy)
87+ if err := wireRequest(c, p, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, uint32(0), uint32(0), uint32(256), uint32(0), uint32(0)); err != nil {
8888 t.Fatal(err)
8989 }
9090 requestProtocol(t, c, p, linuxdmabuf.ZwpLinuxBufferParamsV1RequestCreate, int32(64), height, format, uint32(0))
@@ -106,7 +106,7 @@ func TestDMABufParams(t *testing.T) {
106106 // Taller than the file: out of bounds.
107107 big := c.AllocateID()
108108 requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, big)
109- if err := c.SendRequestWithFDs(big, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, uint32(0), uint32(0), uint32(256), uint32(0), uint32(0)); err != nil {
109+ if err := wireRequest(c, big, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, uint32(0), uint32(0), uint32(256), uint32(0), uint32(0)); err != nil {
110110 t.Fatal(err)
111111 }
112112 requestProtocol(t, c, big, linuxdmabuf.ZwpLinuxBufferParamsV1RequestCreate, int32(64), int32(17), linearARGB.Format, uint32(0))
@@ -118,7 +118,7 @@ func TestDMABufParamsReuse(t *testing.T) {
118118 s, _, _, dir := dmabufServer(t, ports.DMABufSupport{Formats: []ports.DMABufFormat{linearARGB}})
119119 c := protocolClient(t, s, dir)
120120 g, _ := c.Registry().FindGlobal("zwp_linux_dmabuf_v1")
121- dm, err := c.Registry().BindID(g.Name, g.Interface, dmabufVersion)
121+ dm, err := bindWireID(c, g.Name, g.Interface, dmabufVersion)
122122 if err != nil {
123123 t.Fatal(err)
124124 }
@@ -134,8 +134,8 @@ func TestDMABufParamsReuse(t *testing.T) {
134134 requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, p)
135135 proxy := &paramsProxy{result: make(chan uint16, 2)}
136136 proxy.SetID(p)
137- c.Context().Register(proxy)
138- if err := c.SendRequestWithFDs(p, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, uint32(0), uint32(0), uint32(256), uint32(0), uint32(0)); err != nil {
137+ registerWireProxy(c, proxy)
138+ if err := wireRequest(c, p, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, uint32(0), uint32(0), uint32(256), uint32(0), uint32(0)); err != nil {
139139 t.Fatal(err)
140140 }
141141 requestProtocol(t, c, p, linuxdmabuf.ZwpLinuxBufferParamsV1RequestCreate, int32(64), int32(16), linearARGB.Format, uint32(0))
@@ -232,7 +232,7 @@ func TestDMABufScanoutTranche(t *testing.T) {
232232 fb := c.AllocateID()
233233 proxy := &feedbackProxy{rounds: make(chan []tranche, 8)}
234234 proxy.SetID(fb)
235- c.Context().Register(proxy)
235+ registerWireProxy(c, proxy)
236236 requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestGetSurfaceFeedback, fb, surf)
237237 round := func(what string) []tranche {
238238 t.Helper()
M internal/adapters/wayland/dmabuf_yuv_test.go +2 -2
@@ -32,7 +32,7 @@ func TestYUVParams(t *testing.T) {
3232 requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, p)
3333 proxy := &paramsProxy{result: make(chan uint16, 2)}
3434 proxy.SetID(p)
35- c.Context().Register(proxy)
35+ registerWireProxy(c, proxy)
3636 fd, err := unix.MemfdCreate("yuv-params", 0)
3737 if err != nil {
3838 t.Fatal(err)
@@ -43,7 +43,7 @@ func TestYUVParams(t *testing.T) {
4343 }
4444 add := func(idx, offset, stride, mod uint32) {
4545 t.Helper()
46- if err := c.SendRequestWithFDs(p, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, idx, offset, stride, uint32(0), mod); err != nil {
46+ if err := wireRequest(c, p, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, idx, offset, stride, uint32(0), mod); err != nil {
4747 t.Fatal(err)
4848 }
4949 }
M internal/adapters/wayland/drmlease.go +8 -1
@@ -90,13 +90,16 @@ func (s *Server) applyLeaseEvent(event ports.LeaseMessage) {
9090 card := s.pendingLeaseCards[v.ID]
9191 delete(s.pendingLeases, v.ID)
9292 delete(s.pendingLeaseCards, v.ID)
93- if obj == nil || !obj.res.Alive() {
93+ if obj == nil || !obj.res.Alive() || s.security != nil && s.security.Snapshot().Protected {
9494 if v.FD != nil {
9595 v.FD.Close()
9696 }
9797 if v.LeaseID != 0 && card != "" {
9898 s.sendLeaseRequest(ports.LeaseRevoke{Card: card, LeaseID: v.LeaseID})
9999 }
100+ if obj != nil {
101+ obj.finish()
102+ }
100103 return
101104 }
102105 if v.Err != nil || v.FD == nil {
@@ -316,6 +319,10 @@ func (r *leaseRequestHandler) Submit(self *drmlease.WpDrmLeaseRequestV1, id uint
316319 }
317320 obj.res = res
318321 res.OnDestroy = func() { obj.destroy() }
322+ if s.security != nil && s.security.Snapshot().Protected {
323+ obj.finish()
324+ return
325+ }
319326 for _, name := range r.names {
320327 if _, ok := r.bind.device.connectors[name]; !ok || r.withdrawn {
321328 obj.finish()
M internal/adapters/wayland/drmlease_test.go +10 -10
@@ -31,7 +31,7 @@ func (p *leaseDeviceEvents) Dispatch(e *wlturbo.Event) {
3131 p.connectors = append(p.connectors, id)
3232 child := &leaseConnectorEvents{}
3333 child.SetID(id)
34- p.c.Context().Register(child)
34+ registerWireProxy(p.c, child)
3535 }
3636 }
3737
@@ -117,7 +117,7 @@ func TestDRMLeaseFlow(t *testing.T) {
117117 dev := bindProtocol(t, c, "wp_drm_lease_device_v1")
118118 p := &leaseDeviceEvents{c: c}
119119 p.SetID(dev)
120- c.Context().Register(p)
120+ registerWireProxy(c, p)
121121 if err := c.Roundtrip(); err != nil {
122122 t.Fatal(err)
123123 }
@@ -133,7 +133,7 @@ func TestDRMLeaseFlow(t *testing.T) {
133133 lease := c.AllocateID()
134134 l := &leaseResultEvents{}
135135 l.SetID(lease)
136- c.Context().Register(l)
136+ registerWireProxy(c, l)
137137 requestProtocol(t, c, req, dl.WpDrmLeaseRequestV1RequestSubmit, lease)
138138 if err := c.Roundtrip(); err != nil {
139139 t.Fatal(err)
@@ -194,7 +194,7 @@ func TestDRMLeaseErrors(t *testing.T) {
194194 dev := bindProtocol(t, c, "wp_drm_lease_device_v1")
195195 p := &leaseDeviceEvents{c: c}
196196 p.SetID(dev)
197- c.Context().Register(p)
197+ registerWireProxy(c, p)
198198 if err := c.Roundtrip(); err != nil {
199199 t.Fatal(err)
200200 }
@@ -222,7 +222,7 @@ func TestDRMLeaseFinished(t *testing.T) {
222222 dev := bindProtocol(t, c, "wp_drm_lease_device_v1")
223223 p := &leaseDeviceEvents{c: c}
224224 p.SetID(dev)
225- c.Context().Register(p)
225+ registerWireProxy(c, p)
226226 if err := c.Roundtrip(); err != nil {
227227 t.Fatal(err)
228228 }
@@ -234,7 +234,7 @@ func TestDRMLeaseFinished(t *testing.T) {
234234 lease := c.AllocateID()
235235 l := &leaseResultEvents{}
236236 l.SetID(lease)
237- c.Context().Register(l)
237+ registerWireProxy(c, l)
238238 requestProtocol(t, c, req, dl.WpDrmLeaseRequestV1RequestSubmit, lease)
239239 if err := c.Roundtrip(); err != nil {
240240 t.Fatal(err)
@@ -347,7 +347,7 @@ func TestLeaseIDsAreScopedToCard(t *testing.T) {
347347 var devices []uint32
348348 for _, g := range c.Registry().GetGlobals() {
349349 if g.Interface == "wp_drm_lease_device_v1" {
350- id, err := c.Registry().BindID(g.Name, g.Interface, 1)
350+ id, err := bindWireID(c, g.Name, g.Interface, 1)
351351 if err != nil {
352352 t.Fatal(err)
353353 }
@@ -361,7 +361,7 @@ func TestLeaseIDsAreScopedToCard(t *testing.T) {
361361 for _, dev := range devices {
362362 p := &leaseDeviceEvents{c: c}
363363 p.SetID(dev)
364- c.Context().Register(p)
364+ registerWireProxy(c, p)
365365 bound = append(bound, p)
366366 }
367367 if err := c.Roundtrip(); err != nil {
@@ -380,7 +380,7 @@ func TestLeaseIDsAreScopedToCard(t *testing.T) {
380380 id := c.AllocateID()
381381 l := &leaseResultEvents{}
382382 l.SetID(id)
383- c.Context().Register(l)
383+ registerWireProxy(c, l)
384384 requestProtocol(t, c, req, dl.WpDrmLeaseRequestV1RequestSubmit, id)
385385 }
386386 if err := c.Roundtrip(); err != nil {
@@ -447,7 +447,7 @@ func TestLeaseDestroyPendingRevokesLateReply(t *testing.T) {
447447 dev := bindProtocol(t, c, "wp_drm_lease_device_v1")
448448 p := &leaseDeviceEvents{c: c}
449449 p.SetID(dev)
450- c.Context().Register(p)
450+ registerWireProxy(c, p)
451451 if err := c.Roundtrip(); err != nil {
452452 t.Fatal(err)
453453 }
M internal/adapters/wayland/fifo.go +92 -13
@@ -217,12 +217,14 @@ type update struct {
217217 region *ports.Rect
218218 layer *layerSurface
219219 layerNext layerState
220+ lockAck lockConfigure
220221 readyGeneration uint64
221222 readyResult bool
222223 // refs counts incoming deps and prev links. Retired updates remain readable
223224 // until their last incoming link goes away, even after dropQueue.
224225 refs int
225226 retired bool
227+ failed bool // acquire observation failed: discard, never publish
226228 }
227229
228230 // updatePool contains only retired updates with no incoming graph links. A
@@ -308,6 +310,9 @@ func (s *surface) takePending() update {
308310 if s.layer != nil {
309311 u.layer, u.layerNext = s.layer, s.layer.pending
310312 }
313+ if s.lock != nil {
314+ u.lockAck = s.lock.ack
315+ }
311316 return u
312317 }
313318
@@ -349,7 +354,8 @@ func (s *surface) queueUpdate() {
349354 if b, ok := s.server.buffers[u.buffer.Resource].(*dmabufBuffer); ok {
350355 err := s.server.syncWait.watchImplicit(b.buf, &u.implicit)
351356 if err != nil {
352- // Partial registrations were cancelled; do not block this update.
357+ // Partial registrations were cancelled; discard this update.
358+ u.failed = true
353359 s.server.log.Warn().Str("component", "wayland").Err(err).Msg("implicit buffer wait")
354360 }
355361 }
@@ -396,7 +402,7 @@ func (s *surface) applyUpdate(u *update) {
396402 u.transform = s.transform
397403 // The viewport change was made for that buffer: keep the crop that
398404 // matches the retained content instead of validating a mismatch.
399- if u.vp != nil && u.vp.resource != nil && u.vp.resource.Resource.Alive() {
405+ if u.vp != nil && (s.lock != nil || u.vp.resource != nil && u.vp.resource.Alive()) {
400406 v := &s.committedViewport
401407 u.vpW, u.vpH, u.vpSet, u.vpSrc, u.vpCrop = v.destW, v.destH, v.dest, v.src, v.crop
402408 }
@@ -408,8 +414,19 @@ func (s *surface) applyUpdate(u *update) {
408414 // dropQueue discards the queued updates of a destroyed surface or role.
409415 // A live surface still gets its queued frame callbacks.
410416 func (s *surface) dropQueue() {
417+ if s.kind == roleSessionLock {
418+ s.dropLockDeps()
419+ }
420+ s.dropQueuePrefix(len(s.queue))
421+}
422+
423+// dropQueuePrefix retires only the first n updates. Leave the preserved suffix
424+// visible to release checks: a buffer shared with it is still client-owned work.
425+func (s *surface) dropQueuePrefix(n int) {
426+ queue := s.queue[:n]
427+ s.queue = s.queue[n:]
411428 released := map[*server.Resource]bool{}
412- for _, u := range s.queue {
429+ for _, u := range queue {
413430 if s.destroyed {
414431 for _, cb := range u.callbacks {
415432 cb.Destroy()
@@ -432,20 +449,22 @@ func (s *surface) dropQueue() {
432449 u.buffer.SendRelease()
433450 }
434451 }
435- for i, u := range s.queue {
436- s.queue[i] = nil // do not retain retired objects in the queue backing array
452+ for i, u := range queue {
453+ queue[i] = nil // do not retain retired objects in the queue backing array
437454 u.retireUpdate()
438455 }
439- s.queue = s.queue[:0]
440- delete(s.server.fifoSurfaces, s)
456+ if len(s.queue) == 0 {
457+ s.queue = queue[:0] // preserve steady-state capacity when all were retired
458+ delete(s.server.fifoSurfaces, s)
459+ }
441460 }
442461
443462 func (s *surface) bufferQueuedElsewhere(b *wayland.Buffer) bool {
444463 for _, other := range s.server.surfaces {
445- if other == s {
446- continue
464+ if sameBuffer(other.next.buffer, b) {
465+ return true
447466 }
448- if sameBuffer(other.current, b) {
467+ if other != s && sameBuffer(other.current, b) {
449468 return true
450469 }
451470 for _, u := range other.queue {
@@ -611,16 +630,32 @@ func (u *update) graphReady(now time.Time) bool {
611630 u.implicit[i] = nil
612631 }
613632 }
614- } else {
633+ } else if !u.failed {
615634 if !s.syncReady(u.sync) {
616635 return false
617636 }
637+ if u.sync != nil && u.sync.wait != nil && u.sync.wait.failed {
638+ u.failed = true
639+ }
640+ waiting := false
618641 for _, w := range u.implicit {
619- if w != nil && !s.server.syncWait.fired(w) {
620- return false
642+ if w == nil {
643+ continue
621644 }
645+ if !s.server.syncWait.fired(w) {
646+ waiting = true
647+ } else if w.failed {
648+ u.failed = true
649+ }
650+ }
651+ if waiting && !u.failed {
652+ return false
622653 }
623654 }
655+ if u.failed {
656+ u.readyResult = true // ready only to retire, never to apply
657+ return true
658+ }
624659 for _, dep := range u.deps {
625660 if !dep.owner.destroyed && !dep.graphReady(now) {
626661 return false
@@ -635,6 +670,24 @@ func (u *update) applyGraph() {
635670 return
636671 }
637672 s := u.owner
673+ if !u.failed && u.sync != nil && (u.buffer == nil || u.buffer.Alive()) && !s.prepareSync(u.sync) {
674+ u.failed = true
675+ }
676+ if u.failed {
677+ // Earlier commits still apply in order; only this update is discarded.
678+ for len(s.queue) > 0 && s.queue[0] != u {
679+ s.queue[0].applyGraph()
680+ }
681+ // Retire captured children too, cancelling their waits without replay.
682+ u.dropFailedDeps()
683+ for i, queued := range s.queue {
684+ if queued == u {
685+ s.dropQueuePrefix(i + 1)
686+ break
687+ }
688+ }
689+ return
690+ }
638691 for len(s.queue) > 0 && s.queue[0] != u {
639692 s.queue[0].applyGraph()
640693 }
@@ -661,6 +714,32 @@ func (u *update) applyGraph() {
661714 u.retireUpdate()
662715 }
663716
717+// dropFailedDeps retires just the child prefixes owned by a rejected commit.
718+// Incoming graph refs retain their storage until the failed parent retires.
719+func (u *update) dropFailedDeps() {
720+ for _, dep := range u.deps {
721+ if dep.retired {
722+ continue
723+ }
724+ // Every predecessor in the retired prefix can own a different
725+ // dependency graph. Traverse it before severing any incoming refs.
726+ for _, prefix := range dep.owner.queue {
727+ if !prefix.retired {
728+ prefix.dropFailedDeps()
729+ }
730+ if prefix == dep {
731+ break
732+ }
733+ }
734+ for i, queued := range dep.owner.queue {
735+ if queued == dep {
736+ dep.owner.dropQueuePrefix(i + 1)
737+ break
738+ }
739+ }
740+ }
741+}
742+
664743 // sameBuffer reports whether two wrappers are the same wl_buffer.
665744 func sameBuffer(a, b *wayland.Buffer) bool {
666745 return a != nil && b != nil && a.Resource == b.Resource
M internal/adapters/wayland/fifo_test.go +1 -1
@@ -44,7 +44,7 @@ func shmBuffer(t *testing.T, c *wlturbo.Display) uint32 {
4444 pool, buffer := c.AllocateID(), c.AllocateID()
4545 registerProtocol(t, c, pool)
4646 registerProtocol(t, c, buffer)
47- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
47+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
4848 t.Fatal(err)
4949 }
5050 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
M internal/adapters/wayland/foreigntoplevel.go +33 -9
@@ -62,10 +62,14 @@ func (m *toplevelManager) Stop(*wlr.ZwlrForeignToplevelManagerV1) {
6262
6363 // refresh announces w or sends what changed since the last done.
6464 func (m *toplevelManager) refresh(w *window) {
65- if !m.res.Alive() {
65+ if m.s.protected() || !m.res.Alive() {
6666 return
6767 }
6868 h := m.handles[w.id]
69+ if h != nil && h.req.closed {
70+ m.closed(w.id)
71+ h = nil
72+ }
6973 fresh := h == nil
7074 if fresh {
7175 req := &toplevelRequests{s: m.s, id: w.id}
@@ -110,9 +114,14 @@ func (m *toplevelManager) closed(id ports.WindowID) {
110114 if h == nil {
111115 return
112116 }
113- delete(m.handles, id)
114- // A remapped window keeps its ID but gets a new handle.
117+ // A remapped window keeps its ID but gets a new handle. Mark requests
118+ // inert immediately, but defer the wire event to avoid leaking lock-time
119+ // unmap/disconnect timing. Keep the handle for the unlock refresh.
115120 h.req.closed = true
121+ if m.s.protected() {
122+ return
123+ }
124+ delete(m.handles, id)
116125 if h.res.Alive() {
117126 h.res.SendClosed()
118127 }
@@ -192,15 +201,30 @@ func (s *Server) toplevelClosed(id ports.WindowID) {
192201 }
193202 }
194203
195-// refreshToplevels resends the output events of every handle after a
196-// wl_output was bound or an output removed.
204+// refreshToplevels reconciles inventory after output changes or unlock:
205+// retire handles closed during protection, then announce current windows,
206+// including those mapped (or managers bound) while inventory was suppressed.
197207 func (s *Server) refreshToplevels() {
208+ if s.protected() {
209+ return
210+ }
211+ ids := make([]ports.WindowID, 0, len(s.windows))
212+ for id, w := range s.windows {
213+ if w.toplevel != nil && w.mapped {
214+ ids = append(ids, id)
215+ }
216+ }
217+ slices.Sort(ids)
198218 for _, m := range s.toplevelManagers {
199- for id := range m.handles {
200- if w := s.windows[id]; w != nil {
201- m.refresh(w)
219+ for id, h := range m.handles {
220+ w := s.windows[id]
221+ if h.req.closed || w == nil || w.toplevel == nil || !w.mapped {
222+ m.closed(id)
202223 }
203224 }
225+ for _, id := range ids {
226+ m.refresh(s.windows[id])
227+ }
204228 }
205229 }
206230
@@ -213,7 +237,7 @@ type toplevelRequests struct {
213237 }
214238
215239 func (t *toplevelRequests) window() *window {
216- if t.closed {
240+ if t.s.protected() || t.closed {
217241 return nil
218242 }
219243 if w := t.s.windows[t.id]; w != nil && w.toplevel != nil && w.mapped && w.toplevel.Resource.Alive() {
M internal/adapters/wayland/foreigntoplevel_test.go +5 -5
@@ -29,7 +29,7 @@ func (p *toplevelManagerEvents) Dispatch(e *wlturbo.Event) {
2929 id := e.Uint32()
3030 h := &toplevelHandleEvents{events: p.events}
3131 h.SetID(id)
32- p.client.Context().Register(h)
32+ registerWireProxy(p.client, h)
3333 p.handles <- id
3434 }
3535
@@ -105,7 +105,7 @@ func TestForeignToplevel(t *testing.T) {
105105 manager := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 3)
106106 p := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)}
107107 p.SetID(manager)
108- c.Context().Register(p)
108+ registerWireProxy(c, p)
109109 if got, want := takeEvents(t, c, p.events), []string{"title editor", "app_id app.editor", "state []", "done"}; !slices.Equal(got, want) {
110110 t.Fatalf("announce = %q, want %q", got, want)
111111 }
@@ -154,7 +154,7 @@ func TestForeignToplevel(t *testing.T) {
154154 serials := make(chan uint32, 8)
155155 xp := &configureProxy{serial: serials}
156156 xp.SetID(xdg)
157- c.Context().Register(xp)
157+ registerWireProxy(c, xp)
158158 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
159159 ackAndAttach(t, c, surf, xdg, shmBuffer(t, c), serials)
160160 if again := mapped(t, events, 2*time.Second); again.ID != w.ID {
@@ -200,7 +200,7 @@ func TestForeignToplevelV1HidesFullscreen(t *testing.T) {
200200 manager := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 1)
201201 p := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)}
202202 p.SetID(manager)
203- c.Context().Register(p)
203+ registerWireProxy(c, p)
204204 takeEvents(t, c, p.events)
205205 commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Activated: true}
206206 want := []string{fmt.Sprint("state ", []uint32{uint32(wlr.ZwlrForeignToplevelHandleV1StateActivated)}), "done"}
@@ -216,7 +216,7 @@ func TestForeignToplevelInvalidRectangle(t *testing.T) {
216216 manager := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 3)
217217 p := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)}
218218 p.SetID(manager)
219- c.Context().Register(p)
219+ registerWireProxy(c, p)
220220 takeEvents(t, c, p.events)
221221 handle := <-p.handles
222222 requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestSetRectangle, surf, int32(0), int32(0), int32(-1), int32(1))
M internal/adapters/wayland/frames.go +2 -0
@@ -149,6 +149,8 @@ func (s *Server) frameOutput(surf *surface) string {
149149 return surf.xdg.window.last.Output
150150 case surf.layer != nil && surf.layer.output != nil:
151151 return surf.layer.output.name()
152+ case surf.lock != nil && surf.lock.output != nil && !surf.lock.closed:
153+ return surf.lock.output.name()
152154 }
153155 return ""
154156 }
M internal/adapters/wayland/globals.go +9 -0
@@ -12,6 +12,15 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
1212 for _, register := range []func() error{
1313 func() error { return registerXDG(d, s) },
1414 func() error { return registerLayer(d, s) },
15+ func() error {
16+ // Advertise only with the production owner gate and authoritative
17+ // backend proof/barrier channels. Standalone adapters cannot claim
18+ // protection from composed-frame submission alone.
19+ if s.securityController == nil || s.channels.SecurityChanges == nil || s.channels.SecurityEvents == nil {
20+ return nil
21+ }
22+ return registerSessionLock(d, s)
23+ },
1524 func() error { return registerXDGOutput(d, s) },
1625 func() error { return registerOutputManagement(d, s) },
1726 func() error { return registerWorkspaces(d, s) },
M internal/adapters/wayland/idle_test.go +1 -1
@@ -33,7 +33,7 @@ func newEventProxy(c *wlturbo.Display) (uint32, *eventProxy) {
3333 id := c.AllocateID()
3434 p := &eventProxy{events: make(chan [2]uint32, 16)}
3535 p.SetID(id)
36- c.Context().Register(p)
36+ registerWireProxy(c, p)
3737 return id, p
3838 }
3939
M internal/adapters/wayland/inhibit_test.go +1 -1
@@ -47,7 +47,7 @@ func TestShortcutsInhibitor(t *testing.T) {
4747 in := c.AllocateID()
4848 p := &inhibitorProxy{states: make(chan bool, 4)}
4949 p.SetID(in)
50- c.Context().Register(p)
50+ registerWireProxy(c, p)
5151 requestProtocol(t, c, mgr, keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitManagerV1RequestInhibitShortcuts, in, surf, seat)
5252 if v := clientEvent[ports.ShortcutsInhibit](t, events); v != (ports.ShortcutsInhibit{Window: w.ID, Active: true}) {
5353 t.Fatal(v)
M internal/adapters/wayland/input_region_test.go +1 -1
@@ -90,7 +90,7 @@ func TestSubsurfaceInputRegionOwnCommitAndOffset(t *testing.T) {
9090 pool, buffer := c.AllocateID(), c.AllocateID()
9191 registerProtocol(t, c, pool)
9292 registerProtocol(t, c, buffer)
93- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(64)); err != nil {
93+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(64)); err != nil {
9494 t.Fatal(err)
9595 }
9696 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(4), int32(4), int32(16), uint32(0))
M internal/adapters/wayland/keyboard_test.go +4 -4
@@ -109,7 +109,7 @@ func TestKeyboardKeymapProtocol(t *testing.T) {
109109 id := c.AllocateID()
110110 proxy := &keymapProxy{result: make(chan error, 1)}
111111 proxy.SetID(id)
112- c.Context().Register(proxy)
112+ registerWireProxy(c, proxy)
113113 requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id)
114114 if err := c.Roundtrip(); err != nil {
115115 t.Fatal(err)
@@ -162,7 +162,7 @@ func TestSetKeymap(t *testing.T) {
162162 if !ok {
163163 t.Fatal("missing wl_seat")
164164 }
165- seat, err := c.Registry().BindID(g.Name, g.Interface, 4)
165+ seat, err := bindWireID(c, g.Name, g.Interface, 4)
166166 if err != nil {
167167 t.Fatal(err)
168168 }
@@ -170,7 +170,7 @@ func TestSetKeymap(t *testing.T) {
170170 id := c.AllocateID()
171171 proxy := &eventsProxy{}
172172 proxy.SetID(id)
173- c.Context().Register(proxy)
173+ registerWireProxy(c, proxy)
174174 requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id)
175175 if err := c.Roundtrip(); err != nil {
176176 t.Fatal(err)
@@ -212,7 +212,7 @@ func TestSetKeymap(t *testing.T) {
212212 id2 := c.AllocateID()
213213 late := &eventsProxy{}
214214 late.SetID(id2)
215- c.Context().Register(late)
215+ registerWireProxy(c, late)
216216 requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id2)
217217 if err := c.Roundtrip(); err != nil {
218218 t.Fatal(err)
M internal/adapters/wayland/layer_scale_test.go +4 -4
@@ -56,7 +56,7 @@ func TestLayerTargetScaleBeforeConfigure(t *testing.T) {
5656 name = first
5757 }
5858 var err error
59- out, err = c.Registry().BindID(name, "wl_output", 4)
59+ out, err = bindWireID(c, name, "wl_output", 4)
6060 if err != nil {
6161 t.Fatal(err)
6262 }
@@ -73,13 +73,13 @@ func TestLayerTargetScaleBeforeConfigure(t *testing.T) {
7373 integers := make(chan int32, 4)
7474 sp := &surfaceScaleProxy{scales: integers}
7575 sp.SetID(surf)
76- c.Context().Register(sp)
76+ registerWireProxy(c, sp)
7777 requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
7878 frac := c.AllocateID()
7979 events := make(chan [3]uint32, 8)
8080 fp := &layerScaleOrderProxy{events: events}
8181 fp.SetID(frac)
82- c.Context().Register(fp)
82+ registerWireProxy(c, fp)
8383 requestProtocol(t, c, fm, fractionalscale.WpFractionalScaleManagerV1RequestGetFractionalScale, frac, surf)
8484 if err := c.Roundtrip(); err != nil {
8585 t.Fatal(err)
@@ -93,7 +93,7 @@ func TestLayerTargetScaleBeforeConfigure(t *testing.T) {
9393 layer := c.AllocateID()
9494 lp := &layerScaleOrderProxy{layer: true, events: events}
9595 lp.SetID(layer)
96- c.Context().Register(lp)
96+ registerWireProxy(c, lp)
9797 requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, layer, surf, out, uint32(ports.LayerOverlay), "scale-test")
9898 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(15))
9999 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(0))
M internal/adapters/wayland/layer_test.go +10 -10
@@ -52,7 +52,7 @@ func TestLayerLifecycle(t *testing.T) {
5252 surf, layer := layerProtocol(t, c)
5353 proxy := &layerProxy{configured: make(chan [3]uint32, 2)}
5454 proxy.SetID(layer)
55- c.Context().Register(proxy)
55+ registerWireProxy(c, proxy)
5656 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(13))
5757 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(30))
5858 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetExclusiveZone, int32(30))
@@ -83,7 +83,7 @@ func TestLayerLifecycle(t *testing.T) {
8383 pool, buf := c.AllocateID(), c.AllocateID()
8484 registerProtocol(t, c, pool)
8585 registerProtocol(t, c, buf)
86- if err = c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(1920*30*4)); err != nil {
86+ if err = wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(1920*30*4)); err != nil {
8787 t.Fatal(err)
8888 }
8989 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1920), int32(30), int32(1920*4), uint32(wayland.ShmFormatArgb8888))
@@ -199,18 +199,18 @@ func TestXDGOutputDone(t *testing.T) {
199199 if !ok {
200200 t.Fatal("missing wl_output")
201201 }
202- output, err := c.Registry().BindID(global.Name, global.Interface, version)
202+ output, err := bindWireID(c, global.Name, global.Interface, version)
203203 if err != nil {
204204 t.Fatal(err)
205205 }
206206 op := &doneProxy{opcode: uint16(wayland.OutputEventDone)}
207207 op.SetID(output)
208- c.Context().Register(op)
208+ registerWireProxy(c, op)
209209 global, ok = c.Registry().FindGlobal("zxdg_output_manager_v1")
210210 if !ok {
211211 t.Fatal("missing xdg output manager")
212212 }
213- manager, err := c.Registry().BindID(global.Name, global.Interface, 3)
213+ manager, err := bindWireID(c, global.Name, global.Interface, 3)
214214 if err != nil {
215215 t.Fatal(err)
216216 }
@@ -222,7 +222,7 @@ func TestXDGOutputDone(t *testing.T) {
222222 xdg := c.AllocateID()
223223 xp := &doneProxy{opcode: 2}
224224 xp.SetID(xdg)
225- c.Context().Register(xp)
225+ registerWireProxy(c, xp)
226226 requestProtocol(t, c, manager, 1, xdg, output)
227227 if err := c.Roundtrip(); err != nil {
228228 t.Fatal(err)
@@ -260,7 +260,7 @@ func TestLayerPointerAndPopup(t *testing.T) {
260260 pointer := c.AllocateID()
261261 pp := &layerPointerProxy{enters: make(chan [2]float64, 1), buttons: make(chan uint32, 1)}
262262 pp.SetID(pointer)
263- c.Context().Register(pp)
263+ registerWireProxy(c, pp)
264264 requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
265265 comp := bindProtocol(t, c, "wl_compositor")
266266 wm := bindProtocol(t, c, "xdg_wm_base")
@@ -277,7 +277,7 @@ func TestLayerPointerAndPopup(t *testing.T) {
277277 pool, buf := c.AllocateID(), c.AllocateID()
278278 registerProtocol(t, c, pool)
279279 registerProtocol(t, c, buf)
280- if err = c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
280+ if err = wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
281281 t.Fatal(err)
282282 }
283283 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(0))
@@ -285,7 +285,7 @@ func TestLayerPointerAndPopup(t *testing.T) {
285285 surf, layer := layerProtocol(t, c)
286286 lp := &layerProxy{configured: make(chan [3]uint32, 2)}
287287 lp.SetID(layer)
288- c.Context().Register(lp)
288+ registerWireProxy(c, lp)
289289 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(1), uint32(1))
290290 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
291291 if err = c.Roundtrip(); err != nil {
@@ -334,7 +334,7 @@ func TestLayerPointerAndPopup(t *testing.T) {
334334 popup := c.AllocateID()
335335 proxy := &popupDoneProxy{done: done}
336336 proxy.SetID(popup)
337- c.Context().Register(proxy)
337+ registerWireProxy(c, proxy)
338338 requestProtocol(t, c, pxdg, xdgshell.SurfaceRequestGetPopup, popup, uint32(0), positioner)
339339 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestGetPopup, popup)
340340 requestProtocol(t, c, popup, xdgshell.PopupRequestGrab, seat, press)
M internal/adapters/wayland/outputmanagement_test.go +18 -16
@@ -27,7 +27,7 @@ func (p *managementEvents) Dispatch(e *wlturbo.Event) {
2727 if e.Opcode == uint16(wlr.ZwlrOutputManagerV1EventHead) {
2828 h := &managementHeadEvents{client: p.client, modes: p.modes, events: p.headEvents}
2929 h.SetID(msg[1])
30- p.client.Context().Register(h)
30+ registerWireProxy(p.client, h)
3131 }
3232 p.events <- msg
3333 }
@@ -51,7 +51,7 @@ func (p *managementHeadEvents) Dispatch(e *wlturbo.Event) {
5151 if e.Opcode == uint16(wlr.ZwlrOutputHeadV1EventMode) {
5252 mode := &protocolProxy{}
5353 mode.SetID(e.Uint32())
54- p.client.Context().Register(mode)
54+ registerWireProxy(p.client, mode)
5555 if p.modes != nil {
5656 p.modes <- mode.ID()
5757 }
@@ -118,7 +118,7 @@ func TestOutputManagementStaleSerialAndLayout(t *testing.T) {
118118 id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
119119 manager := &managementEvents{events: make(chan [2]uint32, 32), client: c}
120120 manager.SetID(id)
121- c.Context().Register(manager)
121+ registerWireProxy(c, manager)
122122 if err := c.Roundtrip(); err != nil {
123123 t.Fatal(err)
124124 }
@@ -144,7 +144,7 @@ func TestOutputManagementStaleSerialAndLayout(t *testing.T) {
144144 configID := c.AllocateID()
145145 proxy := &managementEvents{events: make(chan [2]uint32, 8), client: c}
146146 proxy.SetID(configID)
147- c.Context().Register(proxy)
147+ registerWireProxy(c, proxy)
148148 requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, first[1])
149149 waitManagementEvent(t, c, proxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventCancelled))
150150 if !s.display.Do(func() { s.setOutputHeads(ports.OutputHeads{}) }) {
@@ -169,7 +169,7 @@ func TestOutputManagementHeadsAndApply(t *testing.T) {
169169 id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
170170 manager := &managementEvents{events: make(chan [2]uint32, 32), client: c}
171171 manager.SetID(id)
172- c.Context().Register(manager)
172+ registerWireProxy(c, manager)
173173 if err := c.Roundtrip(); err != nil {
174174 t.Fatal(err)
175175 }
@@ -187,7 +187,7 @@ func TestOutputManagementHeadsAndApply(t *testing.T) {
187187 configID := c.AllocateID()
188188 configProxy := &managementEvents{events: make(chan [2]uint32, 8), client: c}
189189 configProxy.SetID(configID)
190- c.Context().Register(configProxy)
190+ registerWireProxy(c, configProxy)
191191 requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, serial[1])
192192 chID := c.AllocateID()
193193 registerProtocol(t, c, chID)
@@ -211,7 +211,7 @@ func TestOutputManagementHeadsAndApply(t *testing.T) {
211211 configID = c.AllocateID()
212212 configProxy = &managementEvents{events: make(chan [2]uint32, 8), client: c}
213213 configProxy.SetID(configID)
214- c.Context().Register(configProxy)
214+ registerWireProxy(c, configProxy)
215215 requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, serial[1])
216216 chID = c.AllocateID()
217217 registerProtocol(t, c, chID)
@@ -259,7 +259,7 @@ func TestOutputManagementUnsupportedSettings(t *testing.T) {
259259 managerID := bindVersion(t, c, "zwlr_output_manager_v1", 4)
260260 manager := &managementEvents{events: make(chan [2]uint32, 32), client: c}
261261 manager.SetID(managerID)
262- c.Context().Register(manager)
262+ registerWireProxy(c, manager)
263263 if err := c.Roundtrip(); err != nil {
264264 t.Fatal(err)
265265 }
@@ -271,7 +271,7 @@ func TestOutputManagementUnsupportedSettings(t *testing.T) {
271271 cfg := c.AllocateID()
272272 proxy := &managementEvents{events: make(chan [2]uint32, 8), client: c}
273273 proxy.SetID(cfg)
274- c.Context().Register(proxy)
274+ registerWireProxy(c, proxy)
275275 requestProtocol(t, c, managerID, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, done[1])
276276 ch := c.AllocateID()
277277 registerProtocol(t, c, ch)
@@ -373,7 +373,7 @@ func TestOutputManagementProtocolErrors(t *testing.T) {
373373 managerID := bindVersion(t, c, "zwlr_output_manager_v1", 4)
374374 p := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
375375 p.SetID(managerID)
376- c.Context().Register(p)
376+ registerWireProxy(c, p)
377377 if err := c.Roundtrip(); err != nil {
378378 t.Fatal(err)
379379 }
@@ -390,7 +390,7 @@ func TestOutputManagementProtocolErrors(t *testing.T) {
390390 other := bindVersion(t, c, "zwlr_output_manager_v1", 4)
391391 otherEvents := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
392392 otherEvents.SetID(other)
393- c.Context().Register(otherEvents)
393+ registerWireProxy(c, otherEvents)
394394 if err := c.Roundtrip(); err != nil {
395395 t.Fatal(err)
396396 }
@@ -434,7 +434,7 @@ func TestOutputManagementInitialHeadState(t *testing.T) {
434434 id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
435435 p := &managementEvents{events: make(chan [2]uint32, 32), headEvents: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
436436 p.SetID(id)
437- c.Context().Register(p)
437+ registerWireProxy(c, p)
438438 if err := c.Roundtrip(); err != nil {
439439 t.Fatal(err)
440440 }
@@ -465,7 +465,7 @@ func TestOutputManagementStopDestroysManager(t *testing.T) {
465465 id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
466466 p := &managementEvents{events: make(chan [2]uint32, 8), client: c}
467467 p.SetID(id)
468- c.Context().Register(p)
468+ registerWireProxy(c, p)
469469 requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestStop)
470470 waitManagementEvent(t, c, p.events, wlr.ZwlrOutputManagerV1EventDone)
471471 waitManagementEvent(t, c, p.events, wlr.ZwlrOutputManagerV1EventFinished)
@@ -487,7 +487,7 @@ func TestOutputManagementReleasedModeNotReused(t *testing.T) {
487487 id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
488488 p := &managementEvents{events: make(chan [2]uint32, 32), headEvents: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
489489 p.SetID(id)
490- c.Context().Register(p)
490+ registerWireProxy(c, p)
491491 if err := c.Roundtrip(); err != nil {
492492 t.Fatal(err)
493493 }
@@ -531,7 +531,7 @@ func managementSetup(t *testing.T) (*Server, *wlturbo.Display, uint32, uint32, u
531531 id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
532532 p := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
533533 p.SetID(id)
534- c.Context().Register(p)
534+ registerWireProxy(c, p)
535535 if err := c.Roundtrip(); err != nil {
536536 t.Fatal(err)
537537 }
@@ -585,6 +585,7 @@ func TestOutputManagementDestroyConfigurationChildren(t *testing.T) {
585585 t.Fatal("no child resource")
586586 }
587587 requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestDestroy)
588+ retireWireObject(c, ch) // the parent destructor also destroys its configuration heads
588589 if err := c.Roundtrip(); err != nil {
589590 t.Fatal(err)
590591 }
@@ -602,7 +603,7 @@ func TestOutputManagementStaleConfigurationSingleTerminal(t *testing.T) {
602603 cfg := c.AllocateID()
603604 p := &managementEvents{events: make(chan [2]uint32, 8), client: c}
604605 p.SetID(cfg)
605- c.Context().Register(p)
606+ registerWireProxy(c, p)
606607 requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, serial-1)
607608 waitManagementEvent(t, c, p.events, wlr.ZwlrOutputConfigurationV1EventCancelled)
608609 requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestApply)
@@ -634,6 +635,7 @@ func TestOutputManagementDestroyDropsPendingReply(t *testing.T) {
634635 t.Fatal("display stopped")
635636 }
636637 requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestDestroy)
638+ retireWireObject(c, ch) // the parent destructor also destroys its configuration heads
637639 if err := c.Roundtrip(); err != nil {
638640 t.Fatal(err)
639641 }
M internal/adapters/wayland/outputs.go +14 -0
@@ -193,6 +193,11 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
193193 l.close()
194194 }
195195 }
196+ for _, l := range s.lockSurfaces {
197+ if l.output == o {
198+ l.unmap()
199+ }
200+ }
196201 for _, surf := range s.surfaces {
197202 surf.leave(o)
198203 }
@@ -211,7 +216,16 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
211216 }
212217 scaleChanged := o.place.Scale != p.Scale
213218 sizeChanged := o.place.Info.Width != p.Info.Width || o.place.Info.Height != p.Info.Height
219+ logicalChanged := o.place.Width != p.Width || o.place.Height != p.Height
214220 o.place = p
221+ if logicalChanged {
222+ for _, l := range s.lockSurfaces {
223+ if l.output == o && !l.closed {
224+ l.sendConfigure()
225+ }
226+ }
227+ s.lockSurfaceChanged()
228+ }
215229 if sizeChanged {
216230 for session := range s.captureSessions {
217231 if session.o == o && !session.stopped {
M internal/adapters/wayland/outputs_test.go +4 -2
@@ -92,6 +92,8 @@ func TestFixesAckAndDestroyRegistry(t *testing.T) {
9292 waitOutputs(t, c, 1)
9393 requestProtocol(t, c, fixes, wayland.FixesRequestAckGlobalRemove, c.Registry().ID(), name)
9494 requestProtocol(t, c, fixes, wayland.FixesRequestDestroyRegistry, c.Registry().ID())
95+ // wl_fixes destroys another object, not the request's own proxy.
96+ c.Context().UnregisterID(c.Registry().ID())
9597 if err := c.Roundtrip(); err != nil {
9698 t.Fatalf("valid ack and destroy: %v", err)
9799 }
@@ -121,7 +123,7 @@ func TestOutputHotplugAndLayerOutput(t *testing.T) {
121123 name = n
122124 }
123125 }
124- out, err := c.Registry().BindID(name, "wl_output", 4)
126+ out, err := bindWireID(c, name, "wl_output", 4)
125127 if err != nil {
126128 t.Fatal(err)
127129 }
@@ -134,7 +136,7 @@ func TestOutputHotplugAndLayerOutput(t *testing.T) {
134136 layer := c.AllocateID()
135137 proxy := &closedProxy{configured: make(chan [3]uint32, 2), closed: make(chan struct{})}
136138 proxy.SetID(layer)
137- c.Context().Register(proxy)
139+ registerWireProxy(c, proxy)
138140 requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, layer, surf, out, uint32(ports.LayerTop), "bar")
139141 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(13))
140142 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(30))
M internal/adapters/wayland/presentation_test.go +4 -4
@@ -109,7 +109,7 @@ func TestPresentationFeedback(t *testing.T) {
109109 fb := c.AllocateID()
110110 p := &feedbackEvents{out: make(chan presentedEvent, 4)}
111111 p.SetID(fb)
112- c.Context().Register(p)
112+ registerWireProxy(c, p)
113113 requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, surf, fb)
114114 return p
115115 }
@@ -142,7 +142,7 @@ func TestPresentationFeedback(t *testing.T) {
142142 }
143143 pool := c.AllocateID()
144144 registerProtocol(t, c, pool)
145- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
145+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
146146 t.Fatal(err)
147147 }
148148 commit := func() {
@@ -218,7 +218,7 @@ func TestPresentationFeedback(t *testing.T) {
218218 cfb := c.AllocateID()
219219 cp := &feedbackEvents{out: make(chan presentedEvent, 1)}
220220 cp.SetID(cfb)
221- c.Context().Register(cp)
221+ registerWireProxy(c, cp)
222222 requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, child, cfb)
223223 requestProtocol(t, c, child, wayland.SurfaceRequestCommit)
224224 commit() // the parent applies the subsurface
@@ -248,7 +248,7 @@ func TestPresentationHiddenDiscarded(t *testing.T) {
248248 fb := c.AllocateID()
249249 p := &feedbackEvents{out: make(chan presentedEvent, 1)}
250250 p.SetID(fb)
251- c.Context().Register(p)
251+ registerWireProxy(c, p)
252252 requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, surf, fb)
253253 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
254254 if err := c.Roundtrip(); err != nil {
M internal/adapters/wayland/protocol_test.go +20 -20
@@ -23,7 +23,7 @@ func protocolClient(t *testing.T, s *Server, dir string) *wlturbo.Display {
2323 if err != nil {
2424 t.Fatal(err)
2525 }
26- t.Cleanup(func() { _ = c.Close() })
26+ t.Cleanup(func() { _ = c.Close(); forgetWireClient(c) })
2727 if err := c.Roundtrip(); err != nil {
2828 t.Fatal(err)
2929 }
@@ -41,7 +41,7 @@ func bindVersion(t *testing.T, c *wlturbo.Display, iface string, version uint32)
4141 if !ok {
4242 t.Fatalf("missing %s", iface)
4343 }
44- id, err := c.Registry().BindID(g.Name, g.Interface, version)
44+ id, err := bindWireID(c, g.Name, g.Interface, version)
4545 if err != nil {
4646 t.Fatal(err)
4747 }
@@ -50,7 +50,7 @@ func bindVersion(t *testing.T, c *wlturbo.Display, iface string, version uint32)
5050
5151 func requestProtocol(t *testing.T, c *wlturbo.Display, id uint32, op uint32, args ...any) {
5252 t.Helper()
53- if err := c.SendRequest(id, uint16(op), args...); err != nil {
53+ if err := wireRequest(c, id, uint16(op), nil, args...); err != nil {
5454 t.Fatal(err)
5555 }
5656 }
@@ -89,7 +89,7 @@ func registerProtocol(t *testing.T, c *wlturbo.Display, id uint32) {
8989 t.Helper()
9090 p := &protocolProxy{}
9191 p.SetID(id)
92- c.Context().Register(p)
92+ registerWireProxy(c, p)
9393 }
9494
9595 func TestXDGProtocolErrors(t *testing.T) {
@@ -134,7 +134,7 @@ func TestXDGProtocolErrors(t *testing.T) {
134134 }
135135 pool := c.AllocateID()
136136 registerProtocol(t, c, shm)
137- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
137+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
138138 t.Fatal(err)
139139 }
140140 buffer := c.AllocateID()
@@ -199,7 +199,7 @@ func TestRecreatedToplevelRequiresNewBuffer(t *testing.T) {
199199 serials := make(chan uint32, 4)
200200 proxy := &configureProxy{serial: serials}
201201 proxy.SetID(xdg)
202- c.Context().Register(proxy)
202+ registerWireProxy(c, proxy)
203203 fd, err := unix.MemfdCreate("recreated-buffer", 0)
204204 if err != nil {
205205 t.Fatal(err)
@@ -212,7 +212,7 @@ func TestRecreatedToplevelRequiresNewBuffer(t *testing.T) {
212212 pool, buffer := c.AllocateID(), c.AllocateID()
213213 registerProtocol(t, c, pool)
214214 registerProtocol(t, c, buffer)
215- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
215+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
216216 t.Fatal(err)
217217 }
218218 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
@@ -292,7 +292,7 @@ func TestSHMInvalidStride(t *testing.T) {
292292 pool := c.AllocateID()
293293 registerProtocol(t, c, shm)
294294 registerProtocol(t, c, pool)
295- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(16)); err != nil {
295+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(16)); err != nil {
296296 t.Fatal(err)
297297 }
298298 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, c.AllocateID(), int32(0), int32(2), int32(1), int32(4), uint32(wayland.ShmFormatArgb8888))
@@ -320,7 +320,7 @@ func TestSHMUnpaddedLastRow(t *testing.T) {
320320 pool := c.AllocateID()
321321 registerProtocol(t, c, shm)
322322 registerProtocol(t, c, pool)
323- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, tc.size); err != nil {
323+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, tc.size); err != nil {
324324 t.Fatal(err)
325325 }
326326 buffer := c.AllocateID()
@@ -390,7 +390,7 @@ func TestSHMBufferReleasedOnReplaceAndDestroy(t *testing.T) {
390390 pool := c.AllocateID()
391391 registerProtocol(t, c, shm)
392392 registerProtocol(t, c, pool)
393- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(64)); err != nil {
393+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(64)); err != nil {
394394 t.Fatal(err)
395395 }
396396 released := make(chan uint32, 4)
@@ -399,7 +399,7 @@ func TestSHMBufferReleasedOnReplaceAndDestroy(t *testing.T) {
399399 bufs[i] = c.AllocateID()
400400 p := &releaseProxy{released: released}
401401 p.SetID(bufs[i])
402- c.Context().Register(p)
402+ registerWireProxy(c, p)
403403 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, bufs[i], int32(i*32), int32(2), int32(2), int32(8), uint32(wayland.ShmFormatXrgb8888))
404404 }
405405 surf := c.AllocateID()
@@ -466,7 +466,7 @@ func TestPointerProtocol(t *testing.T) {
466466 pointer := c.AllocateID()
467467 p := &pointerEvents{enters: make(chan [2]float64, 1), buttons: make(chan uint32, 8), axes: make(chan string, 16)}
468468 p.SetID(pointer)
469- c.Context().Register(p)
469+ registerWireProxy(c, p)
470470 requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
471471 comp := bindProtocol(t, c, "wl_compositor")
472472 wm := bindProtocol(t, c, "xdg_wm_base")
@@ -479,7 +479,7 @@ func TestPointerProtocol(t *testing.T) {
479479 serials := make(chan uint32, 1)
480480 xp := &configureProxy{serial: serials}
481481 xp.SetID(xdg)
482- c.Context().Register(xp)
482+ registerWireProxy(c, xp)
483483 top := c.AllocateID()
484484 registerProtocol(t, c, top)
485485 requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
@@ -499,7 +499,7 @@ func TestPointerProtocol(t *testing.T) {
499499 pool, buffer := c.AllocateID(), c.AllocateID()
500500 registerProtocol(t, c, pool)
501501 registerProtocol(t, c, buffer)
502- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
502+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
503503 t.Fatal(err)
504504 }
505505 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
@@ -577,7 +577,7 @@ func TestHeldKeyOnKeyboardFocusTransfer(t *testing.T) {
577577 keyboard := c.AllocateID()
578578 kp := &heldKeyProxy{enters: make(chan []byte, 4)}
579579 kp.SetID(keyboard)
580- c.Context().Register(kp)
580+ registerWireProxy(c, kp)
581581 requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, keyboard)
582582 mapWindow := toplevelMapper(t, c, events)
583583 a := mapWindow().ID
@@ -642,7 +642,7 @@ func surfaceMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientE
642642 pool, buffer := c.AllocateID(), c.AllocateID()
643643 registerProtocol(t, c, pool)
644644 registerProtocol(t, c, buffer)
645- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
645+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
646646 t.Fatal(err)
647647 }
648648 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
@@ -654,7 +654,7 @@ func surfaceMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientE
654654 serials := make(chan uint32, 1)
655655 xp := &configureProxy{serial: serials}
656656 xp.SetID(xdg)
657- c.Context().Register(xp)
657+ registerWireProxy(c, xp)
658658 registerProtocol(t, c, top)
659659 requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
660660 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
@@ -697,7 +697,7 @@ func xdgWindow(t *testing.T, c *wlturbo.Display, comp, wm, buffer uint32, role f
697697 serials = make(chan uint32, 4)
698698 proxy := &configureProxy{serial: serials}
699699 proxy.SetID(xdg)
700- c.Context().Register(proxy)
700+ registerWireProxy(c, proxy)
701701 role(xdg)
702702 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
703703 if err := c.Roundtrip(); err != nil {
@@ -746,7 +746,7 @@ func TestPopupAndFloatingLifecycle(t *testing.T) {
746746 pool, buffer := c.AllocateID(), c.AllocateID()
747747 registerProtocol(t, c, pool)
748748 registerProtocol(t, c, buffer)
749- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
749+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
750750 t.Fatal(err)
751751 }
752752 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
@@ -777,7 +777,7 @@ func TestPopupAndFloatingLifecycle(t *testing.T) {
777777 popup := c.AllocateID()
778778 proxy := &popupDoneProxy{done: done}
779779 proxy.SetID(popup)
780- c.Context().Register(proxy)
780+ registerWireProxy(c, proxy)
781781 requestProtocol(t, c, pxdg, xdgshell.SurfaceRequestGetPopup, popup, xdg, positioner)
782782 })
783783 req, ok := waitEvent(t, events, 2*time.Second).(ports.PopupRequest)
M internal/adapters/wayland/release_test.go +2 -2
@@ -35,7 +35,7 @@ func TestCachedSubsurfaceBufferReleaseOnRoleDestroy(t *testing.T) {
3535 b := shmBuffer(t, c)
3636 p := &syncReleaseProxy{released: make(chan struct{}, 4)}
3737 p.SetID(b)
38- c.Context().Register(p)
38+ registerWireProxy(c, p)
3939 requestProtocol(t, c, child, wayland.SurfaceRequestAttach, b, int32(0), int32(0))
4040 requestProtocol(t, c, child, wayland.SurfaceRequestCommit)
4141 if err := c.Roundtrip(); err != nil {
@@ -245,7 +245,7 @@ func TestHeldBufferRequeuedBeforeOutputAck(t *testing.T) {
245245 b, other := shmBuffer(t, c), shmBuffer(t, c)
246246 p := &syncReleaseProxy{released: make(chan struct{}, 4)}
247247 p.SetID(b)
248- c.Context().Register(p)
248+ registerWireProxy(c, p)
249249 attach := func(buf uint32) {
250250 requestProtocol(t, c, root, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
251251 requestProtocol(t, c, root, wayland.SurfaceRequestCommit)
M internal/adapters/wayland/scale.go +5 -0
@@ -28,6 +28,11 @@ func (s *Server) outputOfSurface(surf *surface) *output {
2828 name = surf.xdg.window.last.Output
2929 case surf.layer != nil && surf.layer.output != nil:
3030 return surf.layer.output
31+ case surf.lock != nil:
32+ if !surf.lock.closed {
33+ return surf.lock.output
34+ }
35+ return nil
3136 }
3237 return s.outputByName(name)
3338 }
M internal/adapters/wayland/scale_test.go +7 -7
@@ -60,7 +60,7 @@ func TestFractionalScaleAndViewport(t *testing.T) {
6060 c := protocolClient(t, s, dir)
6161 // wl_surface.preferred_buffer_scale needs wl_compositor v6.
6262 g, _ := c.Registry().FindGlobal("wl_compositor")
63- comp, err := c.Registry().BindID(g.Name, g.Interface, 6)
63+ comp, err := bindWireID(c, g.Name, g.Interface, 6)
6464 if err != nil {
6565 t.Fatal(err)
6666 }
@@ -74,13 +74,13 @@ func TestFractionalScaleAndViewport(t *testing.T) {
7474 bufferScales := make(chan int32, 4)
7575 sp := &surfaceScaleProxy{scales: bufferScales}
7676 sp.SetID(surf)
77- c.Context().Register(sp)
77+ registerWireProxy(c, sp)
7878 requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
7979 frac := c.AllocateID()
8080 values := make(chan uint32, 4)
8181 proxy := &scaleProxy{values: values}
8282 proxy.SetID(frac)
83- c.Context().Register(proxy)
83+ registerWireProxy(c, proxy)
8484 requestProtocol(t, c, fm, fractionalscale.WpFractionalScaleManagerV1RequestGetFractionalScale, frac, surf)
8585 vp := c.AllocateID()
8686 requestProtocol(t, c, vpm, viewporter.WpViewporterRequestGetViewport, vp, surf)
@@ -121,7 +121,7 @@ func TestFractionalScaleAndViewport(t *testing.T) {
121121 serials := make(chan uint32, 4)
122122 cp := &configureProxy{serial: serials}
123123 cp.SetID(xdg)
124- c.Context().Register(cp)
124+ registerWireProxy(c, cp)
125125 top := c.AllocateID()
126126 registerProtocol(t, c, top)
127127 requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
@@ -142,7 +142,7 @@ func TestFractionalScaleAndViewport(t *testing.T) {
142142 pool, buffer := c.AllocateID(), c.AllocateID()
143143 registerProtocol(t, c, pool)
144144 registerProtocol(t, c, buffer)
145- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, size); err != nil {
145+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, size); err != nil {
146146 t.Fatal(err)
147147 }
148148 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(30), int32(15), int32(120), uint32(0))
@@ -270,7 +270,7 @@ func TestViewportApplyErrors(t *testing.T) {
270270 pool, buf := c.AllocateID(), c.AllocateID()
271271 registerProtocol(t, c, pool)
272272 registerProtocol(t, c, buf)
273- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
273+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
274274 t.Fatal(err)
275275 }
276276 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(0))
@@ -321,7 +321,7 @@ func TestViewportSourceEdgeRounding(t *testing.T) {
321321 pool, buf := c.AllocateID(), c.AllocateID()
322322 registerProtocol(t, c, pool)
323323 registerProtocol(t, c, buf)
324- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(w*h*4)); err != nil {
324+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(w*h*4)); err != nil {
325325 t.Fatal(err)
326326 }
327327 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(w), int32(h), int32(w*4), uint32(0))
M internal/adapters/wayland/screencopy.go +14 -2
@@ -154,6 +154,10 @@ func (s *Server) validCaptureBuffer(b *wayland.Buffer, rect image.Rectangle, for
154154 // requestCapture duplicates the pool descriptor, so destroying the wl_buffer
155155 // cannot invalidate a capture in flight.
156156 func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b *wayland.Buffer, format uint32, reply func(ports.CaptureDone), life *server.Resource) (uint64, bool) {
157+ if s.protected() {
158+ s.log.Debug().Str("component", "wayland").Msg("capture refused: session protected")
159+ return 0, false
160+ }
157161 if o == nil || b == nil || s.channels.Captures == nil || s.channels.Captured == nil || s.outputByNameExact(o.name()) != o {
158162 return 0, false
159163 }
@@ -220,6 +224,7 @@ func (s *Server) forwardCaptured(ctxDone <-chan struct{}) {
220224 delete(s.captureReplies, done.ID)
221225 fn(done)
222226 }
227+ s.maybeLocked()
223228 }) {
224229 return
225230 }
@@ -264,7 +269,7 @@ func (m copyCaptureManager) CreateSession(r *ext.ExtImageCopyCaptureManagerV1, i
264269 m.s.captureSessions[state] = struct{}{}
265270 // Frames outlive their session (the spec): each frame drops its own reply.
266271 res.OnDestroy = func() { delete(m.s.captureSessions, state) }
267- if o == nil {
272+ if m.s.protected() || o == nil {
268273 res.SendStopped()
269274 state.stopped = true
270275 return
@@ -274,6 +279,13 @@ func (m copyCaptureManager) CreateSession(r *ext.ExtImageCopyCaptureManagerV1, i
274279
275280 // sendConstraints sends one complete constraint batch: formats, size, done.
276281 func (c *captureSession) sendConstraints(w, h int) {
282+ if c.s.protected() {
283+ if !c.stopped {
284+ c.stopped = true
285+ c.res.SendStopped()
286+ }
287+ return
288+ }
277289 c.res.SendBufferSize(uint32(w), uint32(h))
278290 c.res.SendShmFormat(uint32(wayland.ShmFormatXrgb8888))
279291 c.res.SendShmFormat(uint32(wayland.ShmFormatArgb8888))
@@ -369,7 +381,7 @@ func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) {
369381 }
370382 f.used = true
371383 c := f.session
372- if c.stopped || c.o == nil || c.s.outputByNameExact(c.o.name()) != c.o {
384+ if c.stopped || c.o == nil || c.s.protected() || c.s.outputByNameExact(c.o.name()) != c.o {
373385 f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonStopped))
374386 return
375387 }
M internal/adapters/wayland/screencopy_test.go +18 -18
@@ -66,7 +66,7 @@ func TestScreencopyProtocol(t *testing.T) {
6666 if out == 0 {
6767 t.Fatal("output global missing")
6868 }
69- outputID, err := c.Registry().BindID(out, "wl_output", 4)
69+ outputID, err := bindWireID(c, out, "wl_output", 4)
7070 if err != nil {
7171 t.Fatal(err)
7272 }
@@ -75,7 +75,7 @@ func TestScreencopyProtocol(t *testing.T) {
7575 frame := c.AllocateID()
7676 events := &captureEvents{events: make(chan uint16, 8)}
7777 events.SetID(frame)
78- c.Context().Register(events)
78+ registerWireProxy(c, events)
7979 requestProtocol(t, c, manager, wlr.ZwlrScreencopyManagerV1RequestCaptureOutputRegion, frame, int32(0), outputID, int32(1), int32(1), int32(2), int32(2))
8080 if err := c.Roundtrip(); err != nil {
8181 t.Fatal(err)
@@ -99,7 +99,7 @@ func TestScreencopyProtocol(t *testing.T) {
9999 pool, buf := c.AllocateID(), c.AllocateID()
100100 registerProtocol(t, c, pool)
101101 registerProtocol(t, c, buf)
102- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(16)); err != nil {
102+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(16)); err != nil {
103103 t.Fatal(err)
104104 }
105105 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(2), int32(2), int32(8), uint32(wayland.ShmFormatXrgb8888))
@@ -153,7 +153,7 @@ func captureTestClient(t *testing.T) (*Server, *wlturbo.Display, uint32) {
153153 if !ok {
154154 t.Fatal("wl_output missing")
155155 }
156- out, err := c.Registry().BindID(g.Name, g.Interface, 4)
156+ out, err := bindWireID(c, g.Name, g.Interface, 4)
157157 if err != nil {
158158 t.Fatal(err)
159159 }
@@ -173,7 +173,7 @@ func captureTestExt(t *testing.T, c *wlturbo.Display, out uint32) (uint32, uint3
173173 requestProtocol(t, c, sourceManager, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, out)
174174 events := &captureEvents{events: make(chan uint16, 16)}
175175 events.SetID(session)
176- c.Context().Register(events)
176+ registerWireProxy(c, events)
177177 requestProtocol(t, c, manager, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0))
178178 if err := c.Roundtrip(); err != nil {
179179 t.Fatal(err)
@@ -228,7 +228,7 @@ func TestCaptureProtocolErrors(t *testing.T) {
228228 registerProtocol(t, c, pool)
229229 registerProtocol(t, c, buf)
230230 registerProtocol(t, c, shm)
231- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(testOutputs[0].Info.Width*testOutputs[0].Info.Height*4)); err != nil {
231+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(testOutputs[0].Info.Width*testOutputs[0].Info.Height*4)); err != nil {
232232 t.Fatal(err)
233233 }
234234 w := int32(1)
@@ -282,7 +282,7 @@ func TestCaptureProtocolErrors(t *testing.T) {
282282 frame := c.AllocateID()
283283 frameEvents := &captureDetails{events: make(chan []uint32, 4)}
284284 frameEvents.SetID(frame)
285- c.Context().Register(frameEvents)
285+ registerWireProxy(c, frameEvents)
286286 requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame)
287287 switch tc.object {
288288 case "frame":
@@ -301,7 +301,7 @@ func TestCaptureProtocolErrors(t *testing.T) {
301301 pool, buf := c.AllocateID(), c.AllocateID()
302302 registerProtocol(t, c, pool)
303303 registerProtocol(t, c, buf)
304- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
304+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
305305 t.Fatal(err)
306306 }
307307 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(wayland.ShmFormatXrgb8888))
@@ -436,7 +436,7 @@ func TestExtCaptureLifecycle(t *testing.T) {
436436 if !ok {
437437 t.Fatal("output missing")
438438 }
439- out, err := c.Registry().BindID(g.Name, g.Interface, 4)
439+ out, err := bindWireID(c, g.Name, g.Interface, 4)
440440 if err != nil {
441441 t.Fatal(err)
442442 }
@@ -447,7 +447,7 @@ func TestExtCaptureLifecycle(t *testing.T) {
447447 registerProtocol(t, c, src)
448448 p := &captureDetails{events: make(chan []uint32, 16)}
449449 p.SetID(session)
450- c.Context().Register(p)
450+ registerWireProxy(c, p)
451451 requestProtocol(t, c, srcMgr, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, out)
452452 requestProtocol(t, c, mgr, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0))
453453 if err := c.Roundtrip(); err != nil {
@@ -473,14 +473,14 @@ func TestExtCaptureLifecycle(t *testing.T) {
473473 pool, buf := c.AllocateID(), c.AllocateID()
474474 registerProtocol(t, c, pool)
475475 registerProtocol(t, c, buf)
476- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(80)); err != nil {
476+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(80)); err != nil {
477477 t.Fatal(err)
478478 }
479479 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(4), int32(4), int32(16), uint32(wayland.ShmFormatXrgb8888))
480480 frame := c.AllocateID()
481481 fp := &captureDetails{events: make(chan []uint32, 16)}
482482 fp.SetID(frame)
483- c.Context().Register(fp)
483+ registerWireProxy(c, fp)
484484 requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame)
485485 requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf)
486486 requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestCapture)
@@ -534,7 +534,7 @@ func TestExtCaptureLifecycle(t *testing.T) {
534534 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf2, int32(0), int32(5), int32(4), int32(20), uint32(wayland.ShmFormatXrgb8888))
535535 fp2 := &captureDetails{events: make(chan []uint32, 16)}
536536 fp2.SetID(frame2)
537- c.Context().Register(fp2)
537+ registerWireProxy(c, fp2)
538538 requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame2)
539539 requestProtocol(t, c, frame2, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf2)
540540 requestProtocol(t, c, frame2, ext.ExtImageCopyCaptureFrameV1RequestCapture)
@@ -592,7 +592,7 @@ func TestExtCaptureStoppedOnOutputRemoval(t *testing.T) {
592592 if !ok {
593593 t.Fatal("output missing")
594594 }
595- out, err := c.Registry().BindID(g.Name, g.Interface, 4)
595+ out, err := bindWireID(c, g.Name, g.Interface, 4)
596596 if err != nil {
597597 t.Fatal(err)
598598 }
@@ -603,7 +603,7 @@ func TestExtCaptureStoppedOnOutputRemoval(t *testing.T) {
603603 registerProtocol(t, c, src)
604604 p := &captureDetails{events: make(chan []uint32, 16)}
605605 p.SetID(session)
606- c.Context().Register(p)
606+ registerWireProxy(c, p)
607607 requestProtocol(t, c, srcMgr, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, out)
608608 requestProtocol(t, c, mgr, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0))
609609 if err := c.Roundtrip(); err != nil {
@@ -647,7 +647,7 @@ func TestWlrOutputGoneAndDestroyedBeforeReply(t *testing.T) {
647647 if !ok {
648648 t.Fatal("output missing")
649649 }
650- out, err := c.Registry().BindID(g.Name, g.Interface, 4)
650+ out, err := bindWireID(c, g.Name, g.Interface, 4)
651651 if err != nil {
652652 t.Fatal(err)
653653 }
@@ -656,7 +656,7 @@ func TestWlrOutputGoneAndDestroyedBeforeReply(t *testing.T) {
656656 frame := c.AllocateID()
657657 events := &captureEvents{events: make(chan uint16, 8)}
658658 events.SetID(frame)
659- c.Context().Register(events)
659+ registerWireProxy(c, events)
660660 requestProtocol(t, c, manager, wlr.ZwlrScreencopyManagerV1RequestCaptureOutput, frame, int32(0), out)
661661 if err := c.Roundtrip(); err != nil {
662662 t.Fatal(err)
@@ -732,7 +732,7 @@ func captureSmallBuffer(t *testing.T, c *wlturbo.Display) (uint32, uint32, int)
732732 pool, buf := c.AllocateID(), c.AllocateID()
733733 registerProtocol(t, c, pool)
734734 registerProtocol(t, c, buf)
735- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(16)); err != nil {
735+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(16)); err != nil {
736736 t.Fatal(err)
737737 }
738738 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(2), int32(2), int32(8), uint32(wayland.ShmFormatXrgb8888))
M internal/adapters/wayland/seat.go +48 -1
@@ -47,10 +47,40 @@ type seatState struct {
4747
4848 // applyInput delivers a core input command to the focused client.
4949 func (s *Server) applyInput(cmd ports.ClientCommand) {
50+ if s.security != nil && s.security.Snapshot().Protected {
51+ var target ports.WindowID
52+ switch c := cmd.(type) {
53+ case ports.SetKeymap:
54+ // Configuration updates do not authorize normal-client input.
55+ case ports.PointerFocus:
56+ target = c.ID
57+ case ports.PointerMotionTo:
58+ target = c.ID
59+ case ports.PointerButtonTo:
60+ target = c.ID
61+ case ports.PointerAxisTo:
62+ target = c.ID
63+ case ports.FocusWindow:
64+ target = c.ID
65+ case ports.ForwardKey:
66+ target = c.ID
67+ }
68+ if target != 0 && !s.lockInputTarget(target) {
69+ return
70+ }
71+ }
5072 switch c := cmd.(type) {
5173 case ports.PointerFocus:
5274 s.changePointerFocus(c.ID, c.X, c.Y)
5375 case ports.PointerMotionTo:
76+ if l := s.lockSurfaces[c.ID]; l != nil && s.lockInputTarget(c.ID) && c.ID == s.seat.pointerFocus {
77+ s.seat.pointerX, s.seat.pointerY = c.X, c.Y
78+ for _, p := range s.clientPointers(l.resource.Client()) {
79+ p.SendMotion(c.TimeMsec, server.FixedFromFloat(c.X), server.FixedFromFloat(c.Y))
80+ pointerFrame(p)
81+ }
82+ return
83+ }
5484 if l := s.layers[c.ID]; l != nil && c.ID == s.seat.pointerFocus {
5585 s.seat.pointerX, s.seat.pointerY = c.X, c.Y
5686 for _, p := range s.layerPointers(l) {
@@ -111,7 +141,7 @@ func (s *Server) applyInput(cmd ports.ClientCommand) {
111141 s.changeFocus(c.ID)
112142 }
113143 case ports.ForwardKey:
114- if s.grabKey(c) {
144+ if !(s.security != nil && s.security.Snapshot().Protected) && s.grabKey(c) {
115145 return
116146 }
117147 _, keyboards := s.focusTarget(c.ID)
@@ -170,6 +200,11 @@ func (s *Server) clientKeyboards(c server.Client) []*wayland.Keyboard {
170200
171201 // focusTarget resolves a window or layer ID to its wl_surface and keyboards.
172202 func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Keyboard) {
203+ if l := s.lockSurfaces[id]; l != nil && !l.closed && l.surface.wl.Alive() {
204+ // Outgoing leaves also resolve a role whose OnDestroy resource is
205+ // already dead. Admission is enforced before entering or delivering.
206+ return l.surface.wl, s.clientKeyboards(l.surface.wl.Client())
207+ }
173208 if w := s.windows[id]; w != nil && w.mapped {
174209 if surf := w.xdg.surfaceResource(); surf != nil {
175210 return surf, s.windowKeyboards(w)
@@ -186,6 +221,9 @@ func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Ke
186221 // pointerTarget resolves a mapped window or layer ID to its client and
187222 // live pointers.
188223 func (s *Server) pointerTarget(id ports.WindowID) (server.Client, []*wayland.Pointer, bool) {
224+ if l := s.lockSurfaces[id]; l != nil && s.lockInputTarget(id) {
225+ return l.resource.Client(), s.clientPointers(l.resource.Client()), true
226+ }
189227 if w := s.windows[id]; w != nil && w.mapped && w.xdg.resource.Resource.Alive() {
190228 return w.xdg.resource.Client(), s.windowPointers(w), true
191229 }
@@ -198,6 +236,9 @@ func (s *Server) pointerTarget(id ports.WindowID) (server.Client, []*wayland.Poi
198236 // pointerSurface is the wl_surface of a mapped window or layer and the
199237 // point in its surface coordinates.
200238 func (s *Server) pointerSurface(id ports.WindowID, x, y float64) (*wayland.Surface, []*wayland.Pointer, float64, float64) {
239+ if l := s.lockSurfaces[id]; l != nil && !l.closed && l.surface.wl.Alive() {
240+ return l.surface.wl, s.clientPointers(l.surface.wl.Client()), x, y
241+ }
201242 if w := s.windows[id]; w != nil {
202243 if surf := w.xdg.surfaceResource(); surf != nil && surf.Resource.Alive() {
203244 x, y := w.surfacePoint(x, y)
@@ -285,6 +326,9 @@ func (s *Server) sendModifiers(k *wayland.Keyboard) {
285326 }
286327
287328 func (s *Server) changeFocus(id ports.WindowID) {
329+ if s.security != nil && s.security.Snapshot().Protected && id != 0 && !s.lockInputTarget(id) {
330+ return
331+ }
288332 // Enter carries seat held keys and modifiers: they need the seat keymap.
289333 s.useKeymap(nil)
290334 old := s.focusClient()
@@ -421,6 +465,9 @@ func (s *Server) clientPointers(c server.Client) []*wayland.Pointer {
421465 return result
422466 }
423467 func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) {
468+ if s.security != nil && s.security.Snapshot().Protected && id != 0 && !s.lockInputTarget(id) {
469+ return
470+ }
424471 if id == s.seat.pointerFocus {
425472 return
426473 }
A internal/adapters/wayland/sensitive_test.go +473 -0
@@ -0,0 +1,473 @@
1+package wayland
2+
3+import (
4+ "sync/atomic"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/adapters/wayland/capturesession"
9+ "github.com/bnema/purego-libwayland/protocol/cursorshape"
10+ wlr "github.com/bnema/purego-libwayland/protocol/wlrforeigntoplevel"
11+ screencopy "github.com/bnema/purego-libwayland/protocol/wlrscreencopy"
12+ "github.com/bnema/purego-libwayland/protocol/xdgactivation"
13+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
14+
15+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
16+ "github.com/bnema/neferwl/internal/ports"
17+ source "github.com/bnema/purego-libwayland/protocol/extimagecapturesource"
18+ ext "github.com/bnema/purego-libwayland/protocol/extimagecopycapture"
19+ "github.com/bnema/purego-libwayland/protocol/inputmethod"
20+ "github.com/bnema/purego-libwayland/protocol/textinput"
21+ "github.com/bnema/purego-libwayland/protocol/virtualkeyboard"
22+ "github.com/bnema/purego-libwayland/protocol/wayland"
23+ "golang.org/x/sys/unix"
24+)
25+
26+// installSecurity uses the canonical generated mock, with coherent atomic
27+// snapshots; transitions are serialized through the real DISPLAY loop.
28+func installSecurity(t *testing.T, s *Server) *atomic.Pointer[ports.SecurityState] {
29+ t.Helper()
30+ state := &atomic.Pointer[ports.SecurityState]{}
31+ state.Store(&ports.SecurityState{})
32+ security := portsmocks.NewMockSessionSecurity(t)
33+ security.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return *state.Load() }).Maybe()
34+ if !s.display.Do(func() { s.security = security }) {
35+ t.Fatal("display stopped")
36+ }
37+ return state
38+}
39+
40+func protect(t *testing.T, s *Server, state *atomic.Pointer[ports.SecurityState], generation ports.LockGeneration, protected bool) {
41+ t.Helper()
42+ if !s.display.Do(func() { state.Store(&ports.SecurityState{Generation: generation, Protected: protected}) }) {
43+ t.Fatal("display stopped")
44+ }
45+}
46+
47+func TestProtectedClipboardPreexistingOffer(t *testing.T) {
48+ s, events, commands, dir := keyboardServer(t)
49+ state := installSecurity(t, s)
50+ a := newClipClient(t, s, dir, events)
51+ a.focus(t, commands)
52+ src := a.copy(t)
53+ offer, ok := a.selection(t)
54+ if !ok || offer == 0 {
55+ t.Fatal("no unlocked offer")
56+ }
57+ protect(t, s, state, 1, true)
58+ var fds [2]int
59+ if err := unix.Pipe2(fds[:], unix.O_CLOEXEC|unix.O_NONBLOCK); err != nil {
60+ t.Fatal(err)
61+ }
62+ defer unix.Close(fds[0])
63+ if err := wireRequest(a.c, offer, uint16(wayland.DataOfferRequestReceive), []int{fds[1]}, "text/plain"); err != nil {
64+ t.Fatal(err)
65+ }
66+ unix.Close(fds[1])
67+ roundtrip(t, a.c)
68+ select {
69+ case <-src.sends:
70+ t.Fatal("protected receive handed pipe to source")
71+ default:
72+ }
73+ var b [1]byte
74+ n, err := unix.Read(fds[0], b[:])
75+ if err != nil || n != 0 {
76+ t.Fatalf("receive pipe not closed: n=%d err=%v", n, err)
77+ }
78+ rejected := a.copy(t)
79+ select {
80+ case <-rejected.cancelled:
81+ default:
82+ t.Fatal("protected selection not rejected")
83+ }
84+ if id, ok := a.selection(t); ok {
85+ t.Fatalf("protected offer announced %d", id)
86+ }
87+ protect(t, s, state, 2, false)
88+ // Existing clipboard data remains usable when unlocked.
89+ a.copy(t)
90+ if id, ok := a.selection(t); !ok || id == 0 {
91+ t.Fatal("unlocked selection not restored")
92+ }
93+}
94+
95+func TestProtectedCaptureAdmission(t *testing.T) {
96+ h := newAdmissionHarness(t, 4)
97+ state := installSecurity(t, h.s)
98+ a := h.client(t)
99+ // Create the ext session before protection, then test its late frame.
100+ p := a.captureExt(t)
101+ req, ok := h.accepted(t, p)
102+ if !ok {
103+ t.Fatal("unlocked ext capture rejected")
104+ }
105+ a.destroy(t, p)
106+ h.complete(t, req, 0)
107+ protect(t, h.s, state, 1, true)
108+ if _, ok := h.accepted(t, a.captureWlr(t)); ok {
109+ t.Fatal("protected wlr capture admitted")
110+ }
111+ if _, ok := h.accepted(t, a.captureExt(t)); ok {
112+ t.Fatal("protected ext capture admitted")
113+ }
114+ if h.inflight(t) != 0 {
115+ t.Fatal("protected captures reserved credits")
116+ }
117+ var next uint64
118+ if !h.s.display.Do(func() { next = h.s.nextCapture }) {
119+ t.Fatal("display stopped")
120+ }
121+ if next != req.ID {
122+ t.Fatalf("protected capture allocated ID: %d", next)
123+ }
124+ // A newly created ext session is stopped and never admits capture.
125+ b := h.client(t)
126+ sourceManager := bindProtocol(t, b.c, "ext_output_image_capture_source_manager_v1")
127+ manager := bindProtocol(t, b.c, "ext_image_copy_capture_manager_v1")
128+ src, session := b.c.AllocateID(), b.c.AllocateID()
129+ registerProtocol(t, b.c, src)
130+ requestProtocol(t, b.c, sourceManager, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, b.output)
131+ ev := &captureEvents{events: make(chan uint16, 16)}
132+ ev.SetID(session)
133+ registerWireProxy(b.c, ev)
134+ requestProtocol(t, b.c, manager, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0))
135+ roundtrip(t, b.c)
136+ select {
137+ case op := <-ev.events:
138+ if op != uint16(ext.ExtImageCopyCaptureSessionV1EventStopped) {
139+ t.Fatalf("new session event %d", op)
140+ }
141+ default:
142+ t.Fatal("new protected ext session not stopped")
143+ }
144+ if len(ev.events) != 0 {
145+ t.Fatal("protected session disclosed constraints")
146+ }
147+ b.session = session
148+ if _, ok := h.accepted(t, b.captureExt(t)); ok {
149+ t.Fatal("new protected session admitted")
150+ }
151+ protect(t, h.s, state, 2, false)
152+ req, ok = h.accepted(t, a.captureWlr(t))
153+ if !ok {
154+ t.Fatal("unlocked wlr capture rejected")
155+ }
156+ h.complete(t, req, 0)
157+}
158+
159+func TestProtectedVirtualKeyboard(t *testing.T) {
160+ for _, destroyProtected := range []bool{true, false} {
161+ t.Run(map[bool]string{true: "protected-destroy", false: "post-unlock-destroy"}[destroyProtected], func(t *testing.T) {
162+ s, events, commands, dir := keyboardServer(t)
163+ state := installSecurity(t, s)
164+ target, log := focusedTarget(t, s, events, commands, dir)
165+ typer, kb := virtualTyper(t, s, dir)
166+ sendVirtualKeymap(t, typer, kb, keymapText(t))
167+ requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(1), uint32(30), uint32(1))
168+ roundtrip(t, typer, target)
169+ if len(log.opcodes) == 0 {
170+ t.Fatal("unlocked virtual key missing")
171+ }
172+ log.opcodes = nil
173+ protect(t, s, state, 1, true)
174+ requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(2), uint32(31), uint32(1))
175+ requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestModifiers, uint32(1), uint32(0), uint32(0), uint32(0))
176+ roundtrip(t, typer, target)
177+ if len(log.opcodes) != 0 {
178+ t.Fatalf("protected virtual events: %v", log.opcodes)
179+ }
180+ if !destroyProtected {
181+ protect(t, s, state, 2, false)
182+ }
183+ requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestDestroy)
184+ roundtrip(t, typer, target)
185+ if len(log.opcodes) != 0 {
186+ t.Fatalf("old virtual held state injected on destroy: %v", log.opcodes)
187+ }
188+ })
189+ }
190+}
191+
192+func TestProtectedInputMethod(t *testing.T) {
193+ s, events, commands, dir := keyboardServer(t)
194+ state := installSecurity(t, s)
195+ ime := newIMEApp(t, s, dir)
196+ app := newTextApp(t, s, events, commands, dir)
197+ app.enable(t)
198+ _, grab := ime.grab(t)
199+ settle(t, app.c, ime.c)
200+ app.log.take()
201+ ime.log.take()
202+ grab.take()
203+ protect(t, s, state, 1, true)
204+ app.request(t, textinput.ZwpTextInputV3RequestSetSurroundingText, "locker-secret", int32(13), int32(13))
205+ app.request(t, textinput.ZwpTextInputV3RequestCommit)
206+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommitString, "injected")
207+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommit, uint32(1))
208+ // Exercise the live grab's delivery entrypoint; normal seat routing/reset is
209+ // deliberately outside this adapter assignment.
210+ if !s.display.Do(func() {
211+ s.grabKey(ports.ForwardKey{ID: app.window, Key: ports.KeyEvent{Keycode: 30, Pressed: true, State: ports.ModState{Depressed: 1}}})
212+ s.sendGrabKeymap(true)
213+ }) {
214+ t.Fatal("display stopped")
215+ }
216+ _, late := ime.grab(t)
217+ settle(t, app.c, ime.c)
218+ expectEvents(t, "protected IM state", ime.log.take())
219+ expectEvents(t, "protected IM injection", app.log.take())
220+ expectEvents(t, "existing protected grab", grab.take())
221+ expectEvents(t, "new protected grab", late.take())
222+ protect(t, s, state, 2, false)
223+ app.enable(t)
224+ settle(t, app.c, ime.c)
225+ if len(ime.log.take()) == 0 {
226+ t.Fatal("unlocked IM state not delivered")
227+ }
228+}
229+
230+func TestProtectedPrivateCapture(t *testing.T) {
231+ h := newPrivateHarness(t)
232+ state := installSecurity(t, h.s)
233+ owner := h.client(t)
234+ _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0)
235+ begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
236+ h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1})
237+ token := waitMsg(t, owner.c, msgs).token
238+ // Establish that this active owner's capture is admitted and clean before
239+ // protection, then refuse the same connection without ending its session.
240+ captureOnce(t, h, owner)
241+ accepted := receive1(t, h.captures)
242+ if !accepted.Clean || accepted.Session != begin.ID {
243+ t.Fatalf("owner capture not clean: %+v", accepted)
244+ }
245+ accepted.Dst.File.Close()
246+ h.captured <- ports.CaptureDone{ID: accepted.ID, Output: accepted.Output, Time: time.Now()}
247+ hd := h.hud(t, ports.LayerTop, 0)
248+ protect(t, h.s, state, 1, true)
249+ failed, _ := captureOnce(t, h, owner)
250+ if !eventsInclude(failed, uint16(screencopy.ZwlrScreencopyFrameV1EventFailed)) {
251+ t.Fatal("protected active owner's clean capture not failed")
252+ }
253+ select {
254+ case req := <-h.captures:
255+ req.Dst.File.Close()
256+ t.Fatal("protected active owner's capture admitted")
257+ default:
258+ }
259+ att := hd.attach(t, token)
260+ expectAttachment(t, hd.c, att, capturesession.NeferwlCaptureLayerV1EventFailed, int64(capturesession.NeferwlCaptureLayerV1FailureUnauthorized))
261+ other := h.client(t)
262+ _, refused := other.begin(t, 0, 0, 0, 0, 0, 0)
263+ expectAttachment(t, other.c, refused, capturesession.NeferwlCaptureSessionV1EventStopped, int64(capturesession.NeferwlCaptureSessionV1StopReasonUnauthorized))
264+ snapshot := ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 1, Name: "sensitive"}}}}}
265+ if !h.s.display.Do(func() { h.s.workspaceSnapshot = snapshot; h.s.updateCaptureWorkspaces(snapshot) }) {
266+ t.Fatal("display stopped")
267+ }
268+ noMessage(t, owner.c, owner.mgr.msgs)
269+ noMessage(t, other.c, other.mgr.msgs)
270+ late := h.client(t)
271+ noMessage(t, late.c, late.mgr.msgs)
272+ for len(h.events) > 0 {
273+ switch ev := (<-h.events).(type) {
274+ case ports.CaptureSessionBegin, ports.CaptureSessionLayer:
275+ t.Fatalf("protected private request reached core: %+v", ev)
276+ }
277+ }
278+}
279+
280+func TestProtectedActivationAndForeignInventory(t *testing.T) {
281+ s, events, commands, dir := lifecycleServer(t)
282+ state := installSecurity(t, s)
283+ c := protocolClient(t, s, dir)
284+ w, surf, xdg := surfaceMapper(t, c, events)()
285+ commands <- ports.FocusWindow{ID: w.ID}
286+ waitFocus(t, s, w.ID)
287+ manager := bindProtocol(t, c, "xdg_activation_v1")
288+ token := newToken(t, c, manager)
289+ foreign := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 3)
290+ log := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)}
291+ log.SetID(foreign)
292+ registerWireProxy(c, log)
293+ takeEvents(t, c, log.events)
294+ handle := <-log.handles
295+ seat := bindProtocol(t, c, "wl_seat")
296+ registerProtocol(t, c, seat)
297+ protect(t, s, state, 1, true)
298+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
299+ requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestActivate, seat)
300+ requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestSetFullscreen, uint32(0))
301+ requestProtocol(t, c, xdg+1, xdgshell.ToplevelRequestSetTitle, "sensitive-title")
302+ noActivation(t, c, events)
303+ if len(log.events) != 0 {
304+ t.Fatal("foreign inventory updated while protected")
305+ }
306+ lateID := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 3)
307+ late := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)}
308+ late.SetID(lateID)
309+ registerWireProxy(c, late)
310+ roundtrip(t, c)
311+ if len(late.handles) != 0 || len(late.events) != 0 {
312+ t.Fatal("new foreign manager disclosed protected inventory")
313+ }
314+ protect(t, s, state, 2, false)
315+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
316+ noActivation(t, c, events) // the protected attempt consumed the token
317+}
318+
319+func TestProtectedCursorChanges(t *testing.T) {
320+ s, events, commands, cursors, dir := cursorServer(t)
321+ state := installSecurity(t, s)
322+ c := protocolClient(t, s, dir)
323+ seat := bindProtocol(t, c, "wl_seat")
324+ registerProtocol(t, c, seat)
325+ pointer := c.AllocateID()
326+ registerProtocol(t, c, pointer)
327+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
328+ manager := bindProtocol(t, c, "wp_cursor_shape_manager_v1")
329+ device := c.AllocateID()
330+ registerProtocol(t, c, device)
331+ requestProtocol(t, c, manager, cursorshape.WpCursorShapeManagerV1RequestGetPointer, device, pointer)
332+ w := toplevelMapper(t, c, events)()
333+ commands <- ports.PointerFocus{ID: w.ID}
334+ nextCursor(t, cursors)
335+ protect(t, s, state, 1, true)
336+ requestProtocol(t, c, device, cursorshape.WpCursorShapeDeviceV1RequestSetShape, uint32(1), uint32(cursorshape.WpCursorShapeDeviceV1ShapeText))
337+ requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), uint32(0), int32(0), int32(0))
338+ roundtrip(t, c)
339+ select {
340+ case change := <-cursors:
341+ t.Fatalf("normal cursor overrode protection: %+v", change)
342+ case <-time.After(50 * time.Millisecond):
343+ }
344+}
345+
346+func TestProtectedVirtualKeyboardQuietEpoch(t *testing.T) {
347+ s, events, commands, dir := keyboardServer(t)
348+ state := installSecurity(t, s)
349+ target, log := focusedTarget(t, s, events, commands, dir)
350+ typer, kb := virtualTyper(t, s, dir)
351+ sendVirtualKeymap(t, typer, kb, keymapText(t))
352+ requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(1), uint32(30), uint32(1))
353+ roundtrip(t, typer, target)
354+ log.opcodes = nil
355+ protect(t, s, state, 1, true)
356+ protect(t, s, state, 2, false)
357+ // No requests during protection: generation must still invalidate held keys.
358+ requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(2), uint32(30), uint32(0))
359+ requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestDestroy)
360+ roundtrip(t, typer, target)
361+ if len(log.opcodes) != 0 {
362+ t.Fatalf("old virtual state crossed quiet lock epoch: %v", log.opcodes)
363+ }
364+}
365+
366+func TestProtectedVirtualKeymapRequiresResend(t *testing.T) {
367+ for _, resend := range []bool{false, true} {
368+ t.Run(map[bool]string{false: "old-keymap-rejected", true: "fresh-keymap-accepted"}[resend], func(t *testing.T) {
369+ s, events, commands, dir := keyboardServer(t)
370+ state := installSecurity(t, s)
371+ target, log := focusedTarget(t, s, events, commands, dir)
372+ typer, kb := virtualTyper(t, s, dir)
373+ sendVirtualKeymap(t, typer, kb, keymapText(t))
374+ requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(1), uint32(30), uint32(1))
375+ roundtrip(t, typer, target)
376+ log.opcodes = nil
377+ var owner *virtualKeyboard
378+ var oldFD int
379+ if !s.display.Do(func() { owner = s.seat.keymapOwner; oldFD = owner.fd }) {
380+ t.Fatal("display stopped")
381+ }
382+ protect(t, s, state, 1, true)
383+ sendVirtualKeymap(t, typer, kb, keymapText(t))
384+ roundtrip(t, typer, target)
385+ var cleared bool
386+ if !s.display.Do(func() { cleared = owner.fd == -1 && owner.text == "" && owner.size == 0 }) {
387+ t.Fatal("display stopped")
388+ }
389+ if !cleared {
390+ t.Fatal("protected keymap retained old layout")
391+ }
392+ if _, err := unix.FcntlInt(uintptr(oldFD), unix.F_GETFD, 0); err != unix.EBADF {
393+ t.Fatalf("old keymap FD not closed: %v", err)
394+ }
395+ if len(log.opcodes) != 0 {
396+ t.Fatalf("protected keymap events: %v", log.opcodes)
397+ }
398+ protect(t, s, state, 2, false)
399+ if resend {
400+ sendVirtualKeymap(t, typer, kb, keymapText(t))
401+ }
402+ requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(2), uint32(31), uint32(1))
403+ if !resend {
404+ expectProtocolError(t, typer, kb, uint32(virtualkeyboard.ZwpVirtualKeyboardV1ErrorNoKeymap))
405+ roundtrip(t, target)
406+ if len(log.opcodes) != 0 {
407+ t.Fatalf("old keymap injected after unlock: %v", log.opcodes)
408+ }
409+ } else {
410+ roundtrip(t, typer, target)
411+ found := false
412+ for _, op := range log.opcodes {
413+ if op == uint16(wayland.KeyboardEventKey) {
414+ found = true
415+ }
416+ }
417+ if !found {
418+ t.Fatal("fresh post-unlock keymap cannot type")
419+ }
420+ }
421+ })
422+ }
423+}
424+
425+func TestProtectedForeignCloseDeferredUntilRefresh(t *testing.T) {
426+ s, events, _, dir := lifecycleServer(t)
427+ state := installSecurity(t, s)
428+ c := protocolClient(t, s, dir)
429+ w, surf, _ := surfaceMapper(t, c, events)()
430+ foreign := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 3)
431+ log := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)}
432+ log.SetID(foreign)
433+ registerWireProxy(c, log)
434+ takeEvents(t, c, log.events)
435+ <-log.handles
436+ protect(t, s, state, 1, true)
437+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, uint32(0), int32(0), int32(0))
438+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
439+ roundtrip(t, c)
440+ unmapped(t, events, w.ID)
441+ if !s.display.Do(func() { s.refreshToplevels() }) {
442+ t.Fatal("display stopped")
443+ }
444+ roundtrip(t, c)
445+ if len(log.events) != 0 {
446+ t.Fatal("protected foreign close leaked timing")
447+ }
448+ var retained bool
449+ if !s.display.Do(func() { retained = s.toplevelManagers[0].handles[w.ID] != nil }) {
450+ t.Fatal("display stopped")
451+ }
452+ if !retained {
453+ t.Fatal("deferred foreign handle lost before unlock")
454+ }
455+ protect(t, s, state, 2, false)
456+ if !s.display.Do(func() { s.refreshToplevels() }) {
457+ t.Fatal("display stopped")
458+ }
459+ got := takeEvents(t, c, log.events)
460+ if len(got) != 1 || got[0] != "closed" {
461+ t.Fatalf("unlock deferred events: %v", got)
462+ }
463+ if !s.display.Do(func() { retained = s.toplevelManagers[0].handles[w.ID] != nil; s.refreshToplevels() }) {
464+ t.Fatal("display stopped")
465+ }
466+ if retained {
467+ t.Fatal("stale foreign inventory retained on unlock")
468+ }
469+ roundtrip(t, c)
470+ if len(log.events) != 0 {
471+ t.Fatal("deferred close sent twice")
472+ }
473+}
M internal/adapters/wayland/server.go +49 -18
@@ -11,6 +11,7 @@ import (
1111
1212 "github.com/bnema/neferwl/internal/adapters/clock"
1313 "github.com/bnema/neferwl/internal/ports"
14+ "github.com/bnema/neferwl/internal/sessionlock"
1415 "github.com/bnema/purego-libwayland/protocol/fractionalscale"
1516 "github.com/bnema/purego-libwayland/protocol/wayland"
1617 "github.com/bnema/purego-libwayland/server"
@@ -33,18 +34,25 @@ type Options struct {
3334 RepeatRate, RepeatDelay int
3435 // Clock runs idle notification timers; nil is the system clock.
3536 Clock ports.Clock
37+ // Security is display-owned protection control. Nil leaves session-lock
38+ // unadvertised; independent protocol tests remain unlocked.
39+ Security ports.SessionSecurityController
3640 // syncDev replaces the render node's syncobj interface (tests).
3741 syncDev syncobjDevice
3842 }
3943
4044 // Channels carries client notifications and commands. Events may be unbuffered.
4145 type Channels struct {
42- LeaseRequests chan<- ports.LeaseMessage
43- LeaseEvents <-chan ports.LeaseMessage
44- Events chan<- ports.ClientEvent
45- Contents chan<- ports.SurfaceContent
46- Commands <-chan ports.ClientCommand
47- Workspaces <-chan ports.Workspaces
46+ // SecurityChanges requests ordered backend protection/release. The
47+ // backend echoes actual inventory/proofs/barriers on SecurityEvents.
48+ SecurityChanges chan<- ports.SecurityState
49+ SecurityEvents <-chan ports.SecurityBackendEvent
50+ LeaseRequests chan<- ports.LeaseMessage
51+ LeaseEvents <-chan ports.LeaseMessage
52+ Events chan<- ports.ClientEvent
53+ Contents chan<- ports.SurfaceContent
54+ Commands <-chan ports.ClientCommand
55+ Workspaces <-chan ports.Workspaces
4856 // Cursors receives the cursor the client under the pointer asks for.
4957 Cursors chan<- ports.CursorChange
5058 // Presented paces frame callbacks on the outputs' page flips; outputs
@@ -65,17 +73,25 @@ type Channels struct {
6573 OutputApply chan<- ports.OutputApply
6674 OutputApplied <-chan ports.OutputApplied
6775 }
76+
6877 type Server struct {
69- leaseDevices map[string]*leaseDevice
70- pendingLeases map[uint64]*leaseObject
71- pendingLeaseCards map[uint64]string
72- activeLeases map[leaseKey]*leaseObject
73- leaseMu sync.Mutex // only the outbound lease queue; never window state
74- leaseOut []ports.LeaseMessage
75- leaseReady chan struct{}
76- nextLeaseID uint64
77- nextCapture uint64
78- captureReplies map[uint64]func(ports.CaptureDone)
78+ security ports.SessionSecurity
79+ securityController ports.SessionSecurityController
80+ securityPending []ports.SecurityState // display-owned ordered backend outbox
81+ securityWake chan struct{}
82+ sessionLock *sessionLock
83+ lockReadiness sessionlock.Readiness
84+ lockSurfaces map[ports.WindowID]*lockSurface
85+ leaseDevices map[string]*leaseDevice
86+ pendingLeases map[uint64]*leaseObject
87+ pendingLeaseCards map[uint64]string
88+ activeLeases map[leaseKey]*leaseObject
89+ leaseMu sync.Mutex // only the outbound lease queue; never window state
90+ leaseOut []ports.LeaseMessage
91+ leaseReady chan struct{}
92+ nextLeaseID uint64
93+ nextCapture uint64
94+ captureReplies map[uint64]func(ports.CaptureDone)
7995 // captureInflight counts accepted captures until the backend completes
8096 // them, independent of client resources; only the display loop touches it.
8197 captureInflight map[uint64]struct{}
@@ -237,7 +253,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
237253 d.Close()
238254 return nil, err
239255 }
240- s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), damage: map[ports.WindowID][]ports.SeqDamage{}, contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, seat: seatState{keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), enters: make(map[*server.Resource]uint32), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}}
256+ s := &Server{securityWake: make(chan struct{}, 1), security: opts.Security, securityController: opts.Security, display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), damage: map[ports.WindowID][]ports.SeqDamage{}, contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, seat: seatState{keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), enters: make(map[*server.Resource]uint32), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}}
241257 s.leaseDevices = map[string]*leaseDevice{}
242258 s.pendingLeases = map[uint64]*leaseObject{}
243259 s.pendingLeaseCards = map[uint64]string{}
@@ -336,7 +352,9 @@ func (s *Server) Run(ctx context.Context) error {
336352 s.started = time.Now()
337353 s.ctx = ctx
338354 var wg sync.WaitGroup
339- wg.Add(12)
355+ wg.Add(14)
356+ go func() { defer wg.Done(); s.forwardBackendSecurity(ctx) }()
357+ go func() { defer wg.Done(); s.forwardSecurityEvents(ctx) }()
340358 go func() { defer wg.Done(); s.forwardLeases(ctx) }()
341359 go func() { defer wg.Done(); s.forwardLeaseRequests(ctx) }()
342360 go func() { defer wg.Done(); s.forwardWorkspaces(ctx) }()
@@ -609,6 +627,19 @@ func drainCommands(first ports.ClientCommand, cmds <-chan ports.ClientCommand) (
609627 }
610628
611629 func (s *Server) apply(cmd ports.ClientCommand) {
630+ if envelope, ok := cmd.(ports.SecurityCommand); ok {
631+ if s.security == nil || envelope.State != s.security.Snapshot() {
632+ return
633+ }
634+ cmd = envelope.Command
635+ } else if s.security != nil && s.security.Snapshot().Generation != 0 {
636+ // Only input/focus commands require an owner envelope. Config relay
637+ // also produces SetKeymap and other epoch-independent commands.
638+ switch cmd.(type) {
639+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.FocusWindow, ports.ForwardKey:
640+ return
641+ }
642+ }
612643 switch c := cmd.(type) {
613644 case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey:
614645 s.applyInput(c)
A internal/adapters/wayland/session_lock.go +278 -0
@@ -0,0 +1,278 @@
1+package wayland
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+ "github.com/bnema/purego-libwayland/protocol/extsessionlock"
8+ "github.com/bnema/purego-libwayland/protocol/wayland"
9+ "github.com/bnema/purego-libwayland/server"
10+)
11+
12+// sessionLock is owned by the display goroutine. Resource destruction removes
13+// its owner, never the protection gate. An ordinary new owner may take over.
14+type sessionLock struct {
15+ s *Server
16+ res *extsessionlock.ExtSessionLockV1
17+ state ports.SecurityState
18+ locked bool
19+ surfaces map[*output]*lockSurface
20+}
21+
22+type sessionLockManager struct{ s *Server }
23+
24+// registerSessionLock is enabled only when the display and backend share
25+// protection control and authoritative, non-dropping proof channels.
26+func registerSessionLock(d *server.Display, s *Server) error {
27+ return extsessionlock.NewExtSessionLockManagerV1Global(d, 1, func(c server.Client, version, id uint32) {
28+ _, _ = extsessionlock.NewExtSessionLockManagerV1(c, int32(version), id, sessionLockManager{s})
29+ })
30+}
31+func (sessionLockManager) Destroy(*extsessionlock.ExtSessionLockManagerV1) {}
32+
33+func (m sessionLockManager) Lock(r *extsessionlock.ExtSessionLockManagerV1, id uint32) {
34+ s := m.s
35+ l := &sessionLock{s: s, surfaces: make(map[*output]*lockSurface)}
36+ res, err := extsessionlock.NewExtSessionLockV1(r.Client(), r.Version(), id, l)
37+ if err != nil {
38+ return
39+ }
40+ l.res = res
41+ if s.securityController == nil || s.channels.SecurityChanges == nil || s.channels.SecurityEvents == nil || s.sessionLock != nil || len(s.securityPending) >= maxSecurityPending {
42+ res.SendFinished()
43+ return
44+ }
45+ // Engage before queueing any core event. No display-owned protocol
46+ // admission can observe the old policy after this point.
47+ state, err := s.securityController.Engage()
48+ if err != nil {
49+ res.SendFinished()
50+ return
51+ }
52+ l.state = state
53+ s.sessionLock = l
54+ s.lockReadiness.Begin(state.Generation)
55+ res.OnDestroy = l.orphan
56+ s.resetProtectionInput()
57+ if ps := s.private.session; ps != nil {
58+ ps.end(true)
59+ }
60+ for _, lease := range s.activeLeases {
61+ lease.destroy()
62+ }
63+ // Preserve card tombstones until late replies are consumed/revoked, but
64+ // revoke their protocol authority across both acquire and release.
65+ for id, lease := range s.pendingLeases {
66+ lease.finish()
67+ delete(s.pendingLeases, id)
68+ }
69+ s.updateWorkspaceManagers(s.workspaceSnapshot)
70+ s.emit(ports.SessionLockChanged{State: state})
71+ s.requestBackendSecurity(state)
72+ s.maybeLocked()
73+}
74+
75+func (l *sessionLock) Destroy(r *extsessionlock.ExtSessionLockV1) {
76+ if l.locked {
77+ r.PostError(uint32(extsessionlock.ExtSessionLockV1ErrorInvalidDestroy), "destroy while locked")
78+ }
79+}
80+func (l *sessionLock) UnlockAndDestroy(r *extsessionlock.ExtSessionLockV1) {
81+ s := l.s
82+ if !l.locked || s.sessionLock != l {
83+ r.PostError(uint32(extsessionlock.ExtSessionLockV1ErrorInvalidUnlock), "unlock before protection confirmation")
84+ return
85+ }
86+ state, err := s.securityController.Release(l.state.Generation)
87+ if err != nil {
88+ r.PostError(uint32(extsessionlock.ExtSessionLockV1ErrorInvalidUnlock), "protection release rejected")
89+ return
90+ }
91+ // Clear input before any desktop focus enter; no password held keys.
92+ s.resetProtectionInput()
93+ s.lockReadiness.End(l.state.Generation)
94+ s.sessionLock = nil
95+ s.refreshToplevels()
96+ s.updateWorkspaceManagers(s.workspaceSnapshot)
97+ s.updateCaptureWorkspaces(s.workspaceSnapshot)
98+ for _, surface := range l.surfaces {
99+ surface.unmap()
100+ }
101+ s.emit(ports.SessionLockChanged{State: state})
102+ s.requestBackendSecurity(state)
103+}
104+
105+func (l *sessionLock) GetLockSurface(r *extsessionlock.ExtSessionLockV1, id uint32, wl *wayland.Surface, out *wayland.Output) {
106+ s := l.s
107+ if s.sessionLock != l {
108+ _, _ = extsessionlock.NewExtSessionLockSurfaceV1(r.Client(), r.Version(), id, refusedLockSurface{})
109+ return
110+ }
111+ surface, output := s.surfaceOf(wl), s.outputOf(out)
112+ if surface == nil || output == nil || wl == nil || out == nil || wl.Client() != r.Client() || out.Client() != r.Client() {
113+ r.PostError(uint32(extsessionlock.ExtSessionLockV1ErrorRole), "lock surface and output must belong to lock owner")
114+ return
115+ }
116+ if _, exists := l.surfaces[output]; output != nil && exists {
117+ r.PostError(uint32(extsessionlock.ExtSessionLockV1ErrorDuplicateOutput), "duplicate lock output")
118+ return
119+ }
120+ ls, err := s.newLockSurface(r, id, surface, output, func(*lockSurface) { s.lockSurfaceChanged() })
121+ if err != nil || ls == nil {
122+ return
123+ }
124+ l.surfaces[output] = ls
125+ s.lockSurfaceChanged()
126+}
127+
128+func (l *sessionLock) orphan() {
129+ s := l.s
130+ if s.sessionLock != l {
131+ return
132+ }
133+ // Never release or send finished merely because a client died.
134+ s.resetProtectionInput()
135+ s.sessionLock = nil
136+ for _, surface := range l.surfaces {
137+ surface.unmap()
138+ }
139+ s.emit(ports.SessionLockChanged{State: l.state})
140+}
141+
142+func (s *Server) resetProtectionInput() {
143+ s.changeFocus(0)
144+ s.changePointerFocus(0, 0, 0)
145+ s.deactivateText()
146+ s.deactivateConstraint()
147+ clear(s.seat.heldKeys)
148+ clear(s.seat.grabKeys)
149+ s.seat.modState = ports.ModState{}
150+ s.seat.press, s.seat.pressClient = 0, server.Client{}
151+ s.seat.wheelRest, s.seat.wheelHeld = [2]int32{}, [2]float64{}
152+ s.cursorSurface = nil
153+}
154+
155+// maxSecurityPending bounds takeover churn while the backend is stalled.
156+// Acquire checks this bound before engaging; release retains a reserved slot.
157+const maxSecurityPending = 16
158+
159+func (s *Server) requestBackendSecurity(state ports.SecurityState) {
160+ s.securityPending = append(s.securityPending, state)
161+ select {
162+ case s.securityWake <- struct{}{}:
163+ default:
164+ }
165+}
166+
167+// forwardBackendSecurity transfers immutable states without blocking display
168+// dispatch. Queue mutation is always on the display owner, not this goroutine.
169+func (s *Server) forwardBackendSecurity(ctx context.Context) {
170+ for {
171+ var state ports.SecurityState
172+ var pending bool
173+ if !s.display.Do(func() {
174+ if len(s.securityPending) != 0 {
175+ state, pending = s.securityPending[0], true
176+ }
177+ }) {
178+ return
179+ }
180+ if !pending {
181+ select {
182+ case <-ctx.Done():
183+ return
184+ case <-s.display.Stopped():
185+ return
186+ case <-s.securityWake:
187+ continue
188+ }
189+ }
190+ select {
191+ case <-ctx.Done():
192+ return
193+ case <-s.display.Stopped():
194+ return
195+ case s.channels.SecurityChanges <- state:
196+ }
197+ if !s.display.Do(func() {
198+ // Only this sender pops; appending on display preserves the head.
199+ if len(s.securityPending) != 0 && s.securityPending[0] == state {
200+ s.securityPending[0] = ports.SecurityState{}
201+ s.securityPending = s.securityPending[1:]
202+ }
203+ }) {
204+ return
205+ }
206+ }
207+}
208+
209+func (s *Server) forwardSecurityEvents(ctx context.Context) {
210+ for {
211+ select {
212+ case <-ctx.Done():
213+ return
214+ case <-s.display.Stopped():
215+ return
216+ case event, ok := <-s.channels.SecurityEvents:
217+ if !ok {
218+ return
219+ }
220+ if !s.display.Do(func() { s.applySecurityEvent(event) }) {
221+ return
222+ }
223+ }
224+ }
225+}
226+
227+func (s *Server) applySecurityEvent(event ports.SecurityBackendEvent) {
228+ switch v := event.(type) {
229+ case ports.SecurityOutputAdded:
230+ s.lockReadiness.Add(v.Instance)
231+ case ports.SecurityOutputRemoved:
232+ s.lockReadiness.Remove(v.Instance)
233+ case ports.SecurityOutputProof:
234+ s.lockReadiness.Record(v.Proof)
235+ case ports.SecurityOutputInvalidated:
236+ s.lockReadiness.Invalidate(v.Generation, v.Instance)
237+ case ports.SecurityBackendBarrier:
238+ if v.Err == nil {
239+ s.lockReadiness.BackendBarrier(v.Generation)
240+ } else {
241+ s.log.Error().Err(v.Err).Msg("session security backend barrier failed")
242+ }
243+ }
244+ s.maybeLocked()
245+}
246+
247+func (s *Server) lockInputTarget(id ports.WindowID) bool {
248+ l := s.sessionLock
249+ if l == nil || !l.locked {
250+ return false
251+ }
252+ surface := s.lockSurfaces[id]
253+ if surface == nil || !surface.mapped || surface.closed || !surface.resource.Alive() {
254+ return false
255+ }
256+ for _, owned := range l.surfaces {
257+ if surface == owned {
258+ return true
259+ }
260+ }
261+ return false
262+}
263+
264+func (s *Server) maybeLocked() {
265+ l := s.sessionLock
266+ if l == nil || l.locked || !l.res.Alive() {
267+ return
268+ }
269+ if len(s.captureInflight) == 0 {
270+ s.lockReadiness.CaptureBarrier(l.state.Generation)
271+ }
272+ if !s.lockReadiness.Ready() {
273+ return
274+ }
275+ l.locked = true
276+ l.res.SendLocked()
277+ s.lockSurfaceChanged()
278+}
A internal/adapters/wayland/session_lock_barrier_test.go +277 -0
@@ -0,0 +1,277 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "errors"
6+ "image"
7+ "os"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/neferwl/internal/adapters/capture"
12+ "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
13+ "github.com/bnema/neferwl/internal/logging"
14+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
15+ "github.com/bnema/neferwl/internal/ports"
16+ "github.com/stretchr/testify/mock"
17+ "golang.org/x/sys/unix"
18+)
19+
20+// Real server fixture: channels are wired before Run, so the completion loop
21+// cannot latch a nil channel. No backend or protocol handler is replaced.
22+type barrierServer struct {
23+ s *Server
24+ gate *sessionsecurity.Gate
25+ changes chan ports.SecurityState
26+ captures chan ports.CaptureRequest
27+ captured chan ports.CaptureDone
28+ cancel context.CancelFunc
29+ stopped chan struct{}
30+ dir string
31+}
32+
33+func newBarrierServer(t *testing.T) *barrierServer {
34+ t.Helper()
35+ h := &barrierServer{gate: &sessionsecurity.Gate{}, changes: make(chan ports.SecurityState, 1), captures: make(chan ports.CaptureRequest, 4), captured: make(chan ports.CaptureDone, 4), stopped: make(chan struct{}), dir: t.TempDir()}
36+ layout := ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 2, Height: 2}, Width: 2, Height: 2, Scale: 1}}
37+ var err error
38+ h.s, err = New(Options{RuntimeDir: h.dir, Outputs: layout, Security: h.gate}, Channels{SecurityChanges: h.changes, SecurityEvents: make(chan ports.SecurityBackendEvent, 16), Captures: h.captures, Captured: h.captured}, logging.For(context.Background(), "wayland"))
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ ctx, cancel := context.WithCancel(context.Background())
43+ h.cancel = cancel
44+ var runErr error
45+ go func() { runErr = h.s.Run(ctx); close(h.stopped) }()
46+ t.Cleanup(func() {
47+ cancel()
48+ select {
49+ case <-h.stopped:
50+ if runErr != nil {
51+ t.Error(runErr)
52+ }
53+ case <-time.After(3 * time.Second):
54+ t.Error("barrier server shutdown blocked")
55+ }
56+ })
57+ return h
58+}
59+
60+func TestSessionLockBarrierWaitsForAdmittedCopyAfterFrameDeath(t *testing.T) {
61+ h := newBarrierServer(t)
62+ // Reuse the real raw-wire admission client, retaining its SHM memfd so a
63+ // write performed after lock admission can be observed independently.
64+ client := protocolClient(t, h.s, h.dir)
65+ output := bindVersion(t, client, "wl_output", 4)
66+ registerProtocol(t, client, output)
67+ _, buffer, fd := captureSmallBuffer(t, client)
68+ defer unix.Close(fd)
69+ a := &admissionClient{c: client, wlr: bindVersion(t, client, "zwlr_screencopy_manager_v1", 3), output: output, buf: buffer}
70+ frame := a.captureWlr(t)
71+ var request ports.CaptureRequest
72+ select {
73+ case request = <-h.captures:
74+ case <-time.After(2 * time.Second):
75+ t.Fatal("actual capture not admitted")
76+ }
77+ // Pipeline owns the dup'd destination from this point, including closure.
78+ renderer := portsmocks.NewMockRenderer(t)
79+ pixels := portsmocks.NewMockCaptureFrame(t)
80+ entered, release := make(chan struct{}), make(chan struct{})
81+ pixels.EXPECT().Done().Return(nil)
82+ pixels.EXPECT().Read(image.Rect(0, 0, 2, 2), mock.Anything, 8).RunAndReturn(func(_ image.Rectangle, dst []byte, _ int) error {
83+ close(entered)
84+ <-release
85+ copy(dst, []byte{0x42, 0x31, 0x20, 0xff})
86+ return nil
87+ }).Once()
88+ renderer.EXPECT().BeginCapture().Return(pixels, nil).Once()
89+ renderer.EXPECT().EndCapture(pixels).Return().Once()
90+ pipelineReplies := make(chan ports.CaptureDone, 1)
91+ pipeline := capture.NewPipeline(context.Background(), pipelineReplies)
92+ pipeline.Security = h.gate
93+ released := false
94+ t.Cleanup(func() {
95+ if !released {
96+ close(release)
97+ }
98+ pipeline.Close(renderer)
99+ // testify records the mmap-backed Read argument. Forget its call
100+ // history after joining the worker: expectation counters still verify
101+ // Once, but cleanup must not format bytes that are now unmapped.
102+ pixels.Calls = nil // worker joined; follows capture/pipeline_test.go
103+ })
104+ pipeline.SubmitScoped(h.gate.Snapshot(), renderer, []ports.CaptureRequest{request})
105+ select {
106+ case <-entered:
107+ case <-time.After(2 * time.Second):
108+ t.Fatal("capture worker did not enter Read")
109+ }
110+ a.destroy(t, frame)
111+ locker := protocolClient(t, h.s, h.dir)
112+ _, locked := lifecycleAcquire(t, locker)
113+ state := lifecycleTransition(t, h.changes)
114+ h.s.display.Do(func() {
115+ h.s.applySecurityEvent(ports.SecurityOutputAdded{Instance: 1, Output: "HEADLESS-1"})
116+ h.s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation})
117+ lifecycleProof(h.s, state, 1)
118+ if len(h.s.captureInflight) != 1 || h.s.sessionLock.locked {
119+ t.Error("destroyed resource bypassed held copy barrier")
120+ }
121+ })
122+ roundtrip(t, locker)
123+ if *locked != 0 {
124+ t.Fatal("locked while Read can still write normal pixels")
125+ }
126+ close(release)
127+ released = true
128+ var completion ports.CaptureDone
129+ select {
130+ case completion = <-pipelineReplies:
131+ case <-time.After(2 * time.Second):
132+ t.Fatal("capture pipeline did not complete")
133+ }
134+ if completion.Err != nil || completion.ID != request.ID {
135+ t.Fatalf("capture completion %+v", completion)
136+ }
137+ if _, err := request.Dst.File.Stat(); !errors.Is(err, os.ErrClosed) {
138+ t.Fatalf("completion precedes destination close: %v", err)
139+ }
140+ got := make([]byte, 4)
141+ if _, err := unix.Pread(fd, got, 0); err != nil {
142+ t.Fatal(err)
143+ }
144+ if got[0] != 0x42 || got[3] != 0xff {
145+ t.Fatalf("actual copy not visible: %x", got)
146+ }
147+ h.s.display.Do(func() {
148+ if len(h.s.captureInflight) != 1 || h.s.sessionLock.locked {
149+ t.Error("worker completion not forwarded yet must retain credit")
150+ }
151+ })
152+ // Reply channel is the sole production completion funnel. Closing a frame
153+ // never synthesizes this reply or cancels its outstanding memory write.
154+ processed := make(chan struct{})
155+ h.s.display.Do(func() {
156+ original := h.s.captureReplies[request.ID]
157+ h.s.captureReplies[request.ID] = func(done ports.CaptureDone) {
158+ if original != nil {
159+ original(done)
160+ } // frame destruction removes the wire reply, not credit
161+ close(processed)
162+ }
163+ })
164+ h.captured <- completion
165+ select {
166+ case <-processed:
167+ case <-time.After(2 * time.Second):
168+ t.Fatal("server did not process actual capture completion")
169+ }
170+ h.s.display.Do(func() {
171+ if len(h.s.captureInflight) != 0 || !h.s.sessionLock.locked {
172+ t.Error("completed copy did not clear lock barrier")
173+ }
174+ })
175+ // The opaque completed token returns the real capture lease on its owner.
176+ select {
177+ case token := <-pipeline.Completed():
178+ pipeline.Recycle(token, renderer)
179+ case <-time.After(2 * time.Second):
180+ t.Fatal("capture lease not returned")
181+ }
182+ roundtrip(t, locker)
183+ if *locked != 1 {
184+ t.Fatalf("locked events=%d", *locked)
185+ }
186+}
187+
188+func TestSessionLockBarrierTakeoverSaturationRefusesWithoutGenerationChange(t *testing.T) {
189+ h := newBarrierServer(t)
190+ sentinel := ports.SecurityState{Generation: 999}
191+ h.changes <- sentinel // authoritative backend stalled for the whole churn
192+ for i := 0; i < maxSecurityPending; i++ {
193+ client := protocolClient(t, h.s, h.dir)
194+ _, locked := lifecycleAcquire(t, client)
195+ if *locked != 0 {
196+ t.Fatal("stalled backend confirmed lock")
197+ }
198+ orphaned := make(chan struct{})
199+ h.s.display.Do(func() { r := h.s.sessionLock.res; old := r.OnDestroy; r.OnDestroy = func() { old(); close(orphaned) } })
200+ if err := client.Close(); err != nil {
201+ t.Fatal(err)
202+ }
203+ select {
204+ case <-orphaned:
205+ case <-time.After(2 * time.Second):
206+ t.Fatal("owner death not processed")
207+ }
208+ }
209+ before := h.gate.Snapshot()
210+ client := protocolClient(t, h.s, h.dir)
211+ refused, err := bindLockManager(t, client).Lock()
212+ if err != nil {
213+ t.Fatal(err)
214+ }
215+ finished := 0
216+ refused.OnFinished(func() { finished++ })
217+ roundtrip(t, client)
218+ h.s.display.Do(func() {
219+ if len(h.s.securityPending) != maxSecurityPending || h.s.sessionLock != nil {
220+ t.Error("stalled outbox exceeded bound or admitted owner")
221+ }
222+ })
223+ if finished != 1 || h.gate.Snapshot() != before || !before.Protected {
224+ t.Fatalf("refusal finished=%d before=%+v after=%+v", finished, before, h.gate.Snapshot())
225+ }
226+}
227+
228+func TestSessionLockBarrierReservedReleaseAndBlockedSenderShutdown(t *testing.T) {
229+ h := newBarrierServer(t)
230+ sentinel := ports.SecurityState{Generation: 999}
231+ h.changes <- sentinel
232+ client := protocolClient(t, h.s, h.dir)
233+ lock, locked := lifecycleAcquire(t, client)
234+ state := h.gate.Snapshot()
235+ var queued []ports.SecurityState
236+ h.s.display.Do(func() {
237+ // Immutable channel messages exercise the reserved release slot without
238+ // fabricating another accepted owner while this one is live.
239+ for len(h.s.securityPending) < maxSecurityPending {
240+ h.s.requestBackendSecurity(ports.SecurityState{Generation: ports.LockGeneration(100 + len(h.s.securityPending)), Protected: true})
241+ }
242+ queued = append(queued, h.s.securityPending...)
243+ h.s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation})
244+ })
245+ roundtrip(t, client)
246+ if *locked != 1 {
247+ t.Fatal("owner not confirmed")
248+ }
249+ if err := lock.UnlockAndDestroy(); err != nil {
250+ t.Fatal(err)
251+ }
252+ roundtrip(t, client)
253+ released := h.gate.Snapshot()
254+ h.s.display.Do(func() {
255+ if len(h.s.securityPending) != maxSecurityPending+1 || h.s.securityPending[maxSecurityPending] != released {
256+ t.Error("reserved release slot lost")
257+ }
258+ })
259+ if got := lifecycleTransition(t, h.changes); got != sentinel {
260+ t.Fatal("backend sentinel changed")
261+ }
262+ for _, want := range append(queued, released) {
263+ if got := lifecycleTransition(t, h.changes); got != want {
264+ t.Fatalf("outbox got %+v want %+v", got, want)
265+ }
266+ }
267+ // Block another transition and cancel the real server. Run joins the sender;
268+ // successful return proves it did not leak in a blocked channel send.
269+ h.changes <- sentinel
270+ h.s.display.Do(func() { h.s.requestBackendSecurity(ports.SecurityState{Generation: 777, Protected: true}) })
271+ h.cancel()
272+ select {
273+ case <-h.stopped:
274+ case <-time.After(3 * time.Second):
275+ t.Fatal("blocked backend sender prevented Run shutdown")
276+ }
277+}
A internal/adapters/wayland/session_lock_input_epoch_test.go +272 -0
@@ -0,0 +1,272 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "slices"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
11+ "github.com/bnema/neferwl/internal/logging"
12+ "github.com/bnema/neferwl/internal/ports"
13+ "github.com/bnema/purego-libwayland/protocol/wayland"
14+ "github.com/bnema/wlturbo"
15+ "github.com/bnema/wlturbo/protocol/core"
16+)
17+
18+// This fixture wires real channel ports before starting the display owner.
19+func inputEpochLockServer(t *testing.T) (*Server, *sessionsecurity.Gate, chan ports.SecurityState, chan ports.ClientEvent, string) {
20+ t.Helper()
21+ gate := &sessionsecurity.Gate{}
22+ changes := make(chan ports.SecurityState, 16)
23+ events := make(chan ports.ClientEvent, 128)
24+ dir := t.TempDir()
25+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, Security: gate, Keymap: keymapText(t)}, Channels{Events: events, SecurityChanges: changes, SecurityEvents: make(chan ports.SecurityBackendEvent, 16)}, logging.For(context.Background(), "wayland"))
26+ if err != nil {
27+ t.Fatal(err)
28+ }
29+ ctx, cancel := context.WithCancel(context.Background())
30+ done := make(chan error, 1)
31+ go func() { done <- s.Run(ctx) }()
32+ t.Cleanup(func() {
33+ cancel()
34+ select {
35+ case err := <-done:
36+ if err != nil {
37+ t.Error(err)
38+ }
39+ case <-time.After(3 * time.Second):
40+ t.Error("input epoch server did not stop")
41+ }
42+ })
43+ return s, gate, changes, events, dir
44+}
45+
46+func inputEpochKeyboard(t *testing.T, c *wlturbo.Display) *core.Keyboard {
47+ t.Helper()
48+ seat := core.NewSeat(c.Context())
49+ g, ok := c.Registry().FindGlobal("wl_seat")
50+ if !ok {
51+ t.Fatal("seat missing")
52+ }
53+ if err := c.Registry().Bind(g.Name, g.Interface, min(g.Version, 4), seat); err != nil {
54+ t.Fatal(err)
55+ }
56+ keyboard, err := seat.GetKeyboard()
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ return keyboard
61+}
62+
63+func inputEpochCommitLocker(t *testing.T, s *Server, c *wlturbo.Display, lockSurface *core.Surface, ack func(uint32) error, onConfigure func(func(uint32, uint32, uint32))) (ports.WindowID, lockConfigure) {
64+ t.Helper()
65+ var cfg lockConfigure
66+ onConfigure(func(serial, w, h uint32) { cfg = lockConfigure{serial: serial, width: int(w), height: int(h)} })
67+ roundtrip(t, c)
68+ if cfg.serial == 0 {
69+ t.Fatal("missing lock configure")
70+ }
71+ if err := ack(cfg.serial); err != nil {
72+ t.Fatal(err)
73+ }
74+ lockViewport(t, c, lockSurface.ID(), cfg.width, cfg.height)
75+ requestProtocol(t, c, lockSurface.ID(), wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0))
76+ requestProtocol(t, c, lockSurface.ID(), wayland.SurfaceRequestCommit)
77+ roundtrip(t, c)
78+ var id ports.WindowID
79+ s.display.Do(func() {
80+ surf := s.surfaceOf(findTestSurface(s, lockSurface.ID()))
81+ if surf == nil || surf.lock == nil || !surf.lock.mapped {
82+ t.Error("acknowledged buffered lock role not mapped")
83+ return
84+ }
85+ id = surf.lock.id
86+ })
87+ return id, cfg
88+}
89+
90+// Marker follows display-owned event emission; no sleeps or polling are
91+// needed to establish that all earlier SessionLockChanged events were read.
92+func inputEpochLockEvents(t *testing.T, s *Server, events <-chan ports.ClientEvent) []ports.SessionLockChanged {
93+ t.Helper()
94+ const marker ports.WindowID = ^ports.WindowID(0)
95+ if !s.display.Do(func() { s.emit(ports.WindowAppID{ID: marker, AppID: "input-epoch-marker"}) }) {
96+ t.Fatal("display stopped")
97+ }
98+ deadline := time.After(2 * time.Second)
99+ var locks []ports.SessionLockChanged
100+ for {
101+ select {
102+ case ev := <-events:
103+ if v, ok := ev.(ports.WindowAppID); ok && v.ID == marker {
104+ return locks
105+ }
106+ if v, ok := ev.(ports.SessionLockChanged); ok {
107+ locks = append(locks, v)
108+ }
109+ case <-deadline:
110+ t.Fatal("event marker missing")
111+ return nil
112+ }
113+ }
114+}
115+
116+func TestSessionLockInputEpochHeldPasswordClearedBeforeDesktopEnter(t *testing.T) {
117+ s, gate, changes, events, dir := inputEpochLockServer(t)
118+ desktop := protocolClient(t, s, dir)
119+ normal, normalSurface, _ := surfaceMapper(t, desktop, events)()
120+ keys := inputEpochKeyboard(t, desktop)
121+ var enters [][]byte
122+ var enterSurfaces []uint32
123+ var mods []ports.ModState
124+ var delivered []uint32
125+ keys.OnEnter(func(_ uint32, surface uint32, held []byte) {
126+ enters = append(enters, slices.Clone(held))
127+ enterSurfaces = append(enterSurfaces, surface)
128+ })
129+ keys.OnModifiers(func(_ uint32, d, l, k, g uint32) {
130+ mods = append(mods, ports.ModState{Depressed: d, Latched: l, Locked: k, Group: g})
131+ })
132+ keys.OnKey(func(_, _, key, _ uint32) { delivered = append(delivered, key) })
133+ roundtrip(t, desktop)
134+ s.display.Do(func() {
135+ s.apply(ports.SecurityCommand{State: gate.Snapshot(), Command: ports.FocusWindow{ID: normal.ID}})
136+ })
137+ roundtrip(t, desktop)
138+ enters, enterSurfaces, mods, delivered = nil, nil, nil, nil
139+ ime := newIMEApp(t, s, dir)
140+ _, grab := ime.grab(t)
141+ roundtrip(t, ime.c)
142+ grab.take()
143+ locker := protocolClient(t, s, dir)
144+ lock, locked := lifecycleAcquire(t, locker)
145+ state := lifecycleTransition(t, changes)
146+ surf, out := lifecycleSurfaceOutput(t, locker)
147+ role, err := lock.GetLockSurface(surf, out)
148+ if err != nil {
149+ t.Fatal(err)
150+ }
151+ id, _ := inputEpochCommitLocker(t, s, locker, surf, role.AckConfigure, role.OnConfigure)
152+ lockerKeys := inputEpochKeyboard(t, locker)
153+ var passwordKeys []uint32
154+ lockerKeys.OnKey(func(_, _, key, _ uint32) { passwordKeys = append(passwordKeys, key) })
155+ roundtrip(t, locker)
156+ s.display.Do(func() { s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) })
157+ roundtrip(t, locker)
158+ if *locked != 1 {
159+ t.Fatal("lock not confirmed")
160+ }
161+ password := ports.KeyEvent{Keycode: 30, Pressed: true, TimeMsec: 1, State: ports.ModState{Depressed: 65, Latched: 2, Locked: 16, Group: 1}}
162+ s.display.Do(func() {
163+ s.apply(ports.SecurityCommand{State: state, Command: ports.FocusWindow{ID: id}})
164+ s.apply(ports.SecurityCommand{State: state, Command: ports.ForwardKey{ID: id, Key: password}})
165+ if !s.seat.heldKeys[30] || s.seat.modState != password.State {
166+ t.Error("test did not seed held password/native seat modifier state")
167+ }
168+ })
169+ settle(t, locker, ime.c, desktop)
170+ if !slices.Equal(passwordKeys, []uint32{30}) {
171+ t.Fatalf("locker key events %v", passwordKeys)
172+ }
173+ if len(delivered) != 0 {
174+ t.Fatalf("locker password reached desktop before release: %v", delivered)
175+ }
176+ for _, event := range grab.take() {
177+ if strings.HasPrefix(event, "key ") {
178+ t.Fatalf("password reached IME: %s", event)
179+ }
180+ }
181+ if err := lock.UnlockAndDestroy(); err != nil {
182+ t.Fatal(err)
183+ }
184+ roundtrip(t, locker)
185+ released := lifecycleTransition(t, changes)
186+ if released.Protected || released == state {
187+ t.Fatal(released)
188+ }
189+ s.display.Do(func() {
190+ if len(s.seat.heldKeys) != 0 || len(s.seat.grabKeys) != 0 || s.seat.modState != (ports.ModState{}) {
191+ t.Error("release retained password input state")
192+ }
193+ s.apply(ports.SecurityCommand{State: state, Command: ports.FocusWindow{ID: normal.ID}})
194+ s.apply(ports.FocusWindow{ID: normal.ID})
195+ if s.seat.focused != 0 {
196+ t.Error("stale or raw focus crossed release")
197+ }
198+ s.apply(ports.SecurityCommand{State: released, Command: ports.FocusWindow{ID: normal.ID}})
199+ // Old envelopes aimed at the newly focused desktop are still rejected.
200+ s.apply(ports.SecurityCommand{State: state, Command: ports.ForwardKey{ID: normal.ID, Key: password}})
201+ password.Pressed = false
202+ s.apply(ports.SecurityCommand{State: state, Command: ports.ForwardKey{ID: normal.ID, Key: password}})
203+ s.apply(ports.ForwardKey{ID: normal.ID, Key: password})
204+ })
205+ settle(t, desktop, locker, ime.c)
206+ if len(delivered) != 0 {
207+ t.Fatalf("password/stale key reached desktop: %v", delivered)
208+ }
209+ if len(enters) != 1 || enterSurfaces[0] != normalSurface || len(enters[0]) != 0 {
210+ t.Fatalf("desktop enter surfaces=%v held=%v", enterSurfaces, enters)
211+ }
212+ if len(mods) == 0 || mods[len(mods)-1] != (ports.ModState{}) {
213+ t.Fatalf("desktop modifiers not reset: %+v", mods)
214+ }
215+ for _, event := range grab.take() {
216+ if strings.HasPrefix(event, "key ") {
217+ t.Fatalf("stale password reached IME after release: %s", event)
218+ }
219+ }
220+}
221+
222+func TestSessionLockInputEpochMappedRolePublishedOnlyAfterEveryProof(t *testing.T) {
223+ s, _, changes, events, dir := inputEpochLockServer(t)
224+ c := protocolClient(t, s, dir)
225+ s.display.Do(func() {
226+ s.applySecurityEvent(ports.SecurityOutputAdded{Instance: 11, Output: "HEADLESS-1"})
227+ s.applySecurityEvent(ports.SecurityOutputAdded{Instance: 12, Output: "HEADLESS-2"})
228+ })
229+ lock, locked := lifecycleAcquire(t, c)
230+ state := lifecycleTransition(t, changes)
231+ surf, out := lifecycleSurfaceOutput(t, c)
232+ role, err := lock.GetLockSurface(surf, out)
233+ if err != nil {
234+ t.Fatal(err)
235+ }
236+ id, cfg := inputEpochCommitLocker(t, s, c, surf, role.AckConfigure, role.OnConfigure)
237+ before := inputEpochLockEvents(t, s, events)
238+ if len(before) == 0 {
239+ t.Fatal("no core protection event")
240+ }
241+ for _, ev := range before {
242+ if ev.State != state || len(ev.Surfaces) != 0 {
243+ t.Fatalf("preconfirm placement leaked %+v", ev)
244+ }
245+ }
246+ s.display.Do(func() {
247+ s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation})
248+ lifecycleProof(s, state, 11)
249+ })
250+ roundtrip(t, c)
251+ if *locked != 0 {
252+ t.Fatal("locked without second output proof")
253+ }
254+ for _, ev := range inputEpochLockEvents(t, s, events) {
255+ if len(ev.Surfaces) != 0 {
256+ t.Fatalf("one-proof placement leaked %+v", ev)
257+ }
258+ }
259+ s.display.Do(func() { lifecycleProof(s, state, 12) })
260+ roundtrip(t, c)
261+ if *locked != 1 {
262+ t.Fatalf("locked callbacks %d", *locked)
263+ }
264+ after := inputEpochLockEvents(t, s, events)
265+ if cfg.width != testOutputs[0].Width || cfg.height != testOutputs[0].Height {
266+ t.Fatalf("lock configure %+v does not match output %+v", cfg, testOutputs[0])
267+ }
268+ want := ports.LockSurfacePlacement{ID: id, Output: "HEADLESS-1", Width: cfg.width, Height: cfg.height}
269+ if len(after) != 1 || after[0].State != state || !slices.Equal(after[0].Surfaces, []ports.LockSurfacePlacement{want}) {
270+ t.Fatalf("validated placement events %+v want %+v", after, want)
271+ }
272+}
A internal/adapters/wayland/session_lock_lifecycle_test.go +543 -0
@@ -0,0 +1,543 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "slices"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
11+ "github.com/bnema/neferwl/internal/logging"
12+ "github.com/bnema/neferwl/internal/ports"
13+ "github.com/bnema/purego-libwayland/protocol/drmlease"
14+ serverlock "github.com/bnema/purego-libwayland/protocol/extsessionlock"
15+ "github.com/bnema/purego-libwayland/protocol/wayland"
16+ "github.com/bnema/wlturbo"
17+ "github.com/bnema/wlturbo/protocol/core"
18+ lockclient "github.com/bnema/wlturbo/protocol/extsessionlock"
19+ "golang.org/x/sys/unix"
20+)
21+
22+// A real display with capture completion wiring established before Run starts.
23+func lifecycleLockServer(t *testing.T, leaseRequests ...chan ports.LeaseMessage) (*Server, *sessionsecurity.Gate, chan ports.SecurityState, chan ports.CaptureDone, string) {
24+ t.Helper()
25+ gate := &sessionsecurity.Gate{}
26+ changes := make(chan ports.SecurityState, 16)
27+ captured := make(chan ports.CaptureDone, 16)
28+ dir := t.TempDir()
29+ channels := Channels{SecurityChanges: changes, SecurityEvents: make(chan ports.SecurityBackendEvent, 16), Captured: captured}
30+ if len(leaseRequests) != 0 {
31+ channels.LeaseRequests = leaseRequests[0]
32+ }
33+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, Security: gate, Keymap: keymapText(t)}, channels, logging.For(context.Background(), "wayland"))
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ ctx, cancel := context.WithCancel(context.Background())
38+ done := make(chan error, 1)
39+ go func() { done <- s.Run(ctx) }()
40+ t.Cleanup(func() {
41+ cancel()
42+ select {
43+ case err := <-done:
44+ if err != nil {
45+ t.Error(err)
46+ }
47+ case <-time.After(3 * time.Second):
48+ t.Error("lock lifecycle server did not stop")
49+ }
50+ })
51+ return s, gate, changes, captured, dir
52+}
53+
54+func lifecycleTransition(t *testing.T, changes <-chan ports.SecurityState) ports.SecurityState {
55+ t.Helper()
56+ select {
57+ case state := <-changes:
58+ return state
59+ case <-time.After(2 * time.Second):
60+ t.Fatal("missing security transition")
61+ return ports.SecurityState{}
62+ }
63+}
64+
65+func lifecycleAcquire(t *testing.T, c *wlturbo.Display) (*lockclient.ExtSessionLock, *int) {
66+ t.Helper()
67+ lock, err := bindLockManager(t, c).Lock()
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ count := new(int)
72+ lock.OnLocked(func() { *count++ })
73+ roundtrip(t, c)
74+ return lock, count
75+}
76+
77+func lifecycleProof(s *Server, state ports.SecurityState, instance ports.OutputInstance) {
78+ s.applySecurityEvent(ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: state.Generation, Instance: instance, Kind: ports.ProtectionProtectedFrame}})
79+}
80+
81+func TestSessionLockLifecycleCaptureCompletionAndProofEpoch(t *testing.T) {
82+ s, gate, changes, captured, dir := lifecycleLockServer(t)
83+ c := protocolClient(t, s, dir)
84+ s.display.Do(func() {
85+ s.captureInflight[77] = struct{}{}
86+ s.applySecurityEvent(ports.SecurityOutputAdded{Instance: 1, Output: "HEADLESS-1"})
87+ })
88+ _, locked := lifecycleAcquire(t, c)
89+ state := lifecycleTransition(t, changes)
90+ s.display.Do(func() {
91+ s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation})
92+ lifecycleProof(s, ports.SecurityState{Generation: state.Generation + 1}, 1)
93+ lifecycleProof(s, state, 99) // unregistered output must not satisfy inventory
94+ lifecycleProof(s, state, 1)
95+ if s.sessionLock.locked {
96+ t.Error("capture credit bypassed")
97+ }
98+ })
99+ roundtrip(t, c)
100+ if *locked != 0 {
101+ t.Fatal("locked before capture completion")
102+ }
103+ // Unknown completion cannot retire an outstanding credit.
104+ unknown := make(chan struct{})
105+ s.display.Do(func() { s.captureReplies[88] = func(ports.CaptureDone) { close(unknown) } })
106+ captured <- ports.CaptureDone{ID: 88}
107+ select {
108+ case <-unknown:
109+ case <-time.After(2 * time.Second):
110+ t.Fatal("unknown completion not consumed")
111+ }
112+ s.display.Do(func() {
113+ if _, exists := s.captureInflight[77]; !exists || s.sessionLock.locked {
114+ t.Error("unknown completion retired real credit")
115+ }
116+ })
117+ processed := make(chan struct{})
118+ s.display.Do(func() { s.captureReplies[77] = func(ports.CaptureDone) { close(processed) } })
119+ captured <- ports.CaptureDone{ID: 77}
120+ select {
121+ case <-processed:
122+ case <-time.After(2 * time.Second):
123+ t.Fatal("capture completion not consumed")
124+ }
125+ roundtrip(t, c)
126+ if *locked != 1 || !gate.Snapshot().Protected {
127+ t.Fatalf("completion locked=%d gate=%+v", *locked, gate.Snapshot())
128+ }
129+ s.display.Do(func() { lifecycleProof(s, state, 1); s.maybeLocked() })
130+ roundtrip(t, c)
131+ if *locked != 1 {
132+ t.Fatal("locked event repeated")
133+ }
134+}
135+
136+func TestSessionLockLifecycleUnlockProtocolErrors(t *testing.T) {
137+ for _, scenario := range []string{"unlock before locked", "destroy after locked"} {
138+ t.Run(scenario, func(t *testing.T) {
139+ s, gate, changes, _, dir := lockManagerServer(t)
140+ c := protocolClient(t, s, dir)
141+ lock, locked := lifecycleAcquire(t, c)
142+ state := lifecycleTransition(t, changes)
143+ code := serverlock.ExtSessionLockV1ErrorInvalidUnlock
144+ id := lock.ID()
145+ if scenario == "destroy after locked" {
146+ s.display.Do(func() { s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) })
147+ roundtrip(t, c)
148+ if *locked != 1 {
149+ t.Fatal("zero-output backend barrier did not confirm")
150+ }
151+ code = serverlock.ExtSessionLockV1ErrorInvalidDestroy
152+ if err := lock.Destroy(); err != nil {
153+ t.Fatal(err)
154+ }
155+ } else {
156+ if err := lock.UnlockAndDestroy(); err != nil {
157+ t.Fatal(err)
158+ }
159+ }
160+ expectProtocolError(t, c, id, uint32(code))
161+ if !gate.Snapshot().Protected {
162+ t.Fatal("invalid destructor unlocked")
163+ }
164+ })
165+ }
166+}
167+
168+func TestSessionLockLifecycleOrphanTakeoverAndValidUnlock(t *testing.T) {
169+ s, gate, changes, _, dir := lockManagerServer(t)
170+ c := protocolClient(t, s, dir)
171+ _, locked := lifecycleAcquire(t, c)
172+ firstState := lifecycleTransition(t, changes)
173+ s.display.Do(func() {
174+ s.applySecurityEvent(ports.SecurityOutputAdded{Instance: 1, Output: "HEADLESS-1"})
175+ s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: firstState.Generation})
176+ lifecycleProof(s, firstState, 1)
177+ })
178+ roundtrip(t, c)
179+ if *locked != 1 {
180+ t.Fatal("first lock not confirmed")
181+ }
182+ orphaned := make(chan struct{})
183+ s.display.Do(func() { r := s.sessionLock.res; old := r.OnDestroy; r.OnDestroy = func() { old(); close(orphaned) } })
184+ if err := c.Close(); err != nil {
185+ t.Fatal(err)
186+ }
187+ select {
188+ case <-orphaned:
189+ case <-time.After(2 * time.Second):
190+ t.Fatal("owner disconnect not processed")
191+ }
192+ s.display.Do(func() {
193+ if s.sessionLock != nil || !gate.Snapshot().Protected {
194+ t.Error("orphan released protection")
195+ }
196+ })
197+ next := protocolClient(t, s, dir)
198+ second, secondLocked := lifecycleAcquire(t, next)
199+ state := lifecycleTransition(t, changes)
200+ if state.Generation <= firstState.Generation || !state.Protected {
201+ t.Fatalf("takeover state %+v", state)
202+ }
203+ s.display.Do(func() {
204+ s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation})
205+ lifecycleProof(s, firstState, 1)
206+ })
207+ roundtrip(t, next)
208+ if *secondLocked != 0 {
209+ t.Fatal("stale proof legalized takeover")
210+ }
211+ s.display.Do(func() { lifecycleProof(s, state, 1) })
212+ roundtrip(t, next)
213+ if *secondLocked != 1 {
214+ t.Fatal("takeover not confirmed")
215+ }
216+ if err := second.UnlockAndDestroy(); err != nil {
217+ t.Fatal(err)
218+ }
219+ roundtrip(t, next) // explicit server processing before client exit
220+ released := lifecycleTransition(t, changes)
221+ s.display.Do(func() {
222+ if s.sessionLock != nil || gate.Snapshot().Protected || len(s.seat.heldKeys) != 0 || len(s.seat.grabKeys) != 0 {
223+ t.Error("valid unlock failed to reset ownership/input")
224+ }
225+ })
226+ if released.Protected || released.Generation <= state.Generation {
227+ t.Fatalf("unlock transition %+v", released)
228+ }
229+}
230+
231+func lifecycleSurfaceOutput(t *testing.T, c *wlturbo.Display) (*core.Surface, *core.Output) {
232+ t.Helper()
233+ comp := core.NewCompositor(c.Context())
234+ g, ok := c.Registry().FindGlobal("wl_compositor")
235+ if !ok {
236+ t.Fatal("compositor missing")
237+ }
238+ if err := c.Registry().Bind(g.Name, g.Interface, 6, comp); err != nil {
239+ t.Fatal(err)
240+ }
241+ surf, err := comp.CreateSurface()
242+ if err != nil {
243+ t.Fatal(err)
244+ }
245+ out := core.NewOutput(c.Context())
246+ g, ok = c.Registry().FindGlobal("wl_output")
247+ if !ok {
248+ t.Fatal("output missing")
249+ }
250+ if err := c.Registry().Bind(g.Name, g.Interface, 4, out); err != nil {
251+ t.Fatal(err)
252+ }
253+ return surf, out
254+}
255+
256+func TestSessionLockLifecycleRefusedPipelinedSurfaceInert(t *testing.T) {
257+ s, gate, changes, _, dir := lockManagerServer(t)
258+ c := protocolClient(t, s, dir)
259+ manager := bindLockManager(t, c)
260+ accepted, err := manager.Lock()
261+ if err != nil {
262+ t.Fatal(err)
263+ }
264+ refused, err := manager.Lock()
265+ if err != nil {
266+ t.Fatal(err)
267+ }
268+ finished, configured := 0, 0
269+ refused.OnFinished(func() { finished++ })
270+ surf, out := lifecycleSurfaceOutput(t, c)
271+ role, err := refused.GetLockSurface(surf, out)
272+ if err != nil {
273+ t.Fatal(err)
274+ }
275+ role.OnConfigure(func(uint32, uint32, uint32) { configured++ })
276+ requestProtocol(t, c, surf.ID(), wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0))
277+ requestProtocol(t, c, surf.ID(), wayland.SurfaceRequestCommit)
278+ roundtrip(t, c)
279+ state := lifecycleTransition(t, changes)
280+ s.display.Do(func() {
281+ stateSurf := s.surfaceOf(findTestSurface(s, surf.ID()))
282+ if stateSurf.kind != roleNone || stateSurf.lock != nil || len(s.lockSurfaces) != 0 || s.sessionLock.res.ID() != accepted.ID() || s.lockInputTarget(stateSurf.windowID()) {
283+ t.Error("refused surface gained active role/input")
284+ }
285+ })
286+ if finished != 1 || configured != 0 || gate.Snapshot() != state {
287+ t.Fatalf("refused finished=%d configure=%d", finished, configured)
288+ }
289+ if err := role.Destroy(); err != nil {
290+ t.Fatal(err)
291+ }
292+ if err := refused.Destroy(); err != nil {
293+ t.Fatal(err)
294+ }
295+ roundtrip(t, c)
296+}
297+
298+func TestSessionLockLifecycleIMEGrabCannotReceiveLockerKey(t *testing.T) {
299+ s, _, changes, _, dir := lifecycleLockServer(t)
300+ ime := newIMEApp(t, s, dir)
301+ _, grab := ime.grab(t)
302+ roundtrip(t, ime.c)
303+ grab.take()
304+ locker := protocolClient(t, s, dir)
305+ lock, locked := lifecycleAcquire(t, locker)
306+ state := lifecycleTransition(t, changes)
307+ surf, out := lifecycleSurfaceOutput(t, locker)
308+ role, err := lock.GetLockSurface(surf, out)
309+ if err != nil {
310+ t.Fatal(err)
311+ }
312+ var cfg lockConfigure
313+ role.OnConfigure(func(serial, w, h uint32) { cfg = lockConfigure{serial: serial, width: int(w), height: int(h)} })
314+ roundtrip(t, locker)
315+ if cfg.serial == 0 {
316+ t.Fatal("configure missing")
317+ }
318+ if err := role.AckConfigure(cfg.serial); err != nil {
319+ t.Fatal(err)
320+ }
321+ lockViewport(t, locker, surf.ID(), cfg.width, cfg.height)
322+ requestProtocol(t, locker, surf.ID(), wayland.SurfaceRequestAttach, shmBuffer(t, locker), int32(0), int32(0))
323+ requestProtocol(t, locker, surf.ID(), wayland.SurfaceRequestCommit)
324+ seat := bindProtocol(t, locker, "wl_seat")
325+ keys := &eventsProxy{}
326+ keys.SetID(locker.AllocateID())
327+ registerWireProxy(locker, keys)
328+ requestProtocol(t, locker, seat, wayland.SeatRequestGetKeyboard, keys.ID())
329+ roundtrip(t, locker)
330+ s.display.Do(func() { s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) })
331+ roundtrip(t, locker)
332+ if *locked != 1 {
333+ t.Fatal("lock not confirmed")
334+ }
335+ s.display.Do(func() {
336+ l := s.surfaceOf(findTestSurface(s, surf.ID())).lock
337+ s.apply(ports.SecurityCommand{State: state, Command: ports.FocusWindow{ID: l.id}})
338+ s.apply(ports.SecurityCommand{State: state, Command: ports.ForwardKey{ID: l.id, Key: ports.KeyEvent{Keycode: 30, Pressed: true, TimeMsec: 1}}})
339+ })
340+ settle(t, locker, ime.c)
341+ for _, event := range grab.take() {
342+ if strings.HasPrefix(event, "key ") {
343+ t.Fatalf("locker key leaked to IME: %s", event)
344+ }
345+ }
346+ if !slices.Contains(keys.opcodes, uint16(wayland.KeyboardEventKey)) {
347+ t.Fatal("locker did not receive key")
348+ }
349+}
350+
351+func TestSessionLockLifecycleLatePrelockLeaseRevokedAfterUnlock(t *testing.T) {
352+ requests := make(chan ports.LeaseMessage, 16)
353+ s, gate, changes, _, dir := lifecycleLockServer(t, requests)
354+ c := protocolClient(t, s, dir)
355+ const card = "/dev/dri/card-lifecycle"
356+ s.display.Do(func() {
357+ s.applyLeaseEvent(ports.LeaseConnectors{Card: card, Device: leaseFD(t), Connectors: []ports.LeaseConnector{{Card: card, Name: "DP-1", ConnectorID: 42}}})
358+ })
359+ roundtrip(t, c)
360+ dev := bindProtocol(t, c, "wp_drm_lease_device_v1")
361+ device := &leaseDeviceEvents{c: c}
362+ device.SetID(dev)
363+ registerWireProxy(c, device)
364+ roundtrip(t, c)
365+ for _, fd := range device.fds {
366+ defer unix.Close(fd)
367+ }
368+ if len(device.connectors) != 1 {
369+ t.Fatal("lease connector missing")
370+ }
371+ req := c.AllocateID()
372+ registerProtocol(t, c, req)
373+ requestProtocol(t, c, dev, drmlease.WpDrmLeaseDeviceV1RequestCreateLeaseRequest, req)
374+ requestProtocol(t, c, req, drmlease.WpDrmLeaseRequestV1RequestRequestConnector, device.connectors[0])
375+ result := &leaseResultEvents{}
376+ result.SetID(c.AllocateID())
377+ registerWireProxy(c, result)
378+ requestProtocol(t, c, req, drmlease.WpDrmLeaseRequestV1RequestSubmit, result.ID())
379+ roundtrip(t, c)
380+ var pending ports.LeaseRequest
381+ select {
382+ case message := <-requests:
383+ var ok bool
384+ pending, ok = message.(ports.LeaseRequest)
385+ if !ok {
386+ t.Fatalf("request %T", message)
387+ }
388+ case <-time.After(2 * time.Second):
389+ t.Fatal("lease request not sent")
390+ }
391+ lock, locked := lifecycleAcquire(t, c)
392+ state := lifecycleTransition(t, changes)
393+ s.display.Do(func() { s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) })
394+ roundtrip(t, c)
395+ if *locked != 1 || result.finished != 1 {
396+ t.Fatalf("acquire locked=%d lease finished=%d", *locked, result.finished)
397+ }
398+ if err := lock.UnlockAndDestroy(); err != nil {
399+ t.Fatal(err)
400+ }
401+ roundtrip(t, c)
402+ lifecycleTransition(t, changes)
403+ if gate.Snapshot().Protected {
404+ t.Fatal("valid unlock failed")
405+ }
406+ fd := leaseFD(t)
407+ s.display.Do(func() { s.applyLeaseEvent(ports.LeaseReply{ID: pending.ID, LeaseID: 123, FD: fd}) })
408+ roundtrip(t, c)
409+ if _, err := fd.Stat(); err == nil {
410+ t.Fatal("late lease FD not closed")
411+ }
412+ select {
413+ case message := <-requests:
414+ revoke, ok := message.(ports.LeaseRevoke)
415+ if !ok || revoke.Card != card || revoke.LeaseID != 123 {
416+ t.Fatalf("late reply request %+v", message)
417+ }
418+ case <-time.After(2 * time.Second):
419+ t.Fatal("late lease was not revoked")
420+ }
421+ s.display.Do(func() {
422+ if len(s.pendingLeases) != 0 || len(s.pendingLeaseCards) != 0 || len(s.activeLeases) != 0 {
423+ t.Error("late reply retained authority")
424+ }
425+ })
426+ if len(result.fds) != 0 {
427+ for _, fd := range result.fds {
428+ unix.Close(fd)
429+ }
430+ t.Fatal("late lease FD delivered after unlock")
431+ }
432+}
433+
434+func TestSessionLockLifecycleRoleUnmapSendsSeatLeaves(t *testing.T) {
435+ for _, kind := range []string{"role destroy", "output removal"} {
436+ t.Run(kind, func(t *testing.T) {
437+ s, gate, changes, _, dir := lifecycleLockServer(t)
438+ c := protocolClient(t, s, dir)
439+ lock, locked := lifecycleAcquire(t, c)
440+ state := lifecycleTransition(t, changes)
441+ surf, out := lifecycleSurfaceOutput(t, c)
442+ role, err := lock.GetLockSurface(surf, out)
443+ if err != nil {
444+ t.Fatal(err)
445+ }
446+ var cfg lockConfigure
447+ role.OnConfigure(func(serial, w, h uint32) { cfg = lockConfigure{serial: serial, width: int(w), height: int(h)} })
448+ roundtrip(t, c)
449+ if err := role.AckConfigure(cfg.serial); err != nil {
450+ t.Fatal(err)
451+ }
452+ lockViewport(t, c, surf.ID(), cfg.width, cfg.height)
453+ requestProtocol(t, c, surf.ID(), wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0))
454+ requestProtocol(t, c, surf.ID(), wayland.SurfaceRequestCommit)
455+ seat := bindProtocol(t, c, "wl_seat")
456+ keys := &eventsProxy{}
457+ keys.SetID(c.AllocateID())
458+ registerWireProxy(c, keys)
459+ requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, keys.ID())
460+ pointer := newIMELog(c, c.AllocateID(), "enter:uoff", "leave:uo", "motion:uff", "button:uuuu", "axis:uuf")
461+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer.ID())
462+ roundtrip(t, c)
463+ s.display.Do(func() { s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) })
464+ roundtrip(t, c)
465+ if *locked != 1 {
466+ t.Fatal("lock not confirmed")
467+ }
468+ s.display.Do(func() {
469+ l := s.surfaceOf(findTestSurface(s, surf.ID())).lock
470+ s.changeFocus(l.id)
471+ s.changePointerFocus(l.id, 1, 2)
472+ })
473+ roundtrip(t, c)
474+ if !slices.Contains(keys.opcodes, uint16(wayland.KeyboardEventEnter)) {
475+ t.Fatal("keyboard enter missing")
476+ }
477+ keys.opcodes = nil
478+ pointer.take()
479+ if kind == "role destroy" {
480+ if err := role.Destroy(); err != nil {
481+ t.Fatal(err)
482+ }
483+ } else {
484+ s.display.Do(func() { s.setOutputs(ports.SetOutputs{}) })
485+ }
486+ roundtrip(t, c)
487+ if !slices.Contains(keys.opcodes, uint16(wayland.KeyboardEventLeave)) {
488+ t.Fatal("live lock keyboard did not receive leave")
489+ }
490+ leave := false
491+ for _, event := range pointer.take() {
492+ if strings.HasPrefix(event, "leave ") {
493+ leave = true
494+ }
495+ }
496+ if !leave {
497+ t.Fatal("live lock pointer did not receive leave")
498+ }
499+ s.display.Do(func() {
500+ if s.seat.focused != 0 || s.seat.pointerFocus != 0 || !gate.Snapshot().Protected || !findTestSurface(s, surf.ID()).Alive() {
501+ t.Error("unmap lost protection/live wl_surface or retained seat focus")
502+ }
503+ })
504+ })
505+ }
506+}
507+
508+func TestSessionLockLifecycleBackendOutboxPreservesBlockedTransitions(t *testing.T) {
509+ s, gate, changes, _, dir := lockManagerServer(t)
510+ // Fill the backend channel before any transition; display remains responsive.
511+ sentinel := ports.SecurityState{Generation: 999}
512+ changes <- sentinel
513+ c := protocolClient(t, s, dir)
514+ lock, locked := lifecycleAcquire(t, c)
515+ state := gate.Snapshot()
516+ s.display.Do(func() { s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) })
517+ roundtrip(t, c)
518+ if *locked != 1 {
519+ t.Fatal("test protection proof not consumed")
520+ }
521+ if err := lock.UnlockAndDestroy(); err != nil {
522+ t.Fatal(err)
523+ }
524+ roundtrip(t, c)
525+ released := gate.Snapshot()
526+ s.display.Do(func() {
527+ if len(s.securityPending) < 2 || s.securityPending[0] != state || s.securityPending[1] != released {
528+ t.Errorf("blocked outbox lost ordered transitions: %+v", s.securityPending)
529+ }
530+ })
531+ if got := lifecycleTransition(t, changes); got != sentinel {
532+ t.Fatalf("sentinel %+v", got)
533+ }
534+ if got := lifecycleTransition(t, changes); got != state {
535+ t.Fatalf("acquire %+v", got)
536+ }
537+ if got := lifecycleTransition(t, changes); got != released {
538+ t.Fatalf("release %+v", got)
539+ }
540+ if released.Protected || released.Generation <= state.Generation {
541+ t.Fatal("unlock generation did not advance")
542+ }
543+}
A internal/adapters/wayland/session_lock_refused.go +12 -0
@@ -0,0 +1,12 @@
1+package wayland
2+
3+import "github.com/bnema/purego-libwayland/protocol/extsessionlock"
4+
5+// A constructor pipelined before finished must still create its new object,
6+// but a refused lock never assigns a wl_surface role or changes active focus.
7+type refusedLockSurface struct{}
8+
9+func (refusedLockSurface) Destroy(*extsessionlock.ExtSessionLockSurfaceV1) {}
10+func (refusedLockSurface) AckConfigure(r *extsessionlock.ExtSessionLockSurfaceV1, _ uint32) {
11+ r.PostError(uint32(extsessionlock.ExtSessionLockSurfaceV1ErrorInvalidSerial), "refused lock has no configure")
12+}
A internal/adapters/wayland/session_lock_surface.go +267 -0
@@ -0,0 +1,267 @@
1+package wayland
2+
3+import (
4+ "cmp"
5+ "slices"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/bnema/purego-libwayland/protocol/extsessionlock"
9+ "github.com/bnema/purego-libwayland/protocol/wayland"
10+)
11+
12+// lockConfigure is an issued configure, or the last acknowledged configure.
13+// It is copied into each commit: a later ACK cannot change a queued response.
14+type lockConfigure struct {
15+ serial uint32
16+ width, height int
17+}
18+
19+type lockSurface struct {
20+ resource *extsessionlock.ExtSessionLockSurfaceV1
21+ server *Server
22+ surface *surface
23+ output *output
24+ id ports.WindowID
25+ serials []lockConfigure
26+ ack lockConfigure
27+ mapped, closed bool
28+ // onUnmap is a display-owner lifecycle notification, like Resource.OnDestroy.
29+ // The lock owner must remain protected; this never requests an unlock.
30+ onUnmap func(*lockSurface)
31+}
32+
33+// newLockSurface only assigns the surface role. The caller validates ownership,
34+// admission, output lifetime and duplicate outputs before calling it. No global
35+// is registered and no locked/finished event is sent here.
36+func (s *Server) newLockSurface(r *extsessionlock.ExtSessionLockV1, id uint32, surf *surface, out *output, onUnmap func(*lockSurface)) (*lockSurface, error) {
37+ if surf.kind != roleNone {
38+ r.PostError(uint32(extsessionlock.ExtSessionLockV1ErrorRole), "surface already has a role")
39+ return nil, nil
40+ }
41+ if surf.current != nil || surf.next.buffer != nil || surf.bufferCommitted {
42+ r.PostError(uint32(extsessionlock.ExtSessionLockV1ErrorAlreadyConstructed), "surface has an attached or previously committed buffer")
43+ return nil, nil
44+ }
45+ l := &lockSurface{server: s, surface: surf, output: out, id: s.nextWindow, onUnmap: onUnmap}
46+ res, err := extsessionlock.NewExtSessionLockSurfaceV1(r.Client(), r.Version(), id, l)
47+ if err != nil {
48+ return nil, err
49+ }
50+ l.resource = res
51+ s.nextWindow++
52+ surf.kind, surf.lock, surf.role = roleSessionLock, l, l.commit
53+ if s.lockSurfaces == nil {
54+ s.lockSurfaces = make(map[ports.WindowID]*lockSurface)
55+ }
56+ s.lockSurfaces[l.id] = l
57+ s.lockSurfaceChanged()
58+ res.OnDestroy = l.unmap
59+ l.sendConfigure()
60+ return l, nil
61+}
62+
63+// sendConfigure uses exact logical dimensions, not the physical output mode.
64+// Output inventory changes are routed here by the lock owner.
65+func (l *lockSurface) sendConfigure() {
66+ if l.closed || l.output == nil || !l.resource.Alive() {
67+ return
68+ }
69+ l.server.serial++
70+ // Serial zero is reserved internally as "no ACK".
71+ if l.server.serial == 0 {
72+ l.server.serial++
73+ }
74+ c := lockConfigure{serial: l.server.serial, width: l.output.place.Width, height: l.output.place.Height}
75+ l.serials = append(l.serials, c)
76+ l.resource.SendConfigure(c.serial, uint32(c.width), uint32(c.height))
77+}
78+
79+func (l *lockSurface) AckConfigure(r *extsessionlock.ExtSessionLockSurfaceV1, serial uint32) {
80+ for i, c := range l.serials {
81+ if c.serial == serial {
82+ l.ack = c
83+ l.serials = l.serials[i+1:]
84+ return
85+ }
86+ }
87+ r.PostError(uint32(extsessionlock.ExtSessionLockSurfaceV1ErrorInvalidSerial), "unknown or consumed configure serial")
88+}
89+
90+func (*lockSurface) Destroy(*extsessionlock.ExtSessionLockSurfaceV1) {}
91+
92+// lockSurfaceChanged publishes validated placements for the admitted owner.
93+// Role commits never release protection or certify physical presentation.
94+func (s *Server) lockSurfaceChanged() {
95+ l := s.sessionLock
96+ if l == nil {
97+ return
98+ }
99+ placements := make([]ports.LockSurfacePlacement, 0, len(l.surfaces))
100+ // Before locked, only compositor black is displayed. Protocol configure
101+ // and buffer commits may still prepare the UI while proofs are pending.
102+ if l.locked {
103+ for _, surface := range l.surfaces {
104+ if surface.mapped && !surface.closed && surface.output != nil && surface.resource.Alive() {
105+ p := surface.output.place
106+ c := surface.surface.content
107+ if c.LogicalW == p.Width && c.LogicalH == p.Height {
108+ placements = append(placements, ports.LockSurfacePlacement{ID: surface.id, Output: surface.output.name(), Width: p.Width, Height: p.Height})
109+ }
110+ }
111+ }
112+ }
113+ slices.SortFunc(placements, func(a, b ports.LockSurfacePlacement) int { return cmp.Compare(a.ID, b.ID) })
114+ s.emit(ports.SessionLockChanged{State: l.state, Surfaces: placements})
115+}
116+
117+// lockBuffer is the buffer after all earlier captured commits, not merely the
118+// currently applied buffer. A queued attach is sticky for subsequent commits.
119+func (s *surface) lockBuffer() *wayland.Buffer {
120+ if s.next.attached {
121+ return s.next.buffer
122+ }
123+ for i := len(s.queue) - 1; i >= 0; i-- {
124+ if s.queue[i].attached {
125+ return s.queue[i].buffer
126+ }
127+ }
128+ return s.current
129+}
130+
131+// checkCommit runs before capture or fence acquisition. Waiting behind FIFO or
132+// an acquire fence must not let a later ACK legalize this commit.
133+func (l *lockSurface) checkCommit() bool {
134+ s := l.surface
135+ g := &surface{server: s.server, wl: s.wl, viewport: s.viewport, bufferScale: max(s.next.scale, 1), transform: s.next.transform}
136+ if v := s.viewport; v != nil {
137+ g.committedViewport = viewportState{destW: v.pendingW, destH: v.pendingH, dest: v.pendingSet, src: v.pendingSrc, crop: v.pendingCrop}
138+ }
139+ return l.checkGeometry(l.ack, s.lockBuffer(), g)
140+}
141+
142+func (l *lockSurface) checkGeometry(ack lockConfigure, b *wayland.Buffer, g *surface) bool {
143+ if ack.serial == 0 {
144+ l.resource.PostError(uint32(extsessionlock.ExtSessionLockSurfaceV1ErrorCommitBeforeFirstAck), "commit before first configure ACK")
145+ return false
146+ }
147+ if b == nil {
148+ l.resource.PostError(uint32(extsessionlock.ExtSessionLockSurfaceV1ErrorNullBuffer), "null lock buffer")
149+ return false
150+ }
151+ state := l.server.buffers[b.Resource]
152+ var bw, bh int
153+ if state != nil {
154+ bw, bh = state.size()
155+ } else if sameBuffer(b, l.surface.current) && l.surface.has {
156+ // Destroying an applied wl_buffer does not detach its content. The
157+ // retained physical size remains authoritative for no-attach commits.
158+ bw, bh = l.surface.content.Width, l.surface.content.Height
159+ } else {
160+ l.resource.PostError(uint32(extsessionlock.ExtSessionLockSurfaceV1ErrorDimensionsMismatch), "unknown lock buffer dimensions")
161+ return false
162+ }
163+ if !g.validateViewport(bw, bh) {
164+ return false
165+ }
166+ w, h := g.logicalSize(bw, bh)
167+ if w != ack.width || h != ack.height {
168+ l.resource.PostError(uint32(extsessionlock.ExtSessionLockSurfaceV1ErrorDimensionsMismatch), "lock buffer does not match acknowledged logical dimensions")
169+ return false
170+ }
171+ return true
172+}
173+
174+// checkCaptured validates only captured ACK and geometry, never live requests.
175+func (l *lockSurface) checkCaptured(u *update) bool {
176+ s := l.surface
177+ b := s.current
178+ if u.attached {
179+ b = u.buffer
180+ }
181+ g := &surface{server: s.server, wl: s.wl, viewport: u.vp, bufferScale: max(u.scale, 1), transform: u.transform}
182+ if u.vp != nil {
183+ g.committedViewport = viewportState{destW: u.vpW, destH: u.vpH, dest: u.vpSet, src: u.vpSrc, crop: u.vpCrop}
184+ }
185+ return l.checkGeometry(u.lockAck, b, g)
186+}
187+
188+func (l *lockSurface) commit(buffer bool) {
189+ if l.surface.destroyed || !buffer {
190+ l.unmap()
191+ return
192+ }
193+ if !l.closed {
194+ l.mapped = true
195+ l.server.lockSurfaceChanged()
196+ }
197+}
198+
199+// dropLockDeps retires the synchronized child prefixes captured by this root.
200+// Incoming graph links keep retired nodes readable until the root releases its
201+// refs. Later, uncaptured child commits remain owned by their own queues.
202+func (s *surface) dropLockDeps() {
203+ prefixes := make(map[*surface]int)
204+ var visit func(*update)
205+ seen := make(map[*update]bool)
206+ visit = func(u *update) {
207+ if u == nil || u.retired || seen[u] {
208+ return
209+ }
210+ seen[u] = true
211+ visit(u.prev)
212+ for _, dep := range u.deps {
213+ visit(dep)
214+ if dep.retired || dep.owner == s {
215+ continue
216+ }
217+ for i, queued := range dep.owner.queue {
218+ if queued == dep {
219+ prefixes[dep.owner] = max(prefixes[dep.owner], i+1)
220+ break
221+ }
222+ }
223+ }
224+ }
225+ for _, u := range s.queue {
226+ visit(u)
227+ }
228+ for child, n := range prefixes {
229+ child.dropQueuePrefix(n)
230+ }
231+}
232+
233+// unmap also handles output loss/owner teardown. Role identity is permanent.
234+// Drop queued graphs through their normal retirement path and release current
235+// content while the mapped identity is still available to the hold machinery.
236+func (l *lockSurface) unmap() {
237+ if l.closed {
238+ return
239+ }
240+ s := l.surface
241+ // Send leaves while outgoing role identity remains resolvable. Destroying
242+ // the lock role may leave its wl_surface and seat resources alive.
243+ if l.server.seat.focused == l.id {
244+ l.server.changeFocus(0)
245+ }
246+ if l.server.seat.pointerFocus == l.id {
247+ l.server.changePointerFocus(0, 0, 0)
248+ }
249+ s.dropQueue()
250+ s.dropSync(s.next.sync)
251+ s.next.sync = nil
252+ if s.current != nil {
253+ l.server.releaseBuffer(s, s.current, s.hold)
254+ }
255+ s.current, s.next.buffer, s.hold = nil, nil, syncHold{}
256+ s.next.attached = false
257+ s.content, s.has = ports.SurfaceContent{}, false
258+ l.mapped, l.closed = false, true
259+ l.serials = nil
260+ delete(l.server.lockSurfaces, l.id)
261+ s.lock, s.role = nil, nil
262+ l.server.emitContent(ports.SurfaceContent{ID: l.id}, damage{full: true})
263+ l.server.lockSurfaceChanged()
264+ if l.onUnmap != nil {
265+ l.onUnmap(l)
266+ }
267+}
A internal/adapters/wayland/session_lock_surface_test.go +599 -0
@@ -0,0 +1,599 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/purego-libwayland/protocol/extsessionlock"
8+ "github.com/bnema/purego-libwayland/protocol/linuxdrmsyncobj"
9+ "github.com/bnema/purego-libwayland/protocol/viewporter"
10+ "github.com/bnema/purego-libwayland/protocol/wayland"
11+ "github.com/bnema/purego-libwayland/protocol/wlrlayershell"
12+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
13+ "github.com/bnema/wlturbo"
14+ "golang.org/x/sys/unix"
15+)
16+
17+// This is a real wire proxy, not a double for a project interface.
18+type lockConfigureProxy struct {
19+ wlturbo.BaseProxy
20+ configures chan lockConfigure
21+}
22+
23+func (p *lockConfigureProxy) Dispatch(e *wlturbo.Event) {
24+ if e.Opcode == uint16(extsessionlock.ExtSessionLockSurfaceV1EventConfigure) {
25+ p.configures <- lockConfigure{serial: e.Uint32(), width: int(e.Uint32()), height: int(e.Uint32())}
26+ }
27+}
28+
29+func lockTestSurface(t *testing.T, s *Server, c *wlturbo.Display) (uint32, uint32, uint32, *surface, *lockSurface, <-chan lockConfigure) {
30+ t.Helper()
31+ comp := bindVersion(t, c, "wl_compositor", 6)
32+ surfID := c.AllocateID()
33+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surfID)
34+ registerProtocol(t, c, surfID)
35+ if err := c.Roundtrip(); err != nil {
36+ t.Fatal(err)
37+ }
38+ ownerID, lockID := c.AllocateID(), c.AllocateID()
39+ registerProtocol(t, c, ownerID)
40+ setWireSchema(c, ownerID, "ext_session_lock_v1", 1)
41+ setWireSchema(c, lockID, "ext_session_lock_surface_v1", 1)
42+ configures := make(chan lockConfigure, 8)
43+ p := &lockConfigureProxy{configures: configures}
44+ p.SetID(lockID)
45+ registerWireProxy(c, p)
46+ var surf *surface
47+ var l *lockSurface
48+ s.display.Do(func() {
49+ for _, v := range s.surfaces {
50+ if v.wl.ID() == surfID {
51+ surf = v
52+ }
53+ }
54+ if surf == nil {
55+ t.Error("surface missing")
56+ return
57+ }
58+ // The role factory takes an already created owner resource. No manager
59+ // or test global is needed and no admission behavior is simulated.
60+ r, err := surf.wl.Client().CreateResource(extsessionlock.ExtSessionLockV1Interface, 1, ownerID, nil)
61+ if err != nil {
62+ t.Error(err)
63+ return
64+ }
65+ l, err = s.newLockSurface(extsessionlock.WrapExtSessionLockV1(r), lockID, surf, s.outputs[0], nil)
66+ if err != nil {
67+ t.Error(err)
68+ }
69+ })
70+ if l == nil {
71+ t.Fatal("lock role missing")
72+ }
73+ if err := c.Roundtrip(); err != nil {
74+ t.Fatal(err)
75+ }
76+ return surfID, ownerID, lockID, surf, l, configures
77+}
78+
79+func lockViewport(t *testing.T, c *wlturbo.Display, surfID uint32, w, h int) uint32 {
80+ t.Helper()
81+ manager := bindProtocol(t, c, "wp_viewporter")
82+ vp := c.AllocateID()
83+ registerProtocol(t, c, vp)
84+ requestProtocol(t, c, manager, viewporter.WpViewporterRequestGetViewport, vp, surfID)
85+ requestProtocol(t, c, vp, viewporter.WpViewportRequestSetDestination, int32(w), int32(h))
86+ return vp
87+}
88+
89+func TestLockSurfaceInitialConfigureAndCommitErrors(t *testing.T) {
90+ for _, scenario := range []string{"before ack", "queued before ack", "null", "dimensions", "unknown ack", "reused ack", "old ack"} {
91+ t.Run(scenario, func(t *testing.T) {
92+ s, _, _, dir := lifecycleServer(t)
93+ c := protocolClient(t, s, dir)
94+ surfID, _, lockID, surf, l, configures := lockTestSurface(t, s, c)
95+ cfg := <-configures
96+ if cfg.width != testOutputs[0].Width || cfg.height != testOutputs[0].Height || cfg.serial == 0 {
97+ t.Fatalf("configure = %+v", cfg)
98+ }
99+ code := extsessionlock.ExtSessionLockSurfaceV1ErrorInvalidSerial
100+ switch scenario {
101+ case "before ack", "queued before ack":
102+ if scenario == "queued before ack" {
103+ s.display.Do(func() { surf.barrier = true; surf.next.wait = true })
104+ }
105+ requestProtocol(t, c, surfID, wayland.SurfaceRequestCommit)
106+ // This later ACK must not legalize an earlier FIFO commit.
107+ requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial)
108+ code = extsessionlock.ExtSessionLockSurfaceV1ErrorCommitBeforeFirstAck
109+ case "null":
110+ requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial)
111+ requestProtocol(t, c, surfID, wayland.SurfaceRequestCommit)
112+ code = extsessionlock.ExtSessionLockSurfaceV1ErrorNullBuffer
113+ case "dimensions":
114+ requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial)
115+ requestProtocol(t, c, surfID, wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0))
116+ requestProtocol(t, c, surfID, wayland.SurfaceRequestCommit)
117+ code = extsessionlock.ExtSessionLockSurfaceV1ErrorDimensionsMismatch
118+ case "unknown ack":
119+ requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, uint32(0))
120+ case "reused ack":
121+ requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial)
122+ requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial)
123+ case "old ack":
124+ s.display.Do(l.sendConfigure)
125+ if err := c.Roundtrip(); err != nil {
126+ t.Fatal(err)
127+ }
128+ newer := <-configures
129+ requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, newer.serial)
130+ requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial)
131+ }
132+ expectProtocolError(t, c, lockID, uint32(code))
133+ })
134+ }
135+}
136+
137+func TestLockSurfaceCapturedAckGeometryAndDestroy(t *testing.T) {
138+ s, _, _, contents, dir := contentServer(t)
139+ c := protocolClient(t, s, dir)
140+ surfID, _, lockID, surf, l, configures := lockTestSurface(t, s, c)
141+ cfg := <-configures
142+ vpID := lockViewport(t, c, surfID, cfg.width, cfg.height)
143+ requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial)
144+ requestProtocol(t, c, surfID, wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0))
145+ if err := c.Roundtrip(); err != nil {
146+ t.Fatal(err)
147+ }
148+ // Capture a real buffer and geometry behind a future commit-time gate.
149+ s.display.Do(func() {
150+ surf.next.at = time.Now().Add(time.Hour)
151+ surf.Commit(surf.wl)
152+ if len(surf.queue) != 1 || surf.queue[0].lockAck != cfg {
153+ t.Errorf("captured ACK missing")
154+ }
155+ l.output.place.Width++
156+ l.sendConfigure()
157+ })
158+ if err := c.Roundtrip(); err != nil {
159+ t.Fatal(err)
160+ }
161+ newer := <-configures
162+ requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, newer.serial)
163+ requestProtocol(t, c, vpID, viewporter.WpViewportRequestSetDestination, int32(newer.width), int32(newer.height))
164+ if err := c.Roundtrip(); err != nil {
165+ t.Fatal(err)
166+ }
167+ s.display.Do(func() {
168+ u := surf.queue[0]
169+ u.at = time.Time{}
170+ u.applyGraph()
171+ if !l.mapped || surf.committedViewport.destW != int32(cfg.width) || l.ack != newer {
172+ t.Errorf("captured commit read live geometry/ACK")
173+ }
174+ // Queue another update and destroy: retirement must clear all holds.
175+ surf.next.at = time.Now().Add(time.Hour)
176+ surf.Commit(surf.wl)
177+ })
178+ requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestDestroy)
179+ if err := c.Roundtrip(); err != nil {
180+ t.Fatal(err)
181+ }
182+ s.display.Do(func() {
183+ if surf.kind != roleSessionLock || surf.lock != nil || len(surf.queue) != 0 || surf.current != nil || len(s.lockSurfaces) != 0 || !l.closed {
184+ t.Error("destroy lost permanent role or retained queued content")
185+ }
186+ })
187+ select {
188+ case content := <-contents:
189+ if !content.Empty() {
190+ select {
191+ case content = <-contents:
192+ if !content.Empty() {
193+ t.Error("destroy did not emit empty content")
194+ }
195+ case <-time.After(time.Second):
196+ t.Fatal("no empty content")
197+ }
198+ }
199+ case <-time.After(time.Second):
200+ t.Fatal("no content emitted")
201+ }
202+ wm := bindProtocol(t, c, "xdg_wm_base")
203+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, c.AllocateID(), surfID)
204+ expectProtocolError(t, c, wm, uint32(xdgshell.WmBaseErrorRole))
205+}
206+
207+func TestLockSurfaceRejectsHistoricalBuffer(t *testing.T) {
208+ s, _, _, dir := lifecycleServer(t)
209+ c := protocolClient(t, s, dir)
210+ comp := bindProtocol(t, c, "wl_compositor")
211+ surfID := c.AllocateID()
212+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surfID)
213+ registerProtocol(t, c, surfID)
214+ requestProtocol(t, c, surfID, wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0))
215+ requestProtocol(t, c, surfID, wayland.SurfaceRequestCommit)
216+ requestProtocol(t, c, surfID, wayland.SurfaceRequestAttach, uint32(0), int32(0), int32(0))
217+ requestProtocol(t, c, surfID, wayland.SurfaceRequestCommit)
218+ if err := c.Roundtrip(); err != nil {
219+ t.Fatal(err)
220+ }
221+ ownerID, id := c.AllocateID(), c.AllocateID()
222+ registerProtocol(t, c, ownerID)
223+ s.display.Do(func() {
224+ for _, surf := range s.surfaces {
225+ if surf.wl.ID() != surfID {
226+ continue
227+ }
228+ r, err := surf.wl.Client().CreateResource(extsessionlock.ExtSessionLockV1Interface, 1, ownerID, nil)
229+ if err != nil {
230+ t.Error(err)
231+ return
232+ }
233+ if l, err := s.newLockSurface(extsessionlock.WrapExtSessionLockV1(r), id, surf, s.outputs[0], nil); l != nil || err != nil {
234+ t.Error("accepted historical buffer")
235+ }
236+ }
237+ })
238+ expectProtocolError(t, c, ownerID, uint32(extsessionlock.ExtSessionLockV1ErrorAlreadyConstructed))
239+}
240+
241+func lockSizedBuffer(t *testing.T, c *wlturbo.Display, w, h int) uint32 {
242+ t.Helper()
243+ shm := bindProtocol(t, c, "wl_shm")
244+ registerProtocol(t, c, shm)
245+ fd, err := unix.MemfdCreate("lock-geometry", 0)
246+ if err != nil {
247+ t.Fatal(err)
248+ }
249+ defer unix.Close(fd)
250+ size := int32(w * h * 4)
251+ if err := unix.Ftruncate(fd, int64(size)); err != nil {
252+ t.Fatal(err)
253+ }
254+ pool, buffer := c.AllocateID(), c.AllocateID()
255+ registerProtocol(t, c, pool)
256+ registerProtocol(t, c, buffer)
257+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, size); err != nil {
258+ t.Fatal(err)
259+ }
260+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(w), int32(h), int32(w*4), uint32(0))
261+ return buffer
262+}
263+
264+func TestLockSurfaceScaleTransform(t *testing.T) {
265+ s, _, _, dir := lifecycleServer(t)
266+ c := protocolClient(t, s, dir)
267+ surfID, _, lockID, surf, l, configures := lockTestSurface(t, s, c)
268+ <-configures
269+ s.display.Do(func() { l.output.place.Width, l.output.place.Height = 3, 2; l.sendConfigure() })
270+ if err := c.Roundtrip(); err != nil {
271+ t.Fatal(err)
272+ }
273+ cfg := <-configures
274+ requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial)
275+ buffer := lockSizedBuffer(t, c, 4, 6)
276+ requestProtocol(t, c, surfID, wayland.SurfaceRequestSetBufferScale, int32(2))
277+ requestProtocol(t, c, surfID, wayland.SurfaceRequestSetBufferTransform, int32(1))
278+ requestProtocol(t, c, surfID, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
279+ requestProtocol(t, c, surfID, wayland.SurfaceRequestCommit)
280+ if err := c.Roundtrip(); err != nil {
281+ t.Fatal(err)
282+ }
283+ s.display.Do(func() {
284+ if !l.mapped || surf.content.LogicalW != 3 || surf.content.LogicalH != 2 {
285+ t.Errorf("scaled transformed content = %+v", surf.content)
286+ }
287+ })
288+ requestProtocol(t, c, surfID, wayland.SurfaceRequestDestroy)
289+ if err := c.Roundtrip(); err != nil {
290+ t.Fatal(err)
291+ }
292+ s.display.Do(func() {
293+ if !l.closed || len(s.lockSurfaces) != 0 {
294+ t.Error("wl_surface destruction did not unmap")
295+ }
296+ })
297+}
298+
299+func TestLockSurfaceDestroyedReplacementAndViewportRetainsGeometry(t *testing.T) {
300+ s, _, _, dir := lifecycleServer(t)
301+ c := protocolClient(t, s, dir)
302+ surfID, _, lockID, surf, l, configures := lockTestSurface(t, s, c)
303+ cfg := <-configures
304+ vp := lockViewport(t, c, surfID, cfg.width, cfg.height)
305+ requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial)
306+ first := shmBuffer(t, c)
307+ requestProtocol(t, c, surfID, wayland.SurfaceRequestAttach, first, int32(0), int32(0))
308+ requestProtocol(t, c, surfID, wayland.SurfaceRequestCommit)
309+ if err := c.Roundtrip(); err != nil {
310+ t.Fatal(err)
311+ }
312+ s.display.Do(func() { l.output.place.Width++; l.sendConfigure() })
313+ if err := c.Roundtrip(); err != nil {
314+ t.Fatal(err)
315+ }
316+ newer := <-configures
317+ requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, newer.serial)
318+ requestProtocol(t, c, vp, viewporter.WpViewportRequestSetDestination, int32(newer.width), int32(newer.height))
319+ replacement := shmBuffer(t, c)
320+ requestProtocol(t, c, surfID, wayland.SurfaceRequestAttach, replacement, int32(0), int32(0))
321+ if err := c.Roundtrip(); err != nil {
322+ t.Fatal(err)
323+ }
324+ s.display.Do(func() { surf.next.at = time.Now().Add(time.Hour); surf.Commit(surf.wl) })
325+ requestProtocol(t, c, replacement, wayland.BufferRequestDestroy)
326+ requestProtocol(t, c, vp, viewporter.WpViewportRequestDestroy)
327+ if err := c.Roundtrip(); err != nil {
328+ t.Fatal(err)
329+ }
330+ s.display.Do(func() {
331+ surf.queue[0].at = time.Time{}
332+ surf.queue[0].applyGraph()
333+ if !l.mapped || surf.current.ID() != first || surf.content.LogicalW != cfg.width || surf.committedViewport.destW != int32(cfg.width) {
334+ t.Error("destroyed replacement changed retained geometry")
335+ }
336+ })
337+}
338+
339+func TestLockSurfaceDestroyedCurrentBufferAllowsNoAttachCommit(t *testing.T) {
340+ s, _, _, dir := lifecycleServer(t)
341+ c := protocolClient(t, s, dir)
342+ surfID, _, lockID, surf, l, configures := lockTestSurface(t, s, c)
343+ cfg := <-configures
344+ lockViewport(t, c, surfID, cfg.width, cfg.height)
345+ requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial)
346+ buffer := shmBuffer(t, c)
347+ requestProtocol(t, c, surfID, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
348+ requestProtocol(t, c, surfID, wayland.SurfaceRequestCommit)
349+ if err := c.Roundtrip(); err != nil {
350+ t.Fatal(err)
351+ }
352+ requestProtocol(t, c, buffer, wayland.BufferRequestDestroy)
353+ callback := c.AllocateID()
354+ registerProtocol(t, c, callback)
355+ requestProtocol(t, c, surfID, wayland.SurfaceRequestFrame, callback)
356+ requestProtocol(t, c, surfID, wayland.SurfaceRequestCommit)
357+ if err := c.Roundtrip(); err != nil {
358+ t.Fatal(err)
359+ }
360+ s.display.Do(func() {
361+ if !l.mapped || !surf.has || surf.content.LogicalW != cfg.width {
362+ t.Error("retained destroyed buffer unmapped")
363+ }
364+ })
365+}
366+
367+func TestLockSurfaceDestroyBlocksLayerReuse(t *testing.T) {
368+ s, _, _, dir := lifecycleServer(t)
369+ c := protocolClient(t, s, dir)
370+ surfID, _, lockID, _, _, _ := lockTestSurface(t, s, c)
371+ requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestDestroy)
372+ if err := c.Roundtrip(); err != nil {
373+ t.Fatal(err)
374+ }
375+ shell := bindVersion(t, c, "zwlr_layer_shell_v1", 4)
376+ requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, c.AllocateID(), surfID, uint32(0), uint32(2), "role-reuse")
377+ expectProtocolError(t, c, shell, uint32(wlrlayershell.ZwlrLayerShellV1ErrorAlreadyConstructed))
378+}
379+
380+func TestLockSurfaceDestroyRetiresFencedChildDependencies(t *testing.T) {
381+ for _, destroy := range []string{"role", "surface", "role ready"} {
382+ t.Run(destroy, func(t *testing.T) {
383+ h := newSyncHarness(t)
384+ h.releases = map[uint32]*syncReleaseProxy{}
385+ c, s := h.c, h.s
386+ rootID, _, lockID, root, _, configs := lockTestSurface(t, s, c)
387+ cfg := <-configs
388+ lockViewport(t, c, rootID, cfg.width, cfg.height)
389+ requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial)
390+ rootBuffer := shmBuffer(t, c)
391+ requestProtocol(t, c, rootID, wayland.SurfaceRequestAttach, rootBuffer, int32(0), int32(0))
392+ requestProtocol(t, c, rootID, wayland.SurfaceRequestCommit)
393+ if err := c.Roundtrip(); err != nil {
394+ t.Fatal(err)
395+ }
396+ comp := bindProtocol(t, c, "wl_compositor")
397+ subcomp := bindProtocol(t, c, "wl_subcompositor")
398+ mgr := bindProtocol(t, c, "wp_linux_drm_syncobj_manager_v1")
399+ childID := c.AllocateID()
400+ registerProtocol(t, c, childID)
401+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, childID)
402+ sub := c.AllocateID()
403+ registerProtocol(t, c, sub)
404+ requestProtocol(t, c, subcomp, wayland.SubcompositorRequestGetSubsurface, sub, childID, rootID)
405+ syncID := c.AllocateID()
406+ registerProtocol(t, c, syncID)
407+ requestProtocol(t, c, mgr, linuxdrmsyncobj.WpLinuxDrmSyncobjManagerV1RequestGetSurface, syncID, childID)
408+ h.surf, h.syncSurf = childID, syncID
409+ buffer := h.dmabuf()
410+ h.commit(buffer, 31, 32)
411+ var child *surface
412+ var dep, parent *update
413+ var wait *syncWait
414+ var tl *timeline
415+ s.display.Do(func() {
416+ for _, v := range s.surfaces {
417+ if v.wl.ID() == childID {
418+ child = v
419+ }
420+ }
421+ root.next.at = time.Now().Add(time.Hour)
422+ root.Commit(root.wl)
423+ dep, parent = child.queue[0], root.queue[0]
424+ dep.refs++ // Pin for inspection just like another graph consumer.
425+ parent.refs++
426+ wait, tl = dep.sync.wait, dep.sync.acquire.tl
427+ if !dep.synced || !dep.bound || len(parent.deps) != 1 || tl.uses != 2 {
428+ t.Error("child was not captured with its fence")
429+ }
430+ })
431+ if destroy == "role ready" {
432+ h.fire(31)
433+ deadline := time.NewTimer(time.Second)
434+ defer deadline.Stop()
435+ ready := false
436+ for !ready {
437+ s.display.Do(func() { ready = child.syncReady(dep.sync) })
438+ if ready {
439+ break
440+ }
441+ select {
442+ case <-deadline.C:
443+ t.Fatal("acquire waiter did not become ready")
444+ case <-time.After(time.Millisecond):
445+ }
446+ }
447+ }
448+ // A later child commit is not owned by the captured root graph.
449+ requestProtocol(t, c, childID, wayland.SurfaceRequestCommit)
450+ if err := c.Roundtrip(); err != nil {
451+ t.Fatal(err)
452+ }
453+ if destroy != "surface" {
454+ requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestDestroy)
455+ } else {
456+ requestProtocol(t, c, rootID, wayland.SurfaceRequestDestroy)
457+ }
458+ if err := c.Roundtrip(); err != nil {
459+ t.Fatal(err)
460+ }
461+ s.display.Do(func() {
462+ remaining, refs := 1, 2 // later child commit retains its prev link
463+ if destroy == "surface" {
464+ remaining, refs = 0, 1 // parent destruction detaches the child
465+ }
466+ if len(root.queue) != 0 || len(child.queue) != remaining || !dep.retired || !parent.retired || dep.refs != refs || parent.refs != 1 || !wait.cancelled.Load() || tl.uses != 0 {
467+ t.Errorf("dependency teardown: child=%d dep refs=%d parent refs=%d uses=%d cancelled=%v", len(child.queue), dep.refs, parent.refs, tl.uses, wait.cancelled.Load())
468+ }
469+ dep.applyGraph()
470+ parent.applyGraph()
471+ if child.current != nil || root.current != nil {
472+ t.Error("retired graph replayed")
473+ }
474+ child.dropQueue()
475+ if dep.refs != 1 {
476+ t.Errorf("retired dependency refs after later commit retirement = %d", dep.refs)
477+ }
478+ dep.releaseRef()
479+ parent.releaseRef()
480+ })
481+ count := 0
482+ for _, point := range h.signalled() {
483+ if point == 32 {
484+ count++
485+ }
486+ }
487+ want := 0
488+ if destroy == "role ready" {
489+ want = 1
490+ }
491+ if count != want {
492+ t.Errorf("release signals = %d, want %d", count, want)
493+ }
494+ select {
495+ case <-h.releases[buffer].released:
496+ t.Error("explicit-sync buffer received wl_buffer.release")
497+ default:
498+ }
499+ })
500+ }
501+}
502+
503+func TestLockSurfaceChildPrefixKeepsSharedBufferOwnership(t *testing.T) {
504+ for _, preserved := range []string{"suffix", "pending", "implicit suffix"} {
505+ t.Run(preserved, func(t *testing.T) {
506+ var s *Server
507+ var c *wlturbo.Display
508+ var h *syncHarness
509+ if preserved == "implicit suffix" {
510+ h = newSyncHarness(t)
511+ h.releases = map[uint32]*syncReleaseProxy{}
512+ s, c = h.s, h.c
513+ } else {
514+ var dir string
515+ s, _, _, dir = lifecycleServer(t)
516+ c = protocolClient(t, s, dir)
517+ }
518+ rootID, _, lockID, root, _, configs := lockTestSurface(t, s, c)
519+ cfg := <-configs
520+ lockViewport(t, c, rootID, cfg.width, cfg.height)
521+ requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial)
522+ requestProtocol(t, c, rootID, wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0))
523+ requestProtocol(t, c, rootID, wayland.SurfaceRequestCommit)
524+ comp := bindProtocol(t, c, "wl_compositor")
525+ subcomp := bindProtocol(t, c, "wl_subcompositor")
526+ childID := c.AllocateID()
527+ registerProtocol(t, c, childID)
528+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, childID)
529+ sub := c.AllocateID()
530+ registerProtocol(t, c, sub)
531+ requestProtocol(t, c, subcomp, wayland.SubcompositorRequestGetSubsurface, sub, childID, rootID)
532+ var buffer uint32
533+ if h != nil {
534+ buffer = h.dmabuf()
535+ } else {
536+ buffer = shmBuffer(t, c)
537+ }
538+ releases := &syncReleaseProxy{released: make(chan struct{}, 4)}
539+ releases.SetID(buffer)
540+ registerWireProxy(c, releases)
541+ requestProtocol(t, c, childID, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
542+ requestProtocol(t, c, childID, wayland.SurfaceRequestCommit)
543+ if err := c.Roundtrip(); err != nil {
544+ t.Fatal(err)
545+ }
546+ s.display.Do(func() { root.next.at = time.Now().Add(time.Hour); root.Commit(root.wl) })
547+ requestProtocol(t, c, childID, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
548+ if preserved != "pending" {
549+ requestProtocol(t, c, childID, wayland.SurfaceRequestCommit)
550+ }
551+ requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestDestroy)
552+ if err := c.Roundtrip(); err != nil {
553+ t.Fatal(err)
554+ }
555+ select {
556+ case <-releases.released:
557+ t.Fatal("captured prefix released shared buffer early")
558+ default:
559+ }
560+ // Convert the pending reference to an update, then retire the last
561+ // reference through the same normal queue path.
562+ if preserved == "pending" {
563+ requestProtocol(t, c, childID, wayland.SurfaceRequestCommit)
564+ if err := c.Roundtrip(); err != nil {
565+ t.Fatal(err)
566+ }
567+ }
568+ s.display.Do(func() {
569+ child := s.surfaceOf(findTestSurface(s, childID))
570+ if len(child.queue) != 1 {
571+ t.Errorf("preserved queue length = %d", len(child.queue))
572+ }
573+ child.dropQueue()
574+ })
575+ if err := c.Roundtrip(); err != nil {
576+ t.Fatal(err)
577+ }
578+ select {
579+ case <-releases.released:
580+ default:
581+ t.Fatal("last queued reference did not release buffer")
582+ }
583+ select {
584+ case <-releases.released:
585+ t.Fatal("buffer released twice")
586+ default:
587+ }
588+ })
589+ }
590+}
591+
592+func findTestSurface(s *Server, id uint32) *wayland.Surface {
593+ for _, surf := range s.surfaces {
594+ if surf.wl.ID() == id {
595+ return surf.wl
596+ }
597+ }
598+ return nil
599+}
A internal/adapters/wayland/session_lock_test.go +112 -0
@@ -0,0 +1,112 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
9+ "github.com/bnema/neferwl/internal/logging"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/bnema/wlturbo"
12+ lockclient "github.com/bnema/wlturbo/protocol/extsessionlock"
13+)
14+
15+func lockManagerServer(t *testing.T) (*Server, *sessionsecurity.Gate, chan ports.SecurityState, chan ports.SecurityBackendEvent, string) {
16+ t.Helper()
17+ gate := &sessionsecurity.Gate{}
18+ changes := make(chan ports.SecurityState, 1)
19+ events := make(chan ports.SecurityBackendEvent, 16)
20+ dir := t.TempDir()
21+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, Security: gate}, Channels{SecurityChanges: changes, SecurityEvents: events}, logging.For(context.Background(), "wayland"))
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ ctx, cancel := context.WithCancel(context.Background())
26+ done := make(chan error, 1)
27+ go func() { done <- s.Run(ctx) }()
28+ t.Cleanup(func() {
29+ cancel()
30+ select {
31+ case err := <-done:
32+ if err != nil {
33+ t.Error(err)
34+ }
35+ case <-time.After(3 * time.Second):
36+ t.Error("session-lock server did not stop")
37+ }
38+ })
39+ return s, gate, changes, events, dir
40+}
41+
42+func bindLockManager(t *testing.T, c *wlturbo.Display) *lockclient.ExtSessionLockManager {
43+ t.Helper()
44+ manager := lockclient.NewExtSessionLockManager(c.Context())
45+ g, ok := c.Registry().FindGlobal(lockclient.ExtSessionLockManagerInterface)
46+ if !ok {
47+ t.Fatal("missing test lock manager")
48+ }
49+ if err := c.Registry().Bind(g.Name, g.Interface, 1, manager); err != nil {
50+ t.Fatal(err)
51+ }
52+ return manager
53+}
54+
55+func TestSessionLockWaitsForEveryBarrierAndOwnerDeathStaysProtected(t *testing.T) {
56+ s, gate, changes, _, dir := lockManagerServer(t)
57+ c := protocolClient(t, s, dir)
58+ manager := bindLockManager(t, c)
59+ lock, err := manager.Lock()
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+ locked := 0
64+ lock.OnLocked(func() { locked++ })
65+ if err := c.Roundtrip(); err != nil {
66+ t.Fatal(err)
67+ }
68+ var state ports.SecurityState
69+ select {
70+ case state = <-changes:
71+ case <-time.After(2 * time.Second):
72+ t.Fatal("no backend transition")
73+ }
74+ if !state.Protected || !gate.Snapshot().Protected || locked != 0 {
75+ t.Fatalf("early lock %+v events %d", state, locked)
76+ }
77+ if !s.display.Do(func() {
78+ s.applySecurityEvent(ports.SecurityOutputAdded{Instance: 1, Output: "OUT-1"})
79+ s.applySecurityEvent(ports.SecurityOutputAdded{Instance: 2, Output: "OUT-2"})
80+ s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation})
81+ s.applySecurityEvent(ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: state.Generation, Instance: 1, Kind: ports.ProtectionProtectedFrame}})
82+ }) {
83+ t.Fatal("display stopped")
84+ }
85+ if err := c.Roundtrip(); err != nil {
86+ t.Fatal(err)
87+ }
88+ if locked != 0 {
89+ t.Fatal("locked without second output proof")
90+ }
91+ if !s.display.Do(func() {
92+ s.applySecurityEvent(ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: state.Generation, Instance: 2, Kind: ports.ProtectionInactiveOutput}})
93+ }) {
94+ t.Fatal("display stopped")
95+ }
96+ if err := c.Roundtrip(); err != nil {
97+ t.Fatal(err)
98+ }
99+ if locked != 1 {
100+ t.Fatalf("locked events %d, want 1", locked)
101+ }
102+ // Closing the owner connection never opens the session.
103+ if err := c.Close(); err != nil {
104+ t.Fatal(err)
105+ }
106+ if !s.display.Do(func() {}) {
107+ t.Fatal("display stopped")
108+ }
109+ if !gate.Snapshot().Protected {
110+ t.Fatal("owner death unlocked")
111+ }
112+}
M internal/adapters/wayland/slot_test.go +6 -2
@@ -12,13 +12,17 @@ func TestWindowMappedCarriesSlot(t *testing.T) {
1212 env := newMockprocEnv(t)
1313 // The test client runs in this process: its PID is ours.
1414 env.EXPECT().Lookup(os.Getpid(), ports.SlotEnv).Return("7", true).Once()
15- s, events, commands, dir := keyboardServer(t, func(s *Server) { s.env = env })
15+ s, events, _, dir := keyboardServer(t, func(s *Server) { s.env = env })
1616 mapWindow := toplevelMapper(t, protocolClient(t, s, dir), events)
1717 // No slot pending: /proc is not read (the mock would fail on a call).
1818 if w := mapWindow(); w.Slot != "" || w.PID != os.Getpid() {
1919 t.Fatalf("slot %q pid %d", w.Slot, w.PID)
2020 }
21- commands <- ports.SlotsPending{Pending: true}
21+ // This tests environment lookup, not command transport. Apply the gate
22+ // synchronously before mapping over the independent protocol connection.
23+ if !s.display.Do(func() { s.apply(ports.SlotsPending{Pending: true}) }) {
24+ t.Fatal("display stopped before applying SlotsPending")
25+ }
2226 if w := mapWindow(); w.Slot != "7" {
2327 t.Fatalf("slot %q", w.Slot)
2428 }
M internal/adapters/wayland/subsurface_test.go +4 -4
@@ -171,7 +171,7 @@ func TestSubsurfaceSupersededBufferReleased(t *testing.T) {
171171 first := shmBuffer(t, c)
172172 p := &syncReleaseProxy{released: make(chan struct{}, 4)}
173173 p.SetID(first)
174- c.Context().Register(p)
174+ registerWireProxy(c, p)
175175 for _, b := range []uint32{first, shmBuffer(t, c)} {
176176 requestProtocol(t, c, child, wayland.SurfaceRequestAttach, b, int32(0), int32(0))
177177 requestProtocol(t, c, child, wayland.SurfaceRequestCommit)
@@ -479,7 +479,7 @@ func TestSubsurfaceContent(t *testing.T) {
479479 registerProtocol(t, c, pool)
480480 registerProtocol(t, c, big)
481481 registerProtocol(t, c, small)
482- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(64)); err != nil {
482+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(64)); err != nil {
483483 t.Fatal(err)
484484 }
485485 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, big, int32(0), int32(4), int32(4), int32(16), uint32(0))
@@ -498,7 +498,7 @@ func TestSubsurfaceContent(t *testing.T) {
498498 serials := make(chan uint32, 4)
499499 xp := &configureProxy{serial: serials}
500500 xp.SetID(xdg)
501- c.Context().Register(xp)
501+ registerWireProxy(c, xp)
502502 top := c.AllocateID()
503503 registerProtocol(t, c, top)
504504 requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
@@ -615,7 +615,7 @@ func TestSubsurfaceGraphSupersededFeedback(t *testing.T) {
615615 first := &feedbackEvents{out: make(chan presentedEvent, 2)}
616616 id := c.AllocateID()
617617 first.SetID(id)
618- c.Context().Register(first)
618+ registerWireProxy(c, first)
619619 requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, child, id)
620620 requestProtocol(t, c, child, wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0))
621621 requestProtocol(t, c, child, wayland.SurfaceRequestCommit)
M internal/adapters/wayland/surface.go +32 -6
@@ -3,6 +3,7 @@ package wayland
33 import (
44 "image"
55 "math"
6+ "os"
67 "slices"
78 "time"
89
@@ -23,6 +24,7 @@ const (
2324 roleLayer
2425 roleCursor
2526 roleInputPopup
27+ roleSessionLock
2628 )
2729
2830 type surface struct {
@@ -40,11 +42,14 @@ type surface struct {
4042 kind roleKind
4143 xdg *xdgSurface
4244 layer *layerSurface
43- server *Server
44- current *wayland.Buffer
45- lastW, lastH int // last logged buffer size
46- role func(bool)
47- destroyed bool
45+ lock *lockSurface
46+ // bufferCommitted is permanent, including commits later discarded by FIFO.
47+ bufferCommitted bool
48+ server *Server
49+ current *wayland.Buffer
50+ lastW, lastH int // last logged buffer size
51+ role func(bool)
52+ destroyed bool
4853 // hotX, hotY is the click point of a cursor surface (logical).
4954 hotX, hotY int
5055 // on is the output the surface entered; scale is the last scale sent.
@@ -135,6 +140,7 @@ type pendingCommit struct {
135140 type commitSync struct {
136141 acquire, release syncPoint
137142 wait *syncWait
143+ fence *os.File // exported before application; owned until apply/drop
138144 }
139145
140146 // damage is what one commit changed in a surface's buffer.
@@ -173,6 +179,9 @@ func (s *surface) windowID() ports.WindowID {
173179 if s.layer != nil && s.layer.mapped {
174180 return s.layer.id
175181 }
182+ if s.lock != nil && s.lock.mapped {
183+ return s.lock.id
184+ }
176185 return 0
177186 }
178187
@@ -332,6 +341,12 @@ func (s *surface) Frame(r *wayland.Surface, id uint32) {
332341 }
333342 }
334343 func (s *surface) Commit(*wayland.Surface) {
344+ if s.next.attached && s.next.buffer != nil {
345+ s.bufferCommitted = true
346+ }
347+ if s.lock != nil && !s.lock.checkCommit() {
348+ return
349+ }
335350 if s.layer != nil && s.next.attached && s.next.buffer != nil && !s.layer.acked {
336351 s.layer.resource.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSurfaceState), "buffer before configure ack")
337352 return
@@ -388,7 +403,10 @@ func (s *surface) applyCommit(u *update) {
388403 s.async = u.async
389404 s.color = u.color
390405 s.representation = u.representation
391- if u.vp != nil && u.vp.resource != nil && u.vp.resource.Resource.Alive() {
406+ if s.lock != nil && s.commitSkipped {
407+ // A discarded replacement cannot change retained lock geometry, even
408+ // if its viewport was destroyed before capture or before application.
409+ } else if u.vp != nil && (s.lock != nil || u.vp.resource != nil && u.vp.resource.Resource.Alive()) {
392410 s.committedViewport.destW, s.committedViewport.destH, s.committedViewport.dest = u.vpW, u.vpH, u.vpSet
393411 s.committedViewport.src, s.committedViewport.crop = u.vpSrc, u.vpCrop
394412 } else {
@@ -413,6 +431,9 @@ func (s *surface) applyCommit(u *update) {
413431 if len(u.callbacks) > 0 {
414432 s.server.queueFrames(s, u.callbacks)
415433 }
434+ if s.lock != nil && !s.commitSkipped && !s.lock.checkCaptured(u) {
435+ return
436+ }
416437 if s.role != nil {
417438 if u.layer != nil && s.layer == u.layer {
418439 u.layer.commitState(u.layerNext, s.current != nil)
@@ -488,6 +509,11 @@ func (s *surface) applyCommit(u *update) {
488509 if s.xdg != nil && s.xdg.window != nil {
489510 s.xdg.window.afterCommit()
490511 }
512+ if s.lock != nil {
513+ // Role callbacks precede content construction; publish lock placement
514+ // only once validated current dimensions are available.
515+ s.server.lockSurfaceChanged()
516+ }
491517 reshaped := s.has && (s.content.LogicalW != oldW || s.content.LogicalH != oldH || s.content.Source != oldSource || s.content.Transform != oldTransform)
492518 // Opacity changes how every pixel blends, so it repaints the whole surface.
493519 opacity := s.has && (s.content.Opaque != oldOpaque || s.content.Fade != oldFade)
M internal/adapters/wayland/syncobj.go +98 -20
@@ -192,7 +192,19 @@ func (s *surface) pendingIsSHM() bool {
192192 // syncWaiter watches acquire points: each gets an eventfd the kernel
193193 // writes once the point is signalled, and the pacer is woken to apply the
194194 // commits that waited for it.
195+type acquirePoller interface {
196+ poll([]unix.PollFd, int) (int, error)
197+}
198+
199+type unixAcquirePoller struct{}
200+
201+func (unixAcquirePoller) poll(fds []unix.PollFd, timeout int) (int, error) {
202+ return unix.Poll(fds, timeout)
203+}
204+
195205 type syncWaiter struct {
206+ poller acquirePoller
207+ terminal bool // polling ended in failure; protected by mu
196208 mu sync.Mutex
197209 ready map[*syncWait]bool
198210 wake func()
@@ -207,6 +219,7 @@ type syncWaiter struct {
207219 type syncWait struct {
208220 efd int
209221 done bool // the eventfd fired or the wait was cancelled (pacer, under Do)
222+ failed bool // observation failed; done must not authorize publication
210223 cancelled atomic.Bool // run stops polling it and closes efd
211224 }
212225
@@ -215,28 +228,38 @@ func newSyncWaiter(wake func()) (*syncWaiter, error) {
215228 if err != nil {
216229 return nil, err
217230 }
218- return &syncWaiter{ready: map[*syncWait]bool{}, wake: wake, pipeR: r, pipeW: w, log: zerowrap.Default()}, nil
231+ return &syncWaiter{poller: unixAcquirePoller{}, ready: map[*syncWait]bool{}, wake: wake, pipeR: r, pipeW: w, log: zerowrap.Default()}, nil
219232 }
220233
221234 // watch registers an eventfd for p; the wait is ready once it fires.
222235 func (sw *syncWaiter) watch(p syncPoint) (*syncWait, error) {
236+ if sw.stopped() {
237+ return &syncWait{efd: -1, done: true, failed: true}, unix.EIO
238+ }
223239 ready, err := p.tl.dev.signalled(p.tl.handle, p.point)
224240 if err != nil || ready {
225- return &syncWait{efd: -1, done: ready}, err
241+ return &syncWait{efd: -1, done: true, failed: err != nil}, err
226242 }
227243 efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
228244 if err != nil {
229- return nil, err
245+ return &syncWait{efd: -1, done: true, failed: true}, err
230246 }
231247 if err := p.tl.dev.eventfd(p.tl.handle, p.point, efd); err != nil {
232248 unix.Close(efd)
233- return nil, err
249+ return &syncWait{efd: -1, done: true, failed: true}, err
234250 }
235- return sw.add(efd), nil
251+ w := sw.add(efd)
252+ if w.failed {
253+ return w, unix.EIO
254+ }
255+ return w, nil
236256 }
237257
238258 // watchImplicit waits for a dma-buf's exclusive fences without blocking dispatch.
239259 func (sw *syncWaiter) watchImplicit(b *ports.DMABuf, waits *[4]*syncWait) error {
260+ if sw.stopped() {
261+ return unix.EIO
262+ }
240263 var fds [4]unix.PollFd
241264 for i, p := range b.Planes {
242265 if i >= len(fds) {
@@ -258,7 +281,7 @@ func (sw *syncWaiter) watchImplicit(b *ports.DMABuf, waits *[4]*syncWait) error
258281 }
259282 if fd.Revents&(unix.POLLERR|unix.POLLNVAL|unix.POLLHUP) != 0 {
260283 sw.warnPollError()
261- continue
284+ return unix.EIO
262285 }
263286 dup, err := unix.FcntlInt(uintptr(fd.Fd), unix.F_DUPFD_CLOEXEC, 0)
264287 if err != nil {
@@ -269,6 +292,15 @@ func (sw *syncWaiter) watchImplicit(b *ports.DMABuf, waits *[4]*syncWait) error
269292 return err
270293 }
271294 waits[i] = sw.add(dup)
295+ if waits[i].failed {
296+ for j, w := range waits {
297+ if w != nil {
298+ sw.cancel(w)
299+ waits[j] = nil
300+ }
301+ }
302+ return unix.EIO
303+ }
272304 }
273305 return nil
274306 }
@@ -282,6 +314,11 @@ func (sw *syncWaiter) warnPollError() {
282314 func (sw *syncWaiter) add(fd int) *syncWait {
283315 w := &syncWait{efd: fd}
284316 sw.mu.Lock()
317+ if sw.terminal {
318+ sw.mu.Unlock()
319+ unix.Close(fd)
320+ return &syncWait{efd: -1, done: true, failed: true}
321+ }
285322 sw.added = append(sw.added, w)
286323 sw.mu.Unlock()
287324 _, _ = sw.pipeW.Write([]byte{0})
@@ -292,9 +329,9 @@ func (sw *syncWaiter) add(fd int) *syncWait {
292329 func (sw *syncWaiter) fired(w *syncWait) bool {
293330 sw.mu.Lock()
294331 defer sw.mu.Unlock()
295- if sw.ready[w] {
332+ if ready, ok := sw.ready[w]; ok {
296333 delete(sw.ready, w)
297- w.done = true
334+ w.done, w.failed = true, !ready
298335 }
299336 return w.done
300337 }
@@ -312,6 +349,28 @@ func (sw *syncWaiter) cancel(w *syncWait) {
312349 _, _ = sw.pipeW.Write([]byte{0}) // run closes its eventfd
313350 }
314351
352+func (sw *syncWaiter) stopped() bool {
353+ sw.mu.Lock()
354+ defer sw.mu.Unlock()
355+ return sw.terminal
356+}
357+
358+// fail publishes terminal observations before run closes its descriptors.
359+// add checks the same mutex, so no new wait can miss this failure handoff.
360+func (sw *syncWaiter) fail(waits []*syncWait) {
361+ sw.mu.Lock()
362+ sw.terminal = true
363+ for _, group := range [][]*syncWait{waits, sw.added} {
364+ for _, w := range group {
365+ if !w.cancelled.Load() {
366+ sw.ready[w] = false
367+ }
368+ }
369+ }
370+ sw.mu.Unlock()
371+ sw.wake()
372+}
373+
315374 // run polls every watched eventfd; it owns and closes them.
316375 func (sw *syncWaiter) run(ctx context.Context) {
317376 defer sw.pipeR.Close()
@@ -339,8 +398,9 @@ func (sw *syncWaiter) run(ctx context.Context) {
339398 for _, w := range waits {
340399 fds = append(fds, unix.PollFd{Fd: int32(w.efd), Events: unix.POLLIN})
341400 }
342- n, err := unix.Poll(fds, 100)
401+ n, err := sw.poller.poll(fds, 100)
343402 if err != nil && !errors.Is(err, unix.EINTR) {
403+ sw.fail(waits)
344404 return
345405 }
346406 if n <= 0 {
@@ -364,7 +424,7 @@ func (sw *syncWaiter) run(ctx context.Context) {
364424 unix.Close(w.efd)
365425 sw.mu.Lock()
366426 if !w.cancelled.Load() {
367- sw.ready[w] = true
427+ sw.ready[w] = revents&unix.POLLIN != 0
368428 fired = true
369429 }
370430 sw.mu.Unlock()
@@ -432,12 +492,11 @@ func (s *surface) takeSyncPoints() {
432492 cs.release.tl.uses++
433493 w, err := s.server.syncWait.watch(cs.acquire)
434494 if err != nil {
435- // The point cannot be watched: apply now, readers wait on the
436- // fence (or its absence) when they read the buffer.
495+ // Keep points owned by the captured commit for safe discard. An
496+ // unobservable acquire must never reach native rendering.
437497 s.server.log.Warn().Str("component", "wayland").Err(err).Msg("acquire point")
438- } else {
439- cs.wait = w
440498 }
499+ cs.wait = w
441500 s.next.sync = cs
442501 }
443502
@@ -453,7 +512,11 @@ func (s *surface) dropSync(cs *commitSync) {
453512 if cs == nil {
454513 return
455514 }
456- ready := cs.wait != nil && s.server.syncWait.fired(cs.wait)
515+ if cs.fence != nil {
516+ cs.fence.Close()
517+ cs.fence = nil
518+ }
519+ ready := cs.wait != nil && s.server.syncWait.fired(cs.wait) && !cs.wait.failed
457520 if cs.wait != nil {
458521 s.server.syncWait.cancel(cs.wait)
459522 }
@@ -467,6 +530,24 @@ func (s *surface) dropSync(cs *commitSync) {
467530 s.server.dropTimeline(cs.release.tl)
468531 }
469532
533+// prepareSync exports only after observed readiness, before any current state
534+// changes. Export failure must not fall back to native implicit synchronization.
535+func (s *surface) prepareSync(cs *commitSync) bool {
536+ if cs == nil || cs.fence != nil {
537+ return true
538+ }
539+ f, err := cs.acquire.tl.dev.exportSyncFile(cs.acquire.tl.handle, cs.acquire.point)
540+ if err != nil || f == nil {
541+ if f != nil {
542+ f.Close()
543+ }
544+ s.server.log.Warn().Str("component", "wayland").Err(err).Msg("acquire fence export rejected")
545+ return false
546+ }
547+ cs.fence = f
548+ return true
549+}
550+
470551 // applySync makes a commit's points the current buffer's: its acquire
471552 // fence goes with the content, its release point waits for release.
472553 func (s *surface) applySync(cs *commitSync) {
@@ -476,11 +557,8 @@ func (s *surface) applySync(cs *commitSync) {
476557 if cs.wait != nil {
477558 s.server.syncWait.cancel(cs.wait)
478559 }
479- f, err := cs.acquire.tl.dev.exportSyncFile(cs.acquire.tl.handle, cs.acquire.point)
480- if err != nil {
481- s.server.log.Warn().Str("component", "wayland").Err(err).Msg("acquire fence")
482- f = nil
483- }
560+ f := cs.fence
561+ cs.fence = nil
484562 cs.acquire.tl.uses--
485563 s.server.dropTimeline(cs.acquire.tl)
486564 s.hold = syncHold{acquire: f, release: cs.release}
M internal/adapters/wayland/syncobj_test.go +4 -4
@@ -135,7 +135,7 @@ func newSyncHarness(t *testing.T) *syncHarness {
135135 defer unix.Close(fd)
136136 h.timeline = h.c.AllocateID()
137137 registerProtocol(t, h.c, h.timeline)
138- if err := h.c.SendRequestWithFDs(mgr, uint16(linuxdrmsyncobj.WpLinuxDrmSyncobjManagerV1RequestImportTimeline), []int{fd}, h.timeline); err != nil {
138+ if err := wireRequest(h.c, mgr, uint16(linuxdrmsyncobj.WpLinuxDrmSyncobjManagerV1RequestImportTimeline), []int{fd}, h.timeline); err != nil {
139139 t.Fatal(err)
140140 }
141141 for deadline := time.Now().Add(2 * time.Second); ; {
@@ -167,13 +167,13 @@ func (h *syncHarness) dmabuf() uint32 {
167167 p := c.AllocateID()
168168 requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, p)
169169 registerProtocol(t, c, p)
170- if err := c.SendRequestWithFDs(p, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, uint32(0), uint32(0), uint32(256), uint32(0), uint32(0)); err != nil {
170+ if err := wireRequest(c, p, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, uint32(0), uint32(0), uint32(256), uint32(0), uint32(0)); err != nil {
171171 t.Fatal(err)
172172 }
173173 b := c.AllocateID()
174174 rel := &syncReleaseProxy{released: make(chan struct{}, 4)}
175175 rel.SetID(b)
176- c.Context().Register(rel)
176+ registerWireProxy(c, rel)
177177 requestProtocol(t, c, p, linuxdmabuf.ZwpLinuxBufferParamsV1RequestCreateImmed, b, int32(64), int32(16), linearARGB.Format, uint32(0))
178178 h.releases[b] = rel
179179 return b
@@ -479,7 +479,7 @@ func TestSyncobjInvalidTimeline(t *testing.T) {
479479 mgr := bindProtocol(t, c, "wp_linux_drm_syncobj_manager_v1")
480480 fd, _ := unix.MemfdCreate("bad", 0)
481481 defer unix.Close(fd)
482- if err := c.SendRequestWithFDs(mgr, uint16(linuxdrmsyncobj.WpLinuxDrmSyncobjManagerV1RequestImportTimeline), []int{fd}, c.AllocateID()); err != nil {
482+ if err := wireRequest(c, mgr, uint16(linuxdrmsyncobj.WpLinuxDrmSyncobjManagerV1RequestImportTimeline), []int{fd}, c.AllocateID()); err != nil {
483483 t.Fatal(err)
484484 }
485485 expectProtocolError(t, c, mgr, uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjManagerV1ErrorInvalidTimeline))
M internal/adapters/wayland/syncwait_test.go +6 -6
@@ -27,8 +27,8 @@ func TestSyncWaitAlreadySignalled(t *testing.T) {
2727 }
2828 }
2929
30-// An invalid poll result is not readiness; it must not hold the surface.
31-func TestImplicitPollErrorReadyAndWarnOnce(t *testing.T) {
30+// An invalid poll result is an error, never permission to publish.
31+func TestImplicitPollErrorRejectedAndWarnOnce(t *testing.T) {
3232 sw, err := newSyncWaiter(func() {})
3333 if err != nil {
3434 t.Fatal(err)
@@ -42,8 +42,8 @@ func TestImplicitPollErrorReadyAndWarnOnce(t *testing.T) {
4242 b := &ports.DMABuf{Planes: []ports.DMABufPlane{{File: os.NewFile(uintptr(math.MaxInt32), "invalid")}}}
4343 var waits [4]*syncWait
4444 for range 2 {
45- if err := sw.watchImplicit(b, &waits); err != nil || waits[0] != nil {
46- t.Fatalf("poll error should not register wait: %v %+v", err, waits)
45+ if err := sw.watchImplicit(b, &waits); err == nil || waits[0] != nil {
46+ t.Fatalf("poll error should reject without registering wait: %v %+v", err, waits)
4747 }
4848 }
4949 if n := bytes.Count(output.Bytes(), []byte("implicit fence poll error")); n != 1 {
@@ -76,8 +76,8 @@ func TestImplicitPollHangupWakes(t *testing.T) {
7676 for !sw.fired(waits[0]) && time.Now().Before(deadline) {
7777 time.Sleep(time.Millisecond)
7878 }
79- if !sw.fired(waits[0]) {
80- t.Fatal("hangup never woke waiter")
79+ if !sw.fired(waits[0]) || !waits[0].failed {
80+ t.Fatal("hangup must wake waiter as failed, not ready to publish")
8181 }
8282 }
8383
M internal/adapters/wayland/textinput.go +41 -6
@@ -87,6 +87,9 @@ func (t *textInput) Disable(*textinput.ZwpTextInputV3) {
8787 // SetSurroundingText ignores text past the spec limit or a cursor or
8888 // anchor outside it.
8989 func (t *textInput) SetSurroundingText(_ *textinput.ZwpTextInputV3, text string, cursor, anchor int32) {
90+ if t.server.protected() {
91+ return
92+ }
9093 if len(text) > maxIMEText || cursor < 0 || anchor < 0 || int(cursor) > len(text) || int(anchor) > len(text) {
9194 t.server.log.Debug().Int("bytes", len(text)).Msg("surrounding text ignored")
9295 return
@@ -111,6 +114,10 @@ func (t *textInput) SetCursorRectangle(_ *textinput.ZwpTextInputV3, x, y, w, h i
111114 // serial, but after leave the state is ignored until the next enter.
112115 func (t *textInput) Commit(*textinput.ZwpTextInputV3) {
113116 t.serial++
117+ if t.server.protected() {
118+ t.pending, t.current, t.toggled = textState{}, textState{}, false
119+ return
120+ }
114121 if t.focus == nil {
115122 return
116123 }
@@ -128,6 +135,9 @@ func (*textInput) HideInputPanel(*textinput.ZwpTextInputV3) {}
128135
129136 // enter focuses the text input on surf; its state starts over.
130137 func (t *textInput) enter(surf *wayland.Surface) {
138+ if t.server.protected() {
139+ return
140+ }
131141 t.focus = surf
132142 t.pending, t.current, t.toggled = textState{}, textState{}, false
133143 t.res.SendEnter(surf)
@@ -149,6 +159,9 @@ func (t *textInput) leave() {
149159 // textCommitted activates, updates or deactivates the input method for a
150160 // committed text input. Enabling a second text input is ignored.
151161 func (s *Server) textCommitted(t *textInput, reenabled bool) {
162+ if s.protected() {
163+ return
164+ }
152165 switch {
153166 case t.current.enabled && s.activeText == nil:
154167 s.activeText = t
@@ -198,7 +211,7 @@ func (s *Server) deactivateText() {
198211 // activateIM sends activate and the active text input's state.
199212 func (s *Server) activateIM() {
200213 im := s.inputMethod
201- if im == nil || s.activeText == nil {
214+ if s.protected() || im == nil || s.activeText == nil {
202215 return
203216 }
204217 im.pending = imState{}
@@ -210,7 +223,7 @@ func (s *Server) activateIM() {
210223 // sendIMState sends the active text input's state, then done.
211224 func (s *Server) sendIMState() {
212225 im, t := s.inputMethod, s.activeText
213- if im == nil || t == nil {
226+ if s.protected() || im == nil || t == nil {
214227 return
215228 }
216229 c := t.current
@@ -304,14 +317,14 @@ func (im *inputMethod) done() {
304317 }
305318
306319 func (im *inputMethod) CommitString(_ *inputmethod.ZwpInputMethodV2, text string) {
307- if im.inert || !im.fits(text) {
320+ if im.server.protected() || im.inert || !im.fits(text) {
308321 return
309322 }
310323 im.pending.commit, im.pending.hasCommit = text, true
311324 }
312325
313326 func (im *inputMethod) SetPreeditString(_ *inputmethod.ZwpInputMethodV2, text string, begin, end int32) {
314- if im.inert || !im.fits(text) {
327+ if im.server.protected() || im.inert || !im.fits(text) {
315328 return
316329 }
317330 p := &im.pending
@@ -327,7 +340,7 @@ func (im *inputMethod) fits(text string) bool {
327340 }
328341
329342 func (im *inputMethod) DeleteSurroundingText(_ *inputmethod.ZwpInputMethodV2, before, after uint32) {
330- if im.inert {
343+ if im.server.protected() || im.inert {
331344 return
332345 }
333346 im.pending.before, im.pending.after = before, after
@@ -345,6 +358,9 @@ func (im *inputMethod) Commit(_ *inputmethod.ZwpInputMethodV2, serial uint32) {
345358 st := im.pending
346359 im.pending = imState{}
347360 s := im.server
361+ if s.protected() {
362+ return
363+ }
348364 t := s.activeText
349365 if t == nil {
350366 return
@@ -406,7 +422,7 @@ func (im *inputMethod) GetInputPopupSurface(r *inputmethod.ZwpInputMethodV2, id
406422 func (im *inputMethod) GrabKeyboard(r *inputmethod.ZwpInputMethodV2, id uint32) {
407423 g := &keyboardGrab{}
408424 res, err := inputmethod.NewZwpInputMethodKeyboardGrabV2(r.Client(), r.Version(), id, g)
409- if err != nil || im.inert || im.grab != nil {
425+ if err != nil || im.server.protected() || im.inert || im.grab != nil {
410426 return
411427 }
412428 g.res, im.grab = res, g
@@ -439,6 +455,9 @@ func (*inputPopup) Destroy(*inputmethod.ZwpInputPopupSurfaceV2) {}
439455
440456 // sendRect tells the popup where the active text cursor is.
441457 func (p *inputPopup) sendRect() {
458+ if p.im.server.protected() {
459+ return
460+ }
442461 t := p.im.server.activeText
443462 if t == nil || !t.current.hasRect {
444463 return
@@ -455,6 +474,9 @@ func (*keyboardGrab) Release(*inputmethod.ZwpInputMethodKeyboardGrabV2) {}
455474
456475 // grab is the live keyboard grab, nil without one.
457476 func (s *Server) grab() *keyboardGrab {
477+ if s.protected() {
478+ return nil
479+ }
458480 if im := s.inputMethod; im != nil {
459481 return im.grab
460482 }
@@ -467,6 +489,13 @@ func (s *Server) grab() *keyboardGrab {
467489 // does, even after the grab ends.
468490 func (s *Server) grabKey(c ports.ForwardKey) bool {
469491 code := c.Key.Keycode
492+ if s.protected() {
493+ if !c.Key.Pressed && s.seat.grabKeys[code] {
494+ delete(s.seat.grabKeys, code)
495+ return true
496+ }
497+ return false // protected seat routing belongs to the parent
498+ }
470499 g := s.grab()
471500 owned := !c.Key.Pressed && s.seat.grabKeys[code]
472501 if owned {
@@ -500,6 +529,9 @@ func (s *Server) grabKey(c ports.ForwardKey) bool {
500529 // grabReleaseDropped keeps the focused client's modifiers right when the
501530 // release of a grab-owned key is dropped.
502531 func (s *Server) grabReleaseDropped(m ports.ModState) {
532+ if s.protected() {
533+ return
534+ }
503535 if m == s.seat.modState {
504536 return
505537 }
@@ -512,6 +544,9 @@ func (s *Server) grabReleaseDropped(m ports.ModState) {
512544
513545 // grabEnded gives the focused client the seat modifiers it missed.
514546 func (s *Server) grabEnded() {
547+ if s.protected() {
548+ return
549+ }
515550 s.serial++
516551 for _, k := range s.clientKeyboards(s.focusClient()) {
517552 s.sendModifiers(k)
M internal/adapters/wayland/textinput_test.go +3 -3
@@ -63,7 +63,7 @@ func newIMELog(c *wlturbo.Display, id uint32, sigs ...string) *imeLog {
6363 l.sigs = append(l.sigs, struct{ name, args string }{name, args})
6464 }
6565 l.SetID(id)
66- c.Context().Register(l)
66+ registerWireProxy(c, l)
6767 return l
6868 }
6969
@@ -90,7 +90,7 @@ func newTextApp(t *testing.T, s *Server, events chan ports.ClientEvent, commands
9090 registerProtocol(t, c, seat)
9191 keys := &eventsProxy{}
9292 keys.SetID(c.AllocateID())
93- c.Context().Register(keys)
93+ registerWireProxy(c, keys)
9494 requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, keys.ID())
9595 w, surf, xdg := surfaceMapper(t, c, events)()
9696 manager := bindProtocol(t, c, "zwp_text_input_manager_v3")
@@ -459,7 +459,7 @@ func TestTextInputLeaveBeforeEnter(t *testing.T) {
459459 for _, tag := range []string{"t1", "t2"} {
460460 p := &tagged{tag: tag, into: log}
461461 p.SetID(c.AllocateID())
462- c.Context().Register(p)
462+ registerWireProxy(c, p)
463463 requestProtocol(t, c, manager, textinput.ZwpTextInputManagerV3RequestGetTextInput, p.ID(), seat)
464464 }
465465 commands <- ports.FocusWindow{ID: a.ID}
M internal/adapters/wayland/tiled_playback_test.go +2 -2
@@ -47,7 +47,7 @@ func TestHeadlessTiledSHMCallbackAndCopyBudget(t *testing.T) {
4747 }
4848 pool := c.AllocateID()
4949 registerProtocol(t, c, pool)
50- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(size)); err != nil {
50+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(size)); err != nil {
5151 t.Fatal(err)
5252 }
5353 children := make([]uint32, tiles+1)
@@ -112,7 +112,7 @@ func TestHeadlessTiledSHMCallbackAndCopyBudget(t *testing.T) {
112112 cb := c.AllocateID()
113113 p := &callbackBudgetProxy{done: done}
114114 p.SetID(cb)
115- c.Context().Register(p)
115+ registerWireProxy(c, p)
116116 requestProtocol(t, c, children[tiles], wayland.SurfaceRequestFrame, cb)
117117 requestProtocol(t, c, children[tiles], wayland.SurfaceRequestAttach, buffers[tiles], int32(0), int32(0))
118118 requestProtocol(t, c, children[tiles], wayland.SurfaceRequestCommit)
M internal/adapters/wayland/transform_test.go +3 -3
@@ -26,7 +26,7 @@ func newTransformClient(t *testing.T) transformClient {
2626 c := protocolClient(t, s, dir)
2727 // set_buffer_transform needs wl_compositor v2.
2828 g, _ := c.Registry().FindGlobal("wl_compositor")
29- comp, err := c.Registry().BindID(g.Name, g.Interface, 6)
29+ comp, err := bindWireID(c, g.Name, g.Interface, 6)
3030 if err != nil {
3131 t.Fatal(err)
3232 }
@@ -44,7 +44,7 @@ func newTransformClient(t *testing.T) transformClient {
4444 serials := make(chan uint32, 4)
4545 cp := &configureProxy{serial: serials}
4646 cp.SetID(xdg)
47- c.Context().Register(cp)
47+ registerWireProxy(c, cp)
4848 requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
4949 top := c.AllocateID()
5050 registerProtocol(t, c, top)
@@ -66,7 +66,7 @@ func newTransformClient(t *testing.T) transformClient {
6666 pool, bf := c.AllocateID(), c.AllocateID()
6767 registerProtocol(t, c, pool)
6868 registerProtocol(t, c, bf)
69- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, size); err != nil {
69+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, size); err != nil {
7070 t.Fatal(err)
7171 }
7272 // argb8888: not opaque by format.
M internal/adapters/wayland/virtualkeyboard.go +55 -4
@@ -25,8 +25,10 @@ type virtualKeyboard struct {
2525 text string // the keymap, compared with the active one
2626 mods ports.ModState // last modifiers request, zero before one
2727 // pressed are keys held on focus; a focus change releases them by leave.
28- pressed map[uint32]bool
29- focus ports.WindowID
28+ pressed map[uint32]bool
29+ focus ports.WindowID
30+ generation ports.LockGeneration // epoch of held keys; never release across protection
31+ dropReleases bool // after an epoch change, ignore releases without a new press
3032 }
3133
3234 // registerVirtualKeyboard exposes the global to every client, as wlroots
@@ -52,6 +54,17 @@ func (m virtualKeyboardManager) CreateVirtualKeyboard(r *virtualkeyboard.ZwpVirt
5254 // Keymap copies the client keymap into a sealed memfd: other clients never
5355 // see the client's fd. An unreadable or malformed keymap is ignored.
5456 func (k *virtualKeyboard) Keymap(_ *virtualkeyboard.ZwpVirtualKeyboardV1, format uint32, fd int, size uint32) {
57+ if k.server.protected() {
58+ unix.Close(fd)
59+ k.admitted() // discard held state and ownership without delivery
60+ if k.fd >= 0 {
61+ unix.Close(k.fd)
62+ }
63+ k.fd, k.size, k.text = -1, 0, ""
64+ // A rejected replacement must not leave an old layout usable after
65+ // unlock. The client must send a fresh keymap before typing again.
66+ return
67+ }
5568 text, ok := readKeymap(fd, size)
5669 unix.Close(fd)
5770 if format != uint32(wayland.KeyboardKeymapFormatXkbV1) || !ok {
@@ -102,6 +115,9 @@ func readKeymap(fd int, size uint32) (string, bool) {
102115 // keyboards switches the focused client to k's keymap and returns its
103116 // keyboards, or nil after a no_keymap error or without focus.
104117 func (k *virtualKeyboard) keyboards(r *virtualkeyboard.ZwpVirtualKeyboardV1) []*wayland.Keyboard {
118+ if !k.admitted() {
119+ return nil
120+ }
105121 if k.fd < 0 {
106122 r.PostError(uint32(virtualkeyboard.ZwpVirtualKeyboardV1ErrorNoKeymap), "no keymap")
107123 return nil
@@ -121,6 +137,9 @@ func (k *virtualKeyboard) keyboards(r *virtualkeyboard.ZwpVirtualKeyboardV1) []*
121137 }
122138
123139 func (k *virtualKeyboard) Key(r *virtualkeyboard.ZwpVirtualKeyboardV1, time, key, state uint32) {
140+ if !k.admitted() || state != 1 && k.dropReleases && !k.pressed[key] {
141+ return
142+ }
124143 keyboards := k.keyboards(r)
125144 if keyboards == nil {
126145 return
@@ -136,19 +155,46 @@ func (k *virtualKeyboard) Key(r *virtualkeyboard.ZwpVirtualKeyboardV1, time, key
136155 }
137156
138157 func (k *virtualKeyboard) Modifiers(r *virtualkeyboard.ZwpVirtualKeyboardV1, depressed, latched, locked, group uint32) {
158+ if !k.admitted() {
159+ return
160+ }
139161 k.mods = ports.ModState{Depressed: depressed, Latched: latched, Locked: locked, Group: group}
140162 for _, kb := range k.keyboards(r) {
141163 kb.SendModifiers(k.server.serial, depressed, latched, locked, group)
142164 }
143165 }
144166
167+// admitted forgets old held state even if protection began and ended between
168+// this keyboard's requests. Snapshot is coherent; DISPLAY owns admission.
169+func (k *virtualKeyboard) admitted() bool {
170+ state := ports.SecurityState{}
171+ if k.server.security != nil {
172+ state = k.server.security.Snapshot()
173+ }
174+ if state.Protected || state.Generation != k.generation {
175+ clear(k.pressed)
176+ k.mods = ports.ModState{}
177+ k.focus = 0
178+ k.generation = state.Generation
179+ k.dropReleases = true
180+ if k.server.seat.keymapOwner == k {
181+ // Forget ownership silently; the parent resets focus and restores
182+ // the seat layout at the lock transition.
183+ k.server.seat.keymapOwner = nil
184+ }
185+ }
186+ return !state.Protected
187+}
188+
145189 func (*virtualKeyboard) Destroy(*virtualkeyboard.ZwpVirtualKeyboardV1) {}
146190
147191 // release runs when the resource dies, by request or client disconnect:
148192 // keys still held on the focused client are released.
149193 func (k *virtualKeyboard) release() {
150194 s := k.server
151- if len(k.pressed) > 0 && k.focus == s.seat.focused {
195+ generation := k.generation
196+ admitted := k.admitted() && generation == k.generation
197+ if admitted && len(k.pressed) > 0 && k.focus == s.seat.focused {
152198 _, keyboards := s.focusTarget(s.seat.focused)
153199 var ts unix.Timespec
154200 _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)
@@ -162,7 +208,12 @@ func (k *virtualKeyboard) release() {
162208 }
163209 }
164210 if s.seat.keymapOwner == k {
165- s.useKeymap(nil)
211+ if admitted {
212+ s.useKeymap(nil)
213+ } else {
214+ // Drop ownership without delivering keymap/modifiers into the locker.
215+ s.seat.keymapOwner = nil
216+ }
166217 }
167218 if k.fd >= 0 {
168219 unix.Close(k.fd)
M internal/adapters/wayland/virtualkeyboard_test.go +4 -4
@@ -34,7 +34,7 @@ func sendVirtualKeymap(t *testing.T, c *wlturbo.Display, kb uint32, keymap strin
3434 t.Fatal(err)
3535 }
3636 defer unix.Close(fd)
37- if err := c.SendRequestWithFDs(kb, uint16(virtualkeyboard.ZwpVirtualKeyboardV1RequestKeymap), []int{fd}, uint32(wayland.KeyboardKeymapFormatXkbV1), size); err != nil {
37+ if err := wireRequest(c, kb, uint16(virtualkeyboard.ZwpVirtualKeyboardV1RequestKeymap), []int{fd}, uint32(wayland.KeyboardKeymapFormatXkbV1), size); err != nil {
3838 t.Fatal(err)
3939 }
4040 }
@@ -48,7 +48,7 @@ func TestVirtualKeyboardTypesIntoFocus(t *testing.T) {
4848 registerProtocol(t, target, seat)
4949 proxy := &eventsProxy{}
5050 proxy.SetID(target.AllocateID())
51- target.Context().Register(proxy)
51+ registerWireProxy(target, proxy)
5252 requestProtocol(t, target, seat, wayland.SeatRequestGetKeyboard, proxy.ID())
5353 w := toplevelMapper(t, target, events)()
5454 commands <- ports.FocusWindow{ID: w.ID}
@@ -104,7 +104,7 @@ func focusedTarget(t *testing.T, s *Server, events chan ports.ClientEvent, comma
104104 registerProtocol(t, target, seat)
105105 proxy := &eventsProxy{}
106106 proxy.SetID(target.AllocateID())
107- target.Context().Register(proxy)
107+ registerWireProxy(target, proxy)
108108 requestProtocol(t, target, seat, wayland.SeatRequestGetKeyboard, proxy.ID())
109109 w := toplevelMapper(t, target, events)()
110110 commands <- ports.FocusWindow{ID: w.ID}
@@ -167,7 +167,7 @@ func TestVirtualKeymapLateKeyboardAndRejected(t *testing.T) {
167167 registerProtocol(t, target, seat)
168168 late := &eventsProxy{}
169169 late.SetID(target.AllocateID())
170- target.Context().Register(late)
170+ registerWireProxy(target, late)
171171 requestProtocol(t, target, seat, wayland.SeatRequestGetKeyboard, late.ID())
172172 roundtrip(t, target)
173173 if len(late.keymaps) != 1 || late.keymaps[0] != virtual {
A internal/adapters/wayland/wire_client_test.go +435 -0
@@ -0,0 +1,435 @@
1+package wayland
2+
3+// Raw-wire test clients intentionally keep raw requests (including invalid ones).
4+// Their receivers nevertheless obey the real client's context, schema and
5+// destructor contracts. Metadata comes from generated protocol descriptions;
6+// request opcode numbers alone never determine object type or destruction.
7+import (
8+ "encoding/binary"
9+ "strings"
10+ "sync"
11+ "testing"
12+
13+ wire_capturesession "github.com/bnema/neferwl/internal/adapters/wayland/capturesession"
14+ wire_alphamodifier "github.com/bnema/purego-libwayland/protocol/alphamodifier"
15+ wire_colormanagement "github.com/bnema/purego-libwayland/protocol/colormanagement"
16+ wire_colorrepresentation "github.com/bnema/purego-libwayland/protocol/colorrepresentation"
17+ wire_committiming "github.com/bnema/purego-libwayland/protocol/committiming"
18+ wire_cursorshape "github.com/bnema/purego-libwayland/protocol/cursorshape"
19+ wire_drmlease "github.com/bnema/purego-libwayland/protocol/drmlease"
20+ wire_extdatacontrol "github.com/bnema/purego-libwayland/protocol/extdatacontrol"
21+ wire_extidlenotify "github.com/bnema/purego-libwayland/protocol/extidlenotify"
22+ wire_extimagecapturesource "github.com/bnema/purego-libwayland/protocol/extimagecapturesource"
23+ wire_extimagecopycapture "github.com/bnema/purego-libwayland/protocol/extimagecopycapture"
24+ wire_extsessionlock "github.com/bnema/purego-libwayland/protocol/extsessionlock"
25+ wire_extworkspace "github.com/bnema/purego-libwayland/protocol/extworkspace"
26+ wire_fifo "github.com/bnema/purego-libwayland/protocol/fifo"
27+ wire_fractionalscale "github.com/bnema/purego-libwayland/protocol/fractionalscale"
28+ wire_idleinhibit "github.com/bnema/purego-libwayland/protocol/idleinhibit"
29+ wire_inputmethod "github.com/bnema/purego-libwayland/protocol/inputmethod"
30+ wire_kdedecoration "github.com/bnema/purego-libwayland/protocol/kdedecoration"
31+ wire_keyboardshortcutsinhibit "github.com/bnema/purego-libwayland/protocol/keyboardshortcutsinhibit"
32+ wire_linuxdmabuf "github.com/bnema/purego-libwayland/protocol/linuxdmabuf"
33+ wire_linuxdrmsyncobj "github.com/bnema/purego-libwayland/protocol/linuxdrmsyncobj"
34+ wire_pointerconstraints "github.com/bnema/purego-libwayland/protocol/pointerconstraints"
35+ wire_pointerwarp "github.com/bnema/purego-libwayland/protocol/pointerwarp"
36+ wire_presentationtime "github.com/bnema/purego-libwayland/protocol/presentationtime"
37+ wire_primaryselection "github.com/bnema/purego-libwayland/protocol/primaryselection"
38+ wire_relativepointer "github.com/bnema/purego-libwayland/protocol/relativepointer"
39+ wire_tearingcontrol "github.com/bnema/purego-libwayland/protocol/tearingcontrol"
40+ wire_textinput "github.com/bnema/purego-libwayland/protocol/textinput"
41+ wire_viewporter "github.com/bnema/purego-libwayland/protocol/viewporter"
42+ wire_virtualkeyboard "github.com/bnema/purego-libwayland/protocol/virtualkeyboard"
43+ wire_wayland "github.com/bnema/purego-libwayland/protocol/wayland"
44+ wire_wlrforeigntoplevel "github.com/bnema/purego-libwayland/protocol/wlrforeigntoplevel"
45+ wire_wlrlayershell "github.com/bnema/purego-libwayland/protocol/wlrlayershell"
46+ wire_wlroutputmanagement "github.com/bnema/purego-libwayland/protocol/wlroutputmanagement"
47+ wire_wlroutputpower "github.com/bnema/purego-libwayland/protocol/wlroutputpower"
48+ wire_wlrscreencopy "github.com/bnema/purego-libwayland/protocol/wlrscreencopy"
49+ wire_xdgactivation "github.com/bnema/purego-libwayland/protocol/xdgactivation"
50+ wire_xdgoutput "github.com/bnema/purego-libwayland/protocol/xdgoutput"
51+ wire_xdgshell "github.com/bnema/purego-libwayland/protocol/xdgshell"
52+ "github.com/bnema/purego-libwayland/server"
53+ "github.com/bnema/wlturbo"
54+ client_alphamodifier "github.com/bnema/wlturbo/protocol/alphamodifier"
55+ client_colormanagement "github.com/bnema/wlturbo/protocol/colormanagement"
56+ client_colorrepresentation "github.com/bnema/wlturbo/protocol/colorrepresentation"
57+ client_committiming "github.com/bnema/wlturbo/protocol/committiming"
58+ client_core "github.com/bnema/wlturbo/protocol/core"
59+ client_cursorshape "github.com/bnema/wlturbo/protocol/cursorshape"
60+ client_datacontrol "github.com/bnema/wlturbo/protocol/datacontrol"
61+ client_drmlease "github.com/bnema/wlturbo/protocol/drmlease"
62+ client_drmsyncobj "github.com/bnema/wlturbo/protocol/drmsyncobj"
63+ client_extsessionlock "github.com/bnema/wlturbo/protocol/extsessionlock"
64+ client_fifo "github.com/bnema/wlturbo/protocol/fifo"
65+ client_foreigntoplevel "github.com/bnema/wlturbo/protocol/foreigntoplevel"
66+ client_fractionalscale "github.com/bnema/wlturbo/protocol/fractionalscale"
67+ client_idleinhibit "github.com/bnema/wlturbo/protocol/idleinhibit"
68+ client_idlenotify "github.com/bnema/wlturbo/protocol/idlenotify"
69+ client_imagecapturesource "github.com/bnema/wlturbo/protocol/imagecapturesource"
70+ client_imagecopycapture "github.com/bnema/wlturbo/protocol/imagecopycapture"
71+ client_inputmethod "github.com/bnema/wlturbo/protocol/inputmethod"
72+ client_kdeserverdecoration "github.com/bnema/wlturbo/protocol/kdeserverdecoration"
73+ client_layershell "github.com/bnema/wlturbo/protocol/layershell"
74+ client_linuxdmabuf "github.com/bnema/wlturbo/protocol/linuxdmabuf"
75+ client_outputmanagement "github.com/bnema/wlturbo/protocol/outputmanagement"
76+ client_outputpower "github.com/bnema/wlturbo/protocol/outputpower"
77+ client_pointerconstraints "github.com/bnema/wlturbo/protocol/pointerconstraints"
78+ client_pointerwarp "github.com/bnema/wlturbo/protocol/pointerwarp"
79+ client_presentation "github.com/bnema/wlturbo/protocol/presentation"
80+ client_primaryselection "github.com/bnema/wlturbo/protocol/primaryselection"
81+ client_relativepointer "github.com/bnema/wlturbo/protocol/relativepointer"
82+ client_screencopy "github.com/bnema/wlturbo/protocol/screencopy"
83+ client_shortcutsinhibit "github.com/bnema/wlturbo/protocol/shortcutsinhibit"
84+ client_tearingcontrol "github.com/bnema/wlturbo/protocol/tearingcontrol"
85+ client_textinput "github.com/bnema/wlturbo/protocol/textinput"
86+ client_viewporter "github.com/bnema/wlturbo/protocol/viewporter"
87+ client_virtualkeyboard "github.com/bnema/wlturbo/protocol/virtualkeyboard"
88+ client_workspace "github.com/bnema/wlturbo/protocol/workspace"
89+ client_xdgactivation "github.com/bnema/wlturbo/protocol/xdgactivation"
90+ client_xdgoutput "github.com/bnema/wlturbo/protocol/xdgoutput"
91+ client_xdgshell "github.com/bnema/wlturbo/protocol/xdgshell"
92+)
93+
94+type wireSignature interface{ EventSignature(uint16) (string, bool) }
95+type wireSchema struct {
96+ iface *server.Interface
97+ events wireSignature
98+ requestDestructors map[uint16]bool
99+ eventDestructors map[uint16]bool
100+}
101+
102+var wireSchemas = map[string]wireSchema{
103+ "ext_data_control_device_v1": {wire_extdatacontrol.ExtDataControlDeviceV1Interface, &client_datacontrol.ExtDataControlDevice{}, map[uint16]bool{1: true}, nil},
104+ "ext_data_control_manager_v1": {wire_extdatacontrol.ExtDataControlManagerV1Interface, &client_datacontrol.ExtDataControlManager{}, map[uint16]bool{2: true}, nil},
105+ "ext_data_control_offer_v1": {wire_extdatacontrol.ExtDataControlOfferV1Interface, &client_datacontrol.ExtDataControlOffer{}, map[uint16]bool{1: true}, nil},
106+ "ext_data_control_source_v1": {wire_extdatacontrol.ExtDataControlSourceV1Interface, &client_datacontrol.ExtDataControlSource{}, map[uint16]bool{1: true}, nil},
107+ "ext_foreign_toplevel_image_capture_source_manager_v1": {wire_extimagecapturesource.ExtForeignToplevelImageCaptureSourceManagerV1Interface, &client_imagecapturesource.ExtForeignToplevelImageCaptureSourceManager{}, map[uint16]bool{1: true}, nil},
108+ "ext_idle_notification_v1": {wire_extidlenotify.ExtIdleNotificationV1Interface, &client_idlenotify.ExtIdleNotification{}, map[uint16]bool{0: true}, nil},
109+ "ext_idle_notifier_v1": {wire_extidlenotify.ExtIdleNotifierV1Interface, &client_idlenotify.ExtIdleNotifier{}, map[uint16]bool{0: true}, nil},
110+ "ext_image_capture_source_v1": {wire_extimagecapturesource.ExtImageCaptureSourceV1Interface, &client_imagecapturesource.ExtImageCaptureSource{}, map[uint16]bool{0: true}, nil},
111+ "ext_image_copy_capture_cursor_session_v1": {wire_extimagecopycapture.ExtImageCopyCaptureCursorSessionV1Interface, &client_imagecopycapture.ExtImageCopyCaptureCursorSession{}, map[uint16]bool{0: true}, nil},
112+ "ext_image_copy_capture_frame_v1": {wire_extimagecopycapture.ExtImageCopyCaptureFrameV1Interface, &client_imagecopycapture.ExtImageCopyCaptureFrame{}, map[uint16]bool{0: true}, nil},
113+ "ext_image_copy_capture_manager_v1": {wire_extimagecopycapture.ExtImageCopyCaptureManagerV1Interface, &client_imagecopycapture.ExtImageCopyCaptureManager{}, map[uint16]bool{2: true}, nil},
114+ "ext_image_copy_capture_session_v1": {wire_extimagecopycapture.ExtImageCopyCaptureSessionV1Interface, &client_imagecopycapture.ExtImageCopyCaptureSession{}, map[uint16]bool{1: true}, nil},
115+ "ext_output_image_capture_source_manager_v1": {wire_extimagecapturesource.ExtOutputImageCaptureSourceManagerV1Interface, &client_imagecapturesource.ExtOutputImageCaptureSourceManager{}, map[uint16]bool{1: true}, nil},
116+ "ext_session_lock_manager_v1": {wire_extsessionlock.ExtSessionLockManagerV1Interface, &client_extsessionlock.ExtSessionLockManager{}, map[uint16]bool{0: true}, nil},
117+ "ext_session_lock_surface_v1": {wire_extsessionlock.ExtSessionLockSurfaceV1Interface, &client_extsessionlock.ExtSessionLockSurface{}, map[uint16]bool{0: true}, nil},
118+ "ext_session_lock_v1": {wire_extsessionlock.ExtSessionLockV1Interface, &client_extsessionlock.ExtSessionLock{}, map[uint16]bool{0: true, 2: true}, nil},
119+ "ext_workspace_group_handle_v1": {wire_extworkspace.ExtWorkspaceGroupHandleV1Interface, &client_workspace.ExtWorkspaceGroupHandle{}, map[uint16]bool{1: true}, nil},
120+ "ext_workspace_handle_v1": {wire_extworkspace.ExtWorkspaceHandleV1Interface, &client_workspace.ExtWorkspaceHandle{}, map[uint16]bool{0: true}, nil},
121+ "ext_workspace_manager_v1": {wire_extworkspace.ExtWorkspaceManagerV1Interface, &client_workspace.ExtWorkspaceManager{}, nil, map[uint16]bool{3: true}},
122+ "neferwl_capture_layer_v1": {wire_capturesession.NeferwlCaptureLayerV1Interface, nil, map[uint16]bool{0: true}, nil},
123+ "neferwl_capture_manager_v1": {wire_capturesession.NeferwlCaptureManagerV1Interface, nil, map[uint16]bool{0: true}, nil},
124+ "neferwl_capture_session_v1": {wire_capturesession.NeferwlCaptureSessionV1Interface, nil, map[uint16]bool{0: true}, nil},
125+ "org_kde_kwin_server_decoration": {wire_kdedecoration.OrgKdeKwinServerDecorationInterface, &client_kdeserverdecoration.OrgKdeKwinServerDecoration{}, map[uint16]bool{0: true}, nil},
126+ "org_kde_kwin_server_decoration_manager": {wire_kdedecoration.OrgKdeKwinServerDecorationManagerInterface, &client_kdeserverdecoration.OrgKdeKwinServerDecorationManager{}, nil, nil},
127+ "wl_buffer": {wire_wayland.BufferInterface, &client_core.Buffer{}, map[uint16]bool{0: true}, nil},
128+ "wl_callback": {wire_wayland.CallbackInterface, &client_core.Callback{}, nil, map[uint16]bool{0: true}},
129+ "wl_compositor": {wire_wayland.CompositorInterface, &client_core.Compositor{}, map[uint16]bool{2: true}, nil},
130+ "wl_data_device": {wire_wayland.DataDeviceInterface, &client_core.DataDevice{}, map[uint16]bool{2: true}, nil},
131+ "wl_data_device_manager": {wire_wayland.DataDeviceManagerInterface, &client_core.DataDeviceManager{}, map[uint16]bool{2: true}, nil},
132+ "wl_data_offer": {wire_wayland.DataOfferInterface, &client_core.DataOffer{}, map[uint16]bool{2: true}, nil},
133+ "wl_data_source": {wire_wayland.DataSourceInterface, &client_core.DataSource{}, map[uint16]bool{1: true}, nil},
134+ "wl_display": {wire_wayland.DisplayInterface, nil, nil, nil},
135+ "wl_fixes": {wire_wayland.FixesInterface, &client_core.Fixes{}, map[uint16]bool{0: true}, nil},
136+ "wl_keyboard": {wire_wayland.KeyboardInterface, &client_core.Keyboard{}, map[uint16]bool{0: true}, nil},
137+ "wl_output": {wire_wayland.OutputInterface, &client_core.Output{}, map[uint16]bool{0: true}, nil},
138+ "wl_pointer": {wire_wayland.PointerInterface, &client_core.Pointer{}, map[uint16]bool{1: true}, nil},
139+ "wl_region": {wire_wayland.RegionInterface, &client_core.Region{}, map[uint16]bool{0: true}, nil},
140+ "wl_registry": {wire_wayland.RegistryInterface, nil, nil, nil},
141+ "wl_seat": {wire_wayland.SeatInterface, &client_core.Seat{}, map[uint16]bool{3: true}, nil},
142+ "wl_shell": {wire_wayland.ShellInterface, &client_core.Shell{}, nil, nil},
143+ "wl_shell_surface": {wire_wayland.ShellSurfaceInterface, &client_core.ShellSurface{}, nil, nil},
144+ "wl_shm": {wire_wayland.ShmInterface, &client_core.Shm{}, map[uint16]bool{1: true}, nil},
145+ "wl_shm_pool": {wire_wayland.ShmPoolInterface, &client_core.ShmPool{}, map[uint16]bool{1: true}, nil},
146+ "wl_subcompositor": {wire_wayland.SubcompositorInterface, &client_core.Subcompositor{}, map[uint16]bool{0: true}, nil},
147+ "wl_subsurface": {wire_wayland.SubsurfaceInterface, &client_core.Subsurface{}, map[uint16]bool{0: true}, nil},
148+ "wl_surface": {wire_wayland.SurfaceInterface, &client_core.Surface{}, map[uint16]bool{0: true}, nil},
149+ "wl_touch": {wire_wayland.TouchInterface, &client_core.Touch{}, map[uint16]bool{0: true}, nil},
150+ "wp_alpha_modifier_surface_v1": {wire_alphamodifier.WpAlphaModifierSurfaceV1Interface, &client_alphamodifier.WpAlphaModifierSurface{}, map[uint16]bool{0: true}, nil},
151+ "wp_alpha_modifier_v1": {wire_alphamodifier.WpAlphaModifierV1Interface, &client_alphamodifier.WpAlphaModifier{}, map[uint16]bool{0: true}, nil},
152+ "wp_color_management_output_v1": {wire_colormanagement.WpColorManagementOutputV1Interface, &client_colormanagement.WpColorManagementOutput{}, map[uint16]bool{0: true}, nil},
153+ "wp_color_management_surface_feedback_v1": {wire_colormanagement.WpColorManagementSurfaceFeedbackV1Interface, &client_colormanagement.WpColorManagementSurfaceFeedback{}, map[uint16]bool{0: true}, nil},
154+ "wp_color_management_surface_v1": {wire_colormanagement.WpColorManagementSurfaceV1Interface, &client_colormanagement.WpColorManagementSurface{}, map[uint16]bool{0: true}, nil},
155+ "wp_color_manager_v1": {wire_colormanagement.WpColorManagerV1Interface, &client_colormanagement.WpColorManager{}, map[uint16]bool{0: true}, nil},
156+ "wp_color_representation_manager_v1": {wire_colorrepresentation.WpColorRepresentationManagerV1Interface, &client_colorrepresentation.WpColorRepresentationManager{}, map[uint16]bool{0: true}, nil},
157+ "wp_color_representation_surface_v1": {wire_colorrepresentation.WpColorRepresentationSurfaceV1Interface, &client_colorrepresentation.WpColorRepresentationSurface{}, map[uint16]bool{0: true}, nil},
158+ "wp_commit_timer_v1": {wire_committiming.WpCommitTimerV1Interface, &client_committiming.WpCommitTimer{}, map[uint16]bool{1: true}, nil},
159+ "wp_commit_timing_manager_v1": {wire_committiming.WpCommitTimingManagerV1Interface, &client_committiming.WpCommitTimingManager{}, map[uint16]bool{0: true}, nil},
160+ "wp_cursor_shape_device_v1": {wire_cursorshape.WpCursorShapeDeviceV1Interface, &client_cursorshape.WpCursorShapeDevice{}, map[uint16]bool{0: true}, nil},
161+ "wp_cursor_shape_manager_v1": {wire_cursorshape.WpCursorShapeManagerV1Interface, &client_cursorshape.WpCursorShapeManager{}, map[uint16]bool{0: true}, nil},
162+ "wp_drm_lease_connector_v1": {wire_drmlease.WpDrmLeaseConnectorV1Interface, &client_drmlease.WpDrmLeaseConnector{}, map[uint16]bool{0: true}, nil},
163+ "wp_drm_lease_device_v1": {wire_drmlease.WpDrmLeaseDeviceV1Interface, &client_drmlease.WpDrmLeaseDevice{}, nil, map[uint16]bool{3: true}},
164+ "wp_drm_lease_request_v1": {wire_drmlease.WpDrmLeaseRequestV1Interface, &client_drmlease.WpDrmLeaseRequest{}, map[uint16]bool{1: true}, nil},
165+ "wp_drm_lease_v1": {wire_drmlease.WpDrmLeaseV1Interface, &client_drmlease.WpDrmLease{}, map[uint16]bool{0: true}, nil},
166+ "wp_fifo_manager_v1": {wire_fifo.WpFifoManagerV1Interface, &client_fifo.WpFifoManager{}, map[uint16]bool{0: true}, nil},
167+ "wp_fifo_v1": {wire_fifo.WpFifoV1Interface, &client_fifo.WpFifo{}, map[uint16]bool{2: true}, nil},
168+ "wp_fractional_scale_manager_v1": {wire_fractionalscale.WpFractionalScaleManagerV1Interface, &client_fractionalscale.WpFractionalScaleManager{}, map[uint16]bool{0: true}, nil},
169+ "wp_fractional_scale_v1": {wire_fractionalscale.WpFractionalScaleV1Interface, &client_fractionalscale.WpFractionalScale{}, map[uint16]bool{0: true}, nil},
170+ "wp_image_description_creator_icc_v1": {wire_colormanagement.WpImageDescriptionCreatorIccV1Interface, &client_colormanagement.WpImageDescriptionCreatorIcc{}, map[uint16]bool{0: true}, nil},
171+ "wp_image_description_creator_params_v1": {wire_colormanagement.WpImageDescriptionCreatorParamsV1Interface, &client_colormanagement.WpImageDescriptionCreatorParams{}, map[uint16]bool{0: true}, nil},
172+ "wp_image_description_info_v1": {wire_colormanagement.WpImageDescriptionInfoV1Interface, &client_colormanagement.WpImageDescriptionInfo{}, nil, map[uint16]bool{0: true}},
173+ "wp_image_description_reference_v1": {wire_colormanagement.WpImageDescriptionReferenceV1Interface, &client_colormanagement.WpImageDescriptionReference{}, map[uint16]bool{0: true}, nil},
174+ "wp_image_description_v1": {wire_colormanagement.WpImageDescriptionV1Interface, &client_colormanagement.WpImageDescription{}, map[uint16]bool{0: true}, nil},
175+ "wp_linux_drm_syncobj_manager_v1": {wire_linuxdrmsyncobj.WpLinuxDrmSyncobjManagerV1Interface, &client_drmsyncobj.WpLinuxDrmSyncobjManager{}, map[uint16]bool{0: true}, nil},
176+ "wp_linux_drm_syncobj_surface_v1": {wire_linuxdrmsyncobj.WpLinuxDrmSyncobjSurfaceV1Interface, &client_drmsyncobj.WpLinuxDrmSyncobjSurface{}, map[uint16]bool{0: true}, nil},
177+ "wp_linux_drm_syncobj_timeline_v1": {wire_linuxdrmsyncobj.WpLinuxDrmSyncobjTimelineV1Interface, &client_drmsyncobj.WpLinuxDrmSyncobjTimeline{}, map[uint16]bool{0: true}, nil},
178+ "wp_pointer_warp_v1": {wire_pointerwarp.WpPointerWarpV1Interface, &client_pointerwarp.WpPointerWarp{}, map[uint16]bool{0: true}, nil},
179+ "wp_presentation": {wire_presentationtime.WpPresentationInterface, &client_presentation.WpPresentation{}, map[uint16]bool{0: true}, nil},
180+ "wp_presentation_feedback": {wire_presentationtime.WpPresentationFeedbackInterface, &client_presentation.WpPresentationFeedback{}, nil, map[uint16]bool{1: true, 2: true}},
181+ "wp_tearing_control_manager_v1": {wire_tearingcontrol.WpTearingControlManagerV1Interface, &client_tearingcontrol.WpTearingControlManager{}, map[uint16]bool{0: true}, nil},
182+ "wp_tearing_control_v1": {wire_tearingcontrol.WpTearingControlV1Interface, &client_tearingcontrol.WpTearingControl{}, map[uint16]bool{1: true}, nil},
183+ "wp_viewport": {wire_viewporter.WpViewportInterface, &client_viewporter.WpViewport{}, map[uint16]bool{0: true}, nil},
184+ "wp_viewporter": {wire_viewporter.WpViewporterInterface, &client_viewporter.WpViewporter{}, map[uint16]bool{0: true}, nil},
185+ "xdg_activation_token_v1": {wire_xdgactivation.ActivationTokenV1Interface, &client_xdgactivation.XdgActivationToken{}, map[uint16]bool{4: true}, nil},
186+ "xdg_activation_v1": {wire_xdgactivation.ActivationV1Interface, &client_xdgactivation.XdgActivation{}, map[uint16]bool{0: true}, nil},
187+ "xdg_popup": {wire_xdgshell.PopupInterface, &client_xdgshell.XdgPopup{}, map[uint16]bool{0: true}, nil},
188+ "xdg_positioner": {wire_xdgshell.PositionerInterface, &client_xdgshell.XdgPositioner{}, map[uint16]bool{0: true}, nil},
189+ "xdg_surface": {wire_xdgshell.SurfaceInterface, &client_xdgshell.XdgSurface{}, map[uint16]bool{0: true}, nil},
190+ "xdg_toplevel": {wire_xdgshell.ToplevelInterface, &client_xdgshell.XdgToplevel{}, map[uint16]bool{0: true}, nil},
191+ "xdg_wm_base": {wire_xdgshell.WmBaseInterface, &client_xdgshell.XdgWmBase{}, map[uint16]bool{0: true}, nil},
192+ "zwlr_foreign_toplevel_handle_v1": {wire_wlrforeigntoplevel.ZwlrForeignToplevelHandleV1Interface, &client_foreigntoplevel.ForeignToplevelHandle{}, map[uint16]bool{7: true}, nil},
193+ "zwlr_foreign_toplevel_manager_v1": {wire_wlrforeigntoplevel.ZwlrForeignToplevelManagerV1Interface, &client_foreigntoplevel.ForeignToplevelManager{}, nil, map[uint16]bool{1: true}},
194+ "zwlr_layer_shell_v1": {wire_wlrlayershell.ZwlrLayerShellV1Interface, &client_layershell.LayerShell{}, map[uint16]bool{1: true}, nil},
195+ "zwlr_layer_surface_v1": {wire_wlrlayershell.ZwlrLayerSurfaceV1Interface, &client_layershell.LayerSurface{}, map[uint16]bool{7: true}, nil},
196+ "zwlr_output_configuration_head_v1": {wire_wlroutputmanagement.ZwlrOutputConfigurationHeadV1Interface, &client_outputmanagement.OutputConfigurationHead{}, nil, nil},
197+ "zwlr_output_configuration_v1": {wire_wlroutputmanagement.ZwlrOutputConfigurationV1Interface, &client_outputmanagement.OutputConfiguration{}, map[uint16]bool{4: true}, nil},
198+ "zwlr_output_head_v1": {wire_wlroutputmanagement.ZwlrOutputHeadV1Interface, &client_outputmanagement.OutputHead{}, map[uint16]bool{0: true}, nil},
199+ "zwlr_output_manager_v1": {wire_wlroutputmanagement.ZwlrOutputManagerV1Interface, &client_outputmanagement.OutputManager{}, nil, map[uint16]bool{2: true}},
200+ "zwlr_output_mode_v1": {wire_wlroutputmanagement.ZwlrOutputModeV1Interface, &client_outputmanagement.OutputMode{}, map[uint16]bool{0: true}, nil},
201+ "zwlr_output_power_manager_v1": {wire_wlroutputpower.ZwlrOutputPowerManagerV1Interface, &client_outputpower.OutputPowerManager{}, map[uint16]bool{1: true}, nil},
202+ "zwlr_output_power_v1": {wire_wlroutputpower.ZwlrOutputPowerV1Interface, &client_outputpower.OutputPower{}, map[uint16]bool{1: true}, nil},
203+ "zwlr_screencopy_frame_v1": {wire_wlrscreencopy.ZwlrScreencopyFrameV1Interface, &client_screencopy.ScreencopyFrame{}, map[uint16]bool{1: true}, nil},
204+ "zwlr_screencopy_manager_v1": {wire_wlrscreencopy.ZwlrScreencopyManagerV1Interface, &client_screencopy.ScreencopyManager{}, map[uint16]bool{2: true}, nil},
205+ "zwp_confined_pointer_v1": {wire_pointerconstraints.ZwpConfinedPointerV1Interface, &client_pointerconstraints.ConfinedPointer{}, map[uint16]bool{0: true}, nil},
206+ "zwp_idle_inhibit_manager_v1": {wire_idleinhibit.ZwpIdleInhibitManagerV1Interface, &client_idleinhibit.IdleInhibitManager{}, map[uint16]bool{0: true}, nil},
207+ "zwp_idle_inhibitor_v1": {wire_idleinhibit.ZwpIdleInhibitorV1Interface, &client_idleinhibit.IdleInhibitor{}, map[uint16]bool{0: true}, nil},
208+ "zwp_input_method_keyboard_grab_v2": {wire_inputmethod.ZwpInputMethodKeyboardGrabV2Interface, &client_inputmethod.InputMethodKeyboardGrab{}, map[uint16]bool{0: true}, nil},
209+ "zwp_input_method_manager_v2": {wire_inputmethod.ZwpInputMethodManagerV2Interface, &client_inputmethod.InputMethodManager{}, map[uint16]bool{1: true}, nil},
210+ "zwp_input_method_v2": {wire_inputmethod.ZwpInputMethodV2Interface, &client_inputmethod.InputMethod{}, map[uint16]bool{6: true}, nil},
211+ "zwp_input_popup_surface_v2": {wire_inputmethod.ZwpInputPopupSurfaceV2Interface, &client_inputmethod.InputPopupSurface{}, map[uint16]bool{0: true}, nil},
212+ "zwp_keyboard_shortcuts_inhibit_manager_v1": {wire_keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitManagerV1Interface, &client_shortcutsinhibit.KeyboardShortcutsInhibitManager{}, map[uint16]bool{0: true}, nil},
213+ "zwp_keyboard_shortcuts_inhibitor_v1": {wire_keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitorV1Interface, &client_shortcutsinhibit.KeyboardShortcutsInhibitor{}, map[uint16]bool{0: true}, nil},
214+ "zwp_linux_buffer_params_v1": {wire_linuxdmabuf.ZwpLinuxBufferParamsV1Interface, &client_linuxdmabuf.LinuxBufferParams{}, map[uint16]bool{0: true}, nil},
215+ "zwp_linux_dmabuf_feedback_v1": {wire_linuxdmabuf.ZwpLinuxDmabufFeedbackV1Interface, &client_linuxdmabuf.LinuxDmabufFeedback{}, map[uint16]bool{0: true}, nil},
216+ "zwp_linux_dmabuf_v1": {wire_linuxdmabuf.ZwpLinuxDmabufV1Interface, &client_linuxdmabuf.LinuxDmabuf{}, map[uint16]bool{0: true}, nil},
217+ "zwp_locked_pointer_v1": {wire_pointerconstraints.ZwpLockedPointerV1Interface, &client_pointerconstraints.LockedPointer{}, map[uint16]bool{0: true}, nil},
218+ "zwp_pointer_constraints_v1": {wire_pointerconstraints.ZwpPointerConstraintsV1Interface, &client_pointerconstraints.PointerConstraints{}, map[uint16]bool{0: true}, nil},
219+ "zwp_primary_selection_device_manager_v1": {wire_primaryselection.ZwpPrimarySelectionDeviceManagerV1Interface, &client_primaryselection.PrimarySelectionDeviceManager{}, map[uint16]bool{2: true}, nil},
220+ "zwp_primary_selection_device_v1": {wire_primaryselection.ZwpPrimarySelectionDeviceV1Interface, &client_primaryselection.PrimarySelectionDevice{}, map[uint16]bool{1: true}, nil},
221+ "zwp_primary_selection_offer_v1": {wire_primaryselection.ZwpPrimarySelectionOfferV1Interface, &client_primaryselection.PrimarySelectionOffer{}, map[uint16]bool{1: true}, nil},
222+ "zwp_primary_selection_source_v1": {wire_primaryselection.ZwpPrimarySelectionSourceV1Interface, &client_primaryselection.PrimarySelectionSource{}, map[uint16]bool{1: true}, nil},
223+ "zwp_relative_pointer_manager_v1": {wire_relativepointer.ZwpRelativePointerManagerV1Interface, &client_relativepointer.RelativePointerManager{}, map[uint16]bool{0: true}, nil},
224+ "zwp_relative_pointer_v1": {wire_relativepointer.ZwpRelativePointerV1Interface, &client_relativepointer.RelativePointer{}, map[uint16]bool{0: true}, nil},
225+ "zwp_text_input_manager_v3": {wire_textinput.ZwpTextInputManagerV3Interface, &client_textinput.TextInputManagerV3{}, map[uint16]bool{0: true}, nil},
226+ "zwp_text_input_v3": {wire_textinput.ZwpTextInputV3Interface, &client_textinput.TextInputV3{}, map[uint16]bool{0: true}, nil},
227+ "zwp_virtual_keyboard_manager_v1": {wire_virtualkeyboard.ZwpVirtualKeyboardManagerV1Interface, &client_virtualkeyboard.VirtualKeyboardManager{}, nil, nil},
228+ "zwp_virtual_keyboard_v1": {wire_virtualkeyboard.ZwpVirtualKeyboardV1Interface, &client_virtualkeyboard.VirtualKeyboard{}, map[uint16]bool{3: true}, nil},
229+ "zxdg_output_manager_v1": {wire_xdgoutput.ZxdgOutputManagerV1Interface, &client_xdgoutput.ZxdgOutputManager{}, map[uint16]bool{0: true}, nil},
230+ "zxdg_output_v1": {wire_xdgoutput.ZxdgOutputV1Interface, &client_xdgoutput.ZxdgOutput{}, map[uint16]bool{0: true}, nil},
231+}
232+
233+type wireObjectKey struct {
234+ c *wlturbo.Display
235+ id uint32
236+}
237+
238+var wireObjects sync.Map // wireObjectKey -> *wireReceiver; display cleanup removes all entries
239+
240+// wireReceiver is a real wire event receiver, not a replacement project service.
241+// It can keep a quiet receiver until a test installs its observer at the same ID.
242+type wireReceiver struct {
243+ wlturbo.BaseProxy
244+ c *wlturbo.Display
245+ schema wireSchema
246+ observer wlturbo.Proxy
247+}
248+
249+func (p *wireReceiver) EventSignature(op uint16) (string, bool) {
250+ if p.schema.events != nil {
251+ return p.schema.events.EventSignature(op)
252+ }
253+ if p.schema.iface == nil || int(op) >= len(p.schema.iface.Events) {
254+ return "", false
255+ }
256+ return wireSignatureText(p.schema.iface.Events[op].Signature), true
257+}
258+func wireSignatureText(sig string) string {
259+ var b strings.Builder
260+ for _, ch := range sig {
261+ switch ch {
262+ case 'i':
263+ b.WriteString("int,")
264+ case 'u':
265+ b.WriteString("uint,")
266+ case 'f':
267+ b.WriteString("fixed,")
268+ case 's':
269+ b.WriteString("string,")
270+ case 'o':
271+ b.WriteString("object,")
272+ case 'n':
273+ b.WriteString("new_id,")
274+ case 'a':
275+ b.WriteString("array,")
276+ case 'h':
277+ b.WriteString("fd,")
278+ }
279+ }
280+ return b.String()
281+}
282+func (p *wireReceiver) Dispatch(e *wlturbo.Event) {
283+ // Server-created objects must exist before an observer reads their events.
284+ if p.schema.iface != nil && int(e.Opcode) < len(p.schema.iface.Events) {
285+ m := p.schema.iface.Events[e.Opcode]
286+ // A separate cursor preserves the observer's event offset and FD ownership.
287+ pos := 0
288+ arg := 0
289+ for _, ch := range m.Signature {
290+ if ch == '?' || ch >= '0' && ch <= '9' {
291+ continue
292+ }
293+ if ch == 'n' && arg < len(m.Types) && m.Types[arg] != nil && pos+4 <= len(e.Data()) {
294+ id := binary.NativeEndian.Uint32(e.Data()[pos:])
295+ setWireSchema(p.c, id, m.Types[arg].Name, p.Version())
296+ }
297+ switch ch {
298+ case 'h':
299+ case 's', 'a':
300+ if pos+4 <= len(e.Data()) {
301+ n := int(binary.NativeEndian.Uint32(e.Data()[pos:]))
302+ pos += 4 + (n+3)&^3
303+ }
304+ default:
305+ pos += 4
306+ }
307+ arg++
308+ }
309+ }
310+ if p.observer != nil {
311+ p.observer.Dispatch(e)
312+ }
313+ if p.schema.eventDestructors[e.Opcode] {
314+ p.c.Context().Unregister(p)
315+ wireObjects.Delete(wireObjectKey{p.c, p.ID()})
316+ }
317+}
318+func wireObject(c *wlturbo.Display, id uint32) *wireReceiver {
319+ k := wireObjectKey{c, id}
320+ if v, ok := wireObjects.Load(k); ok {
321+ return v.(*wireReceiver)
322+ }
323+ p := &wireReceiver{c: c}
324+ p.SetID(id)
325+ p.SetContext(c.Context())
326+ actual, _ := wireObjects.LoadOrStore(k, p)
327+ return actual.(*wireReceiver)
328+}
329+func registerWireProxy(c *wlturbo.Display, observer wlturbo.Proxy) {
330+ p := wireObject(c, observer.ID())
331+ if setter, ok := observer.(interface{ SetContext(*wlturbo.Context) }); ok {
332+ setter.SetContext(c.Context())
333+ }
334+ p.observer = observer
335+ c.Context().Register(p)
336+}
337+func setWireSchema(c *wlturbo.Display, id uint32, iface string, version uint32) {
338+ schema, ok := wireSchemas[iface]
339+ if !ok {
340+ panic("missing raw-wire test schema: " + iface)
341+ }
342+ p := wireObject(c, id)
343+ p.schema = schema
344+ p.SetVersion(version)
345+ if setter, ok := p.observer.(interface{ SetVersion(uint32) }); ok {
346+ setter.SetVersion(version)
347+ }
348+ c.Context().Register(p)
349+}
350+func bindWireID(c *wlturbo.Display, name uint32, iface string, version uint32) (uint32, error) {
351+ id, err := c.Registry().BindID(name, iface, version)
352+ if err == nil {
353+ setWireSchema(c, id, iface, version)
354+ }
355+ return id, err
356+}
357+func wireRequest(c *wlturbo.Display, id uint32, op uint16, fds []int, args ...any) error {
358+ v, ok := wireObjects.Load(wireObjectKey{c, id})
359+ if !ok {
360+ return c.SendRequestWithFDs(id, op, fds, args...)
361+ } // intentional nonexistent target
362+ p := v.(*wireReceiver)
363+ if p.schema.iface != nil && int(op) < len(p.schema.iface.Requests) {
364+ m := p.schema.iface.Requests[op]
365+ arg, typeIndex := 0, 0
366+ for _, ch := range m.Signature {
367+ if ch == '?' || ch >= '0' && ch <= '9' {
368+ continue
369+ }
370+ // Raw Display.SendRequestWithFDs omits FD placeholders from args.
371+ if ch == 'h' {
372+ typeIndex++
373+ continue
374+ }
375+ if ch == 'n' && typeIndex < len(m.Types) && m.Types[typeIndex] != nil && arg < len(args) {
376+ if child, ok := args[arg].(uint32); ok && child != 0 {
377+ setWireSchema(c, child, m.Types[typeIndex].Name, p.Version())
378+ }
379+ }
380+ arg++
381+ typeIndex++
382+ }
383+ if p.schema.requestDestructors[op] {
384+ err := c.Context().SendDestructorWithFDs(p, uint32(op), fds, args...)
385+ if err == nil {
386+ wireObjects.Delete(wireObjectKey{c, id})
387+ }
388+ return err
389+ }
390+ }
391+ return c.SendRequestWithFDs(id, op, fds, args...)
392+}
393+func forgetWireClient(c *wlturbo.Display) {
394+ wireObjects.Range(func(k, v any) bool {
395+ if k.(wireObjectKey).c == c {
396+ wireObjects.Delete(k)
397+ }
398+ return true
399+ })
400+}
401+
402+// retireWireObject handles protocol-defined destruction caused by another
403+// object (for example configuration heads). No request is sent on the child.
404+func retireWireObject(c *wlturbo.Display, id uint32) {
405+ if v, ok := wireObjects.LoadAndDelete(wireObjectKey{c, id}); ok {
406+ c.Context().Unregister(v.(*wireReceiver))
407+ }
408+}
409+
410+// Catch drift between the generated server descriptions used for new_id
411+// bookkeeping and the canonical generated client schemas used by dispatch.
412+func TestRawWireSchemasMatchGeneratedProtocols(t *testing.T) {
413+ for name, schema := range wireSchemas {
414+ t.Run(name, func(t *testing.T) {
415+ if schema.events != nil {
416+ for op, event := range schema.iface.Events {
417+ got, ok := schema.events.EventSignature(uint16(op))
418+ if !ok || got != wireSignatureText(event.Signature) {
419+ t.Fatalf("event %s schema=%q supported=%t want=%q", event.Name, got, ok, wireSignatureText(event.Signature))
420+ }
421+ }
422+ }
423+ for op := range schema.requestDestructors {
424+ if int(op) >= len(schema.iface.Requests) {
425+ t.Fatalf("destructor %d outside %s", op, name)
426+ }
427+ }
428+ for op := range schema.eventDestructors {
429+ if int(op) >= len(schema.iface.Events) {
430+ t.Fatalf("event destructor %d outside %s", op, name)
431+ }
432+ }
433+ })
434+ }
435+}
M internal/adapters/wayland/workspaces.go +22 -1
@@ -41,6 +41,10 @@ func registerWorkspaces(d *server.Display, s *Server) error {
4141 })
4242 }
4343 func (m *workspaceManager) Commit(*ext.ExtWorkspaceManagerV1) {
44+ if m.s.protected() {
45+ m.pending = nil
46+ return
47+ }
4448 ids := make([]uint64, 0, len(m.pending))
4549 for _, id := range m.pending {
4650 if h := m.handles[id]; h != nil && h.res.Alive() {
@@ -59,6 +63,9 @@ func (m *workspaceManager) Stop(*ext.ExtWorkspaceManagerV1) {
5963 func (*workspaceManager) CreateWorkspace(*ext.ExtWorkspaceGroupHandleV1, string) {}
6064 func (*workspaceManager) Destroy(*ext.ExtWorkspaceGroupHandleV1) {}
6165 func (m *workspaceManager) Activate(r *ext.ExtWorkspaceHandleV1) {
66+ if m.s.protected() {
67+ return
68+ }
6269 for id, h := range m.handles {
6370 if h.res.Resource == r.Resource {
6471 m.pending = append(m.pending, id)
@@ -79,6 +86,9 @@ func (*workspaceRequests) Remove(*ext.ExtWorkspaceHandleV1)
7986 // outputBound announces every wl_output resource bound by this client for an
8087 // assigned output. A client may bind the same global more than once.
8188 func (m *workspaceManager) outputBound(name string) bool {
89+ if m.s.protected() {
90+ return false
91+ }
8292 g := m.groups[name]
8393 o := m.s.outputByNameExact(name)
8494 if g == nil || !g.res.Alive() || o == nil {
@@ -128,10 +138,17 @@ func (s *Server) forwardWorkspaces(ctx context.Context) {
128138 }
129139 func (s *Server) updateWorkspaces(snapshot ports.Workspaces) {
130140 s.workspaceSnapshot = snapshot
141+ s.updateWorkspaceManagers(snapshot)
142+ s.updateCaptureWorkspaces(snapshot)
143+}
144+
145+// updateWorkspaceManagers also refreshes inventory on protection transitions.
146+// Keep the last sent state while protected: old client knowledge cannot be
147+// revoked, but an unlock refresh must compare against it, not suppressed updates.
148+func (s *Server) updateWorkspaceManagers(snapshot ports.Workspaces) {
131149 for _, m := range s.workspaceManagers {
132150 m.update(snapshot)
133151 }
134- s.updateCaptureWorkspaces(snapshot)
135152 }
136153 func workspaceState(w ports.WorkspaceInfo) uint32 {
137154 var state uint32
@@ -149,6 +166,10 @@ func (m *workspaceManager) sendCoordinates(r *ext.ExtWorkspaceHandleV1, index in
149166 r.SendCoordinates(b[:])
150167 }
151168 func (m *workspaceManager) update(snapshot ports.Workspaces) {
169+ if m.s.protected() {
170+ m.pending = nil
171+ return
172+ }
152173 if !m.res.Alive() {
153174 return
154175 }
A internal/adapters/wayland/workspaces_sensitive_test.go +134 -0
@@ -0,0 +1,134 @@
1+package wayland
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+ ext "github.com/bnema/purego-libwayland/protocol/extworkspace"
8+ "github.com/bnema/wlturbo"
9+)
10+
11+// protectedWorkspaceEvents is a real wire client recorder. Child inventory
12+// proxies decode names as well as opcodes so unlock refresh is verified.
13+type protectedWorkspaceEvents struct {
14+ wlturbo.BaseProxy
15+ client *wlturbo.Display
16+ handles []uint32
17+ names []string
18+ events []uint32
19+}
20+
21+func (p *protectedWorkspaceEvents) Dispatch(e *wlturbo.Event) {
22+ p.events = append(p.events, uint32(e.Opcode))
23+ if e.Opcode != uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup) && e.Opcode != uint16(ext.ExtWorkspaceManagerV1EventWorkspace) {
24+ return
25+ }
26+ id := e.Uint32()
27+ group := e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup)
28+ child := &protectedWorkspaceChildEvents{owner: p, group: group}
29+ child.SetID(id)
30+ registerWireProxy(p.client, child)
31+ if !group {
32+ p.handles = append(p.handles, id)
33+ }
34+}
35+
36+type protectedWorkspaceChildEvents struct {
37+ wlturbo.BaseProxy
38+ owner *protectedWorkspaceEvents
39+ group bool
40+}
41+
42+func (p *protectedWorkspaceChildEvents) Dispatch(e *wlturbo.Event) {
43+ kind := uint32(2)
44+ if p.group {
45+ kind = 1
46+ }
47+ p.owner.events = append(p.owner.events, kind<<8|uint32(e.Opcode))
48+ if !p.group && e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventName) {
49+ p.owner.names = append(p.owner.names, e.String())
50+ }
51+}
52+
53+func bindProtectedWorkspaceRecorder(t *testing.T, c *wlturbo.Display) *protectedWorkspaceEvents {
54+ t.Helper()
55+ p := &protectedWorkspaceEvents{client: c}
56+ p.SetID(bindProtocol(t, c, "ext_workspace_manager_v1"))
57+ registerWireProxy(c, p)
58+ roundtrip(t, c)
59+ return p
60+}
61+
62+func TestProtectedWorkspaceInventoryAndActivation(t *testing.T) {
63+ s, events, _, dir := lifecycleServer(t)
64+ state := installSecurity(t, s)
65+ snapshot := ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "workspace-before", Active: true}}}}}
66+ if !s.display.Do(func() { s.updateWorkspaces(snapshot) }) {
67+ t.Fatal("display stopped")
68+ }
69+ existingClient := protocolClient(t, s, dir)
70+ existing := bindProtectedWorkspaceRecorder(t, existingClient)
71+ if len(existing.handles) != 1 || len(existing.names) != 1 || existing.names[0] != "workspace-before" {
72+ t.Fatalf("unlocked inventory: %+v", existing)
73+ }
74+ handle := existing.handles[0]
75+ existing.events, existing.names = nil, nil
76+ // A pre-protection uncommitted activation is discarded by transition refresh.
77+ requestProtocol(t, existingClient, handle, ext.ExtWorkspaceHandleV1RequestActivate)
78+ roundtrip(t, existingClient)
79+ protect(t, s, state, 1, true)
80+ if !s.display.Do(func() { s.updateWorkspaceManagers(s.workspaceSnapshot) }) {
81+ t.Fatal("display stopped")
82+ }
83+ snapshot.Outputs[0].Workspaces[0].Name = "workspace-current"
84+ snapshot.Outputs[0].Workspaces[0].Active = false
85+ snapshot.Outputs[0].Workspaces[0].Hidden = true
86+ if !s.display.Do(func() { s.updateWorkspaces(snapshot) }) {
87+ t.Fatal("display stopped")
88+ }
89+ lateClient := protocolClient(t, s, dir)
90+ late := bindProtectedWorkspaceRecorder(t, lateClient)
91+ roundtrip(t, existingClient)
92+ if len(existing.events) != 0 || len(late.events) != 0 || len(late.handles) != 0 {
93+ t.Fatalf("protected inventory: existing=%v late=%v", existing.events, late.events)
94+ }
95+ // Commit cannot admit pre-lock pending requests; requests made while locked
96+ // must not linger and execute after unlock either.
97+ requestProtocol(t, existingClient, existing.ID(), ext.ExtWorkspaceManagerV1RequestCommit)
98+ requestProtocol(t, existingClient, handle, ext.ExtWorkspaceHandleV1RequestActivate)
99+ roundtrip(t, existingClient)
100+ select {
101+ case ev := <-events:
102+ t.Fatalf("protected activation emitted: %v", ev)
103+ default:
104+ }
105+ protect(t, s, state, 2, false)
106+ if !s.display.Do(func() { s.updateWorkspaceManagers(s.workspaceSnapshot) }) {
107+ t.Fatal("display stopped")
108+ }
109+ roundtrip(t, existingClient, lateClient)
110+ for _, p := range []*protectedWorkspaceEvents{existing, late} {
111+ if len(p.names) != 1 || p.names[0] != "workspace-current" {
112+ t.Fatalf("unlock names %v", p.names)
113+ }
114+ }
115+ requestProtocol(t, existingClient, existing.ID(), ext.ExtWorkspaceManagerV1RequestCommit)
116+ roundtrip(t, existingClient)
117+ select {
118+ case ev := <-events:
119+ t.Fatalf("protected activation survived unlock: %v", ev)
120+ default:
121+ }
122+ requestProtocol(t, existingClient, handle, ext.ExtWorkspaceHandleV1RequestActivate)
123+ requestProtocol(t, existingClient, existing.ID(), ext.ExtWorkspaceManagerV1RequestCommit)
124+ roundtrip(t, existingClient)
125+ select {
126+ case ev := <-events:
127+ activation, ok := ev.(ports.WorkspaceActivate)
128+ if !ok || len(activation.IDs) != 1 || activation.IDs[0] != 42 {
129+ t.Fatalf("unlocked activation: %v", ev)
130+ }
131+ default:
132+ t.Fatal("unlocked activation not emitted")
133+ }
134+}
M internal/adapters/wayland/workspaces_test.go +3 -3
@@ -48,7 +48,7 @@ func (p *workspaceEvents) Dispatch(e *wlturbo.Event) {
4848 v[1] = e.Uint32()
4949 child := &workspaceChildEvents{events: p.events, idEvents: p.idEvents, order: p.order, group: e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup)}
5050 child.SetID(v[1])
51- p.client.Context().Register(child)
51+ registerWireProxy(p.client, child)
5252 }
5353 p.order.add(v[0])
5454 p.events <- v
@@ -107,7 +107,7 @@ func TestWorkspaceProtocol(t *testing.T) {
107107 managerID := bindProtocol(t, c, "ext_workspace_manager_v1")
108108 p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16)}
109109 p.SetID(managerID)
110- c.Context().Register(p)
110+ registerWireProxy(c, p)
111111 var handle, secondHandle uint32
112112 for i := 0; i < 8 && secondHandle == 0; i++ {
113113 if err := c.Roundtrip(); err != nil {
@@ -246,7 +246,7 @@ func TestWorkspaceConfiguredIDAndBatchDone(t *testing.T) {
246246 managerID := bindProtocol(t, c, "ext_workspace_manager_v1")
247247 p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16), order: &workspaceEventOrder{}}
248248 p.SetID(managerID)
249- c.Context().Register(p)
249+ registerWireProxy(c, p)
250250 if err := c.Roundtrip(); err != nil {
251251 t.Fatal(err)
252252 }
A internal/adapters/xkb/security_test.go +167 -0
@@ -0,0 +1,167 @@
1+package xkb
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+func secureKey(t *testing.T, k *Keymap, code uint32, down bool, state ports.SecurityState) ports.KeyEvent {
10+ t.Helper()
11+ ev, deliver, err := k.KeySecure(code, down, 123, state)
12+ if err != nil || !deliver {
13+ t.Fatalf("key %d down=%v: deliver=%v err=%v", code, down, deliver, err)
14+ }
15+ return ev
16+}
17+
18+func suppressedKey(t *testing.T, k *Keymap, code uint32, down bool, state ports.SecurityState) {
19+ t.Helper()
20+ ev, deliver, err := k.KeySecure(code, down, 123, state)
21+ if err != nil || deliver || ev != (ports.KeyEvent{}) {
22+ t.Fatalf("inherited key %d down=%v leaked: %+v deliver=%v err=%v", code, down, ev, deliver, err)
23+ }
24+}
25+
26+func TestSecurityEpochQuarantinesHeldKeysAcrossAcquireAndRelease(t *testing.T) {
27+ k := newTest(t, "us")
28+ initial := ports.SecurityState{}
29+ locked := ports.SecurityState{Generation: 1, Protected: true}
30+ unlocked := ports.SecurityState{Generation: 2}
31+ for _, code := range []uint32{125, 42, 29, 56, 30} {
32+ secureKey(t, k, code, true, initial)
33+ }
34+ for _, code := range []uint32{125, 42, 29, 56, 30} {
35+ suppressedKey(t, k, code, true, locked) // repeats cannot reintroduce held modifiers
36+ }
37+ if ev := secureKey(t, k, 16, true, locked); ev.Keysym != "q" || ev.Mods != 0 || ev.State != (ports.ModState{}) {
38+ t.Fatalf("password inherited native state: %+v", ev)
39+ }
40+ secureKey(t, k, 16, false, locked)
41+ secureKey(t, k, 54, true, locked) // password-era right Shift
42+ if ev := secureKey(t, k, 16, true, unlocked); ev.Keysym != "q" || ev.Mods != 0 || ev.State != (ports.ModState{}) {
43+ t.Fatalf("desktop inherited password/native state: %+v", ev)
44+ }
45+ secureKey(t, k, 16, false, unlocked)
46+ for _, code := range []uint32{30, 56, 29, 42, 125, 54} {
47+ suppressedKey(t, k, code, false, unlocked)
48+ }
49+ secureKey(t, k, 125, true, unlocked)
50+ if ev := secureKey(t, k, 16, true, unlocked); ev.Mods&ports.ModSuper == 0 || ev.State.Depressed == 0 {
51+ t.Fatalf("fresh desktop Super broken: %+v", ev)
52+ }
53+}
54+
55+func TestSecurityEpochResetsLocksButFreshCapsAndNumLockWork(t *testing.T) {
56+ k := newTest(t, "us")
57+ initial := ports.SecurityState{}
58+ locked := ports.SecurityState{Generation: 1, Protected: true}
59+ for _, code := range []uint32{58, 69} {
60+ secureKey(t, k, code, true, initial)
61+ secureKey(t, k, code, false, initial)
62+ }
63+ if ev := secureKey(t, k, 30, true, initial); ev.Keysym != "A" || ev.State.Locked == 0 {
64+ t.Fatalf("baseline caps: %+v", ev)
65+ }
66+ secureKey(t, k, 30, false, initial)
67+ if ev := secureKey(t, k, 30, true, locked); ev.Keysym != "a" || ev.State.Locked != 0 {
68+ t.Fatalf("old locks crossed epoch: %+v", ev)
69+ }
70+ secureKey(t, k, 30, false, locked)
71+ secureKey(t, k, 58, true, locked)
72+ secureKey(t, k, 58, false, locked)
73+ if ev := secureKey(t, k, 30, true, locked); ev.Keysym != "A" || ev.State.Locked == 0 {
74+ t.Fatalf("fresh protected caps broken: %+v", ev)
75+ }
76+ secureKey(t, k, 30, false, locked)
77+ secureKey(t, k, 69, true, locked)
78+ secureKey(t, k, 69, false, locked)
79+ if ev := secureKey(t, k, 79, true, locked); ev.Keysym != "KP_1" || ev.State.Locked == 0 {
80+ t.Fatalf("fresh protected numlock broken: %+v", ev)
81+ }
82+}
83+
84+func TestSecurityEpochUsesActualRemappedModifiers(t *testing.T) {
85+ k, err := New(RMLVO{Layout: "us", Options: "ctrl:swapcaps"})
86+ if err != nil {
87+ t.Fatal(err)
88+ }
89+ defer k.Close()
90+ initial, locked := ports.SecurityState{}, ports.SecurityState{Generation: 1, Protected: true}
91+ secureKey(t, k, 58, true, initial) // physical Caps is Control in this map
92+ if ev := secureKey(t, k, 16, true, locked); ev.Mods != 0 || ev.State != (ports.ModState{}) {
93+ t.Fatalf("remapped inherited Control leaked: %+v", ev)
94+ }
95+ secureKey(t, k, 16, false, locked)
96+ suppressedKey(t, k, 58, false, locked)
97+ secureKey(t, k, 58, true, locked)
98+ if ev := secureKey(t, k, 16, true, locked); ev.Mods&ports.ModCtrl == 0 || ev.State.Depressed == 0 {
99+ t.Fatalf("fresh remapped Control broken: %+v", ev)
100+ }
101+ secureKey(t, k, 16, false, locked)
102+ secureKey(t, k, 58, false, locked)
103+ secureKey(t, k, 29, true, locked) // physical Control is Caps in this map
104+ secureKey(t, k, 29, false, locked)
105+ if ev := secureKey(t, k, 30, true, locked); ev.Keysym != "A" || ev.State.Locked == 0 {
106+ t.Fatalf("fresh remapped Caps broken: %+v", ev)
107+ }
108+}
109+
110+func TestSecurityEpochAltGrAndAltReleaseOrder(t *testing.T) {
111+ for _, releases := range [][]uint32{{100, 56}, {56, 100}} {
112+ k := newTest(t, "fr")
113+ locked := ports.SecurityState{Generation: 1, Protected: true}
114+ secureKey(t, k, 100, true, locked) // ISO_Level3_Shift, not guessed Alt
115+ secureKey(t, k, 56, true, locked)
116+ if ev := secureKey(t, k, 11, true, locked); ev.Keysym != "at" || ev.Mods&ports.ModAlt == 0 || ev.State.Depressed == 0 {
117+ t.Fatalf("fresh AltGr+Alt not mapped by XKB: %+v", ev)
118+ }
119+ secureKey(t, k, 11, false, locked)
120+ for _, code := range releases {
121+ secureKey(t, k, code, false, locked)
122+ }
123+ if ev := secureKey(t, k, 16, true, locked); ev.Keysym != "a" || ev.Mods != 0 || ev.State.Depressed != 0 {
124+ t.Fatalf("release order left modifiers: %+v", ev)
125+ }
126+ }
127+}
128+
129+func TestKeymapReplacementRetainsQuarantine(t *testing.T) {
130+ previous := newTest(t, "us")
131+ locked := ports.SecurityState{Generation: 1, Protected: true}
132+ secureKey(t, previous, 125, true, locked)
133+ next := newTest(t, "fr")
134+ next.QuarantineFrom(previous)
135+ suppressedKey(t, next, 125, true, locked)
136+ if ev := secureKey(t, next, 16, true, locked); ev.Keysym != "a" || ev.Mods != 0 || ev.State.Depressed != 0 {
137+ t.Fatalf("replacement replayed old held state: %+v", ev)
138+ }
139+ suppressedKey(t, next, 125, false, locked)
140+ secureKey(t, next, 125, true, locked)
141+ if ev := secureKey(t, next, 30, true, locked); ev.Mods&ports.ModSuper == 0 {
142+ t.Fatalf("fresh key after replacement broken: %+v", ev)
143+ }
144+}
145+
146+func TestSecurityStateChangeResetsAltGrAndAltWithoutMaskInference(t *testing.T) {
147+ k := newTest(t, "fr")
148+ initial := ports.SecurityState{}
149+ locked := ports.SecurityState{Generation: 1, Protected: true}
150+ secureKey(t, k, 100, true, initial)
151+ secureKey(t, k, 56, true, initial)
152+ if ev := secureKey(t, k, 11, true, locked); ev.Keysym != "agrave" || ev.Mods != 0 || ev.State != (ports.ModState{}) {
153+ t.Fatalf("inherited AltGr/Alt crossed acquisition: %+v", ev)
154+ }
155+ secureKey(t, k, 11, false, locked)
156+ suppressedKey(t, k, 56, false, locked)
157+ suppressedKey(t, k, 100, false, locked)
158+ secureKey(t, k, 100, true, locked)
159+ // Full state comparison is conservative even if a caller changes the
160+ // protection bit without advancing the generation.
161+ changed := locked
162+ changed.Protected = false
163+ if ev := secureKey(t, k, 11, true, changed); ev.Keysym != "agrave" || ev.Mods != 0 || ev.State != (ports.ModState{}) {
164+ t.Fatalf("state change missed native reset: %+v", ev)
165+ }
166+ suppressedKey(t, k, 100, false, changed)
167+}
M internal/adapters/xkb/xkb.go +76 -15
@@ -27,18 +27,23 @@ type Keymap struct {
2727 contextUnref func(uintptr)
2828 keymapUnref func(uintptr)
2929 stateUnref func(uintptr)
30- keymapString func(uintptr, uint32) unsafe.Pointer
31- free func(uintptr)
32- getSym func(uintptr, uint32) uint32
33- symName func(uint32, uintptr, uintptr) int32
34- updateKey func(uintptr, uint32, uint32) uint32
35- serializeMods func(uintptr, uint32) uint32
36- serializeLayout func(uintptr, uint32) uint32
37- modActive func(uintptr, uintptr, uint32) int32
38- minCode func(uintptr) uint32
39- maxCode func(uintptr) uint32
40- symsLevel func(uintptr, uint32, uint32, uint32, *unsafe.Pointer) int32
41- fromName func(uintptr, uint32) uint32
30+ stateNew func(uintptr) uintptr
31+ // Secure tracking belongs to the same producer goroutine as native state.
32+ epoch ports.SecurityState
33+ epochSet bool
34+ held, quarantined map[uint32]bool
35+ keymapString func(uintptr, uint32) unsafe.Pointer
36+ free func(uintptr)
37+ getSym func(uintptr, uint32) uint32
38+ symName func(uint32, uintptr, uintptr) int32
39+ updateKey func(uintptr, uint32, uint32) uint32
40+ serializeMods func(uintptr, uint32) uint32
41+ serializeLayout func(uintptr, uint32) uint32
42+ modActive func(uintptr, uintptr, uint32) int32
43+ minCode func(uintptr) uint32
44+ maxCode func(uintptr) uint32
45+ symsLevel func(uintptr, uint32, uint32, uint32, *unsafe.Pointer) int32
46+ fromName func(uintptr, uint32) uint32
4247 }
4348
4449 type ruleNames struct{ rules, model, layout, variant, options uintptr }
@@ -60,13 +65,12 @@ func New(names RMLVO) (_ *Keymap, err error) {
6065 }
6166 var contextNew func(uint32) uintptr
6267 var keymapNew func(uintptr, *ruleNames, uint32) uintptr
63- var stateNew func(uintptr) uintptr
6468 bind := func(fn any, name string) { purego.RegisterLibFunc(fn, k.lib, "xkb_"+name) }
6569 bind(&contextNew, "context_new")
6670 bind(&k.contextUnref, "context_unref")
6771 bind(&keymapNew, "keymap_new_from_names")
6872 bind(&k.keymapUnref, "keymap_unref")
69- bind(&stateNew, "state_new")
73+ bind(&k.stateNew, "state_new")
7074 bind(&k.stateUnref, "state_unref")
7175 bind(&k.keymapString, "keymap_get_as_string")
7276 bind(&k.getSym, "state_key_get_one_sym")
@@ -104,7 +108,7 @@ func New(names RMLVO) (_ *Keymap, err error) {
104108 if k.mapPtr == 0 {
105109 return nil, fmt.Errorf("xkb_keymap_new_from_names failed")
106110 }
107- k.state = stateNew(k.mapPtr)
111+ k.state = k.stateNew(k.mapPtr)
108112 if k.state == 0 {
109113 return nil, fmt.Errorf("xkb_state_new failed")
110114 }
@@ -160,6 +164,63 @@ func (k *Keymap) Key(evdevCode uint32, pressed bool, timeMsec uint32) ports.KeyE
160164 return event
161165 }
162166
167+// KeySecure resets native modifier/lock/layout state before translating in a
168+// new security epoch. Keys physically held across a transition are suppressed
169+// (including repeats and their eventual release), never replayed into XKB.
170+// On reset failure no event is translated against the old native state.
171+func (k *Keymap) KeySecure(code uint32, pressed bool, timeMsec uint32, epoch ports.SecurityState) (ports.KeyEvent, bool, error) {
172+ if !k.epochSet || k.epoch != epoch {
173+ next := k.stateNew(k.mapPtr)
174+ if next == 0 {
175+ // Even a failed translation must remember physical presses so a
176+ // later successful reset cannot admit their repeats.
177+ if k.held == nil {
178+ k.held = make(map[uint32]bool)
179+ }
180+ if pressed {
181+ k.held[code] = true
182+ } else {
183+ delete(k.held, code)
184+ }
185+ return ports.KeyEvent{}, false, fmt.Errorf("xkb_state_new failed during security reset")
186+ }
187+ old := k.state
188+ k.state = next
189+ k.stateUnref(old)
190+ k.epoch, k.epochSet = epoch, true
191+ if k.held == nil {
192+ k.held = make(map[uint32]bool)
193+ }
194+ k.quarantined = make(map[uint32]bool, len(k.held))
195+ for held := range k.held {
196+ k.quarantined[held] = true
197+ }
198+ }
199+ blocked := k.quarantined[code]
200+ if pressed {
201+ k.held[code] = true
202+ } else {
203+ delete(k.held, code)
204+ delete(k.quarantined, code)
205+ }
206+ if blocked {
207+ return ports.KeyEvent{}, false, nil
208+ }
209+ return k.Key(code, pressed, timeMsec), true, nil
210+}
211+
212+// QuarantineFrom transfers physical held-key tracking when a producer replaces
213+// its keymap. No old native state or key presses are replayed into the new map.
214+// Call before closing previous; both maps belong to the calling goroutine.
215+func (k *Keymap) QuarantineFrom(previous *Keymap) {
216+ k.epoch, k.epochSet = previous.epoch, previous.epochSet
217+ k.held = make(map[uint32]bool, len(previous.held))
218+ k.quarantined = make(map[uint32]bool, len(previous.held))
219+ for code := range previous.held {
220+ k.held[code], k.quarantined[code] = true, true
221+ }
222+}
223+
163224 // levelName is the first keysym name of a key at one shift level, or "".
164225 func (k *Keymap) levelName(code, layout, level uint32) string {
165226 var ptr unsafe.Pointer
M internal/app/app.go +15 -5
@@ -19,6 +19,7 @@ import (
1919 "github.com/bnema/neferwl/internal/adapters/launcher"
2020 "github.com/bnema/neferwl/internal/adapters/libinput"
2121 "github.com/bnema/neferwl/internal/adapters/sched"
22+ "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
2223 "github.com/bnema/neferwl/internal/adapters/statefile"
2324 "github.com/bnema/neferwl/internal/adapters/vulkan"
2425 "github.com/bnema/neferwl/internal/adapters/wayland"
@@ -78,6 +79,9 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
7879 }
7980 defer hw.close()
8081 }
82+ security := &sessionsecurity.Gate{}
83+ securityChanges := make(chan ports.SecurityState, 8)
84+ securityEvents := make(chan ports.SecurityBackendEvent, 64)
8185 client := make(chan ports.ClientEvent, 32)
8286 input := make(chan ports.InputEvent, 32)
8387 output := make(chan ports.OutputEvent, 32)
@@ -113,7 +117,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
113117 // Only real sessions save scales: headless runs never touch the config file.
114118 scales = make(chan ports.ScaleChanged, 8)
115119 }
116- ch := core.Channels{Scales: scales, Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, Workspaces: workspaces, ConfigErrors: configErrors, Terminal: !opts.NoTerminal, Clock: clock.System{}, Frames: frames}
120+ ch := core.Channels{Security: security, Scales: scales, Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, Workspaces: workspaces, ConfigErrors: configErrors, Terminal: !opts.NoTerminal, Clock: clock.System{}, Frames: frames}
117121 c, err := core.New(opts.Config, ch)
118122 if err != nil {
119123 return err
@@ -127,7 +131,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
127131 // Every output renders on the same GPU: its formats are the clients'.
128132 dmabuf := vulkan.Probe()
129133 log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")
130- server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Workspaces: workspaces, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats, OutputHeads: outputHeads, LeaseRequests: leaseRequests, LeaseEvents: leaseEvents, OutputApply: applyOutput, OutputApplied: appliedOutput}, logging.For(ctx, "wayland"))
134+ server, err := wayland.New(wayland.Options{Security: security, RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{SecurityChanges: securityChanges, SecurityEvents: securityEvents, Events: client, Commands: commands, Workspaces: workspaces, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats, OutputHeads: outputHeads, LeaseRequests: leaseRequests, LeaseEvents: leaseEvents, OutputApply: applyOutput, OutputApplied: appliedOutput}, logging.For(ctx, "wayland"))
131135 if err != nil {
132136 km.Close()
133137 return err
@@ -151,6 +155,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
151155 defer launcher.UnexportSession(childEnv, logging.For(ctx, "launcher"))
152156 }
153157 child := launcher.New(childEnv, logging.For(ctx, "launcher"))
158+ child.Security = security
154159 if inject != nil {
155160 inject(input)
156161 }
@@ -202,11 +207,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
202207 log.Warn().Str("component", "sched").Err(err).Msg("input scheduling")
203208 }
204209 }
205- return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Touchpad: opts.Config.Touchpad, Touchpads: touchpads, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input"), LogMotion: logging.Enabled(ctx, "input-motion"), LogKeys: logging.Enabled(ctx, "input-keys")}, input)
210+ return libinput.Run(ctx, libinput.Options{Security: security, Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Touchpad: opts.Config.Touchpad, Touchpads: touchpads, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input"), LogMotion: logging.Enabled(ctx, "input-motion"), LogKeys: logging.Enabled(ctx, "input-keys")}, input)
206211 })
207212 return
208213 }
209- _ = headlessinput.Run(ctx, km, keymaps, script, input, layouts, curs.move, logging.For(ctx, "input"))
214+ if err := headlessinput.RunSecure(ctx, km, keymaps, script, input, layouts, curs.move, logging.For(ctx, "input"), security); err != nil && !errors.Is(err, context.Canceled) {
215+ select {
216+ case done <- err:
217+ case <-ctx.Done():
218+ }
219+ }
210220 }()
211221 if opts.Script != nil {
212222 go func() { <-ctx.Done(); _ = opts.Script.Close() }()
@@ -251,7 +261,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
251261
252262 go func() {
253263 defer workers.Done()
254- outputIO := outputChannels{events: output, scenes: renderScenes, contents: contents, cursorChanges: cursorChanges, presented: flips, captures: captures, captured: captured, formats: outputFormats, heads: outputHeads, leaseRequests: leaseRequests, leaseEvents: leaseEvents, reloads: filtered, configured: configChanges, requests: applyOutput, replies: appliedOutput}
264+ outputIO := outputChannels{security: security, securityChanges: securityChanges, securityEvents: securityEvents, events: output, scenes: renderScenes, contents: contents, cursorChanges: cursorChanges, presented: flips, captures: captures, captured: captured, formats: outputFormats, heads: outputHeads, leaseRequests: leaseRequests, leaseEvents: leaseEvents, reloads: filtered, configured: configChanges, requests: applyOutput, replies: appliedOutput}
255265 apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
256266 renderLog := logging.For(ctx, "render")
257267 newRenderer := func(w, h int) (ports.Renderer, error) {
M internal/app/app_test.go +3 -3
@@ -87,7 +87,7 @@ func TestQuitJoinsWorkers(t *testing.T) {
8787 done := make(chan error, 1)
8888 go func() {
8989 done <- run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: config.Defaults(), NoTerminal: true}, func(input chan<- ports.InputEvent) {
90- input <- ports.KeyEvent{Keysym: "BackSpace", Mods: ports.ModCtrl | ports.ModAlt, Pressed: true}
90+ input <- ports.SecurityInput{State: ports.SecurityState{}, Event: ports.KeyEvent{Keysym: "BackSpace", Mods: ports.ModCtrl | ports.ModAlt, Pressed: true}}
9191 })
9292 }()
9393 select {
@@ -133,7 +133,7 @@ func TestHeadlessSpawnClose(t *testing.T) {
133133 t.Error("Run did not stop")
134134 }
135135 }()
136- input <- ports.KeyEvent{Keysym: "Return", Mods: ports.ModSuper, Pressed: true}
136+ input <- ports.SecurityInput{State: ports.SecurityState{}, Event: ports.KeyEvent{Keysym: "Return", Mods: ports.ModSuper, Pressed: true}}
137137 waitScene := func(count int, timeout time.Duration) {
138138 t.Helper()
139139 timer := time.NewTimer(timeout)
@@ -157,7 +157,7 @@ func TestHeadlessSpawnClose(t *testing.T) {
157157 }
158158 }
159159 waitScene(1, 10*time.Second)
160- input <- ports.KeyEvent{Keysym: "q", Mods: ports.ModSuper, Pressed: true}
160+ input <- ports.SecurityInput{State: ports.SecurityState{}, Event: ports.KeyEvent{Keysym: "q", Mods: ports.ModSuper, Pressed: true}}
161161 waitScene(0, 5*time.Second)
162162 }
163163
M internal/app/capture_test.go +1 -1
@@ -144,7 +144,7 @@ func TestOutputStopWithFullReplies(t *testing.T) {
144144 replies := make(chan ports.CaptureDone, 1)
145145 replies <- ports.CaptureDone{}
146146 set := newOutputSet(ctx, replies)
147- set.start(ctx, "test", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, requests <-chan ports.CaptureRequest) error {
147+ set.start(ctx, "test", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, requests <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
148148 <-ctx.Done()
149149 return nil
150150 })
M internal/app/drm.go +199 -11
@@ -26,6 +26,87 @@ import (
2626
2727 var errTimeout = errors.New("timeout")
2828
29+// securityLeaseCard keeps the native operation and its tracked lease inventory
30+// together. A failed revoke must remain in LeaseIDs for the next barrier attempt.
31+type securityLeaseCard interface {
32+ Path() string
33+ Lease([]string) (*os.File, uint32, error)
34+ LeaseIDs() []uint32
35+ Revoke(uint32) error
36+}
37+
38+func securitySnapshot(gate ports.SessionSecurity) ports.SecurityState {
39+ if gate == nil {
40+ return ports.SecurityState{}
41+ }
42+ return gate.Snapshot()
43+}
44+
45+// guardedLease denies requests pending at acquisition and closes any FD created
46+// across a transition, even when the native call succeeds. Failed revocations
47+// stay tracked by the card and prevent the later backend barrier.
48+func guardedLease(c securityLeaseCard, gate ports.SessionSecurity, connectors []string) (*os.File, uint32, error) {
49+ before := securitySnapshot(gate)
50+ if before.Protected {
51+ return nil, 0, errors.New("session protected")
52+ }
53+ fd, id, err := c.Lease(connectors)
54+ if err != nil {
55+ return fd, id, err
56+ }
57+ after := securitySnapshot(gate)
58+ if after.Protected || after.Generation != before.Generation {
59+ if fd != nil {
60+ fd.Close()
61+ }
62+ return nil, 0, errors.Join(errors.New("session transition during lease"), c.Revoke(id))
63+ }
64+ return fd, id, nil
65+}
66+
67+func revokeSecurityLeases(c securityLeaseCard, finished func(uint32)) error {
68+ var errs []error
69+ for _, id := range c.LeaseIDs() {
70+ if err := c.Revoke(id); err != nil {
71+ errs = append(errs, fmt.Errorf("%s lease %d: %w", c.Path(), id, err))
72+ } else {
73+ finished(id)
74+ }
75+ }
76+ if ids := c.LeaseIDs(); len(ids) != 0 {
77+ errs = append(errs, fmt.Errorf("%s leases still active: %v", c.Path(), ids))
78+ }
79+ return errors.Join(errs...)
80+}
81+
82+// registerDiscardedOutput accounts for physical output lifetimes that cannot
83+// enter connector-name routing. They still require protection just like owners.
84+// The same backend-owned counter is used by outputSet.start; no instance reuse.
85+func registerDiscardedOutput(set *outputSet, name string) (ports.OutputInstance, error) {
86+ if set.nextInstance == ^ports.OutputInstance(0) {
87+ set.startErr = errors.New("output instance exhausted")
88+ return 0, set.startErr
89+ }
90+ set.nextInstance++
91+ instance := set.nextInstance
92+ if !set.securityEvent(ports.SecurityOutputAdded{Instance: instance, Output: name}) {
93+ return instance, set.ctx.Err()
94+ }
95+ return instance, nil
96+}
97+
98+// retireDiscardedOutput consumes only affirmative terminal evidence. A failed
99+// close remains in authoritative inventory, even though it has no render owner.
100+func retireDiscardedOutput(set *outputSet, instance ports.OutputInstance, inactive bool) error {
101+ if !inactive {
102+ return fmt.Errorf("discarded output %d shutdown inactivity unconfirmed", instance)
103+ }
104+ if !set.securityEvent(ports.SecurityOutputRemoved{Instance: instance}) {
105+ return set.ctx.Err()
106+ }
107+ return nil
108+}
109+
29110 // applyTimeout bounds how long outputs take to apply a configuration.
30111 const applyTimeout = 5 * time.Second
31112
@@ -165,6 +246,9 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
165246 active := b.seat.Subscribe()
166247 defer b.seat.Unsubscribe(active)
167248 seatActive := true // openDRM waits for the first seat enable
249+ set := newOutputSet(ctx, ch.captured)
250+ set.wireSecurity(ch)
251+ protected := func() bool { return set.state.Protected || securitySnapshot(ch.security).Protected }
168252 clientFDs := map[*drmCard]*os.File{}
169253 lastLeaseConnectors := map[*drmCard][]ports.LeaseConnector{}
170254 defer func() {
@@ -184,12 +268,14 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
184268 sendLease(ports.LeaseFinished{Card: c.Path(), LeaseID: id})
185269 }
186270 var next []ports.LeaseConnector
187- if seatActive {
271+ if seatActive && !protected() {
188272 next = c.Leasable()
189273 }
190274 if len(next) == 0 {
191- if clientFDs[c] != nil {
275+ if protected() || clientFDs[c] != nil {
192276 sendLease(ports.LeaseConnectors{Card: c.Path()})
277+ }
278+ if clientFDs[c] != nil {
193279 clientFDs[c].Close()
194280 delete(clientFDs, c)
195281 }
@@ -222,8 +308,8 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
222308 log.Warn().Err(err).Msg("hotplug watch disabled")
223309 }
224310 }()
225- set := newOutputSet(ctx, ch.captured)
226311 cards := map[string]*drmCard{}
312+ outputs := map[string]*drm.Output{} // owner result, read only after done
227313 send := func(ev ports.OutputEvent) {
228314 select {
229315 case ch.events <- ev:
@@ -250,6 +336,38 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
250336 timer.Stop()
251337 defer timer.Stop()
252338 var deadline <-chan time.Time
339+ // A small bounded retry budget avoids a busy loop on failed KMS revokes.
340+ revokeTimer := b.clock.NewTimer(time.Second)
341+ revokeTimer.Stop()
342+ defer revokeTimer.Stop()
343+ var revokeDeadline <-chan time.Time
344+ var revokeAttempts int
345+ var discardedOutputErr error // first unconfirmed lifetime; inventory retains every instance
346+ barrier := func() {
347+ if !set.state.Protected {
348+ return
349+ }
350+ var errs []error
351+ for _, c := range b.cards {
352+ errs = append(errs, revokeSecurityLeases(c, func(id uint32) {
353+ sendLease(ports.LeaseFinished{Card: c.Path(), LeaseID: id})
354+ }))
355+ publishLeases(c)
356+ }
357+ errs = append(errs, discardedOutputErr)
358+ err := errors.Join(errs...)
359+ // Registration runs synchronously in scan before any barrier. A gate
360+ // transition racing native revocation belongs to a later round.
361+ if securitySnapshot(ch.security).Generation > set.state.Generation {
362+ return
363+ }
364+ set.securityEvent(ports.SecurityBackendBarrier{Generation: set.state.Generation, Err: err})
365+ revokeAttempts++
366+ if err != nil && revokeAttempts < 3 {
367+ revokeTimer.Reset(time.Second)
368+ revokeDeadline = revokeTimer.C()
369+ }
370+ }
253371 var op uint64
254372 complete := func(d applyDecision) {
255373 if !d.reply {
@@ -303,15 +421,37 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
303421 stop(name, true)
304422 }
305423 for _, o := range added {
424+ // Every physical lifetime gets the gate before any early exit:
425+ // Close must not restore saved desktop, even on a name collision.
426+ o.Security = ch.security
306427 name := o.Info().Name
307428 if other := cards[name]; other != nil && other != c {
308429 // Core and clients know outputs by connector name.
309430 log.Warn().Str("connector", name).Str("card", c.Path()).Msg("connector name already used by another card; ignored")
310- o.Close()
431+ instance, registerErr := registerDiscardedOutput(set, name)
432+ o.Instance = instance
433+ o.Close() // synchronous final KMS disable; never starts a renderer
434+ var shutdownErr error
435+ if registerErr == nil {
436+ shutdownErr = retireDiscardedOutput(set, instance, o.InactiveOnClose())
437+ }
438+ if err := errors.Join(registerErr, shutdownErr); err != nil {
439+ if discardedOutputErr == nil {
440+ discardedOutputErr = err
441+ }
442+ scanErrors = append(scanErrors, err)
443+ log.Error().Err(err).Str("connector", name).Str("card", c.Path()).Msg("discarded output protection unconfirmed")
444+ // An exhausted instance cannot represent an unsafe lifetime.
445+ // Stop the backend rather than ever acknowledge this inventory.
446+ if instance == 0 {
447+ cancel()
448+ }
449+ }
311450 c.Release(name)
312451 continue
313452 }
314453 cards[name] = c
454+ outputs[name] = o
315455 readySources[name] = o.Ready()
316456 if progress.active && !want(currentConfig).Disabled[name] {
317457 progress.required[name] = true
@@ -337,8 +477,9 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
337477 }
338478 active := b.seat.Subscribe()
339479 realtime := currentConfig.Performance.Realtime
340- set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest) error {
480+ started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error {
341481 defer b.seat.Unsubscribe(active)
482+ o.Security, o.SecurityChanges, o.Instance = ch.security, secure, instance
342483 return safe("output "+name, func() error {
343484 defer o.Close()
344485 if realtime {
@@ -351,12 +492,19 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
351492 }
352493 return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu, ch.presented, cap, ch.captured)
353494 })
354- })
495+ }, &o.SecurityEvents)
496+ if !started {
497+ b.seat.Unsubscribe(active)
498+ o.Close()
499+ c.Release(name)
500+ continue
501+ }
355502 send(ports.OutputAdded{Info: o.Info()})
356503 }
357504 }
358505 publishInventory()
359506 complete(progress.scanError(errors.Join(scanErrors...)))
507+ barrier()
360508 }
361509 scan()
362510 if len(set.outs) == 0 {
@@ -404,12 +552,38 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
404552 configs, configNext := apply.configOut(ch.configured), apply.config
405553 replies, replyNext := apply.replyOut(ch.replies)
406554 heads, headsNext := apply.headsOut(ch.heads)
555+ contents, contentOut, contentNext, contentOwner := set.contentOut(ch.contents)
407556 select {
408557 case <-ctx.Done():
409558 return set.wait()
410559 case err := <-readerErr:
411560 // No more flips on that card: its outputs would freeze.
412561 return errors.Join(err, set.wait())
562+ case state, ok := <-ch.securityChanges:
563+ if !ok {
564+ ch.securityChanges = nil
565+ continue
566+ }
567+ if state.Generation < set.state.Generation {
568+ continue
569+ }
570+ if set.setSecurity(state) {
571+ revokeAttempts = 0
572+ }
573+ // start can refresh the gate while scanning; its queued transition
574+ // must still receive a barrier even if forwarding already occurred.
575+ revokeTimer.Stop()
576+ revokeDeadline = nil
577+ if set.state.Protected {
578+ barrier()
579+ } else {
580+ for _, c := range b.cards {
581+ publishLeases(c)
582+ }
583+ }
584+ case <-revokeDeadline:
585+ revokeDeadline = nil
586+ barrier()
413587 case <-hotplug:
414588 log.Info().Msg("hotplug")
415589 scan()
@@ -442,14 +616,14 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
442616 switch req := event.(type) {
443617 case ports.LeaseRequest:
444618 reply := ports.LeaseReply{ID: req.ID, Err: fmt.Errorf("unknown card %s", req.Card)}
445- if !seatActive {
446- reply.Err = errors.New("DRM seat inactive")
619+ if !seatActive || protected() {
620+ reply.Err = errors.New("DRM seat inactive or session protected")
447621 sendLease(reply)
448622 break
449623 }
450624 for _, c := range b.cards {
451625 if c.Path() == req.Card {
452- reply.FD, reply.LeaseID, reply.Err = c.Lease(req.Connectors)
626+ reply.FD, reply.LeaseID, reply.Err = guardedLease(c, ch.security, req.Connectors)
453627 publishLeases(c)
454628 break
455629 }
@@ -483,14 +657,28 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
483657 complete(progress.timeout(op))
484658 case s := <-ch.scenes:
485659 set.scenes(s)
486- case c := <-ch.contents:
660+ case c := <-contents:
487661 set.content(c)
662+ case contentOut <- contentNext:
663+ contentOwner.contentSent()
488664 case q := <-ch.captures:
489665 set.routeCapture(q)
490666 case c := <-ch.cursorChanges:
491667 set.setCursor(c)
492- case name := <-set.stopped:
668+ case stopped := <-set.stopped:
669+ if !set.stoppedCurrent(stopped) {
670+ continue
671+ }
672+ name := stopped.name
673+ output := outputs[name]
493674 err := set.finish(name)
675+ // finish observes done after the deferred Close. Only affirmative
676+ // terminal KMS inactivity retires this exact lifetime; renderer
677+ // errors and missing connector inventory are not physical proof.
678+ if output != nil && output.InactiveOnClose() {
679+ set.securityEvent(ports.SecurityOutputRemoved{Instance: stopped.instance})
680+ }
681+ delete(outputs, name)
494682 if ctx.Err() != nil {
495683 return errors.Join(err, set.wait())
496684 }
M internal/app/headless.go +29 -6
@@ -16,6 +16,7 @@ import (
1616 // are fixed, so apply answers every configuration without backend work.
1717 func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
1818 set := newOutputSet(ctx, ch.captured)
19+ set.wireSecurity(ch)
1920 inventory := ports.OutputHeads{}
2021 for i, size := range sizes {
2122 name := fmt.Sprintf("HEADLESS-%d", i+1)
@@ -32,15 +33,23 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap
3233 cur := &headless.Cursor{}
3334 curs.set(name, cur)
3435 opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: hdr, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Captured: ch.captured}
35- set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest) error {
36+ started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error {
37+ opts.Security, opts.SecurityChanges, opts.Instance = ch.security, secure, instance
3638 return headless.Run(octx, opts, sc, cc, cu, cap)
37- })
39+ }, &opts.SecurityEvents)
40+ if !started {
41+ return set.wait()
42+ }
3843 select {
3944 case ch.events <- ports.OutputAdded{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1]}}:
4045 case <-ctx.Done():
4146 return set.wait()
4247 }
4348 }
49+ // All virtual lifetimes have registered before this no-lease barrier.
50+ if set.state.Protected {
51+ set.securityEvent(ports.SecurityBackendBarrier{Generation: set.state.Generation})
52+ }
4453 apply.heads(inventory)
4554 // The pointer starts centred on the first output, like libinput's.
4655 curs.move("HEADLESS-1", float64(sizes[0][0])/2, float64(sizes[0][1])/2)
@@ -48,9 +57,18 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap
4857 configs, configNext := apply.configOut(ch.configured), apply.config
4958 replies, replyNext := apply.replyOut(ch.replies)
5059 heads, headsNext := apply.headsOut(ch.heads)
60+ contents, contentOut, contentNext, contentOwner := set.contentOut(ch.contents)
5161 select {
5262 case <-ctx.Done():
5363 return set.wait()
64+ case state, ok := <-ch.securityChanges:
65+ if !ok {
66+ ch.securityChanges = nil
67+ continue
68+ }
69+ if set.setSecurity(state) && set.state.Protected {
70+ set.securityEvent(ports.SecurityBackendBarrier{Generation: set.state.Generation})
71+ }
5472 case ev := <-ch.reloads:
5573 apply.reload(ev.Config)
5674 case req := <-ch.requests:
@@ -63,15 +81,20 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap
6381 apply.headsSent()
6482 case s := <-ch.scenes:
6583 set.scenes(s)
66- case c := <-ch.contents:
84+ case c := <-contents:
6785 set.content(c)
86+ case contentOut <- contentNext:
87+ contentOwner.contentSent()
6888 case q := <-ch.captures:
6989 set.routeCapture(q)
7090 case c := <-ch.cursorChanges:
7191 set.setCursor(c)
72- case name := <-set.stopped:
73- // A headless output only stops on error: the run ends.
74- err := set.finish(name)
92+ case stopped := <-set.stopped:
93+ if !set.stoppedCurrent(stopped) {
94+ continue
95+ }
96+ // An owner error is not protection or removal evidence.
97+ err := set.finish(stopped.name)
7598 return joinErr(err, set.wait())
7699 }
77100 }
M internal/app/outputapply_e2e_test.go +59 -77
@@ -11,50 +11,9 @@ import (
1111 "github.com/bnema/neferwl/internal/ports"
1212 wlr "github.com/bnema/purego-libwayland/protocol/wlroutputmanagement"
1313 "github.com/bnema/wlturbo"
14+ client "github.com/bnema/wlturbo/protocol/outputmanagement"
1415 )
1516
16-// managerEvents records zwlr_output_manager_v1 head and done events.
17-type managerEvents struct {
18- wlturbo.BaseProxy
19- c *wlturbo.Display
20- heads []uint32
21- done uint32
22-}
23-
24-func (p *managerEvents) Dispatch(e *wlturbo.Event) {
25- switch uint32(e.Opcode) {
26- case wlr.ZwlrOutputManagerV1EventHead:
27- h := &ignoredEvents{c: p.c}
28- h.SetID(e.Uint32())
29- p.c.Context().Register(h)
30- p.heads = append(p.heads, h.ID())
31- case wlr.ZwlrOutputManagerV1EventDone:
32- p.done = e.Uint32()
33- }
34-}
35-
36-// ignoredEvents drops the events of head, mode and configuration head objects.
37-type ignoredEvents struct {
38- wlturbo.BaseProxy
39- c *wlturbo.Display
40-}
41-
42-func (p *ignoredEvents) Dispatch(e *wlturbo.Event) {
43- if e.Opcode == uint16(wlr.ZwlrOutputHeadV1EventMode) {
44- m := &ignoredEvents{c: p.c}
45- m.SetID(e.Uint32())
46- p.c.Context().Register(m)
47- }
48-}
49-
50-// configurationEvents records the result of a zwlr_output_configuration_v1.
51-type configurationEvents struct {
52- wlturbo.BaseProxy
53- result int // 0 pending, then 1 + the event opcode
54-}
55-
56-func (p *configurationEvents) Dispatch(e *wlturbo.Event) { p.result = int(e.Opcode) + 1 }
57-
5817 // A wlr-output-management client goes through Wayland, the headless owner
5918 // loop and core: an accepted scale reaches the scene, a disable is refused.
6019 func TestHeadlessOutputManagementEndToEnd(t *testing.T) {
@@ -119,17 +78,12 @@ func TestHeadlessOutputManagementEndToEnd(t *testing.T) {
11978 if err := c.Roundtrip(); err != nil {
12079 t.Fatal(err)
12180 }
122- g, ok := c.Registry().FindGlobal("zwlr_output_manager_v1")
123- if !ok {
124- t.Fatal("no zwlr_output_manager_v1")
125- }
126- id, err := c.Registry().BindID(g.Name, g.Interface, 4)
127- if err != nil {
128- t.Fatal(err)
129- }
130- manager := &managerEvents{c: c}
131- manager.SetID(id)
132- c.Context().Register(manager)
81+ manager := client.NewOutputManager(c.Context())
82+ var heads []*client.OutputHead
83+ var serial uint32
84+ manager.OnHead(func(head *client.OutputHead) { heads = append(heads, head) })
85+ manager.OnDone(func(value uint32) { serial = value })
86+ bindTestClient(t, c, client.OutputManagerInterface, 4, manager)
13387 roundtrip := func(until func() bool) {
13488 t.Helper()
13589 // The owner loops answer asynchronously: roundtrip until they do,
@@ -141,44 +95,72 @@ func TestHeadlessOutputManagementEndToEnd(t *testing.T) {
14195 }
14296 }
14397 // The inventory reaches Wayland through the owner loop's outbox.
144- roundtrip(func() bool { return manager.done != 0 && len(manager.heads) == 1 })
145- head, serial := manager.heads[0], manager.done
146- configure := func(set func(cfg uint32)) int {
98+ roundtrip(func() bool { return serial != 0 && len(heads) == 1 })
99+ head, initialSerial := heads[0], serial
100+ configure := func(set func(*client.OutputConfiguration) *client.OutputConfigurationHead) int {
147101 t.Helper()
148- cfg := &configurationEvents{}
149- cfg.SetID(c.AllocateID())
150- c.Context().Register(cfg)
151- if err := c.SendRequest(id, uint16(wlr.ZwlrOutputManagerV1RequestCreateConfiguration), cfg.ID(), manager.done); err != nil {
152- t.Fatal(err)
153- }
154- set(cfg.ID())
155- if err := c.SendRequest(cfg.ID(), uint16(wlr.ZwlrOutputConfigurationV1RequestApply)); err != nil {
156- t.Fatal(err)
102+ // Inventory and core layout are independent startup publications. A
103+ // done serial can become stale before create_configuration reaches the
104+ // server. Only protocol cancellation is retryable, never a failed apply.
105+ for attempt := 0; attempt < 3; attempt++ {
106+ requested := serial
107+ cfg, err := manager.CreateConfiguration(requested)
108+ if err != nil {
109+ t.Fatal(err)
110+ }
111+ result := -1
112+ cfg.OnSucceeded(func() { result = int(wlr.ZwlrOutputConfigurationV1EventSucceeded) })
113+ cfg.OnFailed(func() { result = int(wlr.ZwlrOutputConfigurationV1EventFailed) })
114+ cfg.OnCancelled(func() { result = int(wlr.ZwlrOutputConfigurationV1EventCancelled) })
115+ // A configuration cancelled on creation accepts only its destructor.
116+ if err := c.Roundtrip(); err != nil {
117+ t.Fatal(err)
118+ }
119+ var child *client.OutputConfigurationHead
120+ if result < 0 {
121+ child = set(cfg)
122+ if err := cfg.Apply(); err != nil {
123+ t.Fatal(err)
124+ }
125+ roundtrip(func() bool { return result >= 0 })
126+ }
127+ // Configuration heads have no destructor request of their own.
128+ if err := cfg.Destroy(); err != nil {
129+ t.Fatal(err)
130+ }
131+ if child != nil {
132+ c.Context().Unregister(child)
133+ }
134+ if result != int(wlr.ZwlrOutputConfigurationV1EventCancelled) {
135+ return result
136+ }
137+ if serial == requested {
138+ t.Fatalf("configuration cancelled without a newer inventory serial: %d", requested)
139+ }
157140 }
158- roundtrip(func() bool { return cfg.result != 0 })
159- return cfg.result - 1
141+ t.Fatal("output inventory kept changing across three configurations")
142+ return -1
160143 }
161- got := configure(func(cfg uint32) {
162- ch := &ignoredEvents{c: c}
163- ch.SetID(c.AllocateID())
164- c.Context().Register(ch)
165- if err := c.SendRequest(cfg, uint16(wlr.ZwlrOutputConfigurationV1RequestEnableHead), ch.ID(), head); err != nil {
144+ got := configure(func(cfg *client.OutputConfiguration) *client.OutputConfigurationHead {
145+ ch, err := cfg.EnableHead(head)
146+ if err != nil {
166147 t.Fatal(err)
167148 }
168- if err := c.SendRequest(ch.ID(), uint16(wlr.ZwlrOutputConfigurationHeadV1RequestSetScale), int32(512)); err != nil {
149+ if err := ch.SetScale(wlturbo.NewFixed(2)); err != nil {
169150 t.Fatal(err)
170151 }
152+ return ch
171153 })
172154 if got != int(wlr.ZwlrOutputConfigurationV1EventSucceeded) {
173155 t.Fatalf("scale 2: event %d", got)
174156 }
175157 scene(func(s ports.Scene) bool { return s.Scale == 2 })
176- // A headless output cannot be disabled. The new scale gives a new serial.
177- roundtrip(func() bool { return manager.done != serial })
178- got = configure(func(cfg uint32) {
179- if err := c.SendRequest(cfg, uint16(wlr.ZwlrOutputConfigurationV1RequestDisableHead), head); err != nil {
158+ roundtrip(func() bool { return serial != initialSerial })
159+ got = configure(func(cfg *client.OutputConfiguration) *client.OutputConfigurationHead {
160+ if err := cfg.DisableHead(head); err != nil {
180161 t.Fatal(err)
181162 }
163+ return nil
182164 })
183165 if got != int(wlr.ZwlrOutputConfigurationV1EventFailed) {
184166 t.Fatalf("disable: event %d", got)
M internal/app/outputrouting.go +17 -14
@@ -3,21 +3,24 @@ package app
33 import "github.com/bnema/neferwl/internal/ports"
44
55 // outputChannels is the shared app-to-backend wiring. The backend loop remains
6-// the sole owner of outputSet and outputApply; routing does not introduce a
7-// forwarding queue. heads, configured and replies are fed from the
8-// outputApply outbox.
6+// the sole owner of outputSet and outputApply. Security producers use bounded
7+// lossless FIFO forwarders; content backpressure stays inside the backend select.
8+// heads, configured and replies are fed from the outputApply outbox.
99 type outputChannels struct {
10- leaseRequests <-chan ports.LeaseMessage
11- leaseEvents chan<- ports.LeaseMessage
12- events chan<- ports.OutputEvent
13- scenes <-chan []ports.Scene
14- contents <-chan ports.SurfaceContent
15- cursorChanges <-chan ports.CursorChange
16- presented chan<- ports.OutputPresented
17- captures <-chan ports.CaptureRequest
18- captured chan<- ports.CaptureDone
19- formats chan<- ports.OutputFormats
20- heads chan<- ports.OutputHeads
10+ security ports.SessionSecurity
11+ securityChanges <-chan ports.SecurityState
12+ securityEvents chan<- ports.SecurityBackendEvent
13+ leaseRequests <-chan ports.LeaseMessage
14+ leaseEvents chan<- ports.LeaseMessage
15+ events chan<- ports.OutputEvent
16+ scenes <-chan []ports.Scene
17+ contents <-chan ports.SurfaceContent
18+ cursorChanges <-chan ports.CursorChange
19+ presented chan<- ports.OutputPresented
20+ captures <-chan ports.CaptureRequest
21+ captured chan<- ports.CaptureDone
22+ formats chan<- ports.OutputFormats
23+ heads chan<- ports.OutputHeads
2124 // reloads carries file configuration; configured carries the effective
2225 // configuration, overrides included, to core.
2326 reloads <-chan ports.ConfigChanged
M internal/app/outputrouting_test.go +2 -2
@@ -14,7 +14,7 @@ func TestOutputRouting(t *testing.T) {
1414 replies := make(chan ports.CaptureDone, 2)
1515 set := newOutputSet(ctx, replies)
1616 for _, name := range []string{"A", "B"} {
17- set.start(ctx, name, func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest) error {
17+ set.start(ctx, name, func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
1818 <-ctx.Done()
1919 return nil
2020 })
@@ -78,7 +78,7 @@ func TestOutputRoutingAllocations(t *testing.T) {
7878 set.content(content)
7979 ctx, cancel := context.WithCancel(context.Background())
8080 defer cancel()
81- set.start(ctx, "A", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest) error {
81+ set.start(ctx, "A", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
8282 <-ctx.Done()
8383 return nil
8484 })
M internal/app/outputs.go +196 -34
@@ -12,7 +12,7 @@ import (
1212
1313 // outputRun drives one output until ctx ends: it renders the scenes and
1414 // surface contents it receives, with the cursor the client asks for.
15-type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, captures <-chan ports.CaptureRequest) error
15+type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, captures <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error
1616
1717 // outputSet owns the running outputs and feeds them. Core sends one scene
1818 // per output; each goes to its output, latest first. Surface contents go to
@@ -20,50 +20,181 @@ type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-c
2020 // each window is kept so a new output starts with every window drawn.
2121 // Only the goroutine running loop touches the set.
2222 type outputSet struct {
23- outs map[string]*runningOutput
24- latest map[ports.WindowID]ports.SurfaceContent
25- cursor ports.CursorChange
26- stopped chan string
27- captured chan<- ports.CaptureDone
28- ctx context.Context
29- quit chan struct{} // closed by wait: nobody reads stopped any more
23+ outs map[string]*runningOutput
24+ latest map[ports.WindowID]ports.SurfaceContent
25+ cursor ports.CursorChange
26+ stopped chan outputStopped
27+ captured chan<- ports.CaptureDone
28+ ctx context.Context
29+ quit chan struct{} // closed by wait: nobody reads stopped any more
30+ security ports.SessionSecurity
31+ securityEvents chan<- ports.SecurityBackendEvent
32+ state ports.SecurityState
33+ nextInstance ports.OutputInstance
34+ startErr error
35+ barriers chan ports.SecurityBackendEvent
36+ forwarders sync.WaitGroup
37+}
38+
39+// A stop is tied to a lifetime, never just a reusable connector name.
40+type outputStopped struct {
41+ name string
42+ instance ports.OutputInstance
3043 }
3144
3245 type runningOutput struct {
33- scenes chan ports.Scene
34- contents chan ports.SurfaceContent
35- cursor chan ports.CursorChange
36- captures chan ports.CaptureRequest
37- ctx context.Context
38- stop context.CancelFunc
39- err error
40- done chan struct{}
46+ instance ports.OutputInstance
47+ security chan ports.SecurityState
48+ securityEvents chan ports.SecurityBackendEvent
49+ pending []ports.SurfaceContent
50+ scenes chan ports.Scene
51+ contents chan ports.SurfaceContent
52+ cursor chan ports.CursorChange
53+ captures chan ports.CaptureRequest
54+ ctx context.Context
55+ stop context.CancelFunc
56+ err error
57+ done chan struct{}
4158 }
4259
4360 func newOutputSet(ctx context.Context, captured chan<- ports.CaptureDone) *outputSet {
44- return &outputSet{captured: captured, ctx: ctx, outs: map[string]*runningOutput{}, latest: map[ports.WindowID]ports.SurfaceContent{}, stopped: make(chan string), quit: make(chan struct{})}
61+ return &outputSet{captured: captured, ctx: ctx, outs: map[string]*runningOutput{}, latest: map[ports.WindowID]ports.SurfaceContent{}, stopped: make(chan outputStopped), quit: make(chan struct{})}
4562 }
4663
4764 // start runs an output in its own goroutine and replays the window contents.
48-func (s *outputSet) start(ctx context.Context, name string, run outputRun) {
65+func (s *outputSet) start(ctx context.Context, name string, run outputRun, wire ...*chan<- ports.SecurityBackendEvent) bool {
66+ // Exhaustion is permanent: never wrap to zero or reuse a lifetime.
67+ if s.nextInstance == ^ports.OutputInstance(0) {
68+ s.startErr = fmt.Errorf("output instance exhausted")
69+ return false
70+ }
71+ // Register authoritatively before an owner can render or replay contents.
72+ s.nextInstance++
73+ instance := s.nextInstance
74+ if !s.securityEvent(ports.SecurityOutputAdded{Instance: instance, Output: name}) {
75+ return false
76+ }
77+ if s.security != nil {
78+ s.setSecurity(s.security.Snapshot())
79+ }
4980 octx, stop := context.WithCancel(ctx)
50- r := &runningOutput{scenes: make(chan ports.Scene, 1), contents: make(chan ports.SurfaceContent, 64), cursor: make(chan ports.CursorChange, 1), captures: make(chan ports.CaptureRequest, 16), ctx: octx, stop: stop, done: make(chan struct{})}
81+ r := &runningOutput{instance: instance, security: make(chan ports.SecurityState, 1), scenes: make(chan ports.Scene, 1), contents: make(chan ports.SurfaceContent, 64), cursor: make(chan ports.CursorChange, 1), captures: make(chan ports.CaptureRequest, 16), ctx: octx, stop: stop, done: make(chan struct{})}
82+ r.security <- s.state // available before any old scene/content replay
83+ r.securityEvents = s.forwardSecurity()
84+ for _, destination := range wire {
85+ *destination = r.securityEvents
86+ }
87+ for _, c := range s.latest {
88+ select {
89+ case r.contents <- c:
90+ default:
91+ r.pending = append(r.pending, c)
92+ }
93+ }
5194 s.outs[name] = r
5295 go func() {
53- r.err = run(octx, r.scenes, r.contents, r.cursor, r.captures)
96+ r.err = run(octx, r.scenes, r.contents, r.cursor, r.captures, r.security, r.instance)
97+ if r.securityEvents != nil {
98+ close(r.securityEvents)
99+ }
54100 stop()
55101 close(r.done)
56102 select {
57- case s.stopped <- name:
103+ case s.stopped <- outputStopped{name: name, instance: instance}:
58104 case <-s.quit:
59105 }
60106 }()
61107 r.cursor <- s.cursor
62- for _, c := range s.latest {
63- r.send(c)
108+ return true
109+}
110+
111+func (s *outputSet) wireSecurity(ch outputChannels) {
112+ s.security, s.securityEvents = ch.security, ch.securityEvents
113+ if ch.securityEvents != nil {
114+ s.barriers = s.forwardSecurity()
115+ }
116+ if s.security != nil {
117+ s.setSecurity(s.security.Snapshot())
64118 }
65119 }
66120
121+// securityEvent registers/removes directly, while barriers use a bounded FIFO.
122+// No authoritative event is merged or discarded during an active backend.
123+func (s *outputSet) securityEvent(ev ports.SecurityBackendEvent) bool {
124+ if s.securityEvents == nil {
125+ return true // security disabled
126+ }
127+ destination := s.securityEvents
128+ if _, ok := ev.(ports.SecurityBackendBarrier); ok && s.barriers != nil {
129+ destination = s.barriers
130+ }
131+ select {
132+ case destination <- ev:
133+ return true
134+ case <-s.ctx.Done():
135+ return false
136+ }
137+}
138+
139+// Each producer has a finite, FIFO outbox. Saturation applies backpressure;
140+// events are never merged. Registration/removal bypass it for lifecycle ordering.
141+func (s *outputSet) forwardSecurity() chan ports.SecurityBackendEvent {
142+ if s.securityEvents == nil {
143+ return nil
144+ }
145+ incoming := make(chan ports.SecurityBackendEvent, 16)
146+ s.forwarders.Add(1)
147+ go func() {
148+ defer s.forwarders.Done()
149+ for {
150+ select {
151+ case ev, ok := <-incoming:
152+ if !ok {
153+ return
154+ }
155+ select {
156+ case s.securityEvents <- ev:
157+ case <-s.ctx.Done():
158+ return
159+ case <-s.quit:
160+ return
161+ }
162+ case <-s.ctx.Done():
163+ return
164+ case <-s.quit:
165+ return
166+ }
167+ }
168+ }()
169+ return incoming
170+}
171+
172+func (s *outputSet) setSecurity(state ports.SecurityState) bool {
173+ if s.security != nil {
174+ if newest := s.security.Snapshot(); newest.Generation > state.Generation {
175+ state = newest
176+ }
177+ }
178+ if state.Generation < s.state.Generation || state == s.state {
179+ return false
180+ }
181+ s.state = state
182+ for _, r := range s.outs {
183+ select {
184+ case <-r.security:
185+ default:
186+ }
187+ r.security <- state
188+ }
189+ return true
190+}
191+
192+// stoppedCurrent rejects delayed stop messages from a previous incarnation.
193+func (s *outputSet) stoppedCurrent(stopped outputStopped) bool {
194+ r := s.outs[stopped.name]
195+ return r != nil && r.instance == stopped.instance
196+}
197+
67198 // setCursor gives every output the latest cursor, replacing an unread one.
68199 func (s *outputSet) setCursor(c ports.CursorChange) {
69200 s.cursor = c
@@ -76,15 +207,6 @@ func (s *outputSet) setCursor(c ports.CursorChange) {
76207 }
77208 }
78209
79-// send hands content to the output unless it has stopped.
80-func (r *runningOutput) send(c ports.SurfaceContent) {
81- select {
82- case r.contents <- c:
83- case <-r.ctx.Done():
84- case <-r.done:
85- }
86-}
87-
88210 // scenes routes each scene to its output, replacing an unread one.
89211 func (s *outputSet) scenes(set []ports.Scene) {
90212 for _, sc := range set {
@@ -105,7 +227,41 @@ func (s *outputSet) content(c ports.SurfaceContent) {
105227 s.latest[c.ID] = c
106228 }
107229 for _, r := range s.outs {
108- r.send(c)
230+ if r.ctx.Err() != nil {
231+ continue
232+ }
233+ if len(r.pending) == 0 {
234+ select {
235+ case r.contents <- c:
236+ continue
237+ default:
238+ }
239+ }
240+ // The backend disabled intake until the preceding broadcast drained.
241+ if cap(r.pending) == 0 {
242+ r.pending = make([]ports.SurfaceContent, 0, 1)
243+ }
244+ r.pending = append(r.pending, c)
245+ }
246+}
247+
248+// At most one admitted broadcast waits behind each owner's finite replay.
249+// Disable new content intake until all copies have been delivered.
250+func (s *outputSet) contentOut(incoming <-chan ports.SurfaceContent) (<-chan ports.SurfaceContent, chan<- ports.SurfaceContent, ports.SurfaceContent, *runningOutput) {
251+ for _, r := range s.outs {
252+ if len(r.pending) != 0 && r.ctx.Err() == nil {
253+ return nil, r.contents, r.pending[0], r
254+ }
255+ }
256+ return incoming, nil, ports.SurfaceContent{}, nil
257+}
258+
259+func (r *runningOutput) contentSent() {
260+ r.pending[0] = ports.SurfaceContent{}
261+ if len(r.pending) == 1 {
262+ r.pending = r.pending[:0] // retain the one-slot broadcast buffer
263+ } else {
264+ r.pending = r.pending[1:]
109265 }
110266 }
111267
@@ -124,8 +280,14 @@ func (s *outputSet) finish(name string) error {
124280
125281 // wait stops every output and returns the first error.
126282 func (s *outputSet) wait() error {
127- var first error
283+ var first error = s.startErr
284+ // Cancel producers before closing the forwarding quit path. Owners may
285+ // be backpressured on a full FIFO, and need their context to exit.
286+ for _, r := range s.outs {
287+ r.stop()
288+ }
128289 close(s.quit)
290+ s.forwarders.Wait()
129291 for name, r := range s.outs {
130292 r.stop()
131293 <-r.done
M internal/app/outputs_test.go +1 -1
@@ -17,7 +17,7 @@ func TestOutputSetCursor(t *testing.T) {
1717 run := func(name string) outputRun {
1818 got[name] = make(chan ports.CursorChange, 8)
1919 out := got[name]
20- return func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, _ <-chan ports.CaptureRequest) error {
20+ return func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
2121 for {
2222 select {
2323 case <-ctx.Done():
A internal/app/security_duplicate_test.go +122 -0
@@ -0,0 +1,122 @@
1+package app
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/bnema/neferwl/internal/sessionlock"
10+)
11+
12+// Native Close validation is exercised by DRM's generated-KMS tests. This
13+// boundary test proves that even a name-discarded physical lifetime enters the
14+// authoritative ledger, and only affirmative inactivity can retire it.
15+func TestSecurityDuplicatePhysicalLifetimeRequiresConfirmedInactive(t *testing.T) {
16+ for _, inactive := range []bool{false, true} {
17+ name := "unconfirmed"
18+ if inactive {
19+ name = "confirmed"
20+ }
21+ t.Run(name, func(t *testing.T) {
22+ gate := &sessionsecurity.Gate{}
23+ state, err := gate.Engage()
24+ if err != nil {
25+ t.Fatal(err)
26+ }
27+ events := make(chan ports.SecurityBackendEvent, 4)
28+ set := newOutputSet(context.Background(), nil)
29+ set.wireSecurity(outputChannels{security: gate, securityEvents: events})
30+ run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
31+ <-ctx.Done()
32+ return nil
33+ }
34+ if !set.start(context.Background(), "DP-1", run) {
35+ t.Fatal("primary output failed to start")
36+ }
37+ primary := (<-events).(ports.SecurityOutputAdded)
38+ duplicate, err := registerDiscardedOutput(set, "DP-1")
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ added := (<-events).(ports.SecurityOutputAdded)
43+ if added.Instance != duplicate || added.Output != primary.Output || duplicate <= primary.Instance {
44+ t.Fatalf("duplicate lifetime registration: primary=%+v duplicate=%+v", primary, added)
45+ }
46+ if len(set.outs) != 1 || set.outs["DP-1"].instance != primary.Instance {
47+ t.Fatal("discarded lifetime replaced routed output")
48+ }
49+ var ledger sessionlock.Readiness
50+ if !ledger.Add(primary.Instance) || !ledger.Add(duplicate) || !ledger.Begin(state.Generation) {
51+ t.Fatal("ledger setup failed")
52+ }
53+ ledger.BackendBarrier(state.Generation)
54+ ledger.CaptureBarrier(state.Generation)
55+ ledger.Record(ports.OutputProtection{Generation: state.Generation, Instance: primary.Instance, Kind: ports.ProtectionProtectedFrame})
56+ if ledger.Ready() {
57+ t.Fatal("primary proof hid duplicate physical output")
58+ }
59+ err = retireDiscardedOutput(set, duplicate, inactive)
60+ if inactive {
61+ if err != nil {
62+ t.Fatal(err)
63+ }
64+ removed := (<-events).(ports.SecurityOutputRemoved)
65+ if removed.Instance != duplicate {
66+ t.Fatal("removed primary instead of duplicate")
67+ }
68+ if !ledger.Remove(removed.Instance) || !ledger.Ready() {
69+ t.Fatal("confirmed inactive duplicate did not retire")
70+ }
71+ } else {
72+ if err == nil {
73+ t.Fatal("unconfirmed close accepted")
74+ }
75+ select {
76+ case event := <-events:
77+ t.Fatalf("false retirement/proof: %+v", event)
78+ default:
79+ }
80+ if ledger.Ready() {
81+ t.Fatal("unproved duplicate permitted locked")
82+ }
83+ }
84+ if !gate.Snapshot().Protected {
85+ t.Fatal("discarding duplicate released gate")
86+ }
87+ if err := set.wait(); err != nil {
88+ t.Fatal(err)
89+ }
90+ })
91+ }
92+}
93+
94+func TestSecurityDiscardedLifetimeCounterExhaustion(t *testing.T) {
95+ events := make(chan ports.SecurityBackendEvent, 2)
96+ set := newOutputSet(context.Background(), nil)
97+ set.wireSecurity(outputChannels{securityEvents: events})
98+ set.nextInstance = ^ports.OutputInstance(0) - 1
99+ instance, err := registerDiscardedOutput(set, "DP-1")
100+ if err != nil || instance != ^ports.OutputInstance(0) {
101+ t.Fatalf("last lifetime: %d %v", instance, err)
102+ }
103+ if added := (<-events).(ports.SecurityOutputAdded); added.Instance != instance {
104+ t.Fatal("registration differs")
105+ }
106+ for range 2 {
107+ if instance, err := registerDiscardedOutput(set, "DP-1"); instance != 0 || err == nil {
108+ t.Fatalf("exhaustion: %d %v", instance, err)
109+ }
110+ }
111+ if set.nextInstance != ^ports.OutputInstance(0) {
112+ t.Fatal("counter wrapped")
113+ }
114+ select {
115+ case event := <-events:
116+ t.Fatalf("exhaustion registered: %+v", event)
117+ default:
118+ }
119+ if err := set.wait(); err == nil {
120+ t.Fatal("exhaustion not retained")
121+ }
122+}
A internal/app/security_hotplug_test.go +392 -0
@@ -0,0 +1,392 @@
1+package app
2+
3+import (
4+ "context"
5+ "reflect"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+ "github.com/bnema/neferwl/internal/sessionlock"
11+)
12+
13+func hotplugEvent(t *testing.T, events <-chan ports.SecurityBackendEvent) ports.SecurityBackendEvent {
14+ t.Helper()
15+ select {
16+ case event := <-events:
17+ return event
18+ case <-time.After(2 * time.Second):
19+ t.Fatal("missing ordered security event")
20+ return nil
21+ }
22+}
23+
24+// This switch represents the single display owner's ledger consumption, not
25+// an alternate backend implementation. Events come through real outputSet FIFO.
26+func hotplugApply(ledger *sessionlock.Readiness, event ports.SecurityBackendEvent) bool {
27+ switch v := event.(type) {
28+ case ports.SecurityOutputAdded:
29+ return ledger.Add(v.Instance)
30+ case ports.SecurityOutputRemoved:
31+ return ledger.Remove(v.Instance)
32+ case ports.SecurityOutputInvalidated:
33+ return ledger.Invalidate(v.Generation, v.Instance)
34+ case ports.SecurityOutputProof:
35+ return ledger.Record(v.Proof)
36+ case ports.SecurityBackendBarrier:
37+ return v.Err == nil && ledger.BackendBarrier(v.Generation)
38+ default:
39+ return false
40+ }
41+}
42+
43+func TestSecurityHotplugLifetimeAndProofOrdering(t *testing.T) {
44+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
45+ defer cancel()
46+ events := make(chan ports.SecurityBackendEvent, 8)
47+ set := newOutputSet(ctx, nil)
48+ set.wireSecurity(outputChannels{securityEvents: events})
49+ defer func() {
50+ if err := set.wait(); err != nil {
51+ t.Error(err)
52+ }
53+ }()
54+ const generation = ports.LockGeneration(41)
55+ set.setSecurity(ports.SecurityState{Generation: generation, Protected: true})
56+ var ledger sessionlock.Readiness
57+ if !ledger.Begin(generation) || !ledger.BackendBarrier(generation) || !ledger.CaptureBarrier(generation) {
58+ t.Fatal("could not begin display readiness round")
59+ }
60+ // Native Close success is independently tested in DRM. Here the real
61+ // owner callback synchronizes that affirmative result before the backend
62+ // sends removal; mere callback exit never emits removal from outputSet.
63+ start := func(closeSuccess bool) (ports.OutputInstance, chan []ports.SecurityBackendEvent, <-chan bool) {
64+ t.Helper()
65+ commands := make(chan []ports.SecurityBackendEvent, 1)
66+ closed := make(chan bool, 1)
67+ var ownerEvents chan<- ports.SecurityBackendEvent
68+ if !set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error {
69+ defer func() { closed <- closeSuccess }()
70+ state := <-security
71+ proof := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: state.Generation, Instance: instance, Kind: ports.ProtectionProtectedFrame}}
72+ select {
73+ case ownerEvents <- proof:
74+ case <-octx.Done():
75+ return nil
76+ }
77+ for {
78+ select {
79+ case batch := <-commands:
80+ for _, event := range batch {
81+ select {
82+ case ownerEvents <- event:
83+ case <-octx.Done():
84+ return nil
85+ }
86+ }
87+ case <-octx.Done():
88+ return nil
89+ }
90+ }
91+ }, &ownerEvents) {
92+ t.Fatal("start refused")
93+ }
94+ registered := hotplugEvent(t, events)
95+ added, ok := registered.(ports.SecurityOutputAdded)
96+ if !ok || added.Output != "DP-1" || added.Instance == 0 || !hotplugApply(&ledger, registered) {
97+ t.Fatalf("owner evidence preceded lifetime registration: %+v", registered)
98+ }
99+ if ledger.Ready() {
100+ t.Fatal("new lifetime reused previous physical readiness")
101+ }
102+ proof := hotplugEvent(t, events)
103+ if !hotplugApply(&ledger, proof) || !ledger.Ready() {
104+ t.Fatalf("missing exact lifetime proof: %+v", proof)
105+ }
106+ return added.Instance, commands, closed
107+ }
108+ old, commands, closed := start(true)
109+ // A proof already queued by this owner precedes invalidation, even if the
110+ // display reads later. No separate forwarder can replay it after revoke.
111+ oldProof := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: generation, Instance: old, Kind: ports.ProtectionProtectedFrame}}
112+ invalid := ports.SecurityOutputInvalidated{Generation: generation, Instance: old}
113+ commands <- []ports.SecurityBackendEvent{oldProof, invalid}
114+ if got := hotplugEvent(t, events); got != oldProof || !hotplugApply(&ledger, got) {
115+ t.Fatalf("queued proof lost: %+v", got)
116+ }
117+ if got := hotplugEvent(t, events); got != invalid || !hotplugApply(&ledger, got) || ledger.Ready() {
118+ t.Fatalf("invalidation reordered or ineffective: %+v", got)
119+ }
120+ commands <- []ports.SecurityBackendEvent{oldProof}
121+ if got := hotplugEvent(t, events); got != oldProof || !hotplugApply(&ledger, got) || !ledger.Ready() {
122+ t.Fatal("fresh resume proof did not restore readiness")
123+ }
124+ set.outs["DP-1"].stop()
125+ select {
126+ case stopped := <-set.stopped:
127+ if stopped.instance != old || !set.stoppedCurrent(stopped) {
128+ t.Fatalf("wrong stopped lifetime: %+v", stopped)
129+ }
130+ case <-ctx.Done():
131+ t.Fatal("owner did not stop")
132+ }
133+ if err := set.finish("DP-1"); err != nil {
134+ t.Fatal(err)
135+ }
136+ if !<-closed {
137+ t.Fatal("shutdown not affirmative")
138+ }
139+ select {
140+ case got := <-events:
141+ t.Fatalf("owner exit forged removal: %+v", got)
142+ default:
143+ }
144+ if !set.securityEvent(ports.SecurityOutputRemoved{Instance: old}) {
145+ t.Fatal("confirmed removal refused")
146+ }
147+ removal := hotplugEvent(t, events)
148+ if removal != (ports.SecurityOutputRemoved{Instance: old}) || !hotplugApply(&ledger, removal) {
149+ t.Fatalf("wrong removal: %+v", removal)
150+ }
151+ replacement, _, replacementClosed := start(false)
152+ if replacement <= old || set.stoppedCurrent(outputStopped{name: "DP-1", instance: old}) {
153+ t.Fatal("same-name replacement reused retired lifetime")
154+ }
155+ if hotplugApply(&ledger, oldProof) || hotplugApply(&ledger, invalid) {
156+ t.Fatal("retired lifetime affected replacement")
157+ }
158+ stale := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: generation - 1, Instance: replacement, Kind: ports.ProtectionInactiveOutput}}
159+ if hotplugApply(&ledger, stale) || !ledger.Ready() {
160+ t.Fatal("stale generation overwrote valid replacement proof")
161+ }
162+ if !ledger.Invalidate(generation, replacement) || ledger.Ready() {
163+ t.Fatal("replacement invalidation ineffective")
164+ }
165+ set.outs["DP-1"].stop()
166+ select {
167+ case <-set.stopped:
168+ case <-ctx.Done():
169+ t.Fatal("replacement stop blocked")
170+ }
171+ if err := set.finish("DP-1"); err != nil {
172+ t.Fatal(err)
173+ }
174+ if <-replacementClosed {
175+ t.Fatal("expected failed shutdown")
176+ }
177+ select {
178+ case got := <-events:
179+ t.Fatalf("failed shutdown forged removal: %+v", got)
180+ default:
181+ }
182+ if ledger.Ready() {
183+ t.Fatal("failed replacement exit erased required unproved lifetime")
184+ }
185+}
186+
187+func TestSecurityOwnerFIFOSaturationDrainsOnNormalStop(t *testing.T) {
188+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
189+ defer cancel()
190+ events := make(chan ports.SecurityBackendEvent) // block display after registration
191+ set := newOutputSet(ctx, nil)
192+ set.wireSecurity(outputChannels{securityEvents: events})
193+ defer func() {
194+ if err := set.wait(); err != nil {
195+ t.Error(err)
196+ }
197+ }()
198+ const generation = ports.LockGeneration(7)
199+ accepted := make(chan int, 40)
200+ startDone := make(chan bool, 1)
201+ var ownerEvents chan<- ports.SecurityBackendEvent
202+ go func() {
203+ startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error {
204+ for i := range 40 {
205+ var event ports.SecurityBackendEvent = ports.SecurityOutputInvalidated{Generation: generation, Instance: instance}
206+ if i%2 == 0 {
207+ event = ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: generation, Instance: instance, Kind: ports.ProtectionProtectedFrame}}
208+ }
209+ select {
210+ case ownerEvents <- event:
211+ accepted <- i
212+ case <-octx.Done():
213+ return nil
214+ }
215+ }
216+ return nil
217+ }, &ownerEvents)
218+ }()
219+ registered := hotplugEvent(t, events).(ports.SecurityOutputAdded)
220+ if !<-startDone {
221+ t.Fatal("start failed")
222+ }
223+ if cap(set.outs["DP-1"].securityEvents) != 16 {
224+ t.Fatal("owner evidence FIFO is not bounded to 16")
225+ }
226+ // Forwarder holds one event plus its 16-slot input. The eighteenth send
227+ // must remain blocked until the display starts draining.
228+ for i := range 17 {
229+ select {
230+ case got := <-accepted:
231+ if got != i {
232+ t.Fatalf("acceptance order %d want %d", got, i)
233+ }
234+ case <-ctx.Done():
235+ t.Fatal("FIFO failed to fill")
236+ }
237+ }
238+ select {
239+ case got := <-accepted:
240+ t.Fatalf("full FIFO did not backpressure owner: %d", got)
241+ case <-time.After(20 * time.Millisecond):
242+ }
243+ var ledger sessionlock.Readiness
244+ ledger.Add(registered.Instance)
245+ ledger.Begin(generation)
246+ ledger.BackendBarrier(generation)
247+ ledger.CaptureBarrier(generation)
248+ for i := range 40 {
249+ got := hotplugEvent(t, events)
250+ var want ports.SecurityBackendEvent = ports.SecurityOutputInvalidated{Generation: generation, Instance: registered.Instance}
251+ if i%2 == 0 {
252+ want = ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: generation, Instance: registered.Instance, Kind: ports.ProtectionProtectedFrame}}
253+ }
254+ if !reflect.DeepEqual(got, want) || !hotplugApply(&ledger, got) || ledger.Ready() != (i%2 == 0) {
255+ t.Fatalf("evidence lost/duplicated/reordered at %d: %+v", i, got)
256+ }
257+ }
258+ select {
259+ case stopped := <-set.stopped:
260+ if !set.stoppedCurrent(stopped) {
261+ t.Fatal("stop not current")
262+ }
263+ case <-ctx.Done():
264+ t.Fatal("normal owner stop blocked")
265+ }
266+ if err := set.finish("DP-1"); err != nil {
267+ t.Fatal(err)
268+ }
269+ select {
270+ case got := <-events:
271+ t.Fatalf("duplicate evidence or removal after normal drain: %+v", got)
272+ default:
273+ }
274+}
275+
276+// All producer evidence is already buffered when the owner stops normally.
277+// finish must not cancel that owner's forwarder or lose its outstanding FIFO.
278+func TestSecurityOwnerBufferedEvidenceDrainsAfterFinish(t *testing.T) {
279+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
280+ defer cancel()
281+ events := make(chan ports.SecurityBackendEvent)
282+ set := newOutputSet(ctx, nil)
283+ set.wireSecurity(outputChannels{securityEvents: events})
284+ defer func() {
285+ if err := set.wait(); err != nil {
286+ t.Error(err)
287+ }
288+ }()
289+ startDone := make(chan bool, 1)
290+ var ownerEvents chan<- ports.SecurityBackendEvent
291+ go func() {
292+ startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error {
293+ for i := range 16 {
294+ event := ports.SecurityOutputInvalidated{Generation: ports.LockGeneration(i + 1), Instance: instance}
295+ select {
296+ case ownerEvents <- event:
297+ case <-octx.Done():
298+ return nil
299+ }
300+ }
301+ return nil
302+ }, &ownerEvents)
303+ }()
304+ registered := hotplugEvent(t, events).(ports.SecurityOutputAdded)
305+ if !<-startDone {
306+ t.Fatal("start refused")
307+ }
308+ select {
309+ case stopped := <-set.stopped:
310+ if stopped.instance != registered.Instance {
311+ t.Fatal("wrong stopped lifetime")
312+ }
313+ case <-ctx.Done():
314+ t.Fatal("buffered owner did not finish")
315+ }
316+ if err := set.finish("DP-1"); err != nil {
317+ t.Fatal(err)
318+ }
319+ for i := range 16 {
320+ want := ports.SecurityOutputInvalidated{Generation: ports.LockGeneration(i + 1), Instance: registered.Instance}
321+ if got := hotplugEvent(t, events); got != want {
322+ t.Fatalf("buffered stopped-owner evidence %d: %+v", i, got)
323+ }
324+ }
325+ select {
326+ case got := <-events:
327+ t.Fatalf("duplicate buffered evidence: %+v", got)
328+ default:
329+ }
330+}
331+
332+func TestSecurityOwnerFIFOSaturationCancelReleasesOwnerAndForwarders(t *testing.T) {
333+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
334+ defer cancel()
335+ events := make(chan ports.SecurityBackendEvent)
336+ set := newOutputSet(ctx, nil)
337+ set.wireSecurity(outputChannels{securityEvents: events})
338+ accepted := make(chan int, 40)
339+ ownerDone := make(chan struct{})
340+ startDone := make(chan bool, 1)
341+ var ownerEvents chan<- ports.SecurityBackendEvent
342+ go func() {
343+ startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error {
344+ defer close(ownerDone)
345+ for i := range 40 {
346+ select {
347+ case ownerEvents <- ports.SecurityOutputInvalidated{Generation: 7, Instance: instance}:
348+ accepted <- i
349+ case <-octx.Done():
350+ return nil
351+ }
352+ }
353+ return nil
354+ }, &ownerEvents)
355+ }()
356+ hotplugEvent(t, events) // registered before producer starts
357+ if !<-startDone {
358+ t.Fatal("start failed")
359+ }
360+ for range 17 {
361+ select {
362+ case <-accepted:
363+ case <-ctx.Done():
364+ t.Fatal("FIFO never filled")
365+ }
366+ }
367+ select {
368+ case <-accepted:
369+ t.Fatal("producer did not block at finite FIFO")
370+ case <-time.After(20 * time.Millisecond):
371+ }
372+ waitDone := make(chan error, 1)
373+ go func() { waitDone <- set.wait() }()
374+ select {
375+ case err := <-waitDone:
376+ if err != nil {
377+ t.Fatal(err)
378+ }
379+ case <-ctx.Done():
380+ t.Fatal("saturated evidence leaked owner/forwarder on cancellation")
381+ }
382+ select {
383+ case <-ownerDone:
384+ default:
385+ t.Fatal("wait returned before producer exited")
386+ }
387+ if len(set.outs) != 0 {
388+ t.Fatal("stopped lifetime retained in running set")
389+ }
390+ // wait includes forwarders.Wait: termination is directly synchronized,
391+ // rather than guessed from goroutine counts or arbitrary sleeps.
392+}
A internal/app/security_test.go +317 -0
@@ -0,0 +1,317 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "testing"
8+ "time"
9+
10+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11+ "github.com/bnema/neferwl/internal/ports"
12+)
13+
14+func TestSecurityRegistrationBeforeStart(t *testing.T) {
15+ ctx, cancel := context.WithCancel(context.Background())
16+ defer cancel()
17+ events := make(chan ports.SecurityBackendEvent)
18+ set := newOutputSet(ctx, nil)
19+ set.wireSecurity(outputChannels{securityEvents: events})
20+ set.setSecurity(ports.SecurityState{Generation: 7, Protected: true})
21+ started := make(chan ports.OutputInstance, 1)
22+ result := make(chan bool, 1)
23+ go func() {
24+ result <- set.start(ctx, "DP-1", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error {
25+ state := <-security
26+ if state != (ports.SecurityState{Generation: 7, Protected: true}) {
27+ return errors.New("missing startup protection")
28+ }
29+ started <- instance
30+ <-ctx.Done()
31+ return nil
32+ })
33+ }()
34+ select {
35+ case <-started:
36+ t.Fatal("owner ran before registration")
37+ default:
38+ }
39+ var added ports.SecurityOutputAdded
40+ select {
41+ case event := <-events:
42+ added = event.(ports.SecurityOutputAdded)
43+ case <-time.After(2 * time.Second):
44+ t.Fatal("registration missing")
45+ }
46+ if added.Instance == 0 || added.Output != "DP-1" {
47+ t.Fatalf("registration: %+v", added)
48+ }
49+ if !<-result {
50+ t.Fatal("start failed")
51+ }
52+ select {
53+ case instance := <-started:
54+ if instance != added.Instance {
55+ t.Fatal("owner lifetime differs")
56+ }
57+ case <-time.After(2 * time.Second):
58+ t.Fatal("owner missing startup protection")
59+ }
60+ if err := set.wait(); err != nil {
61+ t.Fatal(err)
62+ }
63+}
64+
65+func TestSecurityReplacementAndStaleState(t *testing.T) {
66+ ctx := context.Background()
67+ events := make(chan ports.SecurityBackendEvent, 4)
68+ set := newOutputSet(ctx, nil)
69+ set.wireSecurity(outputChannels{securityEvents: events})
70+ run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
71+ <-ctx.Done()
72+ return nil
73+ }
74+ set.start(ctx, "DP-1", run)
75+ old := (<-events).(ports.SecurityOutputAdded)
76+ set.outs["DP-1"].stop()
77+ if err := set.finish("DP-1"); err != nil {
78+ t.Fatal(err)
79+ }
80+ set.start(ctx, "DP-1", run)
81+ replacement := (<-events).(ports.SecurityOutputAdded)
82+ if replacement.Instance <= old.Instance {
83+ t.Fatal("reused lifetime")
84+ }
85+ if set.stoppedCurrent(outputStopped{name: "DP-1", instance: old.Instance}) {
86+ t.Fatal("old stop matches replacement")
87+ }
88+ set.setSecurity(ports.SecurityState{Generation: 3, Protected: true})
89+ set.setSecurity(ports.SecurityState{Generation: 2})
90+ if state := <-set.outs["DP-1"].security; state.Generation != 3 || !state.Protected {
91+ t.Fatalf("stale transition: %+v", state)
92+ }
93+ set.setSecurity(ports.SecurityState{Generation: 4})
94+ if state := <-set.outs["DP-1"].security; state.Generation != 4 || state.Protected {
95+ t.Fatalf("unlock not forwarded: %+v", state)
96+ }
97+ if err := set.wait(); err != nil {
98+ t.Fatal(err)
99+ }
100+ select {
101+ case ev := <-events:
102+ t.Fatalf("stop emitted false removal: %+v", ev)
103+ default:
104+ }
105+}
106+
107+func TestGuardedLeaseRejectsProtected(t *testing.T) {
108+ gate := portsmocks.NewMockSessionSecurity(t)
109+ gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true}).Once()
110+ card := newMocksecurityLeaseCard(t)
111+ fd, id, err := guardedLease(card, gate, []string{"DP-1"})
112+ if fd != nil || id != 0 || err == nil {
113+ t.Fatalf("protected lease: %v %d %v", fd, id, err)
114+ }
115+}
116+
117+func TestGuardedLeaseCrossingTransition(t *testing.T) {
118+ for _, revokeErr := range []error{nil, errors.New("revoke failed")} {
119+ t.Run(map[bool]string{false: "revoked", true: "failed"}[revokeErr != nil], func(t *testing.T) {
120+ fd, err := os.CreateTemp(t.TempDir(), "lease")
121+ if err != nil {
122+ t.Fatal(err)
123+ }
124+ gate := portsmocks.NewMockSessionSecurity(t)
125+ gate.EXPECT().Snapshot().Return(ports.SecurityState{}).Once()
126+ gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true}).Once()
127+ card := newMocksecurityLeaseCard(t)
128+ card.EXPECT().Lease([]string{"DP-1"}).Return(fd, uint32(12), nil).Once()
129+ card.EXPECT().Revoke(uint32(12)).Return(revokeErr).Once()
130+ got, id, err := guardedLease(card, gate, []string{"DP-1"})
131+ if got != nil || id != 0 || err == nil {
132+ t.Fatalf("late grant escaped: %v %d %v", got, id, err)
133+ }
134+ if _, statErr := fd.Stat(); !errors.Is(statErr, os.ErrClosed) {
135+ t.Fatalf("FD not closed: %v", statErr)
136+ }
137+ if revokeErr != nil && !errors.Is(err, revokeErr) {
138+ t.Fatal("revoke failure lost")
139+ }
140+ })
141+ }
142+}
143+
144+func TestSecurityRevokesAllLeasesAndRetainsFailures(t *testing.T) {
145+ card := newMocksecurityLeaseCard(t)
146+ card.EXPECT().LeaseIDs().Return([]uint32{1, 2}).Once()
147+ card.EXPECT().LeaseIDs().Return([]uint32{2}).Once()
148+ card.EXPECT().Revoke(uint32(1)).Return(nil).Once()
149+ failed := errors.New("native revoke failure")
150+ card.EXPECT().Revoke(uint32(2)).Return(failed).Once()
151+ card.EXPECT().Path().Return("card0")
152+ var finished []uint32
153+ err := revokeSecurityLeases(card, func(id uint32) { finished = append(finished, id) })
154+ if !errors.Is(err, failed) || len(finished) != 1 || finished[0] != 1 {
155+ t.Fatalf("revocation: %v finished %v", err, finished)
156+ }
157+ card.EXPECT().LeaseIDs().Return([]uint32{2}).Once()
158+ card.EXPECT().LeaseIDs().Return([]uint32{}).Once()
159+ card.EXPECT().Revoke(uint32(2)).Return(nil).Once()
160+ if err := revokeSecurityLeases(card, func(id uint32) {}); err != nil {
161+ t.Fatal(err)
162+ }
163+}
164+
165+func TestSecurityInstanceExhaustion(t *testing.T) {
166+ set := newOutputSet(context.Background(), nil)
167+ events := make(chan ports.SecurityBackendEvent, 2)
168+ set.wireSecurity(outputChannels{securityEvents: events})
169+ set.nextInstance = ^ports.OutputInstance(0) - 1
170+ run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
171+ <-ctx.Done()
172+ return nil
173+ }
174+ if !set.start(context.Background(), "last", run) {
175+ t.Fatal("last valid lifetime refused")
176+ }
177+ if added := (<-events).(ports.SecurityOutputAdded); added.Instance != ^ports.OutputInstance(0) {
178+ t.Fatalf("last instance: %+v", added)
179+ }
180+ for range 2 {
181+ if set.start(context.Background(), "overflow", run) {
182+ t.Fatal("exhausted counter launched owner")
183+ }
184+ }
185+ if set.nextInstance != ^ports.OutputInstance(0) || set.startErr == nil {
186+ t.Fatal("counter wrapped or error missing")
187+ }
188+ select {
189+ case ev := <-events:
190+ t.Fatalf("registered overflow: %+v", ev)
191+ default:
192+ }
193+ if err := set.wait(); err == nil {
194+ t.Fatal("exhaustion not returned")
195+ }
196+}
197+
198+// The shared display event channel and an output content queue can both be
199+// saturated. Routing must retain the next content without pinning the backend
200+// outside its select; a lease barrier can then finish before either is drained.
201+func TestSecurityFullEventsAndContentsAllowLeaseBarrier(t *testing.T) {
202+ ctx, cancel := context.WithCancel(context.Background())
203+ defer cancel()
204+ set := newOutputSet(ctx, nil)
205+ events := make(chan ports.SecurityBackendEvent, 1)
206+ set.wireSecurity(outputChannels{securityEvents: events})
207+ var ownerEvents chan<- ports.SecurityBackendEvent
208+ set.start(ctx, "DP-1", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
209+ <-ctx.Done()
210+ return nil
211+ }, &ownerEvents)
212+ <-events // ordered registration
213+ events <- ports.SecurityBackendBarrier{Generation: 99} // blocked display
214+ r := set.outs["DP-1"]
215+ for i := range cap(r.contents) {
216+ r.contents <- ports.SurfaceContent{ID: ports.WindowID(i + 1)}
217+ }
218+ first := ports.SecurityOutputInvalidated{Generation: 1, Instance: r.instance}
219+ second := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: 1, Instance: r.instance, Kind: ports.ProtectionProtectedFrame}}
220+ ownerEvents <- first
221+ ownerEvents <- second
222+ card := newMocksecurityLeaseCard(t)
223+ card.EXPECT().LeaseIDs().Return([]uint32{9}).Once()
224+ card.EXPECT().LeaseIDs().Return([]uint32{}).Once()
225+ card.EXPECT().Revoke(uint32(9)).Return(nil).Once()
226+ progressed := make(chan error, 1)
227+ go func() {
228+ set.content(ports.SurfaceContent{ID: 70})
229+ set.setSecurity(ports.SecurityState{Generation: 1, Protected: true})
230+ err := revokeSecurityLeases(card, func(uint32) {})
231+ set.securityEvent(ports.SecurityBackendBarrier{Generation: 1, Err: err})
232+ progressed <- err
233+ }()
234+ select {
235+ case err := <-progressed:
236+ if err != nil {
237+ t.Fatal(err)
238+ }
239+ case <-time.After(time.Second):
240+ t.Fatal("content/event backpressure blocked lease transition")
241+ }
242+ incoming := make(chan ports.SurfaceContent)
243+ contentIn, output, next, owner := set.contentOut(incoming)
244+ if contentIn != nil || output != r.contents || owner != r || next.ID != 70 || len(r.pending) != 1 {
245+ t.Fatal("pending content not bounded/preserved")
246+ }
247+ for range cap(r.contents) {
248+ <-r.contents
249+ }
250+ output <- next
251+ owner.contentSent()
252+ if got := <-r.contents; got.ID != 70 {
253+ t.Fatal("lost pending content")
254+ }
255+ if contentIn, _, _, _ := set.contentOut(incoming); contentIn != incoming {
256+ t.Fatal("content intake stayed disabled")
257+ }
258+ <-events // unblock display
259+ var ownerSequence []ports.SecurityBackendEvent
260+ for range 3 {
261+ select {
262+ case ev := <-events:
263+ switch ev.(type) {
264+ case ports.SecurityOutputInvalidated, ports.SecurityOutputProof:
265+ ownerSequence = append(ownerSequence, ev)
266+ }
267+ case <-time.After(time.Second):
268+ t.Fatal("lossless forwarder lost event")
269+ }
270+ }
271+ if len(ownerSequence) != 2 || ownerSequence[0] != first || ownerSequence[1] != second {
272+ t.Fatalf("owner order lost: %+v", ownerSequence)
273+ }
274+ if err := set.wait(); err != nil {
275+ t.Fatal(err)
276+ }
277+}
278+
279+func TestSecurityLargeReplayDoesNotBlockStart(t *testing.T) {
280+ set := newOutputSet(context.Background(), nil)
281+ for i := range 100 {
282+ set.content(ports.SurfaceContent{ID: ports.WindowID(i + 1), SHM: &ports.SHMBuffer{}})
283+ }
284+ if !set.start(context.Background(), "A", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error {
285+ <-ctx.Done()
286+ return nil
287+ }) {
288+ t.Fatal("start failed")
289+ }
290+ r := set.outs["A"]
291+ if len(r.contents) != cap(r.contents) || len(r.pending) != 100-cap(r.contents) {
292+ t.Fatal("replay not preserved")
293+ }
294+ seen := map[ports.WindowID]bool{}
295+ for len(seen) != 100 {
296+ select {
297+ case c := <-r.contents:
298+ if seen[c.ID] {
299+ t.Fatal("duplicate replay")
300+ }
301+ seen[c.ID] = true
302+ default:
303+ _, out, c, owner := set.contentOut(nil)
304+ if out == nil {
305+ t.Fatal("replay lost")
306+ }
307+ out <- c
308+ owner.contentSent()
309+ }
310+ }
311+ if len(r.pending) != 0 {
312+ t.Fatal("replay remains")
313+ }
314+ if err := set.wait(); err != nil {
315+ t.Fatal(err)
316+ }
317+}
A internal/app/securitylease_mock_test.go +256 -0
@@ -0,0 +1,256 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package app
6+
7+import (
8+ "os"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// newMocksecurityLeaseCard creates a new instance of mocksecurityLeaseCard. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func newMocksecurityLeaseCard(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *mocksecurityLeaseCard {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &mocksecurityLeaseCard{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// mocksecurityLeaseCard is an autogenerated mock type for the securityLeaseCard type
37+type mocksecurityLeaseCard struct {
38+ mock.Mock
39+}
40+
41+type mocksecurityLeaseCard_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *mocksecurityLeaseCard) EXPECT() *mocksecurityLeaseCard_Expecter {
46+ return &mocksecurityLeaseCard_Expecter{mock: &_m.Mock}
47+}
48+
49+// Lease provides a mock function for the type mocksecurityLeaseCard
50+func (_mock *mocksecurityLeaseCard) Lease(strings []string) (*os.File, uint32, error) {
51+ ret := _mock.Called(strings)
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for Lease")
55+ }
56+
57+ var r0 *os.File
58+ var r1 uint32
59+ var r2 error
60+ if returnFunc, ok := ret.Get(0).(func([]string) (*os.File, uint32, error)); ok {
61+ return returnFunc(strings)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func([]string) *os.File); ok {
64+ r0 = returnFunc(strings)
65+ } else {
66+ if ret.Get(0) != nil {
67+ r0 = ret.Get(0).(*os.File)
68+ }
69+ }
70+ if returnFunc, ok := ret.Get(1).(func([]string) uint32); ok {
71+ r1 = returnFunc(strings)
72+ } else {
73+ r1 = ret.Get(1).(uint32)
74+ }
75+ if returnFunc, ok := ret.Get(2).(func([]string) error); ok {
76+ r2 = returnFunc(strings)
77+ } else {
78+ r2 = ret.Error(2)
79+ }
80+ return r0, r1, r2
81+}
82+
83+// mocksecurityLeaseCard_Lease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Lease'
84+type mocksecurityLeaseCard_Lease_Call struct {
85+ *mock.Call
86+}
87+
88+// Lease is a helper method to define mock.On call
89+// - strings []string
90+func (_e *mocksecurityLeaseCard_Expecter) Lease(strings any) *mocksecurityLeaseCard_Lease_Call {
91+ return &mocksecurityLeaseCard_Lease_Call{Call: _e.mock.On("Lease", strings)}
92+}
93+
94+func (_c *mocksecurityLeaseCard_Lease_Call) Run(run func(strings []string)) *mocksecurityLeaseCard_Lease_Call {
95+ _c.Call.Run(func(args mock.Arguments) {
96+ var arg0 []string
97+ if args[0] != nil {
98+ arg0 = args[0].([]string)
99+ }
100+ run(
101+ arg0,
102+ )
103+ })
104+ return _c
105+}
106+
107+func (_c *mocksecurityLeaseCard_Lease_Call) Return(file *os.File, v uint32, err error) *mocksecurityLeaseCard_Lease_Call {
108+ _c.Call.Return(file, v, err)
109+ return _c
110+}
111+
112+func (_c *mocksecurityLeaseCard_Lease_Call) RunAndReturn(run func(strings []string) (*os.File, uint32, error)) *mocksecurityLeaseCard_Lease_Call {
113+ _c.Call.Return(run)
114+ return _c
115+}
116+
117+// LeaseIDs provides a mock function for the type mocksecurityLeaseCard
118+func (_mock *mocksecurityLeaseCard) LeaseIDs() []uint32 {
119+ ret := _mock.Called()
120+
121+ if len(ret) == 0 {
122+ panic("no return value specified for LeaseIDs")
123+ }
124+
125+ var r0 []uint32
126+ if returnFunc, ok := ret.Get(0).(func() []uint32); ok {
127+ r0 = returnFunc()
128+ } else {
129+ if ret.Get(0) != nil {
130+ r0 = ret.Get(0).([]uint32)
131+ }
132+ }
133+ return r0
134+}
135+
136+// mocksecurityLeaseCard_LeaseIDs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LeaseIDs'
137+type mocksecurityLeaseCard_LeaseIDs_Call struct {
138+ *mock.Call
139+}
140+
141+// LeaseIDs is a helper method to define mock.On call
142+func (_e *mocksecurityLeaseCard_Expecter) LeaseIDs() *mocksecurityLeaseCard_LeaseIDs_Call {
143+ return &mocksecurityLeaseCard_LeaseIDs_Call{Call: _e.mock.On("LeaseIDs")}
144+}
145+
146+func (_c *mocksecurityLeaseCard_LeaseIDs_Call) Run(run func()) *mocksecurityLeaseCard_LeaseIDs_Call {
147+ _c.Call.Run(func(args mock.Arguments) {
148+ run()
149+ })
150+ return _c
151+}
152+
153+func (_c *mocksecurityLeaseCard_LeaseIDs_Call) Return(uint32s []uint32) *mocksecurityLeaseCard_LeaseIDs_Call {
154+ _c.Call.Return(uint32s)
155+ return _c
156+}
157+
158+func (_c *mocksecurityLeaseCard_LeaseIDs_Call) RunAndReturn(run func() []uint32) *mocksecurityLeaseCard_LeaseIDs_Call {
159+ _c.Call.Return(run)
160+ return _c
161+}
162+
163+// Path provides a mock function for the type mocksecurityLeaseCard
164+func (_mock *mocksecurityLeaseCard) Path() string {
165+ ret := _mock.Called()
166+
167+ if len(ret) == 0 {
168+ panic("no return value specified for Path")
169+ }
170+
171+ var r0 string
172+ if returnFunc, ok := ret.Get(0).(func() string); ok {
173+ r0 = returnFunc()
174+ } else {
175+ r0 = ret.Get(0).(string)
176+ }
177+ return r0
178+}
179+
180+// mocksecurityLeaseCard_Path_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Path'
181+type mocksecurityLeaseCard_Path_Call struct {
182+ *mock.Call
183+}
184+
185+// Path is a helper method to define mock.On call
186+func (_e *mocksecurityLeaseCard_Expecter) Path() *mocksecurityLeaseCard_Path_Call {
187+ return &mocksecurityLeaseCard_Path_Call{Call: _e.mock.On("Path")}
188+}
189+
190+func (_c *mocksecurityLeaseCard_Path_Call) Run(run func()) *mocksecurityLeaseCard_Path_Call {
191+ _c.Call.Run(func(args mock.Arguments) {
192+ run()
193+ })
194+ return _c
195+}
196+
197+func (_c *mocksecurityLeaseCard_Path_Call) Return(s string) *mocksecurityLeaseCard_Path_Call {
198+ _c.Call.Return(s)
199+ return _c
200+}
201+
202+func (_c *mocksecurityLeaseCard_Path_Call) RunAndReturn(run func() string) *mocksecurityLeaseCard_Path_Call {
203+ _c.Call.Return(run)
204+ return _c
205+}
206+
207+// Revoke provides a mock function for the type mocksecurityLeaseCard
208+func (_mock *mocksecurityLeaseCard) Revoke(v uint32) error {
209+ ret := _mock.Called(v)
210+
211+ if len(ret) == 0 {
212+ panic("no return value specified for Revoke")
213+ }
214+
215+ var r0 error
216+ if returnFunc, ok := ret.Get(0).(func(uint32) error); ok {
217+ r0 = returnFunc(v)
218+ } else {
219+ r0 = ret.Error(0)
220+ }
221+ return r0
222+}
223+
224+// mocksecurityLeaseCard_Revoke_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Revoke'
225+type mocksecurityLeaseCard_Revoke_Call struct {
226+ *mock.Call
227+}
228+
229+// Revoke is a helper method to define mock.On call
230+// - v uint32
231+func (_e *mocksecurityLeaseCard_Expecter) Revoke(v any) *mocksecurityLeaseCard_Revoke_Call {
232+ return &mocksecurityLeaseCard_Revoke_Call{Call: _e.mock.On("Revoke", v)}
233+}
234+
235+func (_c *mocksecurityLeaseCard_Revoke_Call) Run(run func(v uint32)) *mocksecurityLeaseCard_Revoke_Call {
236+ _c.Call.Run(func(args mock.Arguments) {
237+ var arg0 uint32
238+ if args[0] != nil {
239+ arg0 = args[0].(uint32)
240+ }
241+ run(
242+ arg0,
243+ )
244+ })
245+ return _c
246+}
247+
248+func (_c *mocksecurityLeaseCard_Revoke_Call) Return(err error) *mocksecurityLeaseCard_Revoke_Call {
249+ _c.Call.Return(err)
250+ return _c
251+}
252+
253+func (_c *mocksecurityLeaseCard_Revoke_Call) RunAndReturn(run func(v uint32) error) *mocksecurityLeaseCard_Revoke_Call {
254+ _c.Call.Return(run)
255+ return _c
256+}
A internal/app/session_lock_test.go +398 -0
@@ -0,0 +1,398 @@
1+package app
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/neferwl/internal/adapters/config"
13+ "github.com/bnema/neferwl/internal/adapters/vulkan"
14+ "github.com/bnema/neferwl/internal/ports"
15+ "github.com/bnema/wlturbo"
16+ clientcore "github.com/bnema/wlturbo/protocol/core"
17+ lockclient "github.com/bnema/wlturbo/protocol/extsessionlock"
18+ captureclient "github.com/bnema/wlturbo/protocol/screencopy"
19+ clientxdg "github.com/bnema/wlturbo/protocol/xdgshell"
20+ "golang.org/x/sys/unix"
21+)
22+
23+// Exercise the production gate, backend proofs and protocol owner together.
24+// No test-only global registration or injected security events: bufferless
25+// locked must come from both real headless owners' affirmative black proofs.
26+func TestHeadlessSessionLockEndToEnd(t *testing.T) {
27+ runtime := t.TempDir()
28+ t.Setenv("XDG_RUNTIME_DIR", runtime)
29+ t.Setenv("XDG_STATE_HOME", t.TempDir())
30+ shots := t.TempDir()
31+ scenes := make(chan []ports.Scene, 128)
32+ script, writer := io.Pipe()
33+ defer writer.Close()
34+ ctx, cancel := context.WithCancel(context.Background())
35+ done := make(chan error, 1)
36+ go func() {
37+ done <- Run(ctx, Options{Backend: "headless", NoXwayland: true, NoTerminal: true, Config: config.Defaults(), Sizes: [][2]int{{160, 120}, {128, 96}}, ScreenshotDir: shots, Script: script, testScenes: scenes})
38+ }()
39+ // Cancellation closes clients' sockets too, bounding blocked Roundtrip.
40+ watchdog := time.AfterFunc(20*time.Second, cancel)
41+ exited := false
42+ t.Cleanup(func() {
43+ watchdog.Stop()
44+ cancel()
45+ if exited {
46+ return
47+ }
48+ select {
49+ case err := <-done:
50+ if err != nil {
51+ t.Errorf("Run: %v", err)
52+ }
53+ case <-time.After(3 * time.Second):
54+ t.Error("Run did not stop")
55+ }
56+ })
57+ check := func(err error) {
58+ t.Helper()
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ }
63+ waitScenes := func(what string, accept func([]ports.Scene) bool) []ports.Scene {
64+ t.Helper()
65+ timer := time.NewTimer(5 * time.Second)
66+ defer timer.Stop()
67+ for {
68+ select {
69+ case set := <-scenes:
70+ if accept(set) {
71+ return set
72+ }
73+ case err := <-done:
74+ exited = true
75+ t.Fatalf("app exited waiting for %s: %v", what, err)
76+ case <-timer.C:
77+ t.Fatalf("missing %s scenes", what)
78+ }
79+ }
80+ }
81+ waitScenes("initial outputs", func(set []ports.Scene) bool { return len(set) == 2 })
82+ sockets, err := filepath.Glob(filepath.Join(runtime, "wayland-*[0-9]"))
83+ check(err)
84+ if len(sockets) != 1 {
85+ t.Fatalf("sockets: %v", sockets)
86+ }
87+ connect := func() *wlturbo.Display {
88+ t.Helper()
89+ c, err := wlturbo.Connect(sockets[0])
90+ check(err)
91+ t.Cleanup(func() { _ = c.Close() })
92+ check(c.Roundtrip())
93+ return c
94+ }
95+ until := func(c *wlturbo.Display, what string, ready func() bool) {
96+ t.Helper()
97+ deadline := time.Now().Add(5 * time.Second)
98+ for !ready() {
99+ if time.Now().After(deadline) {
100+ t.Fatalf("missing %s", what)
101+ }
102+ check(c.Roundtrip())
103+ }
104+ }
105+ desktop := connect()
106+ // This fails RED until the parent authorizes production advertisement.
107+ if _, ok := desktop.Registry().FindGlobal(lockclient.ExtSessionLockManagerInterface); !ok {
108+ t.Fatal("production ext_session_lock_manager_v1 global missing: integration prerequisite")
109+ }
110+ comp := clientcore.NewCompositor(desktop.Context())
111+ shm := clientcore.NewShm(desktop.Context())
112+ seat := clientcore.NewSeat(desktop.Context())
113+ wm := clientxdg.NewXdgWmBase(desktop.Context())
114+ wm.OnPing(func(serial uint32) { check(wm.Pong(serial)) })
115+ bindTestClient(t, desktop, clientcore.CompositorInterface, 1, comp)
116+ bindTestClient(t, desktop, clientcore.ShmInterface, 1, shm)
117+ bindTestClient(t, desktop, clientcore.SeatInterface, 1, seat)
118+ bindTestClient(t, desktop, clientxdg.XdgWmBaseInterface, 1, wm)
119+ keyboard, err := seat.GetKeyboard()
120+ check(err)
121+ var focused uint32
122+ var desktopKeys []uint32
123+ keyboard.OnEnter(func(_ uint32, id uint32, _ []byte) { focused = id })
124+ keyboard.OnLeave(func(_ uint32, _ uint32) { focused = 0 })
125+ keyboard.OnKey(func(_, _, key, state uint32) {
126+ if state == 1 {
127+ desktopKeys = append(desktopKeys, key)
128+ }
129+ })
130+ surface, err := comp.CreateSurface()
131+ check(err)
132+ xdg, err := wm.GetXdgSurface(surface)
133+ check(err)
134+ _, err = xdg.GetToplevel()
135+ check(err)
136+ var serial uint32
137+ xdg.OnConfigure(func(value uint32) { serial = value })
138+ check(surface.Commit())
139+ until(desktop, "desktop configure", func() bool { return serial != 0 })
140+ check(xdg.AckConfigure(serial))
141+ buffer := sessionLockBuffer(t, shm, 1, 1, clientcore.FORMAT_ARGB8888)
142+ check(surface.Attach(buffer, 0, 0))
143+ check(surface.Commit())
144+ until(desktop, "desktop focus", func() bool { return focused == surface.ID() })
145+ _, err = io.WriteString(writer, "key a\n")
146+ check(err)
147+ until(desktop, "initial desktop input", func() bool { return len(desktopKeys) != 0 })
148+
149+ var outputs []*clientcore.Output
150+ for name, global := range desktop.Registry().GetGlobals() {
151+ if global.Interface != clientcore.OutputInterface {
152+ continue
153+ }
154+ out := clientcore.NewOutput(desktop.Context())
155+ check(desktop.Registry().Bind(name, global.Interface, 4, out))
156+ outputs = append(outputs, out)
157+ }
158+ if len(outputs) != 2 {
159+ t.Fatalf("outputs: %d", len(outputs))
160+ }
161+ captures := captureclient.NewScreencopyManager(desktop.Context())
162+ bindTestClient(t, desktop, captureclient.ScreencopyManagerInterface, 3, captures)
163+ denyCapture := func() {
164+ t.Helper()
165+ for _, out := range outputs {
166+ frame, err := captures.CaptureOutputRegion(0, out, 0, 0, 1, 1)
167+ check(err)
168+ failed, ready := false, false
169+ frame.OnFailed(func() { failed = true })
170+ frame.OnReady(func(_, _, _ uint32) { ready = true })
171+ frame.OnBufferDone(func() { check(frame.Copy(sessionLockBuffer(t, shm, 1, 1, clientcore.FORMAT_XRGB8888))) })
172+ until(desktop, "capture refusal", func() bool { return failed || ready })
173+ if !failed || ready {
174+ t.Fatal("ordinary capture succeeded while protected")
175+ }
176+ check(frame.Destroy())
177+ }
178+ }
179+ // Positive control: the same ordinary client and valid SHM destination
180+ // can capture before acquisition, so refusal is not a malformed request.
181+ // Software Vulkan without sync-file export (lavapipe) refuses every
182+ // protocol capture by design, so the control only holds where the device
183+ // can capture; the protection assertions below still run everywhere.
184+ if captureSupported(t) {
185+ control, err := captures.CaptureOutputRegion(0, outputs[0], 0, 0, 1, 1)
186+ check(err)
187+ controlReady, controlFailed := false, false
188+ control.OnReady(func(_, _, _ uint32) { controlReady = true })
189+ control.OnFailed(func() { controlFailed = true })
190+ control.OnBufferDone(func() { check(control.Copy(sessionLockBuffer(t, shm, 1, 1, clientcore.FORMAT_XRGB8888))) })
191+ until(desktop, "unlocked capture control", func() bool { return controlReady || controlFailed })
192+ if !controlReady || controlFailed {
193+ t.Fatal("valid unlocked capture failed")
194+ }
195+ check(control.Destroy())
196+ } else {
197+ t.Log("Vulkan device cannot capture (no sync-file export): positive control skipped")
198+ }
199+ // Protocol focus/capture completion is not completion of asynchronous PNG
200+ // writes. Establish both screenshot baselines before engaging protection.
201+ until(desktop, "initial screenshots on both outputs", func() bool {
202+ for _, name := range []string{"HEADLESS-1", "HEADLESS-2"} {
203+ if _, err := os.Stat(filepath.Join(shots, name, "latest.png")); err != nil {
204+ return false
205+ }
206+ }
207+ return true
208+ })
209+ acquire := func(c *wlturbo.Display) *lockclient.ExtSessionLock {
210+ t.Helper()
211+ manager := lockclient.NewExtSessionLockManager(c.Context())
212+ bindTestClient(t, c, lockclient.ExtSessionLockManagerInterface, 1, manager)
213+ lock, err := manager.Lock()
214+ check(err)
215+ locked, finished := 0, false
216+ lock.OnLocked(func() { locked++ })
217+ lock.OnFinished(func() { finished = true })
218+ until(c, "bufferless locked", func() bool { return locked != 0 || finished })
219+ if locked != 1 || finished {
220+ t.Fatalf("lock result: locked=%d finished=%v", locked, finished)
221+ }
222+ check(c.Roundtrip())
223+ if locked != 1 {
224+ t.Fatalf("duplicate locked: %d", locked)
225+ }
226+ return lock
227+ }
228+ locker := connect()
229+ lock := acquire(locker) // deliberately no locker surfaces or buffers yet
230+ protected := waitScenes("protected black", func(set []ports.Scene) bool {
231+ if len(set) != 2 {
232+ return false
233+ }
234+ for _, s := range set {
235+ if !s.Security.Protected || len(s.Windows) != 0 || len(s.Layers) != 0 || s.Background != "#000000" || s.Capture != nil || s.CaptureScene != nil {
236+ return false
237+ }
238+ }
239+ return true
240+ })
241+ generation := protected[0].Security.Generation
242+ if generation == 0 || protected[1].Security.Generation != generation {
243+ t.Fatal("incoherent protection generation")
244+ }
245+ check(desktop.Roundtrip())
246+ if focused != 0 {
247+ t.Fatal("desktop retained keyboard focus under lock")
248+ }
249+ denyCapture()
250+ before := sessionLockShots(t, shots)
251+ for _, name := range []string{"HEADLESS-1", "HEADLESS-2"} {
252+ if _, ok := before[filepath.Join(shots, name, "latest.png")]; !ok {
253+ t.Fatalf("missing initial screenshot for %s", name)
254+ }
255+ }
256+
257+ // A configured real locker surface supplies an input completion witness:
258+ // receipt by the locker proves scripted key processing without timing sleeps.
259+ lockComp := clientcore.NewCompositor(locker.Context())
260+ lockShm := clientcore.NewShm(locker.Context())
261+ lockSeat := clientcore.NewSeat(locker.Context())
262+ bindTestClient(t, locker, clientcore.CompositorInterface, 1, lockComp)
263+ bindTestClient(t, locker, clientcore.ShmInterface, 1, lockShm)
264+ bindTestClient(t, locker, clientcore.SeatInterface, 1, lockSeat)
265+ global, ok := locker.Registry().FindGlobal(clientcore.OutputInterface)
266+ if !ok {
267+ t.Fatal("locker output missing")
268+ }
269+ lockOutput := clientcore.NewOutput(locker.Context())
270+ check(locker.Registry().Bind(global.Name, global.Interface, 4, lockOutput))
271+ lockSurface, err := lockComp.CreateSurface()
272+ check(err)
273+ role, err := lock.GetLockSurface(lockSurface, lockOutput)
274+ check(err)
275+ configured := false
276+ role.OnConfigure(func(serial, width, height uint32) {
277+ check(role.AckConfigure(serial))
278+ check(lockSurface.Attach(sessionLockBuffer(t, lockShm, int32(width), int32(height), clientcore.FORMAT_ARGB8888), 0, 0))
279+ check(lockSurface.Commit())
280+ configured = true
281+ })
282+ lockKeyboard, err := lockSeat.GetKeyboard()
283+ check(err)
284+ lockFocused, lockKey, lockReleased := false, false, false
285+ lockKeyboard.OnEnter(func(_ uint32, id uint32, _ []byte) { lockFocused = id == lockSurface.ID() })
286+ lockKeyboard.OnKey(func(_, _, key, state uint32) {
287+ if key != 48 {
288+ return
289+ } // evdev b
290+ if state == 1 {
291+ lockKey = true
292+ } else {
293+ lockReleased = true
294+ }
295+ })
296+ until(locker, "locker focus", func() bool { return configured && lockFocused })
297+ desktopCount := len(desktopKeys)
298+ _, err = io.WriteString(writer, "key b\n")
299+ check(err)
300+ until(locker, "locker input and release", func() bool { return lockKey && lockReleased })
301+ check(desktop.Roundtrip())
302+ if len(desktopKeys) != desktopCount {
303+ t.Fatal("locker input leaked to desktop")
304+ }
305+ if after := sessionLockShots(t, shots); !equalSessionLockShots(before, after) {
306+ t.Fatal("screenshots changed while protected")
307+ }
308+
309+ check(locker.Close()) // crash never releases the gate
310+ denyCapture()
311+ replacement := connect()
312+ takeover := acquire(replacement)
313+ newer := waitScenes("takeover protection", func(set []ports.Scene) bool {
314+ return len(set) == 2 && set[0].Security.Protected && set[1].Security.Protected && set[0].Security.Generation > generation
315+ })
316+ if newer[0].Security.Generation != newer[1].Security.Generation {
317+ t.Fatal("takeover generation differs across outputs")
318+ }
319+ check(takeover.UnlockAndDestroy())
320+ check(replacement.Roundtrip())
321+ waitScenes("unlocked desktop", func(set []ports.Scene) bool {
322+ return len(set) == 2 && !set[0].Security.Protected && !set[1].Security.Protected && set[0].Security.Generation > newer[0].Security.Generation
323+ })
324+ until(desktop, "restored desktop focus", func() bool { return focused == surface.ID() })
325+ _, err = io.WriteString(writer, "key c\n")
326+ check(err)
327+ until(desktop, "restored desktop input", func() bool { return len(desktopKeys) > desktopCount })
328+ if desktopKeys[len(desktopKeys)-1] != 46 {
329+ t.Fatalf("restored key: %v, want evdev c=46", desktopKeys)
330+ }
331+}
332+
333+// Real protocol SHM is a client buffer, not a renderer fallback or a double.
334+func sessionLockBuffer(t *testing.T, shm *clientcore.Shm, width, height int32, format uint32) *clientcore.Buffer {
335+ t.Helper()
336+ size := width * height * 4
337+ fd, err := unix.MemfdCreate("session-lock-client", unix.MFD_CLOEXEC)
338+ if err != nil {
339+ t.Fatal(err)
340+ }
341+ if err := unix.Ftruncate(fd, int64(size)); err != nil {
342+ unix.Close(fd)
343+ t.Fatal(err)
344+ }
345+ pool, err := shm.CreatePool(fd, size) // generated request consumes fd on success
346+ if err != nil {
347+ unix.Close(fd)
348+ t.Fatal(err)
349+ }
350+ buffer, err := pool.CreateBuffer(0, width, height, width*4, format)
351+ if err != nil {
352+ t.Fatal(err)
353+ }
354+ if err := pool.Destroy(); err != nil {
355+ t.Fatal(err)
356+ }
357+ return buffer
358+}
359+
360+func sessionLockShots(t *testing.T, dir string) map[string][]byte {
361+ t.Helper()
362+ files, err := filepath.Glob(filepath.Join(dir, "HEADLESS-*", "*.png"))
363+ if err != nil {
364+ t.Fatal(err)
365+ }
366+ result := make(map[string][]byte, len(files))
367+ for _, path := range files {
368+ data, err := os.ReadFile(path)
369+ if err != nil {
370+ t.Fatal(err)
371+ }
372+ result[path] = data
373+ }
374+ return result
375+}
376+
377+func equalSessionLockShots(a, b map[string][]byte) bool {
378+ if len(a) != len(b) {
379+ return false
380+ }
381+ for path, data := range a {
382+ if !bytes.Equal(data, b[path]) {
383+ return false
384+ }
385+ }
386+ return true
387+}
388+
389+// captureSupported probes the device the headless outputs will render on.
390+func captureSupported(t *testing.T) bool {
391+ t.Helper()
392+ r, err := vulkan.New(16, 16)
393+ if err != nil {
394+ return false
395+ }
396+ defer r.Close()
397+ return r.CaptureSupported()
398+}
M internal/app/textinput_test.go +54 -112
@@ -12,10 +12,10 @@ import (
1212 "time"
1313
1414 "github.com/bnema/neferwl/internal/adapters/config"
15- "github.com/bnema/purego-libwayland/protocol/textinput"
16- "github.com/bnema/purego-libwayland/protocol/wayland"
17- "github.com/bnema/purego-libwayland/protocol/xdgshell"
1815 "github.com/bnema/wlturbo"
16+ clientcore "github.com/bnema/wlturbo/protocol/core"
17+ clienttext "github.com/bnema/wlturbo/protocol/textinput"
18+ clientxdg "github.com/bnema/wlturbo/protocol/xdgshell"
1919 "golang.org/x/sys/unix"
2020 )
2121
@@ -37,53 +37,14 @@ func (b *lockedBuffer) String() string {
3737 return b.buf.String()
3838 }
3939
40-// textClient records what a text input receives.
40+// textClient retains observations from an ordinary generated text-input proxy.
4141 type textClient struct {
42- wlturbo.BaseProxy
42+ *clienttext.TextInputV3
4343 entered bool
4444 preedit []string
4545 commits []string
4646 }
4747
48-func (p *textClient) Dispatch(e *wlturbo.Event) {
49- switch uint32(e.Opcode) {
50- case textinput.ZwpTextInputV3EventEnter:
51- p.entered = true
52- case textinput.ZwpTextInputV3EventPreeditString:
53- p.preedit = append(p.preedit, e.String())
54- case textinput.ZwpTextInputV3EventCommitString:
55- p.commits = append(p.commits, e.String())
56- }
57-}
58-
59-// xdgConfigure records the last configure serial.
60-type xdgConfigure struct {
61- wlturbo.BaseProxy
62- serial uint32
63-}
64-
65-func (p *xdgConfigure) Dispatch(e *wlturbo.Event) {
66- if uint32(e.Opcode) == xdgshell.SurfaceEventConfigure {
67- p.serial = e.Uint32()
68- }
69-}
70-
71-// quiet ignores the events of objects the test does not inspect.
72-type quiet struct{ wlturbo.BaseProxy }
73-
74-func (*quiet) Dispatch(*wlturbo.Event) {}
75-
76-type wmPing struct {
77- wlturbo.BaseProxy
78- c *wlturbo.Display
79-}
80-
81-func (p *wmPing) Dispatch(e *wlturbo.Event) {
82- if uint32(e.Opcode) == xdgshell.WmBaseEventPing {
83- _ = p.c.SendRequest(p.ID(), uint16(xdgshell.WmBaseRequestPong), e.Uint32())
84- }
85-}
86-
8748 // NeferWL headless, the examples/testime input method and a text input
8849 // client: the input method's preedit and commit reach the client.
8950 func TestHeadlessTextInputIME(t *testing.T) {
@@ -177,16 +138,16 @@ func TestHeadlessTextInputIME(t *testing.T) {
177138 }
178139 }
179140 poll("no text input enter", func() bool { return text.entered })
180- send := func(op uint32, args ...any) {
181- t.Helper()
182- if err := c.SendRequest(text.ID(), uint16(op), args...); err != nil {
141+ for i := 1; i <= 2; i++ {
142+ if err := text.Enable(); err != nil {
143+ t.Fatal(err)
144+ }
145+ if err := text.SetContentType(0, 0); err != nil {
146+ t.Fatal(err)
147+ }
148+ if err := text.Commit(); err != nil {
183149 t.Fatal(err)
184150 }
185- }
186- for i := 1; i <= 2; i++ {
187- send(textinput.ZwpTextInputV3RequestEnable)
188- send(textinput.ZwpTextInputV3RequestSetContentType, uint32(0), uint32(0))
189- send(textinput.ZwpTextInputV3RequestCommit)
190151 poll("missing commit", func() bool { return len(text.commits) == i })
191152 }
192153 if len(text.preedit) != 2 || text.preedit[0] != "nihon" || text.commits[0] != "日本" || text.commits[1] != "日本" {
@@ -206,80 +167,61 @@ func TestHeadlessTextInputIME(t *testing.T) {
206167 // mapTextClient maps a 1x1 toplevel with a text input on c.
207168 func mapTextClient(t *testing.T, c *wlturbo.Display) *textClient {
208169 t.Helper()
209- if err := c.Roundtrip(); err != nil {
210- t.Fatal(err)
211- }
212- bind := func(iface string) uint32 {
170+ check := func(err error) {
213171 t.Helper()
214- g, ok := c.Registry().FindGlobal(iface)
215- if !ok {
216- t.Fatalf("missing %s", iface)
217- }
218- id, err := c.Registry().BindID(g.Name, g.Interface, 1)
219172 if err != nil {
220173 t.Fatal(err)
221174 }
222- return id
223- }
224- // New IDs are used in allocation order; every object gets a proxy.
225- newID := func() uint32 {
226- p := &quiet{}
227- p.SetID(c.AllocateID())
228- c.Context().Register(p)
229- return p.ID()
230175 }
231- req := func(id, op uint32, args ...any) {
232- t.Helper()
233- if err := c.SendRequest(id, uint16(op), args...); err != nil {
234- t.Fatal(err)
235- }
236- }
237- comp, shm, seat, tim := bind("wl_compositor"), bind("wl_shm"), bind("wl_seat"), bind("zwp_text_input_manager_v3")
238- for _, id := range []uint32{comp, shm, seat, tim} {
239- p := &quiet{}
240- p.SetID(id)
241- c.Context().Register(p)
242- }
243- wm := &wmPing{c: c}
244- wm.SetID(bind("xdg_wm_base"))
245- c.Context().Register(wm)
176+ check(c.Roundtrip())
177+ comp := clientcore.NewCompositor(c.Context())
178+ shm := clientcore.NewShm(c.Context())
179+ seat := clientcore.NewSeat(c.Context())
180+ tim := clienttext.NewTextInputManagerV3(c.Context())
181+ wm := clientxdg.NewXdgWmBase(c.Context())
182+ wm.OnPing(func(serial uint32) { check(wm.Pong(serial)) })
183+ bindTestClient(t, c, clientcore.CompositorInterface, 1, comp)
184+ bindTestClient(t, c, clientcore.ShmInterface, 1, shm)
185+ bindTestClient(t, c, clientcore.SeatInterface, 1, seat)
186+ bindTestClient(t, c, clienttext.TextInputManagerV3Interface, 1, tim)
187+ bindTestClient(t, c, clientxdg.XdgWmBaseInterface, 1, wm)
246188 fd, err := unix.MemfdCreate("text-client", 0)
247- if err != nil {
248- t.Fatal(err)
249- }
250- defer unix.Close(fd)
189+ check(err)
251190 if err := unix.Ftruncate(fd, 4); err != nil {
191+ _ = unix.Close(fd)
252192 t.Fatal(err)
253193 }
254- pool := newID()
255- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
194+ // CreatePool owns the sent descriptor on success; only close it on error.
195+ pool, err := shm.CreatePool(fd, 4)
196+ if err != nil {
197+ _ = unix.Close(fd)
256198 t.Fatal(err)
257199 }
258- buffer := newID()
259- req(pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
260- surf := newID()
261- req(comp, wayland.CompositorRequestCreateSurface, surf)
262- xdg := &xdgConfigure{}
263- xdg.SetID(c.AllocateID())
264- c.Context().Register(xdg)
265- req(wm.ID(), xdgshell.WmBaseRequestGetXdgSurface, xdg.ID(), surf)
266- top := newID()
267- req(xdg.ID(), xdgshell.SurfaceRequestGetToplevel, top)
268- text := &textClient{}
269- text.SetID(c.AllocateID())
270- c.Context().Register(text)
271- req(tim, textinput.ZwpTextInputManagerV3RequestGetTextInput, text.ID(), seat)
272- req(surf, wayland.SurfaceRequestCommit)
273- for deadline := time.Now().Add(5 * time.Second); xdg.serial == 0; time.Sleep(10 * time.Millisecond) {
200+ buffer, err := pool.CreateBuffer(0, 1, 1, 4, clientcore.FORMAT_ARGB8888)
201+ check(err)
202+ surf, err := comp.CreateSurface()
203+ check(err)
204+ xdg, err := wm.GetXdgSurface(surf)
205+ check(err)
206+ var serial uint32
207+ xdg.OnConfigure(func(value uint32) { serial = value })
208+ _, err = xdg.GetToplevel()
209+ check(err)
210+ proxy, err := tim.GetTextInput(seat)
211+ check(err)
212+ text := &textClient{TextInputV3: proxy}
213+ proxy.OnEnter(func(uint32) { text.entered = true })
214+ proxy.OnPreeditString(func(value string, _, _ int32) { text.preedit = append(text.preedit, value) })
215+ proxy.OnCommitString(func(value string) { text.commits = append(text.commits, value) })
216+ check(surf.Commit())
217+ for deadline := time.Now().Add(5 * time.Second); serial == 0; time.Sleep(10 * time.Millisecond) {
274218 if time.Now().After(deadline) {
275219 t.Fatal("no configure")
276220 }
277- if err := c.Roundtrip(); err != nil {
278- t.Fatal(err)
279- }
221+ check(c.Roundtrip())
280222 }
281- req(xdg.ID(), xdgshell.SurfaceRequestAckConfigure, xdg.serial)
282- req(surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
283- req(surf, wayland.SurfaceRequestCommit)
223+ check(xdg.AckConfigure(serial))
224+ check(surf.Attach(buffer, 0, 0))
225+ check(surf.Commit())
284226 return text
285227 }
A internal/app/wire_client_test.go +16 -0
@@ -0,0 +1,16 @@
1+package app
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/wlturbo"
7+)
8+
9+// bindTestClient uses the generated real client proxy so context, version,
10+// event signatures and child lifetimes follow the dependency's contracts.
11+func bindTestClient(t *testing.T, c *wlturbo.Display, iface string, version uint32, p wlturbo.Proxy) {
12+ t.Helper()
13+ if _, err := c.Registry().BindNegotiated(iface, version, p); err != nil {
14+ t.Fatal(err)
15+ }
16+}
M internal/core/capture_workspace.go +2 -1
@@ -154,7 +154,8 @@ func (c *Core) captureScene(seq uint64) *ports.Scene {
154154 f := cw.frame
155155 rebase := func(r Rect) Rect { r.X, r.Y = r.X-f.X, r.Y-f.Y; return r }
156156 s := &ports.Scene{
157- Output: cw.sc.name(), Seq: seq, OutputWidth: f.W, OutputHeight: f.H, WorkspaceClip: Rect{W: f.W, H: f.H},
157+ Security: c.security,
158+ Output: cw.sc.name(), Seq: seq, OutputWidth: f.W, OutputHeight: f.H, WorkspaceClip: Rect{W: f.W, H: f.H},
158159 Scale: cw.sc.scale, Background: c.cfg.Background.Color,
159160 Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive},
160161 Windows: make([]ports.SceneWindow, 0, len(cw.placements)),
M internal/core/core.go +130 -265
@@ -15,10 +15,14 @@ import (
1515
1616 var ErrQuit = errors.New("quit requested")
1717
18+var errSecurityChanged = errors.New("security epoch changed during input")
19+
1820 // Channels connects the owner to adapters. Scenes must have capacity 1: core is
1921 // its only sender and may drain a stale set; consumers only receive. Each
2022 // send holds one scene per output.
2123 type Channels struct {
24+ // Security is the defensive session gate; nil preserves standalone operation.
25+ Security ports.SessionSecurity
2226 Client <-chan ports.ClientEvent
2327 Input <-chan ports.InputEvent
2428 Output <-chan ports.OutputEvent
@@ -63,6 +67,12 @@ type binding struct {
6367 key string
6468 }
6569 type Core struct {
70+ security ports.SecurityState
71+ lockSurfaces []ports.LockSurfacePlacement
72+ lockFocus WindowID
73+ inputKeys map[string]bool
74+ inputActive bool
75+ startup [][]string
6676 nextWorkspaceID uint64
6777 ch Channels
6878 cfg ports.Config
@@ -274,13 +284,20 @@ func (c *Core) publishPending(ctx context.Context) error {
274284 // just switched to another workspace), it also refills the empty slots of
275285 // the workspace now on screen.
276286 func (c *Core) spawnSlots(ctx context.Context, shown bool) error {
287+ if c.protectionRequested() {
288+ return nil
289+ }
277290 keys := c.toSpawn
278291 c.toSpawn = nil
279292 if shown {
280293 keys = append(keys, c.refill()...)
281294 }
282295 started := map[slotKey]bool{}
283- for _, key := range keys {
296+ for i, key := range keys {
297+ if c.protectionRequested() {
298+ c.toSpawn = append(c.toSpawn, keys[i:]...)
299+ return nil
300+ }
284301 st, ok := c.slots[key]
285302 // A key can be queued twice (new slot on the workspace on screen).
286303 if !ok || started[key] || st.window != 0 {
@@ -289,13 +306,18 @@ func (c *Core) spawnSlots(ctx context.Context, shown bool) error {
289306 started[key] = true
290307 req := c.spawnSlot(key)
291308 // Wayland must read slot tokens before this window can map.
292- if err := c.publishPending(ctx); err != nil {
293- return err
309+ pendingErr := c.publishPending(ctx)
310+ if pendingErr != nil || c.protectionRequested() {
311+ c.placement.dropSlot(key)
312+ st.stale = false
313+ c.toSpawn = append(c.toSpawn, keys[i:]...)
314+ return pendingErr
294315 }
295- select {
296- case <-ctx.Done():
316+ if !c.trySpawn(ctx, req) {
317+ c.placement.dropSlot(key)
318+ st.stale = false
319+ c.toSpawn = append(c.toSpawn, keys[i:]...)
297320 return ctx.Err()
298- case c.ch.Spawn <- req:
299321 }
300322 }
301323 return nil
@@ -305,7 +327,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
305327 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")
306328 }
307329 // A placeholder screen holds windows until the first output arrives.
308- c := &Core{slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, configures: newConfigures()}
330+ c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, configures: newConfigures()}
309331 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}
310332 if err := c.apply(cfg); err != nil {
311333 return nil, err
@@ -320,10 +342,19 @@ func (c *Core) now() time.Time {
320342 }
321343
322344 func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
345+ if c.inputEpochChanged() {
346+ return errSecurityChanged
347+ }
348+ if c.ch.Security != nil {
349+ v = ports.SecurityCommand{State: c.security, Command: v}
350+ }
323351 select {
324352 case <-ctx.Done():
325353 return ctx.Err()
326354 case c.ch.Commands <- v:
355+ if c.inputEpochChanged() {
356+ return errSecurityChanged
357+ }
327358 return nil
328359 }
329360 }
@@ -380,6 +411,9 @@ func (c *Core) windowFocus() map[*screen]WindowID {
380411
381412 // keyboardFocus is the surface holding the keyboard (see keyboard.focus).
382413 func (c *Core) keyboardFocus() WindowID {
414+ if c.security.Protected {
415+ return c.lockKeyboardFocus()
416+ }
383417 var exclusive WindowID
384418 for _, sc := range c.screens {
385419 for _, l := range sc.layers {
@@ -429,6 +463,13 @@ func (c *Core) setLayers(all []ports.LayerSurface) {
429463 }
430464
431465 func (c *Core) publish(ctx context.Context) error {
466+ if c.inputEpochChanged() {
467+ return errSecurityChanged
468+ }
469+ c.syncSecurity()
470+ if c.security.Protected {
471+ return c.publishProtected(ctx)
472+ }
432473 capture, err := c.captureEvaluate(ctx)
433474 if err != nil {
434475 return err
@@ -470,7 +511,7 @@ func (c *Core) publish(ctx context.Context) error {
470511 if frame != (Rect{W: o.W, H: o.H}) {
471512 clip = frame
472513 }
473- scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)}
514+ scene := ports.Scene{Security: c.security, Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)}
474515 layout := sc.mon.Layout()
475516 var real map[WindowID]Placement
476517 if sc.mon.ov.open {
@@ -543,6 +584,12 @@ func (c *Core) publish(ctx context.Context) error {
543584 return err
544585 }
545586 c.resolveConstraint()
587+ if c.inputEpochChanged() {
588+ return errSecurityChanged
589+ }
590+ if c.syncSecurity() {
591+ return c.publish(ctx)
592+ }
546593 latest(c.ch.Scenes, scenes)
547594 c.publishState()
548595 c.publishWorkspaces()
@@ -600,6 +647,9 @@ func (c *Core) rehit(ctx context.Context) error {
600647 // updateInhibit makes the shortcuts inhibitor of the keyboard focus the
601648 // active one and tells wayland when it changes.
602649 func (c *Core) updateInhibit(ctx context.Context) error {
650+ if c.security.Protected {
651+ return nil
652+ }
603653 f := c.keyboardFocus()
604654 release, activate, changed := c.keyboard.inhibit(f, c.windows.lookup(f).inhibitShortcuts)
605655 if !changed {
@@ -765,6 +815,9 @@ func (c *Core) acceptsInput(id WindowID, x, y float64) bool {
765815 // windows (last visible placement is topmost), then bottom and
766816 // background layers.
767817 func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
818+ if c.security.Protected {
819+ return c.lockHit(x, y)
820+ }
768821 o, ok := c.layout().At(x, y)
769822 if !ok {
770823 return 0, 0, 0
@@ -817,32 +870,50 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
817870 }
818871
819872 func (c *Core) Run(ctx context.Context) error {
873+ c.syncSecurity()
820874 defer c.stopFrame()
821875 defer c.stopCapture()
822- // Startup commands run once per session; a config reload does not
823- // run them again.
824- for _, argv := range c.cfg.Startup {
825- select {
826- case <-ctx.Done():
827- return nil
828- case c.ch.Spawn <- ports.SpawnRequest{Argv: slices.Clone(argv)}:
829- }
876+ // Startup commands run once per session. Full launcher queues retain a
877+ // bounded remainder, selectable alongside owner events without polling.
878+ c.startup = make([][]string, len(c.cfg.Startup))
879+ for i, argv := range c.cfg.Startup {
880+ c.startup[i] = slices.Clone(argv)
830881 }
882+ c.spawnStartup(ctx)
831883 if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil {
832884 return nil
833885 }
834886 c.publishState()
835887 c.publishWorkspaces()
836888 for {
889+ if c.securityCheckpoint(ctx) != nil {
890+ return nil
891+ }
892+ startupC, startupRequest := c.pendingStartup()
837893 select {
838894 case <-ctx.Done():
839895 return nil
896+ case startupC <- startupRequest:
897+ // Successful transfer consumes startup once. Only the receiver can
898+ // decide execution admission; a later gate read cannot infer it.
899+ c.startup = c.startup[1:]
900+ continue
840901 case ev, ok := <-c.ch.Client:
841902 if !ok {
842903 c.ch.Client = nil
843904 continue
844905 }
906+ if c.securityCheckpoint(ctx) != nil {
907+ return nil
908+ }
909+ if c.security.Protected && blockedProtectedEvent(ev) {
910+ continue
911+ }
845912 switch v := ev.(type) {
913+ case ports.SessionLockChanged:
914+ if !c.applyLockChanged(v) {
915+ continue
916+ }
846917 case ports.CaptureSessionBegin:
847918 if c.captureBegin(ctx, v) != nil {
848919 return nil
@@ -951,6 +1022,9 @@ func (c *Core) Run(ctx context.Context) error {
9511022 return nil
9521023 }
9531024 case f, ok := <-c.ch.Frames:
1025+ if c.securityCheckpoint(ctx) != nil {
1026+ return nil
1027+ }
9541028 if !ok {
9551029 c.ch.Frames = nil
9561030 continue
@@ -960,12 +1034,18 @@ func (c *Core) Run(ctx context.Context) error {
9601034 }
9611035 continue
9621036 case <-c.frameC:
1037+ if c.securityCheckpoint(ctx) != nil {
1038+ return nil
1039+ }
9631040 c.frameC, c.frameStop = nil, nil
9641041 if c.step(ctx) != nil {
9651042 return nil
9661043 }
9671044 continue
9681045 case <-c.captureC:
1046+ if c.securityCheckpoint(ctx) != nil {
1047+ return nil
1048+ }
9691049 c.captureExpired()
9701050 if c.publish(ctx) != nil {
9711051 return nil
@@ -976,6 +1056,9 @@ func (c *Core) Run(ctx context.Context) error {
9761056 c.ch.Output = nil
9771057 continue
9781058 }
1059+ if c.securityCheckpoint(ctx) != nil {
1060+ return nil
1061+ }
9791062 switch v := ev.(type) {
9801063 case ports.OutputAdded:
9811064 c.addScreen(v.Info)
@@ -1000,6 +1083,9 @@ func (c *Core) Run(ctx context.Context) error {
10001083 c.ch.Config = nil
10011084 continue
10021085 }
1086+ if c.securityCheckpoint(ctx) != nil {
1087+ return nil
1088+ }
10031089 if err := c.apply(ev.Config); err != nil {
10041090 select {
10051091 case c.ch.ConfigErrors <- err:
@@ -1018,254 +1104,27 @@ func (c *Core) Run(ctx context.Context) error {
10181104 c.ch.Input = nil
10191105 continue
10201106 }
1021- if err := c.userActivity(ctx); err != nil {
1107+ if c.securityCheckpoint(ctx) != nil {
10221108 return nil
10231109 }
1024- switch v := ev.(type) {
1025- case ports.PointerMotion:
1026- c.motionMsec = v.TimeMsec
1027- // A locked pointer stays still; relative motion still flows.
1028- if c.constraint.Mode != ports.ConstraintLock {
1029- c.cursorX, c.cursorY = c.constraint.Clamp(c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y))
1030- }
1031- // The focused screen follows the pointer, so new windows
1032- // and launchers open where the user is.
1033- // Only a pointer entering another output switches: keyboard
1034- // moves to another screen stick until then. Not mid-drag.
1035- if o, ok := c.layout().At(c.cursorX, c.cursorY); ok && o.Info.Name != c.pointerOutput && c.grab == 0 {
1036- c.pointerOutput = o.Info.Name
1037- if o.Info.Name != c.cur().name() {
1038- c.focusScreen = c.screenIndex(o.Info.Name)
1039- if err := c.publish(ctx); err != nil {
1040- return nil
1041- }
1042- }
1043- }
1044- id, x, y := c.hit(c.cursorX, c.cursorY)
1045- // Layout changes are intentionally re-hit-tested only on motion.
1046- if id != c.pointer {
1047- c.pointer = id
1048- if err := c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil {
1049- return nil
1050- }
1051- }
1052- c.pointerAt = [2]float64{x, y}
1053- if id != 0 {
1054- if err := c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, DX: v.DX, DY: v.DY, UnaccelDX: v.UnaccelDX, UnaccelDY: v.UnaccelDY, TimeMsec: v.TimeMsec, TimeUsec: v.TimeUsec}); err != nil {
1055- return nil
1056- }
1057- }
1058- continue
1059- case ports.PointerButton:
1060- if v.Pressed && c.pointer == 0 && c.grab == 0 && len(c.buttons) == 0 {
1061- if picked, err := c.overviewClick(ctx); err != nil {
1062- return nil
1063- } else if picked {
1064- continue
1065- }
1066- }
1067- id := c.pointer
1068- if c.grab != 0 {
1069- id = c.grab
1070- }
1071- if v.Pressed && len(c.buttons) == 0 {
1072- // A click outside an open menu closes it.
1073- if err := c.dismissGrabs(ctx, id); err != nil {
1074- return nil
1075- }
1076- }
1077- if v.Pressed {
1078- if len(c.buttons) == 0 {
1079- c.grab = id
1080- }
1081- c.buttons[v.Button] = true
1082- } else {
1083- delete(c.buttons, v.Button)
1084- }
1085- if id != 0 {
1086- if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil {
1087- return nil
1088- }
1089- // A click on an on-demand layer gives it the keyboard.
1090- if v.Pressed && c.onDemand(id) && c.keyboard.clickLayer(id, c.windowFocus()) {
1091- if err := c.publish(ctx); err != nil {
1092- return nil
1093- }
1094- } else if l := c.keyboard.layer; v.Pressed && l != 0 && id != l && c.popupRoot(id) != l {
1095- // A click anywhere else takes the keyboard back.
1096- c.keyboard.takeBack()
1097- if err := c.publish(ctx); err != nil {
1098- return nil
1099- }
1100- }
1101- // A click focuses the window and its output.
1102- s, w := c.screenOf(id)
1103- if v.Pressed && s != nil && w == s.mon.Current() && (c.keyboard.sent != id || s != c.cur()) {
1104- w.Click(id)
1105- c.focusScreen = c.screenIndex(s.name())
1106- if err := c.publish(ctx); err != nil {
1107- return nil
1108- }
1109- }
1110- }
1111- if len(c.buttons) == 0 {
1112- c.grab = 0
1113- }
1114- continue
1115- case ports.PointerAxis:
1116- // In the overview, scrolling moves the selection.
1117- if c.cur().mon.ov.open && !c.overviewKeyboardTaken() {
1118- if c.cur().mon.overviewScroll(v) {
1119- if c.workspaceVisible(ctx, true) != nil {
1120- return nil
1121- }
1122- if err := c.publish(ctx); err != nil {
1123- return nil
1124- }
1125- }
1126- continue
1127- }
1128- // Scroll goes to the window under the pointer, which has the
1129- // pointer focus even mid-drag.
1130- if c.pointer != 0 {
1131- if err := c.command(ctx, ports.PointerAxisTo{ID: c.pointer, Axis: v}); err != nil {
1132- return nil
1133- }
1134- }
1135- continue
1136- case ports.SwipeBegin:
1137- if c.swipeBegin(v) && c.slid(ctx, false) != nil {
1138- return nil
1139- }
1140- continue
1141- case ports.SwipeUpdate:
1142- if c.swipeUpdate(v) && c.slid(ctx, false) != nil {
1143- return nil
1144- }
1145- continue
1146- case ports.SwipeEnd:
1147- if c.slid(ctx, c.swipeEnd(v)) != nil {
1148- return nil
1149- }
1150- continue
1151- }
1152- key, ok := ev.(ports.KeyEvent)
1153- if !ok {
1154- continue
1110+ // Even stale input is activity, but never becomes client input.
1111+ if err := c.userActivity(ctx); err != nil {
1112+ return nil
11551113 }
1156- // The overview takes its keys before any window; others still
1157- // run binds, and are not forwarded.
1158- // A launcher or a menu holding the keyboard gets them first.
1159- if mon := c.cur().mon; mon.ov.open && !c.overviewKeyboardTaken() {
1160- if key.Pressed && mon.overviewKey(key) {
1161- c.pressed[heldKey(key)] = true
1162- if c.workspaceVisible(ctx, true) != nil {
1163- return nil
1164- }
1165- if err := c.publish(ctx); err != nil {
1166- return nil
1167- }
1168- continue
1169- }
1114+ // Commands can backpressure; recheck after activity before admits/binds.
1115+ if c.securityCheckpoint(ctx) != nil {
1116+ return nil
11701117 }
1171- // A focused window inhibiting shortcuts gets every key:
1172- // no bind runs (emergency quit and VT switch are handled by
1173- // input before core).
1174- if c.keyboard.inhibited(c.keyboardFocus()) {
1175- if held := heldKey(key); !key.Pressed && c.pressed[held] {
1176- // Its press ran a bind before inhibiting began: the
1177- // window never saw it.
1178- delete(c.pressed, held)
1179- continue
1180- }
1181- if err := c.command(ctx, ports.ForwardKey{ID: c.keyboard.inhibiting, Key: key}); err != nil {
1182- return nil
1183- }
1118+ var admitted bool
1119+ ev, admitted = c.admitInput(ev)
1120+ if !admitted {
11841121 continue
11851122 }
1186- name := keyName(key.Keysym)
1187- // A bind on the keysym wins; then the unshifted keysym (Cmd+Shift+1
1188- // prints exclam on US); then the physical key (code:N).
1189- action, bound := c.binds[binding{key: name, mods: key.Mods}]
1190- if !bound && key.Base != "" {
1191- action, bound = c.binds[binding{key: keyName(key.Base), mods: key.Mods}]
1192- }
1193- if !bound && key.Keycode != 0 {
1194- action, bound = c.binds[binding{key: "code:" + strconv.FormatUint(uint64(key.Keycode), 10), mods: key.Mods}]
1195- }
1196- // Track presses by physical key: Shift may be released before the key.
1197- held := heldKey(key)
1198- if !key.Pressed {
1199- consumed := c.pressed[held]
1200- delete(c.pressed, held)
1201- if consumed {
1202- continue
1203- }
1204- }
1205- if key.Pressed {
1206- if bound {
1207- c.pressed[held] = true
1208- if action == ActionScaleUp || action == ActionScaleDown {
1209- dir := 1
1210- if action == ActionScaleDown {
1211- dir = -1
1212- }
1213- sc := c.cur()
1214- prev := sc.scale
1215- sc.setScale(StepScale(sc.info.Width, sc.info.Height, sc.scale, dir))
1216- c.order()
1217- if c.ch.Scales != nil && sc.scale != prev && sc.name() != "" {
1218- // Never wait on persistence: a stalled save drops the change.
1219- select {
1220- case c.ch.Scales <- ports.ScaleChanged{Output: sc.name(), Scale: sc.scale}:
1221- default:
1222- }
1223- }
1224- if err := c.publish(ctx); err != nil {
1225- return nil
1226- }
1227- continue
1228- }
1229- before := c.cur().mon.Current()
1230- swiped := c.swipedWorkspace()
1231- c.keyboard.takeBack() // a bind acts on the windows
1232- effect := c.applyAction(action)
1233- if effect.Quit {
1234- return ErrQuit
1235- }
1236- if effect.Spawn {
1237- argv := effect.Argv
1238- if argv == nil {
1239- argv = append([]string(nil), c.cfg.Terminal.Command...)
1240- }
1241- select {
1242- case <-ctx.Done():
1243- return nil
1244- case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}:
1245- }
1246- }
1247- if c.swipedWorkspace() != swiped {
1248- c.dropSwipe()
1249- }
1250- if c.workspaceVisible(ctx, c.cur().mon.Current() != before) != nil {
1251- return nil
1252- }
1253- if effect.Close != 0 {
1254- if err := c.command(ctx, ports.CloseWindow{ID: effect.Close}); err != nil {
1255- return nil
1256- }
1257- }
1258- if err := c.publish(ctx); err != nil {
1259- return nil
1260- }
1123+ if err := c.handleInput(ctx, ev); err != nil {
1124+ if errors.Is(err, errSecurityChanged) {
12611125 continue
12621126 }
1263- c.pressed[held] = false
1264- }
1265- if id := c.keyboardFocus(); id != 0 && (!c.cur().mon.ov.open || c.overviewKeyboardTaken()) {
1266- if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil {
1267- return nil
1268- }
1127+ return err
12691128 }
12701129 }
12711130 }
@@ -1283,18 +1142,24 @@ func (c *Core) workspaceVisible(ctx context.Context, shown bool) error {
12831142 // it after the output layout and before scenes, so a terminal never maps
12841143 // before wayland knows its output.
12851144 func (c *Core) spawnEmpty(ctx context.Context) error {
1286- terms := c.fillEmpty()
1287- if err := c.publishPending(ctx); err != nil {
1288- return err
1145+ if c.protectionRequested() {
1146+ return nil
12891147 }
1290- for _, req := range terms {
1291- select {
1292- case <-ctx.Done():
1293- return ctx.Err()
1294- case c.ch.Spawn <- req:
1148+ first := c.firstTerminalResolved
1149+ terms := c.fillEmpty()
1150+ err := c.publishPending(ctx)
1151+ for i, req := range terms {
1152+ if err != nil || !c.trySpawn(ctx, req) {
1153+ for _, suppressed := range terms[i:] {
1154+ c.cancelTerminal(suppressed)
1155+ }
1156+ if i == 0 {
1157+ c.firstTerminalResolved = first
1158+ }
1159+ return err
12951160 }
12961161 }
1297- return nil
1162+ return err
12981163 }
12991164
13001165 // activate brings a window forward for a valid xdg-activation token. The
A internal/core/input.go +264 -0
@@ -0,0 +1,264 @@
1+package core
2+
3+import (
4+ "context"
5+ "strconv"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
11+ c.inputActive = true
12+ defer func() { c.inputActive = false }()
13+ if c.security.Protected {
14+ if err := c.protectedInput(ctx, ev); err != nil {
15+ return err
16+ }
17+ return nil
18+ }
19+ switch v := ev.(type) {
20+ case ports.PointerMotion:
21+ c.motionMsec = v.TimeMsec
22+ // A locked pointer stays still; relative motion still flows.
23+ if c.constraint.Mode != ports.ConstraintLock {
24+ c.cursorX, c.cursorY = c.constraint.Clamp(c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y))
25+ }
26+ // The focused screen follows the pointer, so new windows
27+ // and launchers open where the user is.
28+ // Only a pointer entering another output switches: keyboard
29+ // moves to another screen stick until then. Not mid-drag.
30+ if o, ok := c.layout().At(c.cursorX, c.cursorY); ok && o.Info.Name != c.pointerOutput && c.grab == 0 {
31+ c.pointerOutput = o.Info.Name
32+ if o.Info.Name != c.cur().name() {
33+ c.focusScreen = c.screenIndex(o.Info.Name)
34+ if err := c.publish(ctx); err != nil {
35+ return err
36+ }
37+ }
38+ }
39+ id, x, y := c.hit(c.cursorX, c.cursorY)
40+ // Layout changes are intentionally re-hit-tested only on motion.
41+ if id != c.pointer {
42+ c.pointer = id
43+ if err := c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil {
44+ return err
45+ }
46+ }
47+ c.pointerAt = [2]float64{x, y}
48+ if id != 0 {
49+ if err := c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, DX: v.DX, DY: v.DY, UnaccelDX: v.UnaccelDX, UnaccelDY: v.UnaccelDY, TimeMsec: v.TimeMsec, TimeUsec: v.TimeUsec}); err != nil {
50+ return err
51+ }
52+ }
53+ return nil
54+ case ports.PointerButton:
55+ if v.Pressed && c.pointer == 0 && c.grab == 0 && len(c.buttons) == 0 {
56+ if picked, err := c.overviewClick(ctx); err != nil {
57+ return err
58+ } else if picked {
59+ return nil
60+ }
61+ }
62+ id := c.pointer
63+ if c.grab != 0 {
64+ id = c.grab
65+ }
66+ if v.Pressed && len(c.buttons) == 0 {
67+ // A click outside an open menu closes it.
68+ if err := c.dismissGrabs(ctx, id); err != nil {
69+ return err
70+ }
71+ }
72+ if v.Pressed {
73+ if len(c.buttons) == 0 {
74+ c.grab = id
75+ }
76+ c.buttons[v.Button] = true
77+ } else {
78+ delete(c.buttons, v.Button)
79+ }
80+ if id != 0 {
81+ if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil {
82+ return err
83+ }
84+ // A click on an on-demand layer gives it the keyboard.
85+ if v.Pressed && c.onDemand(id) && c.keyboard.clickLayer(id, c.windowFocus()) {
86+ if err := c.publish(ctx); err != nil {
87+ return err
88+ }
89+ } else if l := c.keyboard.layer; v.Pressed && l != 0 && id != l && c.popupRoot(id) != l {
90+ // A click anywhere else takes the keyboard back.
91+ c.keyboard.takeBack()
92+ if err := c.publish(ctx); err != nil {
93+ return err
94+ }
95+ }
96+ // A click focuses the window and its output.
97+ s, w := c.screenOf(id)
98+ if v.Pressed && s != nil && w == s.mon.Current() && (c.keyboard.sent != id || s != c.cur()) {
99+ w.Click(id)
100+ c.focusScreen = c.screenIndex(s.name())
101+ if err := c.publish(ctx); err != nil {
102+ return err
103+ }
104+ }
105+ }
106+ if len(c.buttons) == 0 {
107+ c.grab = 0
108+ }
109+ return nil
110+ case ports.PointerAxis:
111+ // In the overview, scrolling moves the selection.
112+ if c.cur().mon.ov.open && !c.overviewKeyboardTaken() {
113+ if c.cur().mon.overviewScroll(v) {
114+ if err := c.workspaceVisible(ctx, true); err != nil {
115+ return err
116+ }
117+ if err := c.publish(ctx); err != nil {
118+ return err
119+ }
120+ }
121+ return nil
122+ }
123+ // Scroll goes to the window under the pointer, which has the
124+ // pointer focus even mid-drag.
125+ if c.pointer != 0 {
126+ if err := c.command(ctx, ports.PointerAxisTo{ID: c.pointer, Axis: v}); err != nil {
127+ return err
128+ }
129+ }
130+ return nil
131+ case ports.SwipeBegin:
132+ if c.swipeBegin(v) {
133+ return c.slid(ctx, false)
134+ }
135+ return nil
136+ case ports.SwipeUpdate:
137+ if c.swipeUpdate(v) {
138+ return c.slid(ctx, false)
139+ }
140+ return nil
141+ case ports.SwipeEnd:
142+ return c.slid(ctx, c.swipeEnd(v))
143+ }
144+ key, ok := ev.(ports.KeyEvent)
145+ if !ok {
146+ return nil
147+ }
148+ // The overview takes its keys before any window; others still
149+ // run binds, and are not forwarded.
150+ // A launcher or a menu holding the keyboard gets them first.
151+ if mon := c.cur().mon; mon.ov.open && !c.overviewKeyboardTaken() {
152+ if key.Pressed && mon.overviewKey(key) {
153+ c.pressed[heldKey(key)] = true
154+ if err := c.workspaceVisible(ctx, true); err != nil {
155+ return err
156+ }
157+ if err := c.publish(ctx); err != nil {
158+ return err
159+ }
160+ return nil
161+ }
162+ }
163+ // A focused window inhibiting shortcuts gets every key:
164+ // no bind runs (emergency quit and VT switch are handled by
165+ // input before core).
166+ if c.keyboard.inhibited(c.keyboardFocus()) {
167+ if held := heldKey(key); !key.Pressed && c.pressed[held] {
168+ // Its press ran a bind before inhibiting began: the
169+ // window never saw it.
170+ delete(c.pressed, held)
171+ return nil
172+ }
173+ if err := c.command(ctx, ports.ForwardKey{ID: c.keyboard.inhibiting, Key: key}); err != nil {
174+ return err
175+ }
176+ return nil
177+ }
178+ name := keyName(key.Keysym)
179+ // A bind on the keysym wins; then the unshifted keysym (Cmd+Shift+1
180+ // prints exclam on US); then the physical key (code:N).
181+ action, bound := c.binds[binding{key: name, mods: key.Mods}]
182+ if !bound && key.Base != "" {
183+ action, bound = c.binds[binding{key: keyName(key.Base), mods: key.Mods}]
184+ }
185+ if !bound && key.Keycode != 0 {
186+ action, bound = c.binds[binding{key: "code:" + strconv.FormatUint(uint64(key.Keycode), 10), mods: key.Mods}]
187+ }
188+ // Track presses by physical key: Shift may be released before the key.
189+ held := heldKey(key)
190+ if !key.Pressed {
191+ consumed := c.pressed[held]
192+ delete(c.pressed, held)
193+ if consumed {
194+ return nil
195+ }
196+ }
197+ if key.Pressed {
198+ if bound {
199+ c.pressed[held] = true
200+ if action == ActionScaleUp || action == ActionScaleDown {
201+ dir := 1
202+ if action == ActionScaleDown {
203+ dir = -1
204+ }
205+ sc := c.cur()
206+ prev := sc.scale
207+ sc.setScale(StepScale(sc.info.Width, sc.info.Height, sc.scale, dir))
208+ c.order()
209+ if c.ch.Scales != nil && sc.scale != prev && sc.name() != "" {
210+ // Never wait on persistence: a stalled save drops the change.
211+ select {
212+ case c.ch.Scales <- ports.ScaleChanged{Output: sc.name(), Scale: sc.scale}:
213+ default:
214+ }
215+ }
216+ if err := c.publish(ctx); err != nil {
217+ return err
218+ }
219+ return nil
220+ }
221+ before := c.cur().mon.Current()
222+ swiped := c.swipedWorkspace()
223+ c.keyboard.takeBack() // a bind acts on the windows
224+ effect := c.applyAction(action)
225+ if effect.Quit {
226+ return ErrQuit
227+ }
228+ if effect.Spawn {
229+ argv := effect.Argv
230+ if argv == nil {
231+ argv = append([]string(nil), c.cfg.Terminal.Command...)
232+ }
233+ // Explicit actions are suppressed on a full launcher queue;
234+ // never keep the input owner waiting across an epoch change.
235+ c.trySpawn(ctx, ports.SpawnRequest{Argv: argv})
236+ if c.inputEpochChanged() {
237+ return errSecurityChanged
238+ }
239+ }
240+ if c.swipedWorkspace() != swiped {
241+ c.dropSwipe()
242+ }
243+ if err := c.workspaceVisible(ctx, c.cur().mon.Current() != before); err != nil {
244+ return err
245+ }
246+ if effect.Close != 0 {
247+ if err := c.command(ctx, ports.CloseWindow{ID: effect.Close}); err != nil {
248+ return err
249+ }
250+ }
251+ if err := c.publish(ctx); err != nil {
252+ return err
253+ }
254+ return nil
255+ }
256+ c.pressed[held] = false
257+ }
258+ if id := c.keyboardFocus(); id != 0 && (!c.cur().mon.ov.open || c.overviewKeyboardTaken()) {
259+ if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil {
260+ return err
261+ }
262+ }
263+ return nil
264+}
M internal/core/screens_test.go +3 -1
@@ -495,7 +495,9 @@ func TestPointerFocusesOutput(t *testing.T) {
495495 func TestFirstTerminalFollowsSetOutputs(t *testing.T) {
496496 cfg := config.Defaults()
497497 commands := make(chan ports.ClientCommand)
498- spawn := make(chan ports.SpawnRequest)
498+ // The launcher handoff is deliberately nonblocking. A ready buffered
499+ // queue tests ordering without requiring core to wait for this reader.
500+ spawn := make(chan ports.SpawnRequest, 1)
499501 output := make(chan ports.OutputEvent, 1)
500502 scenes := make(chan []ports.Scene, 1)
501503 state := make(chan ports.State, 1)
A internal/core/security.go +290 -0
@@ -0,0 +1,290 @@
1+package core
2+
3+import (
4+ "context"
5+ "slices"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// syncSecurity runs only on the owner. The gate, not event selection order,
11+// establishes the epoch; surface events can only fill in that exact epoch.
12+func (c *Core) syncSecurity() bool {
13+ if c.inputActive {
14+ return false
15+ }
16+ if c.ch.Security == nil {
17+ return false
18+ }
19+ state := c.ch.Security.Snapshot()
20+ if state == c.security {
21+ return false
22+ }
23+ c.security = state
24+ c.lockSurfaces, c.lockFocus = nil, 0
25+ c.pressed, c.buttons, c.inputKeys = map[string]bool{}, map[uint32]bool{}, map[string]bool{}
26+ c.pointer, c.grab, c.pointerOutput = 0, 0, ""
27+ c.pointerAt, c.motionMsec = [2]float64{}, 0
28+ c.keyboard = keyboard{}
29+ c.constrained, c.constraint = ports.PointerConstrained{}, ports.PointerConstraint{}
30+ if c.ch.Constraints != nil {
31+ latest(c.ch.Constraints, c.constraint)
32+ }
33+ c.dropSwipe()
34+ c.swipe = nil
35+ for _, sc := range c.screens {
36+ sc.mon.stopSwitch()
37+ for _, w := range sc.mon.all() {
38+ w.stopSlide()
39+ }
40+ }
41+ c.stopFrame()
42+ if state.Protected {
43+ c.dropCapture()
44+ }
45+ return true
46+}
47+
48+// Check spawns without adopting a new epoch midway through processing an
49+// event: commands emitted by that event must retain its original owner epoch.
50+func (c *Core) inputEpochChanged() bool {
51+ return c.inputActive && c.ch.Security != nil && c.ch.Security.Snapshot() != c.security
52+}
53+
54+func (c *Core) protectionRequested() bool {
55+ return c.security.Protected || c.ch.Security != nil && c.ch.Security.Snapshot() != c.security
56+}
57+
58+func (c *Core) securityCheckpoint(ctx context.Context) error {
59+ if !c.syncSecurity() {
60+ return nil
61+ }
62+ // Clear display focus even if the new epoch has no mapped lock surfaces.
63+ if err := c.command(ctx, ports.PointerFocus{}); err != nil {
64+ return err
65+ }
66+ if err := c.command(ctx, ports.FocusWindow{}); err != nil {
67+ return err
68+ }
69+ return c.publish(ctx)
70+}
71+
72+func blockedProtectedEvent(ev ports.ClientEvent) bool {
73+ switch ev.(type) {
74+ case ports.WindowActivate, ports.WorkspaceActivate, ports.PointerWarp, ports.PointerConstrained,
75+ ports.PopupRequest, ports.ShortcutsInhibit, ports.WindowFullscreenRequest,
76+ ports.CaptureSessionBegin, ports.CaptureSessionLayer, ports.CaptureSessionPing:
77+ return true
78+ }
79+ return false
80+}
81+
82+func (c *Core) applyLockChanged(v ports.SessionLockChanged) bool {
83+ if c.ch.Security == nil || v.State != c.security || v.State != c.ch.Security.Snapshot() {
84+ return false
85+ }
86+ if !c.security.Protected {
87+ return true
88+ }
89+ c.lockSurfaces = slices.Clone(v.Surfaces)
90+ c.lockKeyboardFocus()
91+ // A destroyed or resized role must not retain a pointer grab.
92+ if !c.validLockID(c.pointer) {
93+ c.pointer = 0
94+ }
95+ if !c.validLockID(c.grab) {
96+ c.grab = 0
97+ clear(c.buttons)
98+ }
99+ return true
100+}
101+
102+func (c *Core) lockSurface(sc *screen) (ports.LockSurfacePlacement, bool) {
103+ o := sc.mon.Output()
104+ for _, s := range c.lockSurfaces {
105+ if s.ID != 0 && sc.name() != "" && s.Output == sc.name() && s.Width == o.W && s.Height == o.H && o.W > 0 && o.H > 0 {
106+ return s, true
107+ }
108+ }
109+ return ports.LockSurfacePlacement{}, false
110+}
111+
112+func (c *Core) validLockID(id WindowID) bool {
113+ if id == 0 {
114+ return false
115+ }
116+ for _, sc := range c.screens {
117+ if s, ok := c.lockSurface(sc); ok && s.ID == id {
118+ return true
119+ }
120+ }
121+ return false
122+}
123+
124+func (c *Core) lockKeyboardFocus() WindowID {
125+ if c.validLockID(c.lockFocus) {
126+ return c.lockFocus
127+ }
128+ c.lockFocus = 0
129+ // Prefer the focused output, where the user is looking, then any other.
130+ if c.focusScreen >= 0 && c.focusScreen < len(c.screens) {
131+ if s, ok := c.lockSurface(c.screens[c.focusScreen]); ok {
132+ c.lockFocus = s.ID
133+ return c.lockFocus
134+ }
135+ }
136+ for _, sc := range c.screens {
137+ if s, ok := c.lockSurface(sc); ok {
138+ c.lockFocus = s.ID
139+ break
140+ }
141+ }
142+ return c.lockFocus
143+}
144+
145+func (c *Core) lockHit(x, y float64) (WindowID, float64, float64) {
146+ o, ok := c.layout().At(x, y)
147+ if !ok {
148+ return 0, 0, 0
149+ }
150+ i := c.screenIndex(o.Info.Name)
151+ if i < 0 {
152+ return 0, 0, 0
153+ }
154+ if s, ok := c.lockSurface(c.screens[i]); ok {
155+ return s.ID, x - float64(o.X), y - float64(o.Y)
156+ }
157+ return 0, 0, 0
158+}
159+
160+func (c *Core) publishProtected(ctx context.Context) error {
161+ v := ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}
162+ if !sameOutputs(v, c.sentOutputs) {
163+ if err := c.command(ctx, v); err != nil {
164+ return err
165+ }
166+ if c.ch.Layouts != nil {
167+ latest(c.ch.Layouts, v.Outputs)
168+ }
169+ c.sentOutputs = v
170+ }
171+ focus := c.lockKeyboardFocus()
172+ scenes := make([]ports.Scene, 0, len(c.screens))
173+ for _, sc := range c.screens {
174+ c.seq++
175+ o := sc.mon.Output()
176+ scene := ports.Scene{Security: c.security, Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Off: sc.off, Background: "#000000"}
177+ if s, ok := c.lockSurface(sc); ok {
178+ scene.Windows = []ports.SceneWindow{{ID: s.ID, Rect: Rect{W: o.W, H: o.H}, Fullscreen: true, Focused: s.ID == focus}}
179+ }
180+ scenes = append(scenes, scene)
181+ }
182+ if c.keyboard.sent != focus {
183+ if err := c.command(ctx, ports.FocusWindow{ID: focus}); err != nil {
184+ return err
185+ }
186+ c.keyboard.sent = focus
187+ }
188+ // Surface updates may invalidate the role under a stationary pointer.
189+ if !c.validLockID(c.pointer) {
190+ c.pointer = 0
191+ }
192+ if err := c.command(ctx, ports.PointerFocus{ID: c.pointer, X: c.pointerAt[0], Y: c.pointerAt[1]}); err != nil {
193+ return err
194+ }
195+ latest(c.ch.Scenes, scenes)
196+ c.publishState()
197+ c.publishWorkspaces()
198+ return nil
199+}
200+
201+func (c *Core) admitInput(ev ports.InputEvent) (ports.InputEvent, bool) {
202+ if v, ok := ev.(ports.SecurityInput); ok {
203+ if v.State != c.security || v.Event == nil {
204+ return nil, false
205+ }
206+ ev = v.Event
207+ } else if c.ch.Security != nil || c.security != (ports.SecurityState{}) {
208+ return nil, false
209+ }
210+ // The producer resets native keymap state and quarantines physically held
211+ // keys on transitions. Core preserves that keymap-aware modifier state;
212+ // it must not infer modifier meaning from evdev codes or XKB bit positions.
213+ // No release without a press in this epoch (password keys must not reach
214+ // the desktop after unlock). Standalone legacy channel tests remain raw.
215+ if c.ch.Security != nil {
216+ if b, ok := ev.(ports.PointerButton); ok && !b.Pressed && !c.buttons[b.Button] {
217+ return nil, false
218+ }
219+ if k, ok := ev.(ports.KeyEvent); ok {
220+ held := heldKey(k)
221+ if k.Pressed {
222+ c.inputKeys[held] = true
223+ } else {
224+ if !c.inputKeys[held] {
225+ return nil, false
226+ }
227+ delete(c.inputKeys, held)
228+ }
229+ }
230+ }
231+ return ev, true
232+}
233+
234+func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error {
235+ switch v := ev.(type) {
236+ case ports.KeyEvent:
237+ if id := c.lockKeyboardFocus(); id != 0 {
238+ return c.command(ctx, ports.ForwardKey{ID: id, Key: v})
239+ }
240+ case ports.PointerMotion:
241+ c.motionMsec = v.TimeMsec
242+ c.cursorX, c.cursorY = c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y)
243+ id, x, y := c.lockHit(c.cursorX, c.cursorY)
244+ if id != c.pointer {
245+ c.pointer = id
246+ if err := c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil {
247+ return err
248+ }
249+ }
250+ c.pointerAt = [2]float64{x, y}
251+ if id != 0 {
252+ return c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, DX: v.DX, DY: v.DY, UnaccelDX: v.UnaccelDX, UnaccelDY: v.UnaccelDY, TimeMsec: v.TimeMsec, TimeUsec: v.TimeUsec})
253+ }
254+ case ports.PointerButton:
255+ id := c.pointer
256+ if c.grab != 0 {
257+ id = c.grab
258+ }
259+ if !v.Pressed && !c.buttons[v.Button] {
260+ return nil
261+ }
262+ if v.Pressed {
263+ if len(c.buttons) == 0 {
264+ c.grab = id
265+ }
266+ c.buttons[v.Button] = true
267+ } else {
268+ delete(c.buttons, v.Button)
269+ }
270+ if c.validLockID(id) {
271+ if v.Pressed && c.lockFocus != id {
272+ c.lockFocus = id
273+ if err := c.publishProtected(ctx); err != nil {
274+ return err
275+ }
276+ }
277+ if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil {
278+ return err
279+ }
280+ }
281+ if len(c.buttons) == 0 {
282+ c.grab = 0
283+ }
284+ case ports.PointerAxis:
285+ if c.validLockID(c.pointer) {
286+ return c.command(ctx, ports.PointerAxisTo{ID: c.pointer, Axis: v})
287+ }
288+ }
289+ return nil
290+}
A internal/core/security_internal_test.go +96 -0
@@ -0,0 +1,96 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) {
12+ c, ws, commands := hiddenCaptureCommands(t)
13+ state := ports.SecurityState{Generation: 1, Protected: true}
14+ gate := portsmocks.NewMockSessionSecurity(t)
15+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
16+ c.ch.Security = gate
17+ c.capture = &captureSession{begin: ports.CaptureSessionBegin{ID: 7, Output: "A", Workspace: ws.ID}}
18+ c.screens[0].layers = []ports.LayerSurface{{ID: 50, Layer: ports.LayerOverlay, Keyboard: 1}}
19+ c.pressed["password"] = true
20+ c.buttons[0x110] = true
21+ c.pointer, c.grab = 1, 1
22+ c.keyboard = keyboard{layer: 50, sent: 1, inhibiting: 1}
23+ c.constrained = ports.PointerConstrained{ID: 1, Mode: ports.ConstraintLock}
24+ c.swipe = &swipeGesture{}
25+ if err := c.publish(context.Background()); err != nil {
26+ t.Fatal(err)
27+ }
28+ scenes := <-c.ch.Scenes
29+ s := scenes[0]
30+ if s.Security != state || s.Capture != nil || s.CaptureScene != nil || len(s.Windows) != 0 || len(s.Layers) != 0 || len(s.Separators) != 0 || s.Background != "#000000" {
31+ t.Fatalf("unsafe protected scene %+v", s)
32+ }
33+ if c.capture != nil || c.configures.cw.active() || len(c.pressed) != 0 || len(c.buttons) != 0 || c.pointer != 0 || c.grab != 0 || c.keyboard.layer != 0 || c.constrained.ID != 0 || c.swipe != nil {
34+ t.Fatal("transition kept desktop input/capture state")
35+ }
36+ // Logical dimensions must match exactly, including after output changes.
37+ c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: []ports.LockSurfacePlacement{{ID: 10, Output: "A", Width: 299, Height: 200}}})
38+ if c.lockKeyboardFocus() != 0 {
39+ t.Fatal("wrong dimensions accepted")
40+ }
41+ c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: []ports.LockSurfacePlacement{{ID: 10, Output: "A", Width: 300, Height: 200}}})
42+ if c.lockKeyboardFocus() != 10 {
43+ t.Fatal("mapped role not focused")
44+ }
45+ for _, ev := range []ports.ClientEvent{ports.WindowActivate{ID: 1}, ports.WorkspaceActivate{}, ports.PointerWarp{ID: 1}, ports.PointerConstrained{ID: 1}, ports.PopupRequest{}, ports.CaptureSessionBegin{}} {
46+ if !blockedProtectedEvent(ev) {
47+ t.Fatalf("allowed desktop effect %T", ev)
48+ }
49+ }
50+ for len(commands) > 0 {
51+ if _, ok := (<-commands).(ports.SecurityCommand); !ok {
52+ t.Fatal("unwrapped protected command")
53+ }
54+ }
55+ // Release is a transition too: password presses, button grabs and
56+ // keyboard ownership cannot survive it, even with freshly stamped releases.
57+ c.inputKeys["#30"] = true
58+ c.buttons[0x110] = true
59+ c.pointer, c.grab = 10, 10
60+ c.keyboard = keyboard{sent: 10}
61+ state = ports.SecurityState{Generation: 2}
62+ if !c.syncSecurity() || len(c.inputKeys) != 0 || c.grab != 0 || c.keyboard.sent != 0 || len(c.lockSurfaces) != 0 {
63+ t.Fatal("release did not reset input ownership")
64+ }
65+ for _, ev := range []ports.InputEvent{ports.KeyEvent{Keycode: 30}, ports.PointerButton{Button: 0x110}} {
66+ if _, ok := c.admitInput(ports.SecurityInput{State: state, Event: ev}); ok {
67+ t.Fatalf("release crossed epoch: %T", ev)
68+ }
69+ }
70+}
71+
72+func TestWiredZeroEpochRejectsRawInputAndCommandKeepsOwnerEpoch(t *testing.T) {
73+ c, _, commands := hiddenCaptureCommands(t)
74+ gate := portsmocks.NewMockSessionSecurity(t)
75+ next := ports.SecurityState{Generation: 1, Protected: true}
76+ gate.EXPECT().Snapshot().Return(next)
77+ c.ch.Security = gate
78+ if _, ok := c.admitInput(ports.KeyEvent{Pressed: true}); ok {
79+ t.Fatal("raw wired input admitted")
80+ }
81+ if _, ok := c.admitInput(ports.SecurityInput{Event: ports.KeyEvent{Pressed: true}}); !ok {
82+ t.Fatal("zero epoch wrapped input rejected")
83+ }
84+ // Gate changes after selection must not relabel an old command as new.
85+ if err := c.command(context.Background(), ports.ForwardKey{ID: 1}); err != nil {
86+ t.Fatal(err)
87+ }
88+ v := (<-commands).(ports.SecurityCommand)
89+ if v.State != (ports.SecurityState{}) {
90+ t.Fatalf("relabeled stale command: %+v", v)
91+ }
92+ c.syncSecurity()
93+ if _, ok := c.admitInput(ports.SecurityInput{Event: ports.KeyEvent{Pressed: true}}); ok {
94+ t.Fatal("stale input admitted")
95+ }
96+}
A internal/core/security_review_test.go +190 -0
@@ -0,0 +1,190 @@
1+package core
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11+ "github.com/bnema/neferwl/internal/ports"
12+)
13+
14+func reviewReceive[T any](t *testing.T, ch <-chan T) T {
15+ t.Helper()
16+ select {
17+ case v := <-ch:
18+ return v
19+ case <-time.After(time.Second):
20+ t.Fatal("timed out")
21+ var z T
22+ return z
23+ }
24+}
25+
26+func TestInputOutputSwitchBackpressureKeepsAdmittedEpoch(t *testing.T) {
27+ c, _, _ := hiddenCaptureCommands(t)
28+ c.addScreen(ports.OutputInfo{Name: "B", Width: 200, Height: 100})
29+ var state atomic.Value
30+ state.Store(ports.SecurityState{})
31+ gate := portsmocks.NewMockSessionSecurity(t)
32+ entered := make(chan struct{}, 1)
33+ var calls atomic.Int32
34+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
35+ v := state.Load().(ports.SecurityState)
36+ if calls.Add(1) == 2 {
37+ entered <- struct{}{}
38+ }
39+ return v
40+ })
41+ c.ch.Security = gate
42+ c.sentOutputs = ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}
43+ commands := make(chan ports.ClientCommand)
44+ c.ch.Commands = commands
45+ done := make(chan error, 1)
46+ go func() { done <- c.handleInput(context.Background(), ports.PointerMotion{X: 350, Y: 30}) }()
47+ reviewReceive(t, entered)
48+ state.Store(ports.SecurityState{Generation: 2})
49+ cmd := reviewReceive(t, commands).(ports.SecurityCommand)
50+ if cmd.State != (ports.SecurityState{}) {
51+ t.Fatalf("old handler adopted new epoch: %+v", cmd)
52+ }
53+ if err := reviewReceive(t, done); !errors.Is(err, errSecurityChanged) {
54+ t.Fatalf("handler not aborted: %v", err)
55+ }
56+ if c.security != (ports.SecurityState{}) {
57+ t.Fatal("publish adopted generation mid-input")
58+ }
59+ select {
60+ case cmd := <-commands:
61+ t.Fatalf("remaining old input command: %+v", cmd)
62+ default:
63+ }
64+}
65+
66+func TestExactEpochAdmissionPreservesProducerModifierState(t *testing.T) {
67+ c, _, _ := hiddenCaptureCommands(t)
68+ state := ports.SecurityState{Generation: 2}
69+ gate := portsmocks.NewMockSessionSecurity(t)
70+ gate.EXPECT().Snapshot().Return(state)
71+ c.ch.Security = gate
72+ c.syncSecurity()
73+ key := ports.KeyEvent{Keycode: 58, Keysym: "Control_L", Pressed: true, Mods: ports.ModCtrl, State: ports.ModState{Depressed: 1 << 19, Latched: 1 << 21, Locked: 1 << 23, Group: 2}}
74+ ev, ok := c.admitInput(ports.SecurityInput{State: state, Event: key})
75+ if !ok || ev.(ports.KeyEvent) != key {
76+ t.Fatalf("producer state reinterpreted: %+v", ev)
77+ }
78+ if _, ok := c.admitInput(ports.SecurityInput{State: ports.SecurityState{Generation: 1}, Event: key}); ok {
79+ t.Fatal("stale producer epoch admitted")
80+ }
81+}
82+
83+func TestSlotsPendingBackpressureSuppressesSpawnAndDefersSlots(t *testing.T) {
84+ c, _, _ := hiddenCaptureCommands(t)
85+ key := slotKey{workspace: "dev", index: 1}
86+ c.slots[key] = &slotState{argv: []string{"terminal"}}
87+ c.toSpawn = []slotKey{key}
88+ var state atomic.Value
89+ state.Store(ports.SecurityState{})
90+ gate := portsmocks.NewMockSessionSecurity(t)
91+ checked := make(chan struct{}, 1)
92+ var calls atomic.Int32
93+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
94+ v := state.Load().(ports.SecurityState)
95+ if calls.Add(1) == 2 {
96+ checked <- struct{}{}
97+ }
98+ return v
99+ })
100+ c.ch.Security = gate
101+ commands := make(chan ports.ClientCommand)
102+ c.ch.Commands = commands
103+ spawn := make(chan ports.SpawnRequest, 2)
104+ c.ch.Spawn = spawn
105+ done := make(chan error, 1)
106+ go func() { done <- c.spawnSlots(context.Background(), false) }()
107+ reviewReceive(t, checked)
108+ state.Store(ports.SecurityState{Generation: 1, Protected: true})
109+ cmd := reviewReceive(t, commands).(ports.SecurityCommand)
110+ if _, ok := cmd.Command.(ports.SlotsPending); !ok {
111+ t.Fatal(cmd)
112+ }
113+ if err := reviewReceive(t, done); err != nil {
114+ t.Fatal(err)
115+ }
116+ if len(spawn) != 0 || len(c.toSpawn) != 1 || c.placement.slotPending(key) {
117+ t.Fatal("spawn leaked or deferred slot lost")
118+ }
119+ // Retry after unlock, with a real buffered command sink.
120+ c.ch.Commands = make(chan ports.ClientCommand, 8)
121+ state.Store(ports.SecurityState{Generation: 2})
122+ c.syncSecurity()
123+ if err := c.spawnSlots(context.Background(), false); err != nil {
124+ t.Fatal(err)
125+ }
126+ if len(spawn) != 1 || len(c.toSpawn) != 0 {
127+ t.Fatal("deferred slot did not retry")
128+ }
129+}
130+
131+func TestTerminalPendingBackpressureSuppressesSpawnAndRestoresRetry(t *testing.T) {
132+ c, _, _ := hiddenCaptureCommands(t)
133+ c.cur().mon.RemoveWindow(1)
134+ c.cur().mon.RemoveWindow(2)
135+ c.ch.Terminal = true
136+ c.cfg.Terminal.AutoOpen = "always"
137+ c.cfg.Terminal.Command = []string{"terminal"}
138+ var state atomic.Value
139+ state.Store(ports.SecurityState{})
140+ gate := portsmocks.NewMockSessionSecurity(t)
141+ checked := make(chan struct{}, 1)
142+ var calls atomic.Int32
143+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
144+ v := state.Load().(ports.SecurityState)
145+ if calls.Add(1) == 1 {
146+ checked <- struct{}{}
147+ }
148+ return v
149+ })
150+ c.ch.Security = gate
151+ commands := make(chan ports.ClientCommand)
152+ c.ch.Commands = commands
153+ spawn := make(chan ports.SpawnRequest, 1)
154+ c.ch.Spawn = spawn
155+ done := make(chan error, 1)
156+ go func() { done <- c.spawnEmpty(context.Background()) }()
157+ reviewReceive(t, checked)
158+ state.Store(ports.SecurityState{Generation: 1, Protected: true})
159+ reviewReceive(t, commands)
160+ if err := reviewReceive(t, done); err != nil {
161+ t.Fatal(err)
162+ }
163+ if len(spawn) != 0 || c.placement.anyPending() {
164+ t.Fatal("terminal spawn/claim crossed lock")
165+ }
166+}
167+
168+// The pre-existing slot test can observe a startup placeholder snapshot: a
169+// scene send happens before the real-output workspace publication. Reproduce
170+// the empty snapshot contract without intentionally panicking a test runner.
171+func TestStartupWorkspaceSnapshotHasNoOutputBeforeOutputAdded(t *testing.T) {
172+ var cfg ports.Config
173+ cfg.Keyboard.CmdKey = "super"
174+ cfg.Layout.MaxColumns = 2
175+ workspaces := make(chan ports.Workspaces, 1)
176+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Workspaces: workspaces})
177+ if err != nil {
178+ t.Fatal(err)
179+ }
180+ c.publishWorkspaces()
181+ initial := <-workspaces
182+ if len(initial.Outputs) != 0 {
183+ t.Fatal(initial)
184+ }
185+ c.addScreen(ports.OutputInfo{Name: "A", Width: 100, Height: 80})
186+ c.publishWorkspaces()
187+ if next := <-workspaces; len(next.Outputs) != 1 {
188+ t.Fatal(next)
189+ }
190+}
A internal/core/security_test.go +147 -0
@@ -0,0 +1,147 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "sync/atomic"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/adapters/config"
9+ "github.com/bnema/neferwl/internal/core"
10+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11+ "github.com/bnema/neferwl/internal/ports"
12+)
13+
14+func TestSecurityOwnerAdversarialQueues(t *testing.T) {
15+ var epoch atomic.Value
16+ epoch.Store(ports.SecurityState{})
17+ gate := portsmocks.NewMockSessionSecurity(t)
18+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) })
19+ client := make(chan ports.ClientEvent, 32)
20+ input := make(chan ports.InputEvent, 32)
21+ output := make(chan ports.OutputEvent, 8)
22+ reload := make(chan ports.ConfigChanged, 8)
23+ commands := make(chan ports.ClientCommand, 256)
24+ scenes := make(chan []ports.Scene, 1)
25+ spawn := make(chan ports.SpawnRequest, 8)
26+ constraints := make(chan ports.PointerConstraint, 1)
27+ cfg := config.Defaults()
28+ cfg.Terminal.Command = []string{"terminal"}
29+ cfg.Terminal.AutoOpen = "off"
30+ cfg.Binds = map[string]string{"Super+q": "quit"}
31+ c, err := core.New(cfg, core.Channels{Security: gate, Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes, Spawn: spawn, Constraints: constraints, Terminal: true})
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ ctx, cancel := context.WithCancel(context.Background())
36+ done := make(chan error, 1)
37+ go func() { done <- c.Run(ctx) }()
38+ t.Cleanup(func() {
39+ cancel()
40+ if err := receive(t, done); err != nil {
41+ t.Error(err)
42+ }
43+ })
44+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "A", Width: 300, Height: 200}}
45+ scene(t, scenes)
46+ client <- ports.WindowMapped{ID: 1}
47+ scene(t, scenes)
48+ // A pre-lock key is queued BEFORE any SessionLockChanged event. The gate
49+ // must block it regardless of which channel select chooses next.
50+ locked := ports.SecurityState{Generation: 1, Protected: true}
51+ epoch.Store(locked)
52+ input <- ports.SecurityInput{State: ports.SecurityState{}, Event: ports.KeyEvent{Keysym: "q", Mods: ports.ModSuper, Pressed: true}}
53+ s := sceneMatch(t, scenes, func(s ports.Scene) bool { return s.Security == locked })
54+ assertProtected(t, s)
55+ if len(s.Windows) != 0 {
56+ t.Fatal(s)
57+ }
58+ receive(t, constraints)
59+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "B", Width: 200, Height: 100}}
60+ sceneMatch(t, scenes, func(s ports.Scene) bool { return s.Security == locked })
61+ surfaces := []ports.LockSurfacePlacement{{ID: 10, Output: "A", Width: 300, Height: 200}, {ID: 20, Output: "B", Width: 200, Height: 100}}
62+ client <- ports.SessionLockChanged{State: locked, Surfaces: surfaces}
63+ s = sceneMatch(t, scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 && s.Windows[0].ID == 10 })
64+ assertProtected(t, s)
65+ // Raw input is refused even in the initial unlocked epoch when wired;
66+ // stale, raw, bind and swipe events must not activate desktop behavior.
67+ input <- ports.KeyEvent{Keysym: "raw", Pressed: true}
68+ input <- ports.SecurityInput{State: locked, Event: ports.KeyEvent{Keysym: "q", Mods: ports.ModSuper, Pressed: true}}
69+ input <- ports.SecurityInput{State: locked, Event: ports.SwipeBegin{Fingers: 3}}
70+ input <- ports.SecurityInput{State: locked, Event: ports.KeyEvent{Keysym: "password", Keycode: 30, Pressed: true}}
71+ expectKey(t, commands, locked, 10, "password")
72+ input <- ports.SecurityInput{State: locked, Event: ports.PointerMotion{X: 350, Y: 30}}
73+ input <- ports.SecurityInput{State: locked, Event: ports.PointerButton{Button: 0x110, Pressed: true}}
74+ input <- ports.SecurityInput{State: locked, Event: ports.KeyEvent{Keysym: "clicked", Pressed: true}}
75+ expectKey(t, commands, locked, 20, "clicked")
76+ // Stale surfaces cannot replace the current lock roles. Invalid logical
77+ // dimensions and unknown outputs never become drawn/input targets.
78+ client <- ports.SessionLockChanged{State: ports.SecurityState{}, Surfaces: []ports.LockSurfacePlacement{{ID: 1, Output: "A", Width: 300, Height: 200}}}
79+ client <- ports.SessionLockChanged{State: locked, Surfaces: append(surfaces, ports.LockSurfacePlacement{ID: 99, Output: "unknown", Width: 300, Height: 200})}
80+ s = sceneMatch(t, scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 && s.Windows[0].ID == 10 })
81+ assertProtected(t, s)
82+ cfg.Terminal.AutoOpen = "always"
83+ cfg.Startup = [][]string{{"must-not-start"}}
84+ reload <- ports.ConfigChanged{Config: cfg}
85+ s = scene(t, scenes)
86+ assertProtected(t, s)
87+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "C", Width: 100, Height: 100}}
88+ for _, s := range receive(t, scenes) {
89+ assertProtected(t, s)
90+ if s.Output == "C" && len(s.Windows) != 0 {
91+ t.Fatal(s)
92+ }
93+ }
94+ select {
95+ case req := <-spawn:
96+ t.Fatalf("protected spawn: %+v", req)
97+ default:
98+ }
99+ // Unlock discards all presses/grabs, including a password press with no
100+ // corresponding release until AFTER the transition.
101+ unlocked := ports.SecurityState{Generation: 2}
102+ epoch.Store(unlocked)
103+ input <- ports.SecurityInput{State: locked, Event: ports.KeyEvent{Keysym: "stale-password", Pressed: true}}
104+ input <- ports.SecurityInput{State: unlocked, Event: ports.KeyEvent{Keysym: "password", Keycode: 30, Pressed: false}}
105+ input <- ports.SecurityInput{State: unlocked, Event: ports.KeyEvent{Keysym: "desktop", Pressed: true}}
106+ expectKey(t, commands, unlocked, 1, "desktop")
107+ s = sceneMatch(t, scenes, func(s ports.Scene) bool { return s.Security == unlocked })
108+ if len(s.Windows) == 0 || s.Windows[0].ID != 1 {
109+ t.Fatal(s)
110+ }
111+}
112+
113+func assertProtected(t *testing.T, s ports.Scene) {
114+ t.Helper()
115+ if !s.Security.Protected || s.Background != "#000000" || s.Capture != nil || s.CaptureScene != nil || len(s.Layers) != 0 || len(s.Separators) != 0 || s.Border.Width != 0 || s.Dim != 0 || s.WorkspaceClip != (ports.Rect{}) {
116+ t.Fatalf("unsafe scene: %+v", s)
117+ }
118+ for _, w := range s.Windows {
119+ if !w.Fullscreen || w.Hidden || w.Popup || w.Rect != (ports.Rect{W: s.OutputWidth, H: s.OutputHeight}) {
120+ t.Fatalf("unsafe role: %+v", w)
121+ }
122+ }
123+}
124+
125+func expectKey(t *testing.T, ch <-chan ports.ClientCommand, state ports.SecurityState, id ports.WindowID, name string) {
126+ t.Helper()
127+ for {
128+ wrapped, ok := receive(t, ch).(ports.SecurityCommand)
129+ if !ok {
130+ t.Fatal("unwrapped command")
131+ }
132+ if k, ok := wrapped.Command.(ports.ForwardKey); ok {
133+ if wrapped.State != state {
134+ continue
135+ } // older commands keep their epoch
136+ if k.Key.Keysym == "raw" || k.Key.Keysym == "stale-password" || !k.Key.Pressed {
137+ t.Fatalf("leaked input: %+v", k)
138+ }
139+ if k.Key.Keysym == name {
140+ if k.ID != id {
141+ t.Fatalf("key target %d want %d", k.ID, id)
142+ }
143+ return
144+ }
145+ }
146+ }
147+}
A internal/core/spawn.go +40 -0
@@ -0,0 +1,40 @@
1+package core
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// trySpawn is a bounded handoff, not execution admission. The launcher checks
10+// Security again before exec: the gate may change after this owner's check.
11+func (c *Core) trySpawn(ctx context.Context, req ports.SpawnRequest) bool {
12+ if ctx.Err() != nil || c.protectionRequested() {
13+ return false
14+ }
15+ req.Security = c.security
16+ select {
17+ case c.ch.Spawn <- req:
18+ return true
19+ default:
20+ return false
21+ }
22+}
23+
24+// Startup has at most the configured number of pending commands. Initial
25+// ready sends are bounded; Run selects the remainder alongside owner events.
26+func (c *Core) pendingStartup() (chan<- ports.SpawnRequest, ports.SpawnRequest) {
27+ if len(c.startup) == 0 || c.protectionRequested() {
28+ return nil, ports.SpawnRequest{}
29+ }
30+ return c.ch.Spawn, ports.SpawnRequest{Security: c.security, Argv: c.startup[0]}
31+}
32+
33+func (c *Core) spawnStartup(ctx context.Context) {
34+ for len(c.startup) > 0 {
35+ if !c.trySpawn(ctx, ports.SpawnRequest{Argv: c.startup[0]}) {
36+ return
37+ }
38+ c.startup = c.startup[1:]
39+ }
40+}
A internal/core/spawn_security_test.go +241 -0
@@ -0,0 +1,241 @@
1+package core
2+
3+import (
4+ "context"
5+ "sync/atomic"
6+ "testing"
7+
8+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+func TestFullSpawnQueueSlotsTerminalsExplicitRemainTransitionSafe(t *testing.T) {
13+ for _, kind := range []string{"slot", "terminal", "explicit", "startup"} {
14+ t.Run(kind, func(t *testing.T) {
15+ c, _, commands := hiddenCaptureCommands(t)
16+ c.configures.cw.reset()
17+ c.cur().mon.RemoveWindow(1)
18+ c.cur().mon.RemoveWindow(2)
19+ var epoch atomic.Value
20+ epoch.Store(ports.SecurityState{Generation: 2})
21+ gate := portsmocks.NewMockSessionSecurity(t)
22+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) })
23+ c.ch.Security = gate
24+ c.syncSecurity()
25+ spawn := make(chan ports.SpawnRequest, 1)
26+ c.ch.Spawn = spawn
27+ sentinel := ports.SpawnRequest{Argv: []string{"occupied"}}
28+ spawn <- sentinel
29+ key := slotKey{workspace: "dev", index: 1}
30+ c.cfg.Terminal.Command = []string{"terminal"}
31+ c.cfg.Terminal.AutoOpen = "always"
32+ c.ch.Terminal = kind == "terminal"
33+ if kind == "slot" {
34+ c.slots[key] = &slotState{argv: []string{"slot"}}
35+ c.toSpawn = []slotKey{key}
36+ }
37+ if kind == "startup" {
38+ c.startup = [][]string{{"startup"}}
39+ }
40+ c.binds = map[binding]Action{{key: "t"}: ActionSpawnTerminal}
41+ attempt := func() error {
42+ switch kind {
43+ case "slot":
44+ return c.spawnSlots(context.Background(), false)
45+ case "terminal":
46+ return c.spawnEmpty(context.Background())
47+ case "explicit":
48+ return c.handleInput(context.Background(), ports.KeyEvent{Keysym: "t", Pressed: true})
49+ default:
50+ c.spawnStartup(context.Background())
51+ return nil
52+ }
53+ }
54+ done := make(chan error, 1)
55+ go func() { done <- attempt() }()
56+ if err := reviewReceive(t, done); err != nil {
57+ t.Fatal(err)
58+ }
59+ if len(spawn) != 1 {
60+ t.Fatal("full queue changed")
61+ }
62+ if c.placement.anyPending() {
63+ t.Fatal("unsent spawn kept claim")
64+ }
65+ if kind == "slot" && len(c.toSpawn) != 1 {
66+ t.Fatal("deferred slot lost")
67+ }
68+ if kind == "startup" && len(c.startup) != 1 {
69+ t.Fatal("deferred startup lost")
70+ }
71+ // A full queue never prevents the owner from adopting protection. Empty
72+ // it only after transition; no path may enqueue in the protected epoch.
73+ epoch.Store(ports.SecurityState{Generation: 3, Protected: true})
74+ c.syncSecurity()
75+ <-spawn
76+ if err := attempt(); err != nil {
77+ t.Fatal(err)
78+ }
79+ if len(spawn) != 0 {
80+ t.Fatal("protected spawn enqueued")
81+ }
82+ epoch.Store(ports.SecurityState{Generation: 4})
83+ c.syncSecurity()
84+ if err := attempt(); err != nil {
85+ t.Fatal(err)
86+ }
87+ req := reviewReceive(t, spawn)
88+ if req.Security != (ports.SecurityState{Generation: 4}) {
89+ t.Fatalf("missing owner stamp: %+v", req)
90+ }
91+ // Queued requests retain admission generation, not a later gate value.
92+ epoch.Store(ports.SecurityState{Generation: 5, Protected: true})
93+ if req.Security.Generation != 4 {
94+ t.Fatal("queued request relabeled")
95+ }
96+ for len(commands) > 0 {
97+ <-commands
98+ }
99+ })
100+ }
101+}
102+
103+func TestRunStartupDrainsWithoutFurtherOwnerEvents(t *testing.T) {
104+ c, _, _ := hiddenCaptureCommands(t)
105+ c.cfg.Startup = [][]string{{"one"}, {"two"}, {"three"}}
106+ spawn := make(chan ports.SpawnRequest, 1)
107+ spawn <- ports.SpawnRequest{Argv: []string{"occupied"}}
108+ c.ch.Spawn = spawn
109+ ctx, cancel := context.WithCancel(context.Background())
110+ done := make(chan error, 1)
111+ go func() { done <- c.Run(ctx) }()
112+ if got := reviewReceive(t, spawn); got.Argv[0] != "occupied" {
113+ t.Fatal(got)
114+ }
115+ for _, want := range []string{"one", "two", "three"} {
116+ if got := reviewReceive(t, spawn); got.Argv[0] != want {
117+ t.Fatal(got)
118+ }
119+ }
120+ cancel()
121+ if err := reviewReceive(t, done); err != nil {
122+ t.Fatal(err)
123+ }
124+}
125+
126+func TestRunStartupPendingDisabledWhileLockedResumesOnUnlockEvent(t *testing.T) {
127+ c, _, _ := hiddenCaptureCommands(t)
128+ c.cfg.Startup = [][]string{{"one"}, {"two"}, {"three"}}
129+ var epoch atomic.Value
130+ locked := ports.SecurityState{Generation: 1, Protected: true}
131+ epoch.Store(locked)
132+ gate := portsmocks.NewMockSessionSecurity(t)
133+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) })
134+ c.ch.Security = gate
135+ spawn := make(chan ports.SpawnRequest, 1)
136+ c.ch.Spawn = spawn
137+ client := make(chan ports.ClientEvent, 1)
138+ c.ch.Client = client
139+ ctx, cancel := context.WithCancel(context.Background())
140+ done := make(chan error, 1)
141+ go func() { done <- c.Run(ctx) }()
142+ client <- ports.SessionLockChanged{State: locked}
143+ reviewReceive(t, c.ch.Scenes) // locked event has completed, no startup sent
144+ select {
145+ case req := <-spawn:
146+ t.Fatalf("locked startup sent: %+v", req)
147+ default:
148+ }
149+ unlocked := ports.SecurityState{Generation: 2}
150+ epoch.Store(unlocked)
151+ client <- ports.SessionLockChanged{State: unlocked}
152+ for _, want := range []string{"one", "two", "three"} {
153+ got := reviewReceive(t, spawn)
154+ if got.Argv[0] != want || got.Security != unlocked {
155+ t.Fatal(got)
156+ }
157+ }
158+ cancel()
159+ if err := reviewReceive(t, done); err != nil {
160+ t.Fatal(err)
161+ }
162+}
163+
164+func TestStartupSuccessfulHandoffConsumesDespiteImmediateTransition(t *testing.T) {
165+ c, _, _ := hiddenCaptureCommands(t)
166+ c.startup = [][]string{{"once"}}
167+ spawn := make(chan ports.SpawnRequest, 1)
168+ c.ch.Spawn = spawn
169+ var epoch atomic.Value
170+ epoch.Store(ports.SecurityState{})
171+ gate := portsmocks.NewMockSessionSecurity(t)
172+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
173+ if len(spawn) != 0 {
174+ epoch.Store(ports.SecurityState{Generation: 1, Protected: true})
175+ }
176+ return epoch.Load().(ports.SecurityState)
177+ })
178+ c.ch.Security = gate
179+ c.spawnStartup(context.Background())
180+ if len(c.startup) != 0 {
181+ t.Fatal("successful handoff kept pending startup")
182+ }
183+ if !gate.Snapshot().Protected {
184+ t.Fatal("test did not engage gate after handoff")
185+ }
186+ epoch.Store(ports.SecurityState{Generation: 2})
187+ req := <-spawn
188+ if req.Argv[0] != "once" || req.Security != (ports.SecurityState{}) {
189+ t.Fatal(req)
190+ }
191+ c.syncSecurity()
192+ c.spawnStartup(context.Background())
193+ if len(spawn) != 0 {
194+ t.Fatal("successful startup duplicated after release")
195+ }
196+}
197+
198+func TestRunSelectableStartupConsumedOnceAcrossReceiverTransition(t *testing.T) {
199+ c, _, _ := hiddenCaptureCommands(t)
200+ c.cfg.Startup = [][]string{{"once"}}
201+ var epoch atomic.Value
202+ epoch.Store(ports.SecurityState{})
203+ gate := portsmocks.NewMockSessionSecurity(t)
204+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) })
205+ c.ch.Security = gate
206+ spawn := make(chan ports.SpawnRequest)
207+ c.ch.Spawn = spawn
208+ client := make(chan ports.ClientEvent, 1)
209+ c.ch.Client = client
210+ ctx, cancel := context.WithCancel(context.Background())
211+ done := make(chan error, 1)
212+ go func() { done <- c.Run(ctx) }()
213+ req := reviewReceive(t, spawn)
214+ if req.Argv[0] != "once" {
215+ t.Fatal(req)
216+ }
217+ locked := ports.SecurityState{Generation: 1, Protected: true}
218+ epoch.Store(locked)
219+ client <- ports.SessionLockChanged{State: locked}
220+ reviewReceive(t, c.ch.Scenes)
221+ unlocked := ports.SecurityState{Generation: 2}
222+ epoch.Store(unlocked)
223+ client <- ports.SessionLockChanged{State: unlocked}
224+ for {
225+ if s := reviewReceive(t, c.ch.Scenes); s[0].Security == unlocked {
226+ break
227+ }
228+ }
229+ select {
230+ case duplicate := <-spawn:
231+ t.Fatalf("startup replayed: %+v", duplicate)
232+ default:
233+ }
234+ cancel()
235+ if err := reviewReceive(t, done); err != nil {
236+ t.Fatal(err)
237+ }
238+ if len(c.startup) != 0 {
239+ t.Fatal("startup not consumed")
240+ }
241+}
A internal/core/startup_alloc_test.go +20 -0
@@ -0,0 +1,20 @@
1+package core
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "testing"
6+)
7+
8+// A full launcher must not copy the pending command on every unrelated event.
9+func TestPendingStartupReusesImmutableArguments(t *testing.T) {
10+ c := &Core{startup: [][]string{{"command", "argument"}}}
11+ c.ch.Spawn = make(chan ports.SpawnRequest, 1)
12+ c.ch.Spawn <- ports.SpawnRequest{}
13+ var req ports.SpawnRequest
14+ if allocations := testing.AllocsPerRun(100, func() { _, req = c.pendingStartup() }); allocations != 0 {
15+ t.Fatalf("pending startup: %.1f allocations", allocations)
16+ }
17+ if len(req.Argv) != 2 || &req.Argv[0] != &c.startup[0][0] {
18+ t.Fatal("pending arguments copied or changed")
19+ }
20+}
M internal/core/terminal.go +13 -0
@@ -1,6 +1,7 @@
11 package core
22
33 import (
4+ "strings"
45 "time"
56
67 "github.com/bnema/neferwl/internal/ports"
@@ -48,6 +49,18 @@ func (c *Core) fillEmpty() []ports.SpawnRequest {
4849 return reqs
4950 }
5051
52+// cancelTerminal removes a claim minted for a send suppressed by the gate.
53+func (c *Core) cancelTerminal(req ports.SpawnRequest) {
54+ for _, env := range req.Env {
55+ if token, ok := strings.CutPrefix(env, ports.SlotEnv+"="); ok {
56+ if target, ok := c.placement.pending[token]; ok && target.terminal != nil {
57+ target.terminal.termAt = time.Time{}
58+ delete(c.placement.pending, token)
59+ }
60+ }
61+ }
62+}
63+
5164 func (c *Core) hasSlots(w *Workspace) bool {
5265 for key := range c.slots {
5366 if w.Name != "" && key.workspace == w.Name {
A internal/mocks/ports/session_security_mock.go +283 -0
@@ -0,0 +1,283 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package portsmocks
6+
7+import (
8+ "github.com/bnema/neferwl/internal/ports"
9+ mock "github.com/stretchr/testify/mock"
10+)
11+
12+// NewMockSessionSecurity creates a new instance of MockSessionSecurity. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func NewMockSessionSecurity(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *MockSessionSecurity {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
22+ mock := &MockSessionSecurity{}
23+ mock.Mock.Test(t)
24+
25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
31+
32+ return mock
33+}
34+
35+// MockSessionSecurity is an autogenerated mock type for the SessionSecurity type
36+type MockSessionSecurity struct {
37+ mock.Mock
38+}
39+
40+type MockSessionSecurity_Expecter struct {
41+ mock *mock.Mock
42+}
43+
44+func (_m *MockSessionSecurity) EXPECT() *MockSessionSecurity_Expecter {
45+ return &MockSessionSecurity_Expecter{mock: &_m.Mock}
46+}
47+
48+// Snapshot provides a mock function for the type MockSessionSecurity
49+func (_mock *MockSessionSecurity) Snapshot() ports.SecurityState {
50+ ret := _mock.Called()
51+
52+ if len(ret) == 0 {
53+ panic("no return value specified for Snapshot")
54+ }
55+
56+ var r0 ports.SecurityState
57+ if returnFunc, ok := ret.Get(0).(func() ports.SecurityState); ok {
58+ r0 = returnFunc()
59+ } else {
60+ r0 = ret.Get(0).(ports.SecurityState)
61+ }
62+ return r0
63+}
64+
65+// MockSessionSecurity_Snapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Snapshot'
66+type MockSessionSecurity_Snapshot_Call struct {
67+ *mock.Call
68+}
69+
70+// Snapshot is a helper method to define mock.On call
71+func (_e *MockSessionSecurity_Expecter) Snapshot() *MockSessionSecurity_Snapshot_Call {
72+ return &MockSessionSecurity_Snapshot_Call{Call: _e.mock.On("Snapshot")}
73+}
74+
75+func (_c *MockSessionSecurity_Snapshot_Call) Run(run func()) *MockSessionSecurity_Snapshot_Call {
76+ _c.Call.Run(func(args mock.Arguments) {
77+ run()
78+ })
79+ return _c
80+}
81+
82+func (_c *MockSessionSecurity_Snapshot_Call) Return(securityState ports.SecurityState) *MockSessionSecurity_Snapshot_Call {
83+ _c.Call.Return(securityState)
84+ return _c
85+}
86+
87+func (_c *MockSessionSecurity_Snapshot_Call) RunAndReturn(run func() ports.SecurityState) *MockSessionSecurity_Snapshot_Call {
88+ _c.Call.Return(run)
89+ return _c
90+}
91+
92+// NewMockSessionSecurityController creates a new instance of MockSessionSecurityController. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
93+// The first argument is typically a *testing.T value.
94+func NewMockSessionSecurityController(t interface {
95+ mock.TestingT
96+ Cleanup(func())
97+}) *MockSessionSecurityController {
98+ if helper, ok := t.(interface{ Helper() }); ok {
99+ helper.Helper()
100+ }
101+
102+ mock := &MockSessionSecurityController{}
103+ mock.Mock.Test(t)
104+
105+ t.Cleanup(func() {
106+ if helper, ok := t.(interface{ Helper() }); ok {
107+ helper.Helper()
108+ }
109+ mock.AssertExpectations(t)
110+ })
111+
112+ return mock
113+}
114+
115+// MockSessionSecurityController is an autogenerated mock type for the SessionSecurityController type
116+type MockSessionSecurityController struct {
117+ mock.Mock
118+}
119+
120+type MockSessionSecurityController_Expecter struct {
121+ mock *mock.Mock
122+}
123+
124+func (_m *MockSessionSecurityController) EXPECT() *MockSessionSecurityController_Expecter {
125+ return &MockSessionSecurityController_Expecter{mock: &_m.Mock}
126+}
127+
128+// Engage provides a mock function for the type MockSessionSecurityController
129+func (_mock *MockSessionSecurityController) Engage() (ports.SecurityState, error) {
130+ ret := _mock.Called()
131+
132+ if len(ret) == 0 {
133+ panic("no return value specified for Engage")
134+ }
135+
136+ var r0 ports.SecurityState
137+ var r1 error
138+ if returnFunc, ok := ret.Get(0).(func() (ports.SecurityState, error)); ok {
139+ return returnFunc()
140+ }
141+ if returnFunc, ok := ret.Get(0).(func() ports.SecurityState); ok {
142+ r0 = returnFunc()
143+ } else {
144+ r0 = ret.Get(0).(ports.SecurityState)
145+ }
146+ if returnFunc, ok := ret.Get(1).(func() error); ok {
147+ r1 = returnFunc()
148+ } else {
149+ r1 = ret.Error(1)
150+ }
151+ return r0, r1
152+}
153+
154+// MockSessionSecurityController_Engage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Engage'
155+type MockSessionSecurityController_Engage_Call struct {
156+ *mock.Call
157+}
158+
159+// Engage is a helper method to define mock.On call
160+func (_e *MockSessionSecurityController_Expecter) Engage() *MockSessionSecurityController_Engage_Call {
161+ return &MockSessionSecurityController_Engage_Call{Call: _e.mock.On("Engage")}
162+}
163+
164+func (_c *MockSessionSecurityController_Engage_Call) Run(run func()) *MockSessionSecurityController_Engage_Call {
165+ _c.Call.Run(func(args mock.Arguments) {
166+ run()
167+ })
168+ return _c
169+}
170+
171+func (_c *MockSessionSecurityController_Engage_Call) Return(securityState ports.SecurityState, err error) *MockSessionSecurityController_Engage_Call {
172+ _c.Call.Return(securityState, err)
173+ return _c
174+}
175+
176+func (_c *MockSessionSecurityController_Engage_Call) RunAndReturn(run func() (ports.SecurityState, error)) *MockSessionSecurityController_Engage_Call {
177+ _c.Call.Return(run)
178+ return _c
179+}
180+
181+// Release provides a mock function for the type MockSessionSecurityController
182+func (_mock *MockSessionSecurityController) Release(expected ports.LockGeneration) (ports.SecurityState, error) {
183+ ret := _mock.Called(expected)
184+
185+ if len(ret) == 0 {
186+ panic("no return value specified for Release")
187+ }
188+
189+ var r0 ports.SecurityState
190+ var r1 error
191+ if returnFunc, ok := ret.Get(0).(func(ports.LockGeneration) (ports.SecurityState, error)); ok {
192+ return returnFunc(expected)
193+ }
194+ if returnFunc, ok := ret.Get(0).(func(ports.LockGeneration) ports.SecurityState); ok {
195+ r0 = returnFunc(expected)
196+ } else {
197+ r0 = ret.Get(0).(ports.SecurityState)
198+ }
199+ if returnFunc, ok := ret.Get(1).(func(ports.LockGeneration) error); ok {
200+ r1 = returnFunc(expected)
201+ } else {
202+ r1 = ret.Error(1)
203+ }
204+ return r0, r1
205+}
206+
207+// MockSessionSecurityController_Release_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Release'
208+type MockSessionSecurityController_Release_Call struct {
209+ *mock.Call
210+}
211+
212+// Release is a helper method to define mock.On call
213+// - expected ports.LockGeneration
214+func (_e *MockSessionSecurityController_Expecter) Release(expected any) *MockSessionSecurityController_Release_Call {
215+ return &MockSessionSecurityController_Release_Call{Call: _e.mock.On("Release", expected)}
216+}
217+
218+func (_c *MockSessionSecurityController_Release_Call) Run(run func(expected ports.LockGeneration)) *MockSessionSecurityController_Release_Call {
219+ _c.Call.Run(func(args mock.Arguments) {
220+ var arg0 ports.LockGeneration
221+ if args[0] != nil {
222+ arg0 = args[0].(ports.LockGeneration)
223+ }
224+ run(
225+ arg0,
226+ )
227+ })
228+ return _c
229+}
230+
231+func (_c *MockSessionSecurityController_Release_Call) Return(securityState ports.SecurityState, err error) *MockSessionSecurityController_Release_Call {
232+ _c.Call.Return(securityState, err)
233+ return _c
234+}
235+
236+func (_c *MockSessionSecurityController_Release_Call) RunAndReturn(run func(expected ports.LockGeneration) (ports.SecurityState, error)) *MockSessionSecurityController_Release_Call {
237+ _c.Call.Return(run)
238+ return _c
239+}
240+
241+// Snapshot provides a mock function for the type MockSessionSecurityController
242+func (_mock *MockSessionSecurityController) Snapshot() ports.SecurityState {
243+ ret := _mock.Called()
244+
245+ if len(ret) == 0 {
246+ panic("no return value specified for Snapshot")
247+ }
248+
249+ var r0 ports.SecurityState
250+ if returnFunc, ok := ret.Get(0).(func() ports.SecurityState); ok {
251+ r0 = returnFunc()
252+ } else {
253+ r0 = ret.Get(0).(ports.SecurityState)
254+ }
255+ return r0
256+}
257+
258+// MockSessionSecurityController_Snapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Snapshot'
259+type MockSessionSecurityController_Snapshot_Call struct {
260+ *mock.Call
261+}
262+
263+// Snapshot is a helper method to define mock.On call
264+func (_e *MockSessionSecurityController_Expecter) Snapshot() *MockSessionSecurityController_Snapshot_Call {
265+ return &MockSessionSecurityController_Snapshot_Call{Call: _e.mock.On("Snapshot")}
266+}
267+
268+func (_c *MockSessionSecurityController_Snapshot_Call) Run(run func()) *MockSessionSecurityController_Snapshot_Call {
269+ _c.Call.Run(func(args mock.Arguments) {
270+ run()
271+ })
272+ return _c
273+}
274+
275+func (_c *MockSessionSecurityController_Snapshot_Call) Return(securityState ports.SecurityState) *MockSessionSecurityController_Snapshot_Call {
276+ _c.Call.Return(securityState)
277+ return _c
278+}
279+
280+func (_c *MockSessionSecurityController_Snapshot_Call) RunAndReturn(run func() ports.SecurityState) *MockSessionSecurityController_Snapshot_Call {
281+ _c.Call.Return(run)
282+ return _c
283+}
M internal/ports/messages.go +8 -1
@@ -775,7 +775,10 @@ type CursorChange struct {
775775
776776 // SpawnRequest carries core → launcher process arguments.
777777 type SpawnRequest struct {
778- Argv []string
778+ // Security retains the admitting owner epoch through launcher backpressure.
779+ // A wired launcher rejects stale or protected requests before execution.
780+ Security SecurityState
781+ Argv []string
779782 // Env adds KEY=value entries to the child environment.
780783 Env []string
781784 }
@@ -784,6 +787,10 @@ type SpawnRequest struct {
784787 // one per output. Rects and the output size are logical and local to the
785788 // output; the renderer multiplies by Scale.
786789 type Scene struct {
790+ // Security is the owner epoch captured when this scene was published.
791+ // Output owners reject stale scenes rather than showing a queued desktop
792+ // or accepting old locker buffers across acquisition/release.
793+ Security SecurityState
787794 Output string
788795 Seq uint64
789796 OutputWidth, OutputHeight int
A internal/ports/session_backend.go +42 -0
@@ -0,0 +1,42 @@
1+package ports
2+
3+// SecurityBackendEvent carries authoritative backend inventory and affirmative
4+// evidence to the display owner. Unlike OutputPresented, it is never merged or
5+// dropped. Output lifetimes register before starting a renderer.
6+type SecurityBackendEvent interface{ securityBackendEvent() }
7+
8+type SecurityOutputAdded struct {
9+ Instance OutputInstance
10+ Output string
11+}
12+
13+func (SecurityOutputAdded) securityBackendEvent() {}
14+
15+// SecurityOutputRemoved means the connector is confirmed disconnected, or an
16+// output lifetime was shut down with a successful inactive KMS transaction.
17+// Stopping a renderer, failed KMS, timeout or DRM master loss do not qualify.
18+type SecurityOutputRemoved struct{ Instance OutputInstance }
19+
20+func (SecurityOutputRemoved) securityBackendEvent() {}
21+
22+// SecurityOutputInvalidated revokes prior physical proof on master loss,
23+// resume or recovery, without treating an output error as disconnection.
24+type SecurityOutputInvalidated struct {
25+ Generation LockGeneration
26+ Instance OutputInstance
27+}
28+
29+func (SecurityOutputInvalidated) securityBackendEvent() {}
30+
31+type SecurityOutputProof struct{ Proof OutputProtection }
32+
33+func (SecurityOutputProof) securityBackendEvent() {}
34+
35+// SecurityBackendBarrier acknowledges ordered inventory and actual lease
36+// revocation for this round. Errors are diagnostics, never readiness evidence.
37+type SecurityBackendBarrier struct {
38+ Generation LockGeneration
39+ Err error
40+}
41+
42+func (SecurityBackendBarrier) securityBackendEvent() {}
A internal/ports/session_lock.go +29 -0
@@ -0,0 +1,29 @@
1+package ports
2+
3+// OutputInstance identifies one output lifetime, not its connector or name.
4+// Instances are nonzero and unique for the compositor's lifetime; reconnecting
5+// or replacing an output requires a new instance even if its name is unchanged.
6+type OutputInstance uint64
7+
8+// ProtectionKind describes affirmative evidence of output protection.
9+// The zero value and all values other than the named kinds are invalid.
10+type ProtectionKind string
11+
12+const (
13+ // ProtectionProtectedFrame confirms a protected frame is presented, not
14+ // merely queued or submitted for rendering.
15+ ProtectionProtectedFrame ProtectionKind = "protected-frame"
16+ // ProtectionInactiveOutput confirms the output is actually inactive, not
17+ // that rendering failed or that its disappearance was inferred from an error.
18+ ProtectionInactiveOutput ProtectionKind = "inactive-output"
19+)
20+
21+// OutputProtection is affirmative protection evidence for one output lifetime
22+// in exactly one security generation. It does not acknowledge backend lease
23+// drainage/security handoff or completion of admitted prelock capture copies;
24+// those are separate barriers.
25+type OutputProtection struct {
26+ Generation LockGeneration
27+ Instance OutputInstance
28+ Kind ProtectionKind
29+}
A internal/ports/session_lock_messages.go +36 -0
@@ -0,0 +1,36 @@
1+package ports
2+
3+// SessionLockChanged carries display-owned protection changes to core. The
4+// defensive gate is engaged before this event is queued. Unlock also changes
5+// the generation. A dead owner leaves Protected true and no surfaces.
6+type SessionLockChanged struct {
7+ State SecurityState
8+ Surfaces []LockSurfacePlacement
9+}
10+
11+// LockSurfacePlacement is a validated mapped lock role, never a desktop window.
12+type LockSurfacePlacement struct {
13+ ID WindowID
14+ Output string
15+ Width, Height int
16+}
17+
18+func (SessionLockChanged) clientEvent() {}
19+
20+// SecurityInput snapshots the gate when an input event is produced, before it
21+// enters a forwarding queue. Stale events cannot become password/desktop input.
22+type SecurityInput struct {
23+ State SecurityState
24+ Event InputEvent
25+}
26+
27+func (SecurityInput) inputEvent() {}
28+
29+// SecurityCommand preserves the epoch of core input/focus commands through the
30+// display queue. Unwrapped commands are for standalone unlocked protocol tests.
31+type SecurityCommand struct {
32+ State SecurityState
33+ Command ClientCommand
34+}
35+
36+func (SecurityCommand) clientCommand() {}
A internal/ports/session_security.go +25 -0
@@ -0,0 +1,25 @@
1+package ports
2+
3+// LockGeneration identifies a session transition. Zero is the initial unlocked epoch.
4+type LockGeneration uint64
5+
6+// SecurityState is one coherent admission epoch. Generation changes on both
7+// acquisition and release so queued work cannot cross a lock transition.
8+type SecurityState struct {
9+ Generation LockGeneration
10+ Protected bool
11+}
12+
13+// SessionSecurity exposes the defensive gate to off-display boundaries.
14+// The display goroutine remains the sole authority for protocol admission.
15+type SessionSecurity interface {
16+ Snapshot() SecurityState
17+}
18+
19+// SessionSecurityController is owned by the display goroutine. Releasing
20+// protection requires its exact acquisition generation; crashes do not release.
21+type SessionSecurityController interface {
22+ SessionSecurity
23+ Engage() (SecurityState, error)
24+ Release(expected LockGeneration) (SecurityState, error)
25+}
A internal/sessionlock/readiness.go +164 -0
@@ -0,0 +1,164 @@
1+package sessionlock
2+
3+import "github.com/bnema/neferwl/internal/ports"
4+
5+// Readiness is pure readiness bookkeeping owned by one goroutine. Its zero
6+// value is an empty, disengaged ledger. It must not be copied after first use;
7+// it owns its inventory and never exposes mutable state.
8+//
9+// Readiness is not authentication or protocol policy. All acknowledgements
10+// must be affirmative evidence from the respective owner, never an inference
11+// from a failed operation.
12+type Readiness struct {
13+ generation ports.LockGeneration // high-water mark, retained after End
14+ active bool
15+ backend bool
16+ capture bool
17+ outputs map[ports.OutputInstance]lockOutputReadiness
18+}
19+
20+type lockOutputReadiness struct {
21+ registered bool
22+ proof ports.ProtectionKind
23+}
24+
25+// Add registers a new output lifetime before or during a lock. Zero, duplicate
26+// and retired IDs are rejected without changing state. A new output has no
27+// proof, so adding it while engaged revokes Ready until it is protected. IDs
28+// remain as tombstones after Remove to prevent accepting a replaced lifetime.
29+func (r *Readiness) Add(instance ports.OutputInstance) bool {
30+ if instance == 0 {
31+ return false
32+ }
33+ if _, seen := r.outputs[instance]; seen {
34+ return false
35+ }
36+ if r.outputs == nil {
37+ r.outputs = make(map[ports.OutputInstance]lockOutputReadiness)
38+ }
39+ r.outputs[instance] = lockOutputReadiness{registered: true}
40+ return true
41+}
42+
43+// Remove retires a lifetime only when the caller has confirmed it is actually
44+// disconnected or inactive. An error (including "gone") is not confirmation.
45+// An inactive lifetime that may resume should instead remain registered with
46+// an inactive-output proof. Unknown or already retired instances are rejected.
47+func (r *Readiness) Remove(instance ports.OutputInstance) bool {
48+ output, ok := r.outputs[instance]
49+ if !ok || !output.registered {
50+ return false
51+ }
52+ r.outputs[instance] = lockOutputReadiness{}
53+ return true
54+}
55+
56+// Begin snapshots the registered inventory into a new readiness round by
57+// clearing every proof and both barriers. Registrations may subsequently
58+// change. Generations must increase strictly, even across End; an invalid
59+// Begin leaves the current round untouched. Exhaustion must not wrap to zero.
60+func (r *Readiness) Begin(generation ports.LockGeneration) bool {
61+ if generation == 0 || generation <= r.generation {
62+ return false
63+ }
64+ r.generation, r.active = generation, true
65+ r.backend, r.capture = false, false
66+ for instance, output := range r.outputs {
67+ output.proof = ""
68+ r.outputs[instance] = output
69+ }
70+ return true
71+}
72+
73+// End clears only the exact active round, retaining inventory and the
74+// generation high-water mark. Late End calls cannot cancel a newer round, and
75+// late proofs or barriers cannot reactivate an ended round.
76+func (r *Readiness) End(generation ports.LockGeneration) bool {
77+ if !r.accepts(generation) {
78+ return false
79+ }
80+ r.active, r.backend, r.capture = false, false, false
81+ for instance, output := range r.outputs {
82+ output.proof = ""
83+ r.outputs[instance] = output
84+ }
85+ return true
86+}
87+
88+// Record accepts only affirmative protection evidence for a registered
89+// lifetime in the exact active generation. Repeated valid proofs are harmless;
90+// invalid evidence never overwrites an existing proof.
91+func (r *Readiness) Record(proof ports.OutputProtection) bool {
92+ if !r.accepts(proof.Generation) {
93+ return false
94+ }
95+ switch proof.Kind {
96+ case ports.ProtectionProtectedFrame, ports.ProtectionInactiveOutput:
97+ default:
98+ return false
99+ }
100+ output, ok := r.outputs[proof.Instance]
101+ if !ok || !output.registered {
102+ return false
103+ }
104+ output.proof = proof.Kind
105+ r.outputs[proof.Instance] = output
106+ return true
107+}
108+
109+// Invalidate revokes a prior proof within the active epoch. A recovered or
110+// resumed lifetime must provide fresh affirmative protection, never reuse its
111+// earlier presentation. Unknown, retired and stale epochs have no effect.
112+func (r *Readiness) Invalidate(generation ports.LockGeneration, instance ports.OutputInstance) bool {
113+ if !r.accepts(generation) {
114+ return false
115+ }
116+ output, ok := r.outputs[instance]
117+ if !ok || !output.registered {
118+ return false
119+ }
120+ output.proof = ""
121+ r.outputs[instance] = output
122+ return true
123+}
124+
125+// BackendBarrier acknowledges authoritative output inventory, drainage of all
126+// backend leases, and completed security handoff for the active generation.
127+// This is required even with zero outputs; an empty local inventory is not an
128+// acknowledgement. It does not acknowledge capture memory-copy completion.
129+func (r *Readiness) BackendBarrier(generation ports.LockGeneration) bool {
130+ if !r.accepts(generation) {
131+ return false
132+ }
133+ r.backend = true
134+ return true
135+}
136+
137+// CaptureBarrier acknowledges that all admitted prelock memory-copy work,
138+// including writes to capture FDs, has completed for the active generation.
139+// Output protection and backend drainage cannot substitute for this barrier.
140+func (r *Readiness) CaptureBarrier(generation ports.LockGeneration) bool {
141+ if !r.accepts(generation) {
142+ return false
143+ }
144+ r.capture = true
145+ return true
146+}
147+
148+// Ready requires an active round, both independent barriers, and affirmative
149+// protection evidence for every currently registered output lifetime.
150+func (r *Readiness) Ready() bool {
151+ if !r.active || !r.backend || !r.capture {
152+ return false
153+ }
154+ for _, output := range r.outputs {
155+ if output.registered && output.proof == "" {
156+ return false
157+ }
158+ }
159+ return true
160+}
161+
162+func (r *Readiness) accepts(generation ports.LockGeneration) bool {
163+ return r.active && generation != 0 && generation == r.generation
164+}
A internal/sessionlock/readiness_test.go +296 -0
@@ -0,0 +1,296 @@
1+package sessionlock
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func lockProof(generation ports.LockGeneration, instance ports.OutputInstance) ports.OutputProtection {
11+ return ports.OutputProtection{Generation: generation, Instance: instance, Kind: ports.ProtectionProtectedFrame}
12+}
13+
14+func requireLockAccepted(t *testing.T, accepted bool) {
15+ t.Helper()
16+ if !accepted {
17+ t.Fatal("valid operation rejected")
18+ }
19+}
20+
21+func requireLockReady(t *testing.T, r *Readiness, want bool) {
22+ t.Helper()
23+ if got := r.Ready(); got != want {
24+ t.Fatalf("Ready() = %v, want %v; ledger %+v", got, want, r)
25+ }
26+}
27+
28+func TestLockReadinessZeroValue(t *testing.T) {
29+ var r Readiness
30+ requireLockReady(t, &r, false)
31+ if r.Begin(0) || r.End(0) || r.Add(0) || r.Remove(0) || r.Remove(99) ||
32+ r.Record(ports.OutputProtection{}) || r.Record(lockProof(1, 1)) ||
33+ r.BackendBarrier(0) || r.BackendBarrier(1) || r.CaptureBarrier(0) || r.CaptureBarrier(1) {
34+ t.Fatal("zero/disengaged ledger accepted invalid input")
35+ }
36+ requireLockReady(t, &r, false)
37+}
38+
39+func TestLockReadinessEmptyInventoryNeedsBothBarriers(t *testing.T) {
40+ for _, backendFirst := range []bool{false, true} {
41+ t.Run(fmt.Sprintf("backend-first=%v", backendFirst), func(t *testing.T) {
42+ var r Readiness
43+ requireLockAccepted(t, r.Begin(7))
44+ requireLockReady(t, &r, false)
45+ if backendFirst {
46+ requireLockAccepted(t, r.BackendBarrier(7))
47+ } else {
48+ requireLockAccepted(t, r.CaptureBarrier(7))
49+ }
50+ requireLockReady(t, &r, false)
51+ if backendFirst {
52+ requireLockAccepted(t, r.CaptureBarrier(7))
53+ } else {
54+ requireLockAccepted(t, r.BackendBarrier(7))
55+ }
56+ requireLockReady(t, &r, true)
57+ })
58+ }
59+}
60+
61+func TestLockReadinessEveryPrerequisiteInAnyOrder(t *testing.T) {
62+ // Two output proofs and two independent barriers: all 24 arrival orders.
63+ for a := range 4 {
64+ for b := range 4 {
65+ for c := range 4 {
66+ for d := range 4 {
67+ order := [4]int{a, b, c, d}
68+ if a == b || a == c || a == d || b == c || b == d || c == d {
69+ continue
70+ }
71+ t.Run(fmt.Sprint(order), func(t *testing.T) {
72+ var r Readiness
73+ requireLockAccepted(t, r.Add(11))
74+ requireLockAccepted(t, r.Add(12))
75+ requireLockAccepted(t, r.Begin(5))
76+ requireLockReady(t, &r, false)
77+ for i, step := range order {
78+ var accepted bool
79+ switch step {
80+ case 0:
81+ accepted = r.Record(lockProof(5, 11))
82+ case 1:
83+ accepted = r.Record(ports.OutputProtection{Generation: 5, Instance: 12, Kind: ports.ProtectionInactiveOutput})
84+ case 2:
85+ accepted = r.BackendBarrier(5)
86+ case 3:
87+ accepted = r.CaptureBarrier(5)
88+ }
89+ requireLockAccepted(t, accepted)
90+ requireLockReady(t, &r, i == 3)
91+ }
92+ // Duplicate evidence is idempotent.
93+ requireLockAccepted(t, r.Record(lockProof(5, 11)))
94+ requireLockAccepted(t, r.BackendBarrier(5))
95+ requireLockAccepted(t, r.CaptureBarrier(5))
96+ requireLockReady(t, &r, true)
97+ })
98+ }
99+ }
100+ }
101+ }
102+}
103+
104+func TestLockReadinessGenerationFencing(t *testing.T) {
105+ var r Readiness
106+ requireLockAccepted(t, r.Add(1))
107+ requireLockAccepted(t, r.Begin(10))
108+ requireLockAccepted(t, r.Record(lockProof(10, 1)))
109+ requireLockAccepted(t, r.BackendBarrier(10))
110+ requireLockAccepted(t, r.CaptureBarrier(10))
111+ requireLockReady(t, &r, true)
112+ for _, generation := range []ports.LockGeneration{0, 9, 10} {
113+ if r.Begin(generation) {
114+ t.Fatalf("accepted non-increasing generation %d", generation)
115+ }
116+ requireLockReady(t, &r, true)
117+ }
118+
119+ // A newer Begin while engaged clears both barriers and every proof.
120+ requireLockAccepted(t, r.Begin(11))
121+ requireLockReady(t, &r, false)
122+ for _, generation := range []ports.LockGeneration{0, 9, 10, 12} {
123+ if r.Record(lockProof(generation, 1)) || r.BackendBarrier(generation) || r.CaptureBarrier(generation) || r.End(generation) {
124+ t.Fatalf("accepted wrong-generation evidence/End for %d", generation)
125+ }
126+ }
127+ requireLockAccepted(t, r.BackendBarrier(11))
128+ requireLockAccepted(t, r.CaptureBarrier(11))
129+ requireLockReady(t, &r, false) // old proof was cleared
130+ requireLockAccepted(t, r.Record(lockProof(11, 1)))
131+ requireLockReady(t, &r, true)
132+ requireLockAccepted(t, r.End(11))
133+ requireLockReady(t, &r, false)
134+ if r.Record(lockProof(11, 1)) || r.BackendBarrier(11) || r.CaptureBarrier(11) || r.End(11) || r.Begin(11) || r.Begin(10) {
135+ t.Fatal("ended round accepted late evidence or reused generation")
136+ }
137+
138+ requireLockAccepted(t, r.Begin(13)) // intervening gate release may consume 12
139+ if r.End(11) || r.Record(lockProof(11, 1)) || r.BackendBarrier(11) || r.CaptureBarrier(11) {
140+ t.Fatal("late old round affected newer round")
141+ }
142+ requireLockAccepted(t, r.Record(lockProof(13, 1)))
143+ requireLockReady(t, &r, false) // neither old barrier survived End/Begin
144+ requireLockAccepted(t, r.BackendBarrier(13))
145+ requireLockReady(t, &r, false)
146+ requireLockAccepted(t, r.CaptureBarrier(13))
147+ requireLockReady(t, &r, true)
148+}
149+
150+func TestLockReadinessGenerationExhaustionDoesNotWrap(t *testing.T) {
151+ var r Readiness
152+ maxGeneration := ports.LockGeneration(^uint64(0))
153+ requireLockAccepted(t, r.Begin(maxGeneration))
154+ requireLockAccepted(t, r.End(maxGeneration))
155+ for _, generation := range []ports.LockGeneration{0, 1, maxGeneration} {
156+ if r.Begin(generation) {
157+ t.Fatalf("accepted wrapped/reused generation %d", generation)
158+ }
159+ }
160+ requireLockReady(t, &r, false)
161+}
162+
163+func TestLockReadinessInvalidProofsDoNotCountOrEraseEvidence(t *testing.T) {
164+ invalid := []ports.OutputProtection{
165+ {},
166+ {Generation: 1, Instance: 1},
167+ {Generation: 1, Instance: 1, Kind: "submitted-frame"},
168+ {Generation: 1, Instance: 1, Kind: "error-gone"},
169+ {Generation: 1, Instance: 0, Kind: ports.ProtectionProtectedFrame},
170+ {Generation: 1, Instance: 99, Kind: ports.ProtectionProtectedFrame},
171+ {Generation: 0, Instance: 1, Kind: ports.ProtectionProtectedFrame},
172+ {Generation: 2, Instance: 1, Kind: ports.ProtectionProtectedFrame},
173+ }
174+ var r Readiness
175+ requireLockAccepted(t, r.Add(1))
176+ requireLockAccepted(t, r.Begin(1))
177+ requireLockAccepted(t, r.BackendBarrier(1))
178+ requireLockAccepted(t, r.CaptureBarrier(1))
179+ for _, proof := range invalid {
180+ if r.Record(proof) {
181+ t.Fatalf("accepted invalid proof %+v", proof)
182+ }
183+ requireLockReady(t, &r, false)
184+ }
185+ requireLockAccepted(t, r.Record(lockProof(1, 1)))
186+ for _, proof := range invalid {
187+ if r.Record(proof) {
188+ t.Fatalf("accepted invalid proof %+v", proof)
189+ }
190+ requireLockReady(t, &r, true)
191+ }
192+}
193+
194+func TestLockReadinessOutputInventoryChanges(t *testing.T) {
195+ var r Readiness
196+ requireLockAccepted(t, r.Add(1))
197+ requireLockAccepted(t, r.Add(2))
198+ requireLockAccepted(t, r.Remove(2)) // registration and retirement before Begin
199+ if r.Add(2) || r.Remove(2) || r.Add(1) {
200+ t.Fatal("accepted duplicate or retired lifetime")
201+ }
202+ requireLockAccepted(t, r.Begin(1))
203+ requireLockAccepted(t, r.Add(3)) // new output before Ready
204+ requireLockAccepted(t, r.Record(lockProof(1, 1)))
205+ requireLockAccepted(t, r.BackendBarrier(1))
206+ requireLockAccepted(t, r.CaptureBarrier(1))
207+ requireLockReady(t, &r, false)
208+ requireLockAccepted(t, r.Record(lockProof(1, 3)))
209+ requireLockReady(t, &r, true)
210+ if r.Add(0) || r.Add(3) || r.Remove(99) {
211+ t.Fatal("accepted invalid inventory operation")
212+ }
213+ requireLockReady(t, &r, true) // duplicate registration did not erase proof
214+ requireLockAccepted(t, r.Add(4)) // hotplug after Ready must revoke it
215+ requireLockReady(t, &r, false)
216+ requireLockAccepted(t, r.Remove(4)) // caller-confirmed retired lifetime
217+ requireLockReady(t, &r, true)
218+
219+ // The same output name can return, but the new lifetime is instance 5.
220+ requireLockAccepted(t, r.Remove(3))
221+ requireLockAccepted(t, r.Add(5))
222+ if r.Record(lockProof(1, 3)) || r.Add(3) {
223+ t.Fatal("replacement accepted old-lifetime evidence")
224+ }
225+ requireLockReady(t, &r, false)
226+ requireLockAccepted(t, r.Record(lockProof(1, 5)))
227+ requireLockReady(t, &r, true)
228+
229+ requireLockAccepted(t, r.End(1))
230+ requireLockAccepted(t, r.Add(6)) // changes after End join next snapshot
231+ requireLockAccepted(t, r.Remove(1))
232+ requireLockAccepted(t, r.Begin(2))
233+ requireLockAccepted(t, r.BackendBarrier(2))
234+ requireLockAccepted(t, r.CaptureBarrier(2))
235+ requireLockAccepted(t, r.Record(lockProof(2, 5)))
236+ requireLockReady(t, &r, false)
237+ requireLockAccepted(t, r.Record(lockProof(2, 6)))
238+ requireLockReady(t, &r, true)
239+ if r.Add(1) || r.Add(2) || r.Add(3) || r.Add(4) {
240+ t.Fatal("retired lifetime reused across lock rounds")
241+ }
242+}
243+
244+func TestLockReadinessInvalidationRequiresFreshProof(t *testing.T) {
245+ var r Readiness
246+ requireLockAccepted(t, r.Add(1))
247+ requireLockAccepted(t, r.Begin(7))
248+ requireLockAccepted(t, r.Record(lockProof(7, 1)))
249+ requireLockAccepted(t, r.BackendBarrier(7))
250+ requireLockAccepted(t, r.CaptureBarrier(7))
251+ requireLockReady(t, &r, true)
252+ if r.Invalidate(6, 1) || r.Invalidate(7, 2) {
253+ t.Fatal("invalid invalidation accepted")
254+ }
255+ requireLockReady(t, &r, true)
256+ requireLockAccepted(t, r.Invalidate(7, 1))
257+ requireLockReady(t, &r, false)
258+ requireLockAccepted(t, r.Record(lockProof(7, 1)))
259+ requireLockReady(t, &r, true)
260+ requireLockAccepted(t, r.End(7))
261+ if r.Invalidate(7, 1) {
262+ t.Fatal("ended invalidation accepted")
263+ }
264+}
265+
266+func TestLockReadinessInvalidationIsolatedAndRetired(t *testing.T) {
267+ var r Readiness
268+ requireLockAccepted(t, r.Add(1))
269+ requireLockAccepted(t, r.Add(2))
270+ requireLockAccepted(t, r.Add(3))
271+ requireLockAccepted(t, r.Remove(3))
272+ requireLockAccepted(t, r.Begin(7))
273+ requireLockAccepted(t, r.Record(lockProof(7, 1)))
274+ requireLockAccepted(t, r.Record(lockProof(7, 2)))
275+ requireLockAccepted(t, r.BackendBarrier(7))
276+ requireLockAccepted(t, r.CaptureBarrier(7))
277+ if r.Invalidate(8, 1) || r.Invalidate(7, 3) {
278+ t.Fatal("future or retired invalidation accepted")
279+ }
280+ requireLockReady(t, &r, true)
281+ requireLockAccepted(t, r.Invalidate(7, 1))
282+ requireLockReady(t, &r, false)
283+ requireLockAccepted(t, r.Record(lockProof(7, 1)))
284+ requireLockReady(t, &r, true) // output 2 and both barriers retained
285+}
286+
287+func TestLockReadinessRemovingLastOutputCannotReplaceBackendACK(t *testing.T) {
288+ var r Readiness
289+ requireLockAccepted(t, r.Add(1))
290+ requireLockAccepted(t, r.Begin(1))
291+ requireLockAccepted(t, r.CaptureBarrier(1))
292+ requireLockAccepted(t, r.Remove(1))
293+ requireLockReady(t, &r, false)
294+ requireLockAccepted(t, r.BackendBarrier(1))
295+ requireLockReady(t, &r, true)
296+}

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