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
2 components:2 components:
3 core: { role: core, paths: [internal/core/**] }3 core: { role: core, paths: [internal/core/**] }
4 ports: { role: ports, paths: [internal/ports/**] }4 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/**] }
6 # app is the composition root: it wires adapters to core, like cmd.7 # app is the composition root: it wires adapters to core, like cmd.
7 entrypoints: { role: entrypoint, paths: [cmd/**, internal/app/**] }8 entrypoints: { role: entrypoint, paths: [cmd/**, internal/app/**] }
8 generated: { role: ignore, paths: [internal/mocks/**] }9 generated: { role: ignore, paths: [internal/mocks/**] }
@@ -15,10 +16,17 @@ rules:
15 mocking:16 mocking:
16 generatedMockPaths: [internal/mocks/**]17 generatedMockPaths: [internal/mocks/**]
17 generatedMockNamePatterns: ['Mock{{Interface}}', '{{Interface}}Mock']18 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.
18 ruleSettings:26 ruleSettings:
19 missing-generated-mock-for-port:27 missing-generated-mock-for-port:
20 # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005).28 # 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]
22 suspicious-business-logic-in-adapter:30 suspicious-business-logic-in-adapter:
23 # app is the composition root; starting core and wiring channels is its job.31 # app is the composition root; starting core and wiring channels is its job.
24 excludePaths: [internal/app/app.go]32 excludePaths: [internal/app/app.go]
M .mockery.yml +14 -0
@@ -19,6 +19,8 @@ packages:
19 OutputScaleStore:19 OutputScaleStore:
20 Renderer:20 Renderer:
21 Seat:21 Seat:
22+ SessionSecurity:
23+ SessionSecurityController:
22 Ticker:24 Ticker:
23 Timer:25 Timer:
24 # Unexported adapter seams: mocks live next to the code, in test files.26 # Unexported adapter seams: mocks live next to the code, in test files.
@@ -43,11 +45,23 @@ packages:
43 dir: internal/adapters/wayland45 dir: internal/adapters/wayland
44 pkgname: wayland46 pkgname: wayland
45 filename: proc_env_mock_test.go47 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
46 syncobjDevice:53 syncobjDevice:
47 config:54 config:
48 dir: internal/adapters/wayland55 dir: internal/adapters/wayland
49 pkgname: wayland56 pkgname: wayland
50 filename: syncobj_device_mock_test.go57 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
51 github.com/bnema/neferwl/internal/adapters/statefile:65 github.com/bnema/neferwl/internal/adapters/statefile:
52 interfaces:66 interfaces:
53 procTree:67 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
110 110
111 - [Configuration](docs/config.md): every key, action and default bind, and HDR.111 - [Configuration](docs/config.md): every key, action and default bind, and HDR.
112 - [Desktop integration](docs/desktop.md): running commands, clipboard, input methods, X11 apps, idle and screen off, bars, and the state file for scripts.112 - [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.
113 - [Headless mode](docs/headless.md): run without a screen, take screenshots, play input scripts and test input methods.114 - [Headless mode](docs/headless.md): run without a screen, take screenshots, play input scripts and test input methods.
114 - [Performance](docs/performance.md): how to profile, the allocation guards, and reference numbers.115 - [Performance](docs/performance.md): how to profile, the allocation guards, and reference numbers.
115 116
M docs/desktop.md +5 -1
@@ -68,6 +68,10 @@ xwayland = off # no X11 display
68 68
69 Do not start xwayland-satellite yourself with `startup`. Changing `xwayland` takes effect at the next start.69 Do not start xwayland-satellite yourself with `startup`. Changing `xwayland` takes effect at the next start.
70 70
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+
71 ## Idle and screen off75 ## Idle and screen off
72 76
73 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.77 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 '*'
88 92
89 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.93 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.
90 94
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.
92 96
93 ## State for scripts97 ## State for scripts
94 98
M docs/headless.md +1 -1
@@ -15,7 +15,7 @@ The compositor draws a red border around the visible recording target. An explic
15 15
16 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.16 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.
17 17
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.
19 19
20 `--input <path>`, or `--input -` for stdin, plays an input script. One command per line:20 `--input <path>`, or `--input -` for stdin, plays an input script. One command per line:
21 21
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 (
14 14
15 "github.com/bnema/purego-libwayland/protocol/inputmethod"15 "github.com/bnema/purego-libwayland/protocol/inputmethod"
16 "github.com/bnema/wlturbo"16 "github.com/bnema/wlturbo"
17+ clientcore "github.com/bnema/wlturbo/protocol/core"
18+ clientime "github.com/bnema/wlturbo/protocol/inputmethod"
17 )19 )
18 20
19 func main() {21 func main() {
@@ -40,6 +42,12 @@ type inputMethod struct {
40 serve bool // an activation applied by done, not yet served42 serve bool // an activation applied by done, not yet served
41 }43 }
42 44
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+
43 func (m *inputMethod) Dispatch(e *wlturbo.Event) {51 func (m *inputMethod) Dispatch(e *wlturbo.Event) {
44 switch uint32(e.Opcode) {52 switch uint32(e.Opcode) {
45 case inputmethod.ZwpInputMethodV2EventActivate:53 case inputmethod.ZwpInputMethodV2EventActivate:
@@ -65,6 +73,10 @@ type ignored struct{ wlturbo.BaseProxy }
65 73
66 func (*ignored) Dispatch(*wlturbo.Event) {}74 func (*ignored) Dispatch(*wlturbo.Event) {}
67 75
76+func (*ignored) EventSignature(op uint16) (string, bool) {
77+ return (&clientcore.Seat{}).EventSignature(op)
78+}
79+
68 func (m *inputMethod) send(op uint32, args ...any) error {80 func (m *inputMethod) send(op uint32, args ...any) error {
69 return m.c.SendRequest(m.ID(), uint16(op), args...)81 return m.c.SendRequest(m.ID(), uint16(op), args...)
70 }82 }
@@ -92,6 +104,8 @@ func run(preedit, commit string, delay time.Duration, count int) error {
92 // wlturbo fails on events for unregistered objects: the seat sends some.104 // wlturbo fails on events for unregistered objects: the seat sends some.
93 seatProxy := &ignored{}105 seatProxy := &ignored{}
94 seatProxy.SetID(seat)106 seatProxy.SetID(seat)
107+ seatProxy.SetContext(c.Context())
108+ seatProxy.SetVersion(1)
95 c.Context().Register(seatProxy)109 c.Context().Register(seatProxy)
96 manager, err := bind("zwp_input_method_manager_v2")110 manager, err := bind("zwp_input_method_manager_v2")
97 if err != nil {111 if err != nil {
@@ -99,6 +113,8 @@ func run(preedit, commit string, delay time.Duration, count int) error {
99 }113 }
100 m := &inputMethod{c: c}114 m := &inputMethod{c: c}
101 m.SetID(c.AllocateID())115 m.SetID(c.AllocateID())
116+ m.SetContext(c.Context())
117+ m.SetVersion(1)
102 c.Context().Register(m)118 c.Context().Register(m)
103 if err := c.SendRequest(manager, uint16(inputmethod.ZwpInputMethodManagerV2RequestGetInputMethod), seat, m.ID()); err != nil {119 if err := c.SendRequest(manager, uint16(inputmethod.ZwpInputMethodManagerV2RequestGetInputMethod), seat, m.ID()); err != nil {
104 return err120 return err
M go.mod +1 -1
@@ -6,7 +6,7 @@ require (
6 github.com/bnema/purego v0.13.0-bnema.16 github.com/bnema/purego v0.13.0-bnema.1
7 github.com/bnema/purego-libwayland v0.7.07 github.com/bnema/purego-libwayland v0.7.0
8 github.com/bnema/purego-vulkan v0.6.08 github.com/bnema/purego-vulkan v0.6.0
9- github.com/bnema/wlturbo v0.2.09+ github.com/bnema/wlturbo v0.5.0
10 github.com/bnema/zerowrap v1.4.110 github.com/bnema/zerowrap v1.4.1
11 github.com/rs/zerolog v1.35.111 github.com/rs/zerolog v1.35.1
12 github.com/stretchr/testify v1.12.112 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
346 switch {346 switch {
347 case len(reqs) == 0:347 case len(reqs) == 0:
348 return348 return
349+ case !p.captureAllowed(s.Security):
350+ fail(ErrSecurityState, reqs)
351+ return
349 case s.Off:352 case s.Off:
350 fail(errors.New("output off"), reqs)353 fail(errors.New("output off"), reqs)
351 return354 return
@@ -391,6 +394,7 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports
391 return394 return
392 }395 }
393 o.r, o.p, o.w, o.h, o.session = r, NewPipeline(p.ctx, p.replies), w, h, s.Capture.Session396 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
394 }398 }
395 // Request regions are physical pixels of the output; TargetRect is399 // Request regions are physical pixels of the output; TargetRect is
396 // relative to the workspace viewport, so it cannot place them. The child400 // 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
417 child := *cs421 child := *cs
418 child.Capture = &ports.SceneCapture{Excluded: s.Capture.Excluded}422 child.Capture = &ports.SceneCapture{Excluded: s.Capture.Excluded}
419 child = o.clean.scene(child)423 child = o.clean.scene(child)
420- done, err := o.r.Render(child, surfaces)424+ if !p.captureAllowed(s.Security) {
421- if err != nil {425+ fail(ErrSecurityState, reqs)
422- fail(fmt.Errorf("render workspace: %w", err), reqs)
423 return426 return
424 }427 }
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.
425 if done != nil {431 if done != nil {
426 o.track(done, child, surfaces)432 o.track(done, child, surfaces)
427 }433 }
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)
429 }441 }
M internal/adapters/capture/pipeline.go +15 -3
@@ -2,6 +2,7 @@ package capture
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6 "sync"7 "sync"
7 "time"8 "time"
@@ -15,6 +16,9 @@ const MaxRequests = 8
15 16
16 const pipelineSlots = 217 const pipelineSlots = 2
17 18
19+// ErrSecurityState rejects capture admission across an epoch or while protected.
20+var ErrSecurityState = errors.New("capture security epoch changed or session protected")
21+
18 // batch remains owned by the worker until it is returned through completed.22 // batch remains owned by the worker until it is returned through completed.
19 // Both batches and their request storage are reused for the output's lifetime.23 // Both batches and their request storage are reused for the output's lifetime.
20 type batch struct {24 type batch struct {
@@ -27,6 +31,8 @@ type batch struct {
27 // Pipeline moves fence waiting and SHM copies off the renderer's owner.31 // Pipeline moves fence waiting and SHM copies off the renderer's owner.
28 // Submit, Recycle and Close belong to that owner; one worker handles all jobs.32 // Submit, Recycle and Close belong to that owner; one worker handles all jobs.
29 type Pipeline struct {33 type Pipeline struct {
34+ // Set before owner use; no mutation or worker access to this dependency.
35+ Security ports.SessionSecurity
30 ctx context.Context36 ctx context.Context
31 cancel context.CancelFunc37 cancel context.CancelFunc
32 replies chan<- ports.CaptureDone38 replies chan<- ports.CaptureDone
@@ -55,9 +61,9 @@ func (p *Pipeline) Recycle(b *batch, r ports.Renderer) {
55 *b = batch{}61 *b = batch{}
56 }62 }
57 63
58-// Submit consumes all requests. Saturation fails explicitly instead of64+// SubmitScoped consumes requests with explicit immutable scene admission. The
59-// waiting, growing a queue, or starting another worker.65+// final gate read precedes BeginCapture, not the render that produced pixels.
60-func (p *Pipeline) Submit(r ports.Renderer, requests []ports.CaptureRequest) {66+func (p *Pipeline) SubmitScoped(state ports.SecurityState, r ports.Renderer, requests []ports.CaptureRequest) {
61 if len(requests) == 0 {67 if len(requests) == 0 {
62 return68 return
63 }69 }
@@ -75,6 +81,8 @@ func (p *Pipeline) Submit(r ports.Renderer, requests []ports.CaptureRequest) {
75 var err error81 var err error
76 if b == nil {82 if b == nil {
77 err = fmt.Errorf("capture pipeline full or stopped")83 err = fmt.Errorf("capture pipeline full or stopped")
84+ } else if !p.captureAllowed(state) {
85+ err = ErrSecurityState
78 } else {86 } else {
79 b.frame, err = r.BeginCapture()87 b.frame, err = r.BeginCapture()
80 if err == nil && b.frame == nil {88 if err == nil && b.frame == nil {
@@ -94,6 +102,10 @@ func (p *Pipeline) Submit(r ports.Renderer, requests []ports.CaptureRequest) {
94 p.jobs <- b102 p.jobs <- b
95 }103 }
96 104
105+func (p *Pipeline) captureAllowed(state ports.SecurityState) bool {
106+ return p.Security == nil || !state.Protected && p.Security.Snapshot() == state
107+}
108+
97 func (p *Pipeline) reclaim(r ports.Renderer) {109 func (p *Pipeline) reclaim(r ports.Renderer) {
98 for {110 for {
99 select {111 select {
M internal/adapters/capture/pipeline_fence_test.go +2 -2
@@ -40,7 +40,7 @@ func TestPipelineWaitsForFenceBeforeRead(t *testing.T) {
40 replies := make(chan ports.CaptureDone, 1)40 replies := make(chan ports.CaptureDone, 1)
41 p := NewPipeline(context.Background(), replies)41 p := NewPipeline(context.Background(), replies)
42 q := pipelineRequest(t, 1)42 q := pipelineRequest(t, 1)
43- p.Submit(r, []ports.CaptureRequest{q})43+ p.SubmitScoped(ports.SecurityState{}, r, []ports.CaptureRequest{q})
44 // Longer than one syncfile poll slice: the worker has polled at least once.44 // Longer than one syncfile poll slice: the worker has polled at least once.
45 select {45 select {
46 case <-entered:46 case <-entered:
@@ -79,7 +79,7 @@ func TestPipelinePropagatesReadError(t *testing.T) {
79 replies := make(chan ports.CaptureDone, 1)79 replies := make(chan ports.CaptureDone, 1)
80 p := NewPipeline(context.Background(), replies)80 p := NewPipeline(context.Background(), replies)
81 q := pipelineRequest(t, 1)81 q := pipelineRequest(t, 1)
82- p.Submit(r, []ports.CaptureRequest{q})82+ p.SubmitScoped(ports.SecurityState{}, r, []ports.CaptureRequest{q})
83 done := awaitCapture(t, replies)83 done := awaitCapture(t, replies)
84 if !errors.Is(done.Err, boom) || done.ID != q.ID {84 if !errors.Is(done.Err, boom) || done.ID != q.ID {
85 t.Fatalf("result %+v", done)85 t.Fatalf("result %+v", done)
M internal/adapters/capture/pipeline_test.go +5 -5
@@ -53,7 +53,7 @@ func TestPipelineCopiesOffOwnerAndRecycles(t *testing.T) {
53 replies := make(chan ports.CaptureDone, 1)53 replies := make(chan ports.CaptureDone, 1)
54 p := NewPipeline(context.Background(), replies)54 p := NewPipeline(context.Background(), replies)
55 q := pipelineRequest(t, 1)55 q := pipelineRequest(t, 1)
56- p.Submit(r, []ports.CaptureRequest{q})56+ p.SubmitScoped(ports.SecurityState{}, r, []ports.CaptureRequest{q})
57 select {57 select {
58 case <-entered:58 case <-entered:
59 case <-time.After(time.Second):59 case <-time.After(time.Second):
@@ -92,10 +92,10 @@ func TestPipelineSaturationAndShutdownWithoutGPUCompletion(t *testing.T) {
92 frame.EXPECT().Done().Return(read).Maybe()92 frame.EXPECT().Done().Return(read).Maybe()
93 r.EXPECT().BeginCapture().Return(frame, nil).Once()93 r.EXPECT().BeginCapture().Return(frame, nil).Once()
94 r.EXPECT().EndCapture(frame).Return().Once()94 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)})
96 }96 }
97 q := pipelineRequest(t, 3)97 q := pipelineRequest(t, 3)
98- p.Submit(r, []ports.CaptureRequest{q})98+ p.SubmitScoped(ports.SecurityState{}, r, []ports.CaptureRequest{q})
99 if done := awaitCapture(t, replies); done.ID != 3 || done.Err == nil {99 if done := awaitCapture(t, replies); done.ID != 3 || done.Err == nil {
100 t.Fatalf("saturation result %+v", done)100 t.Fatalf("saturation result %+v", done)
101 }101 }
@@ -120,7 +120,7 @@ func TestPipelineBeginFailureClosesRequest(t *testing.T) {
120 replies := make(chan ports.CaptureDone, 1)120 replies := make(chan ports.CaptureDone, 1)
121 p := NewPipeline(context.Background(), replies)121 p := NewPipeline(context.Background(), replies)
122 q := pipelineRequest(t, 1)122 q := pipelineRequest(t, 1)
123- p.Submit(r, []ports.CaptureRequest{q})123+ p.SubmitScoped(ports.SecurityState{}, r, []ports.CaptureRequest{q})
124 if done := awaitCapture(t, replies); !errors.Is(done.Err, boom) {124 if done := awaitCapture(t, replies); !errors.Is(done.Err, boom) {
125 t.Fatalf("result %+v", done)125 t.Fatalf("result %+v", done)
126 }126 }
@@ -138,7 +138,7 @@ func TestPipelineRejectsOversizedBatch(t *testing.T) {
138 for i := range requests {138 for i := range requests {
139 requests[i] = pipelineRequest(t, uint64(i))139 requests[i] = pipelineRequest(t, uint64(i))
140 }140 }
141- p.Submit(r, requests)141+ p.SubmitScoped(ports.SecurityState{}, r, requests)
142 for range requests {142 for range requests {
143 if done := awaitCapture(t, replies); done.Err == nil {143 if done := awaitCapture(t, replies); done.Err == nil {
144 t.Fatal("oversized batch accepted")144 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) {
295 pipeline.Close(display)295 pipeline.Close(display)
296 }296 }
297 297
298-// GO-002: child holds do not expire, so a finished child read must lift its298+// Child holds do not expire, so a finished child read must lift its
299 // hold even while the display's own commit never completes. The display's299 // hold even while the display's own commit never completes. The display's
300 // Seen stays at the last safe value and the output is not stopped.300 // Seen stays at the last safe value and the output is not stopped.
301 func TestRunChildHoldLiftedWhileDisplayCommitStalls(t *testing.T) {301 func TestRunChildHoldLiftedWhileDisplayCommitStalls(t *testing.T) {
M internal/adapters/drm/frame_lifecycle.go +1 -0
@@ -26,6 +26,7 @@ type frameLifecycle struct {
26 26
27 // pendingFrame is what the pending frame commit shows.27 // pendingFrame is what the pending frame commit shows.
28 type pendingFrame struct {28 type pendingFrame struct {
29+ security ports.SecurityState // admission epoch checked again at KMS boundary
29 frame bool30 frame bool
30 queued uint6431 queued uint64
31 zeroCopy ports.WindowID // window shown without composition32 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 }
23 // without the overlay; a renderError stops the output; other errors are23 // without the overlay; a renderError stops the output; other errors are
24 // commit errors for commitFailed.24 // commit errors for commitFailed.
25 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) {25 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+ }
26 // shows is what the frame puts on screen: presentation feedback of32 // shows is what the frame puts on screen: presentation feedback of
27 // windows it does not draw is discarded, not presented.33 // 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)}
29 o.wantContent(scene, surfaces)35 o.wantContent(scene, surfaces)
30 // Clean requests that cannot be served (their session is not the36 // Clean requests that cannot be served (their session is not the
31 // scene's) are failed here, never answered from the displayed frame.37 // 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.
33 pipeline.Retire(scene)39 pipeline.Retire(scene)
34 hiddenClean := len(clean) > 0 && scene.CaptureScene != nil40 hiddenClean := len(clean) > 0 && scene.CaptureScene != nil
35 if hiddenClean {41 if hiddenClean {
42+ if !o.sceneCurrent(scene) || o.protected {
43+ return false, errSecurityScene
44+ }
36 // A hidden workspace: the child renderer draws it, whatever the display45 // A hidden workspace: the child renderer draws it, whatever the display
37 // does (scanout included). Failures answer the requests and never stop46 // does (scanout included). Failures answer the requests and never stop
38 // the output.47 // the output.
@@ -50,9 +59,13 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
50 // force composition: scanout and the overlay plane would skip them. The59 // force composition: scanout and the overlay plane would skip them. The
51 // hidden-workspace child needs none, so it keeps direct scanout.60 // hidden-workspace child needs none, so it keeps direct scanout.
52 decision := o.decideFrame(scene, surfaces, len(normal) > 0 || len(clean) > 0 || capture.BorderVisible(scene))61 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+ }
53 if decision.fb != 0 {66 if decision.fb != 0 {
54 c := decision.content67 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 {
56 return true, err69 return true, err
57 }70 }
58 decision = o.composeFrame(scene, surfaces)71 decision = o.composeFrame(scene, surfaces)
@@ -65,12 +78,28 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
65 // Cost: a second composition on frames with a clean request.78 // Cost: a second composition on frames with a clean request.
66 cdone, cerr := r.Render(pipeline.CleanScene(composed), surfaces)79 cdone, cerr := r.Render(pipeline.CleanScene(composed), surfaces)
67 if cerr != nil {80 if cerr != nil {
81+ o.holdRead(cdone)
82+ if cdone != nil {
83+ cdone.Close()
84+ }
85+ ov.close()
68 return false, renderError{cerr}86 return false, renderError{cerr}
69 }87 }
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+ }
70 if cdone != nil {99 if cdone != nil {
71 cdone.Close()100 cdone.Close()
72 }101 }
73- pipeline.Submit(r, clean)102+ pipeline.SubmitScoped(scene.Security, r, clean)
74 // Ownership moved to the worker (or an immediate failure reply).103 // Ownership moved to the worker (or an immediate failure reply).
75 clear(clean)104 clear(clean)
76 }105 }
@@ -90,14 +119,30 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
90 return false, renderError{rerr}119 return false, renderError{rerr}
91 }120 }
92 }121 }
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+ }
93 if len(normal) > 0 {130 if len(normal) > 0 {
94 // Scanout and the overlay plane were skipped for this frame.131 // Scanout and the overlay plane were skipped for this frame.
95 o.log.Debug().Str("connector", o.conn.name).Int("captures", len(normal)).Msg("capture frame composed")132 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)
97 // Ownership moved to the worker (or an immediate failure reply).134 // Ownership moved to the worker (or an immediate failure reply).
98 // A later commit panic must not fail the same requests again.135 // A later commit panic must not fail the same requests again.
99 clear(normal)136 clear(normal)
100 }137 }
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+ }
101 // The overlay buffer is on screen like a scanned-out one: it is146 // The overlay buffer is on screen like a scanned-out one: it is
102 // reported shown, so it is not released under the plane.147 // reported shown, so it is not released under the plane.
103 f.queued, f.zeroCopy, f.composed = ov.buf, ov.id, true148 f.queued, f.zeroCopy, f.composed = ov.buf, ov.id, true
M internal/adapters/drm/output.go +324 -34
@@ -26,6 +26,17 @@ import (
26 // it with EBUSY (it retries soon), and an event that never comes is26 // it with EBUSY (it retries soon), and an event that never comes is
27 // bounded (stuckTimeout, then a modeset).27 // bounded (stuckTimeout, then a modeset).
28 type Output struct {28 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+
29 k kms40 k kms
30 flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents41 flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents
31 crtc uint3242 crtc uint32
@@ -130,6 +141,14 @@ type Output struct {
130 // off: the CRTC is inactive for it. Every modeset (resume, recovery)141 // off: the CRTC is inactive for it. Every modeset (resume, recovery)
131 // follows wantOff, so a display turned off never lights up.142 // follows wantOff, so a display turned off never lights up.
132 wantOff, off bool143 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
133 // kind is how the images were made.152 // kind is how the images were made.
134 kind imageKind153 kind imageKind
135 // formats receives the direct scanout formats after each modeset;154 // formats receives the direct scanout formats after each modeset;
@@ -383,12 +402,58 @@ func (o *Output) primaryRectProps(req *atomicReq, fb uint32, rect planeRect) {
383 // other plane on the CRTC; needed at start and after every VT resume.402 // other plane on the CRTC; needed at start and after every VT resume.
384 // It blocks until the kernel applied it.403 // It blocks until the kernel applied it.
385 func (o *Output) modeset() error {404 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+ }
386 blob, err := o.modeBlobFor(o.mode)424 blob, err := o.modeBlobFor(o.mode)
387 if err != nil {425 if err != nil {
426+ if o.protected {
427+ return protectedCommitError{err}
428+ }
388 return err429 return err
389 }430 }
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 {
391 _ = o.k.destroyBlob(blob)453 _ = o.k.destroyBlob(blob)
454+ if o.protected {
455+ return protectedCommitError{fmt.Errorf("modeset: %w", err)}
456+ }
392 return fmt.Errorf("modeset: %w", err)457 return fmt.Errorf("modeset: %w", err)
393 }458 }
394 // Only now is nothing of ours pending or on screen: on failure the459 // Only now is nothing of ours pending or on screen: on failure the
@@ -403,7 +468,7 @@ func (o *Output) modeset() error {
403 _ = o.k.destroyBlob(o.modeBlob)468 _ = o.k.destroyBlob(o.modeBlob)
404 }469 }
405 o.modeBlob = blob470 o.modeBlob = blob
406- o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, o.wantOff471+ o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, !active
407 // The gap and the traced flip interval belonged to the old state.472 // The gap and the traced flip interval belonged to the old state.
408 o.flipGapUntil, o.lastFlipAt = time.Time{}, 0473 o.flipGapUntil, o.lastFlipAt = time.Time{}, 0
409 o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]474 o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]
@@ -413,16 +478,22 @@ func (o *Output) modeset() error {
413 o.cursor.screen, o.cursor.flying = 0, false478 o.cursor.screen, o.cursor.flying = 0, false
414 }479 }
415 o.log.Info().Str("connector", o.conn.name).Bool("off", o.off).Msg("modeset")480 o.log.Info().Str("connector", o.conn.name).Bool("off", o.off).Msg("modeset")
416- if !o.off {481+ if !o.off && !o.protected {
417- // An inactive CRTC tells nothing about the cursor plane.482+ // An inactive CRTC tells nothing about the cursor plane. Protected
483+ // activation keeps it detached; do not test desktop cursor images.
418 o.testCursor()484 o.testCursor()
419 }485 }
420 o.sendFormats()486 o.sendFormats()
487+ o.signalReady()
488+ return nil
489+}
490+
491+// signalReady reports the first successful modeset once.
492+func (o *Output) signalReady() {
421 if o.ready != nil && !o.readySent {493 if o.ready != nil && !o.readySent {
422 o.ready <- nil494 o.ready <- nil
423 o.readySent = true495 o.readySent = true
424 }496 }
425- return nil
426 }497 }
427 498
428 // powerOff turns the display off: the CRTC goes inactive and keeps its499 // powerOff turns the display off: the CRTC goes inactive and keeps its
@@ -513,9 +584,8 @@ func (o *Output) cursorOff(err error) {
513 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("cursor plane refused; hardware cursor off")584 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("cursor plane refused; hardware cursor off")
514 }585 }
515 586
516-// modesetReq is the modeset of the front image with mode blob; active587+// modesetBaseReq sets the CRTC and connector without attaching any plane.
517-// false keeps the display off (output power).588+func (o *Output) modesetBaseReq(blob uint32, active bool) *atomicReq {
518-func (o *Output) modesetReq(blob uint32, active bool) *atomicReq {
519 req := &atomicReq{}589 req := &atomicReq{}
520 req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(blob))590 req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(blob))
521 req.set(o.crtc, o.crtcProps["ACTIVE"], boolValue(active))591 req.set(o.crtc, o.crtcProps["ACTIVE"], boolValue(active))
@@ -523,6 +593,13 @@ func (o *Output) modesetReq(blob uint32, active bool) *atomicReq {
523 req.set(o.conn.id, o.contentProp, o.contentValues[0])593 req.set(o.conn.id, o.contentProp, o.contentValues[0])
524 req.set(o.conn.id, o.connCrtc, uint64(o.crtc))594 req.set(o.conn.id, o.connCrtc, uint64(o.crtc))
525 o.hdrConnectorProps(req, o.hdrOn)595 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)
526 o.primaryProps(req, o.fbs[1-o.back])603 o.primaryProps(req, o.fbs[1-o.back])
527 if o.cursor != nil {604 if o.cursor != nil {
528 o.cursor.props(req, o.crtc, cursorState{})605 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
634 }711 }
635 712
636 func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame, ov overlayWin, rect planeRect) error {713 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+ }
637 cur := cursorState{}718 cur := cursorState{}
638- if o.cursor != nil {719+ if o.cursor != nil && !o.protected {
639 cur = o.cursor.desired()720 cur = o.cursor.desired()
640 }721 }
722+ if o.protected {
723+ async, vrr, ov = false, false, overlayWin{}
724+ }
641 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) {725 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) {
642 async = false726 async = false
643 }727 }
@@ -660,6 +744,12 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool,
660 if fence != nil {744 if fence != nil {
661 req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd()))745 req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd()))
662 }746 }
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+ }
663 // The kernel takes its own reference on the fence.753 // The kernel takes its own reference on the fence.
664 if err := o.k.commit(req, flags, o.frame.userData(userFrame)); err != nil {754 if err := o.k.commit(req, flags, o.frame.userData(userFrame)); err != nil {
665 if !async && cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() {755 if !async && cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() {
@@ -735,6 +825,10 @@ func (o *Output) cursorRefused() bool {
735 825
736 // commitState commits a cursor or VRR change without a new frame.826 // commitState commits a cursor or VRR change without a new frame.
737 func (o *Output) commitState(vrr bool) error {827 func (o *Output) commitState(vrr bool) error {
828+ o.observeSecurity()
829+ if o.protected {
830+ return errSecurityScene
831+ }
738 req := &atomicReq{}832 req := &atomicReq{}
739 cur := cursorState{}833 cur := cursorState{}
740 // A move back to the applied state holds nothing.834 // A move back to the applied state holds nothing.
@@ -757,6 +851,10 @@ func (o *Output) commitState(vrr bool) error {
757 if len(req.objs) == 0 {851 if len(req.objs) == 0 {
758 return nil852 return nil
759 }853 }
854+ o.observeSecurity()
855+ if o.protected {
856+ return errSecurityScene
857+ }
760 if err := o.k.commit(req, atomicNonblock|flipEventFlag, o.frame.userData(userState)); err != nil {858 if err := o.k.commit(req, atomicNonblock|flipEventFlag, o.frame.userData(userState)); err != nil {
761 if o.overlayConflict(err, o.overlayOn) {859 if o.overlayConflict(err, o.overlayOn) {
762 // The next frame is composed without the overlay.860 // The next frame is composed without the overlay.
@@ -864,9 +962,13 @@ func (o *Output) setAsync(on bool) {
864 }962 }
865 }963 }
866 964
867-// Close restores the CRTC state found at startup, when it had a mode, and965+// Close frees buffers and disables scanout. Without a security gate it
868-// frees buffers.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.
869 func (o *Output) Close() {968 func (o *Output) Close() {
969+ o.inactiveOnClose = false
970+ // Close may run after Run stopped, without receiving the engage wake.
971+ o.observeSecurity()
870 req := &atomicReq{}972 req := &atomicReq{}
871 if o.cursor != nil {973 if o.cursor != nil {
872 o.cursor.props(req, o.crtc, cursorState{})974 o.cursor.props(req, o.crtc, cursorState{})
@@ -878,7 +980,7 @@ func (o *Output) Close() {
878 var blob uint32980 var blob uint32
879 p := o.primary981 p := o.primary
880 restored := false982 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 {
882 if b, err := o.modeBlobFor(s.mode); err == nil {984 if b, err := o.modeBlobFor(s.mode); err == nil {
883 blob, restored = b, true985 blob, restored = b, true
884 req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(b))986 req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(b))
@@ -904,6 +1006,16 @@ func (o *Output) Close() {
904 req.set(p.id, p.prop("CRTC_ID"), 0)1006 req.set(p.id, p.prop("CRTC_ID"), 0)
905 req.set(o.conn.id, o.connCrtc, 0)1007 req.set(o.conn.id, o.connCrtc, 0)
906 }1008 }
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+ }
907 // A live metadata blob means an HDR modeset may still be on screen, even1019 // A live metadata blob means an HDR modeset may still be on screen, even
908 // after a failed SDR fallback (hdrOn is then false): it is freed only once1020 // after a failed SDR fallback (hdrOn is then false): it is freed only once
909 // an SDR modeset succeeds.1021 // an SDR modeset succeeds.
@@ -916,8 +1028,12 @@ func (o *Output) Close() {
916 ev = o.log.Warn()1028 ev = o.log.Warn()
917 }1029 }
918 ev.Err(err).Str("connector", o.conn.name).Bool("hdr", hdrShown).Msg("restore crtc")1030 ev.Err(err).Str("connector", o.conn.name).Bool("hdr", hdrShown).Msg("restore crtc")
919- } else if hdrShown {1031+ } else {
920- o.log.Info().Str("connector", o.conn.name).Msg("HDR10 off")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+ }
921 }1037 }
922 if blob != 0 {1038 if blob != 0 {
923 _ = o.k.destroyBlob(blob)1039 _ = o.k.destroyBlob(blob)
@@ -940,6 +1056,12 @@ func (o *Output) Close() {
940 o.dropClientFBs(time.Now(), true)1056 o.dropClientFBs(time.Now(), true)
941 }1057 }
942 1058
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+
943 // Run renders scenes and commits them until ctx ends. active reports seat1065 // Run renders scenes and commits them until ctx ends. active reports seat
944 // enable/disable. What the output shows and read is reported on presented.1066 // enable/disable. What the output shows and read is reported on presented.
945 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) {1067 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
956 panic(p)1078 panic(p)
957 }1079 }
958 }()1080 }()
1081+ o.runContext = ctx
1082+ o.observeSecurity()
959 r, err := newRenderer(o.Width(), o.Height())1083 r, err := newRenderer(o.Width(), o.Height())
960 if err != nil {1084 if err != nil {
961 return fmt.Errorf("create renderer: %w", err)1085 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
978 // While switched away they are made on the first enable.1102 // While switched away they are made on the first enable.
979 if enabled {1103 if enabled {
980 if err := o.showImages(r, imagesDriver, nil); err != nil {1104 if err := o.showImages(r, imagesDriver, nil); err != nil {
981- return err1105+ // 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)
982 }1113 }
983- o.probeAsync(r)
984 }1114 }
985 surfaces := make(map[ports.WindowID]ports.SurfaceContent)1115 surfaces := make(map[ports.WindowID]ports.SurfaceContent)
986 var scene ports.Scene1116 var scene ports.Scene
@@ -992,6 +1122,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
992 requests := requestStorage[:0]1122 requests := requestStorage[:0]
993 ctx, cancelCaptures := context.WithCancel(ctx)1123 ctx, cancelCaptures := context.WithCancel(ctx)
994 pipeline := capture.NewPipeline(ctx, captured)1124 pipeline := capture.NewPipeline(ctx, captured)
1125+ pipeline.Security = o.Security
995 if o.NewCaptureRenderer != nil {1126 if o.NewCaptureRenderer != nil {
996 pipeline.EnableOffscreen(o.NewCaptureRenderer)1127 pipeline.EnableOffscreen(o.NewCaptureRenderer)
997 o.capHidden = pipeline.CapHiddenSeen1128 o.capHidden = pipeline.CapHiddenSeen
@@ -1024,6 +1155,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1024 retryTimer := time.NewTimer(time.Hour)1155 retryTimer := time.NewTimer(time.Hour)
1025 vrrTimer := time.NewTimer(time.Hour)1156 vrrTimer := time.NewTimer(time.Hour)
1026 cursorTimer := time.NewTimer(time.Hour)1157 cursorTimer := time.NewTimer(time.Hour)
1158+ protectTimer := time.NewTimer(time.Hour)
1027 o.frame.startTimer()1159 o.frame.startTimer()
1028 gapTimer := time.NewTimer(time.Hour)1160 gapTimer := time.NewTimer(time.Hour)
1029 gapTimer.Stop()1161 gapTimer.Stop()
@@ -1031,6 +1163,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1031 retryTimer.Stop()1163 retryTimer.Stop()
1032 vrrTimer.Stop()1164 vrrTimer.Stop()
1033 cursorTimer.Stop()1165 cursorTimer.Stop()
1166+ protectTimer.Stop()
1167+ defer protectTimer.Stop()
1034 defer retryTimer.Stop()1168 defer retryTimer.Stop()
1035 defer vrrTimer.Stop()1169 defer vrrTimer.Stop()
1036 defer cursorTimer.Stop()1170 defer cursorTimer.Stop()
@@ -1044,7 +1178,42 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1044 if o.cursor != nil {1178 if o.cursor != nil {
1045 cursorWake = o.cursor.wake1179 cursorWake = o.cursor.wake
1046 }1180 }
1181+ securityChanges := o.SecurityChanges
1047 for {1182 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+ }
1048 readWait := false1217 readWait := false
1049 if reportDirty || o.capped {1218 if reportDirty || o.capped {
1050 published := false1219 published := false
@@ -1084,7 +1253,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1084 }1253 }
1085 // A held cursor move commits alone if no frame comes in time.1254 // A held cursor move commits alone if no frame comes in time.
1086 var cursorDue <-chan time.Time1255 var cursorDue <-chan time.Time
1087- if enabled && o.cursorHeld && !o.frame.pendingCommit() {1256+ if enabled && !o.protected && o.cursorHeld && !o.frame.pendingCommit() {
1088 cursorTimer.Reset(max(0, cursorMinInterval-time.Since(o.lastFrame)))1257 cursorTimer.Reset(max(0, cursorMinInterval-time.Since(o.lastFrame)))
1089 cursorDue = cursorTimer.C1258 cursorDue = cursorTimer.C
1090 } else {1259 } else {
@@ -1098,17 +1267,37 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1098 } else {1267 } else {
1099 gapTimer.Stop()1268 gapTimer.Stop()
1100 }1269 }
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+ }
1101 // A commit whose event never comes must not stop the output.1278 // A commit whose event never comes must not stop the output.
1102 stuck := o.frame.wait(enabled)1279 stuck := o.frame.wait(enabled)
1103 stateDirty := false1280 stateDirty := false
1104 select {1281 select {
1282+ case _, ok := <-securityChanges:
1283+ if !ok {
1284+ securityChanges = nil
1285+ }
1286+ continue // gate is authoritative, not the queued wake payload
1105 case <-retry:1287 case <-retry:
1106 continue1288 continue
1289+ case <-protectDue:
1290+ continue
1107 case <-stuck:1291 case <-stuck:
1292+ o.observeSecurity()
1108 if !o.frame.deadlineReached(time.Now()) {1293 if !o.frame.deadlineReached(time.Now()) {
1109 continue // waits for a fence1294 continue // waits for a fence
1110 }1295 }
1111 if o.expire() {1296 if o.expire() {
1297+ if o.protected {
1298+ o.invalidateSecurity()
1299+ continue // fresh black transaction at the top of the loop
1300+ }
1112 if err := o.modeset(); err != nil {1301 if err := o.modeset(); err != nil {
1113 if !o.commitFailed(err, &enabled) {1302 if !o.commitFailed(err, &enabled) {
1114 return fmt.Errorf("recovery modeset: %w", err)1303 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
1135 pipeline.Retire(scene)1324 pipeline.Retire(scene)
1136 continue1325 continue
1137 case q := <-captures:1326 case q := <-captures:
1138- if !enabled || o.off || o.wantOff {1327+ o.observeSecurity()
1328+ if o.protected || !enabled || o.off || o.wantOff {
1139 capture.Fail(ctx, q, fmt.Errorf("output off"), captured)1329 capture.Fail(ctx, q, fmt.Errorf("output off"), captured)
1140 } else if len(requests) == cap(requests) {1330 } else if len(requests) == cap(requests) {
1141 capture.Fail(ctx, q, fmt.Errorf("output capture batch full"), captured)1331 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
1144 dirty = haveScene1334 dirty = haveScene
1145 }1335 }
1146 case on := <-active:1336 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+ }
1147 // Modeset on every enable: a fast disable+enable can coalesce to one true.1350 // Modeset on every enable: a fast disable+enable can coalesce to one true.
1148 if on {1351 if on {
1352+ enabled = true // this enable may supersede a previous master loss
1149 var err error1353 var err error
1150 if o.fbs[0] == 0 {1354 if o.fbs[0] == 0 {
1151 err = o.showImages(r, imagesDriver, nil)1355 err = o.showImages(r, imagesDriver, nil)
1152 } else {1356 } else {
1153 err = o.modeset()1357 err = o.modeset()
1154 }1358 }
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- }
1160 if err != nil {1359 if err != nil {
1161- o.log.Error().Err(err).Msg("resume")1360+ if !o.commitFailed(err, &enabled) {
1162- return err1361+ return err
1362+ }
1363+ continue // epoch retry or wait disabled for master reacquisition
1364+ }
1365+ o.observeSecurity()
1366+ if o.protected {
1367+ continue
1163 }1368 }
1164 o.probeAsync(r)1369 o.probeAsync(r)
1165 dirty = haveScene1370 dirty = haveScene
@@ -1168,18 +1373,29 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1168 enabled = on1373 enabled = on
1169 o.log.Info().Bool("enabled", on).Msg("output")1374 o.log.Info().Bool("enabled", on).Msg("output")
1170 case ev := <-o.flipped:1375 case ev := <-o.flipped:
1376+ o.observeSecurity()
1171 if !o.completed(ev, seen) {1377 if !o.completed(ev, seen) {
1172 continue // stale event from before a modeset1378 continue // stale event from before a modeset
1173 }1379 }
1174 stateDirty = true1380 stateDirty = true
1175- if o.cursor != nil {1381+ if o.cursor != nil && !o.protected {
1176 // An image that waited for a free slot loads now.1382 // An image that waited for a free slot loads now.
1177 if _, err := o.cursor.flushLater(r); err != nil {1383 if _, err := o.cursor.flushLater(r); err != nil {
1178 o.log.Warn().Err(err).Msg("cursor")1384 o.log.Warn().Err(err).Msg("cursor")
1179 }1385 }
1180 }1386 }
1181 case s := <-scenes:1387 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 {
1183 cursorScale = s.Scale1399 cursorScale = s.Scale
1184 o.setCursor(r, loadCursor, want, s.Scale)1400 o.setCursor(r, loadCursor, want, s.Scale)
1185 stateDirty = true1401 stateDirty = true
@@ -1187,6 +1403,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1187 scene, haveScene, dirty = s, true, true1403 scene, haveScene, dirty = s, true, true
1188 o.wantOff = s.Off1404 o.wantOff = s.Off
1189 case c := <-cursor:1405 case c := <-cursor:
1406+ o.observeSecurity()
1407+ if o.protected {
1408+ continue
1409+ }
1190 want = c1410 want = c
1191 // Before the first scene the scale is unknown: loaded then.1411 // Before the first scene the scale is unknown: loaded then.
1192 if o.cursor != nil && loadCursor != nil && cursorScale > 0 {1412 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
1221 }1441 }
1222 ev.Msg("stats")1442 ev.Msg("stats")
1223 }1443 }
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) {
1225 for _, q := range requests {1455 for _, q := range requests {
1226 capture.Fail(ctx, q, fmt.Errorf("output off"), captured)1456 capture.Fail(ctx, q, fmt.Errorf("output off"), captured)
1227 }1457 }
@@ -1248,8 +1478,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1248 continue1478 continue
1249 }1479 }
1250 if !dirty || !haveScene {1480 if !dirty || !haveScene {
1251- if stateDirty {1481+ if stateDirty && !o.protected {
1252 err := o.commitState(o.stateVRR())1482 err := o.commitState(o.stateVRR())
1483+ if errors.Is(err, errSecurityScene) {
1484+ continue
1485+ }
1253 if errors.Is(err, errOverlayDropped) {1486 if errors.Is(err, errOverlayDropped) {
1254 dirty = haveScene1487 dirty = haveScene
1255 continue1488 continue
@@ -1266,11 +1499,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1266 }1499 }
1267 o.flipGapUntil = time.Time{}1500 o.flipGapUntil = time.Time{}
1268 }1501 }
1502+ if !o.sceneCurrent(scene) {
1503+ continue
1504+ }
1269 start := time.Now()1505 start := time.Now()
1270 direct, err := o.submitFrame(ctx, r, scene, surfaces, seen, requests, pipeline)1506 direct, err := o.submitFrame(ctx, r, scene, surfaces, seen, requests, pipeline)
1271 var fatal renderError1507 var fatal renderError
1272- if errors.As(err, &fatal) {1508+ if errors.As(err, &fatal) || errors.Is(err, errSecurityScene) {
1273- // A failed render did not hand these requests to the worker.1509+ // A failed render or epoch rejection did not hand these requests to the worker.
1274 for _, q := range requests {1510 for _, q := range requests {
1275 if capture.Handed(q) {1511 if capture.Handed(q) {
1276 continue // already owned by the worker, or answered1512 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
1284 return err1520 return err
1285 }1521 }
1286 if err != nil {1522 if err != nil {
1523+ if errors.Is(err, errSecurityScene) {
1524+ continue
1525+ }
1287 if errors.Is(err, errOverlayDropped) {1526 if errors.Is(err, errOverlayDropped) {
1288 dirty = true1527 dirty = true
1289 continue1528 continue
@@ -1307,13 +1546,29 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1307 // lost mid-switch (the seat disable follows).1546 // lost mid-switch (the seat disable follows).
1308 func (o *Output) commitFailed(err error, enabled *bool) bool {1547 func (o *Output) commitFailed(err error, enabled *bool) bool {
1309 switch {1548 switch {
1549+ case errors.Is(err, errSecurityScene), errors.Is(err, errProtectionPending):
1550+ return true // retry at the owner boundary, never certify stale work
1310 case errors.Is(err, unix.EBUSY):1551 case errors.Is(err, unix.EBUSY):
1311 // Any CRTC event can unblock a refusal; absent one, retry on time.1552 // Any CRTC event can unblock a refusal; absent one, retry on time.
1312 if o.frame.busy(time.Now()) {1553 if o.frame.busy(time.Now()) {
1313 o.log.Info().Err(err).Str("connector", o.conn.name).Msg("commit busy")1554 o.log.Info().Err(err).Str("connector", o.conn.name).Msg("commit busy")
1314 }1555 }
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")
1315 return true1566 return true
1316 case lostMaster(err):1567 case lostMaster(err):
1568+ o.observeSecurity()
1569+ if o.protected {
1570+ o.invalidateSecurity()
1571+ }
1317 o.log.Warn().Err(err).Msg("commit refused")1572 o.log.Warn().Err(err).Msg("commit refused")
1318 *enabled = false1573 *enabled = false
1319 return true1574 return true
@@ -1370,7 +1625,8 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
1370 1625
1371 // setCursor loads a cursor for scale into the cursor images.1626 // setCursor loads a cursor for scale into the cursor images.
1372 func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64) {1627 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 {
1374 return1630 return
1375 }1631 }
1376 img, err := load(c, scale, o.cursor.Limit())1632 img, err := load(c, scale, o.cursor.Limit())
@@ -1486,6 +1742,21 @@ func (o *Output) showImages(r ports.Renderer, kind imageKind, cause error) error
1486 r.SetHDR(float64(o.hdrSettings.SDRBrightness))1742 r.SetHDR(float64(o.hdrSettings.SDRBrightness))
1487 err = o.showImageKind(r, imagesDriver, nil)1743 err = o.showImageKind(r, imagesDriver, nil)
1488 }1744 }
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+ }
1489 if err == nil {1760 if err == nil {
1490 if created {1761 if created {
1491 o.hdrBlobData = meta1762 o.hdrBlobData = meta
@@ -1530,7 +1801,19 @@ func (o *Output) showImageKind(r ports.Renderer, kind imageKind, cause error) er
1530 }1801 }
1531 o.kind = got1802 o.kind = got
1532 if err = o.testModeset(); err == nil {1803 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}
1534 }1817 }
1535 if !refused(err) || got == imagesLinear {1818 if !refused(err) || got == imagesLinear {
1536 o.freeImages()1819 o.freeImages()
@@ -1558,6 +1841,9 @@ func lostMaster(err error) bool {
1558 func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error) (imageKind, error) {1841 func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error) (imageKind, error) {
1559 err := cause1842 err := cause
1560 for ; kind <= imagesLinear; kind++ {1843 for ; kind <= imagesLinear; kind++ {
1844+ if o.runContext != nil && o.runContext.Err() != nil {
1845+ return kind, o.runContext.Err()
1846+ }
1561 mods := []uint64(nil)1847 mods := []uint64(nil)
1562 if o.hdrOn {1848 if o.hdrOn {
1563 for _, f := range o.primary.formats {1849 for _, f := range o.primary.formats {
@@ -1611,7 +1897,11 @@ func (o *Output) exportImages(r ports.Renderer, mods []uint64) error {
1611 var done *os.File1897 var done *os.File
1612 if done, err = r.Render(ports.Scene{Background: "#000000"}, nil); done != nil {1898 if done, err = r.Render(ports.Scene{Background: "#000000"}, nil); done != nil {
1613 // The modeset is a blocking commit: wait for the clear.1899 // 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))
1615 done.Close()1905 done.Close()
1616 }1906 }
1617 }1907 }
M internal/adapters/drm/output_test.go +11 -0
@@ -44,6 +44,12 @@ func testOutput(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec) {
44 // testOutputMu is testOutput with the mutex that guards the recorded44 // testOutputMu is testOutput with the mutex that guards the recorded
45 // commits (Run commits on its goroutine).45 // commits (Run commits on its goroutine).
46 func testOutputMu(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec, *sync.Mutex) {46 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) {
47 k := newMockkms(t)53 k := newMockkms(t)
48 var commits []commitRec54 var commits []commitRec
49 commitMu := &sync.Mutex{}55 commitMu := &sync.Mutex{}
@@ -56,6 +62,11 @@ func testOutputMu(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec,
56 c.req.props = append(c.req.props, append([]propValue(nil), ps...))62 c.req.props = append(c.req.props, append([]propValue(nil), ps...))
57 }63 }
58 commits = append(commits, c)64 commits = append(commits, c)
65+ if rule != nil {
66+ if err := rule(r, flags); err != nil {
67+ return err
68+ }
69+ }
59 if len(errs) > 0 {70 if len(errs) > 0 {
60 err := errs[0]71 err := errs[0]
61 errs = errs[1:]72 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) {
249 require.Equal(t, []uint64{1}, ids, "request 1 answered once by its worker, no pseudo request 0")249 require.Equal(t, []uint64{1}, ids, "request 1 answered once by its worker, no pseudo request 0")
250 }250 }
251 251
252-// GO-003: an unchanged child-hold publication reuses its immutable snapshots;252+// An unchanged child-hold publication reuses its immutable snapshots;
253 // a change clones, and earlier reports keep their maps.253 // a change clones, and earlier reports keep their maps.
254 func TestHoldSnapshotsCloneOnlyOnChange(t *testing.T) {254 func TestHoldSnapshotsCloneOnlyOnChange(t *testing.T) {
255 var h holdSnapshots255 var h holdSnapshots
M internal/adapters/headless/cursor.go +5 -1
@@ -37,9 +37,13 @@ func (c *Cursor) set(img ports.CursorImage) {
37 37
38 // draw blends the premultiplied cursor over dst with its hotspot at (x, y).38 // draw blends the premultiplied cursor over dst with its hotspot at (x, y).
39 func (c *Cursor) draw(dst *image.RGBA) {39 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) {
40 c.mu.Lock()44 c.mu.Lock()
41 defer c.mu.Unlock()45 defer c.mu.Unlock()
42- if c.hidden {46+ if c.hidden || security != nil && security.Snapshot().Protected {
43 return47 return
44 }48 }
45 img := c.img49 img := c.img
M internal/adapters/headless/output.go +262 -20
@@ -19,6 +19,12 @@ import (
19 )19 )
20 20
21 type Options struct {21 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
22 // Cursor, when set, is drawn into screenshots with the image from28 // Cursor, when set, is drawn into screenshots with the image from
23 // LoadCursor at the scene scale.29 // LoadCursor at the scene scale.
24 Cursor *Cursor30 Cursor *Cursor
@@ -83,6 +89,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
83 requests := requestStorage[:0]89 requests := requestStorage[:0]
84 ctx, cancelCaptures := context.WithCancel(ctx)90 ctx, cancelCaptures := context.WithCancel(ctx)
85 pipeline := capture.NewPipeline(ctx, opts.Captured)91 pipeline := capture.NewPipeline(ctx, opts.Captured)
92+ pipeline.Security = opts.Security
86 if opts.NewCaptureRenderer != nil {93 if opts.NewCaptureRenderer != nil {
87 pipeline.EnableOffscreen(opts.NewCaptureRenderer)94 pipeline.EnableOffscreen(opts.NewCaptureRenderer)
88 }95 }
@@ -97,7 +104,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
97 }104 }
98 for {105 for {
99 select {106 select {
100- case q := <-incoming:107+ case q, ok := <-incoming:
108+ if !ok {
109+ return
110+ }
101 capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)111 capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)
102 default:112 default:
103 return113 return
@@ -110,6 +120,77 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
110 var holds holdSnapshots120 var holds holdSnapshots
111 // pending is a report the channel could not take, retried soon.121 // pending is a report the channel could not take, retried soon.
112 var pending *ports.OutputPresented122 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+ }
113 update := func(c ports.SurfaceContent) {194 update := func(c ports.SurfaceContent) {
114 seen[c.ID] = max(seen[c.ID], c.Seq)195 seen[c.ID] = max(seen[c.ID], c.Seq)
115 dirty = dirty || capture.Shows(scene, c.ID)196 dirty = dirty || capture.Shows(scene, c.ID)
@@ -129,6 +210,12 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
129 if ctx.Err() != nil {210 if ctx.Err() != nil {
130 return nil211 return nil
131 }212 }
213+ if err := checkSecurity(); err != nil {
214+ if ctx.Err() != nil {
215+ return nil
216+ }
217+ return err
218+ }
132 var retry <-chan time.Time219 var retry <-chan time.Time
133 if pending != nil {220 if pending != nil {
134 retry = time.After(time.Millisecond)221 retry = time.After(time.Millisecond)
@@ -136,15 +223,54 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
136 select {223 select {
137 case <-ctx.Done():224 case <-ctx.Done():
138 return nil225 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
139 case b := <-pipeline.Completed():237 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.
140 pipeline.Recycle(b, r)240 pipeline.Recycle(b, r)
241+ if err := checkSecurity(); err != nil {
242+ if ctx.Err() != nil {
243+ return nil
244+ }
245+ return err
246+ }
141 continue247 continue
142 case b := <-pipeline.HiddenCompleted():248 case b := <-pipeline.HiddenCompleted():
143 pipeline.RecycleHidden(b)249 pipeline.RecycleHidden(b)
250+ if err := checkSecurity(); err != nil {
251+ if ctx.Err() != nil {
252+ return nil
253+ }
254+ return err
255+ }
144 pipeline.Retire(scene)256 pipeline.Retire(scene)
145 continue257 continue
146- case q := <-incoming:258+ case q, ok := <-incoming:
147- if scene.Off {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 {
148 capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)274 capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)
149 } else if len(requests) == cap(requests) {275 } else if len(requests) == cap(requests) {
150 capture.Fail(ctx, q, fmt.Errorf("output capture batch full"), opts.Captured)276 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
153 dirty = haveScene279 dirty = haveScene
154 }280 }
155 case <-retry:281 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+ }
157 continue291 continue
158 case <-trim.C():292 case <-trim.C():
293+ if err := checkSecurity(); err != nil {
294+ if ctx.Err() != nil {
295+ return nil
296+ }
297+ return err
298+ }
159 // An idle output renders nothing: free what windows that left299 // An idle output renders nothing: free what windows that left
160 // it held, and the child of an ended session.300 // it held, and the child of an ended session.
161 pipeline.Retire(scene)301 pipeline.Retire(scene)
@@ -168,14 +308,36 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
168 scenes = nil308 scenes = nil
169 continue309 continue
170 }310 }
171- scene, haveScene, dirty = s, true, true311+ if err := checkSecurity(); err != nil {
312+ if ctx.Err() != nil {
313+ return nil
314+ }
315+ return err
316+ }
317+ acceptScene(s)
172 case c, ok := <-contents:318 case c, ok := <-contents:
173 if !ok {319 if !ok {
174 contents = nil320 contents = nil
175 continue321 continue
176 }322 }
323+ if err := checkSecurity(); err != nil {
324+ if ctx.Err() != nil {
325+ return nil
326+ }
327+ return err
328+ }
177 update(c)329 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+ }
179 // The cursor is only drawn into screenshots: no new frame.341 // The cursor is only drawn into screenshots: no new frame.
180 want, cursorScale = c, -1342 want, cursorScale = c, -1
181 }343 }
@@ -187,18 +349,39 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
187 if !ok {349 if !ok {
188 scenes = nil350 scenes = nil
189 } else {351 } else {
190- scene, haveScene, dirty = s, true, true352+ if err := checkSecurity(); err != nil {
353+ if ctx.Err() != nil {
354+ return nil
355+ }
356+ return err
357+ }
358+ acceptScene(s)
191 }359 }
192 case c, ok := <-contents:360 case c, ok := <-contents:
193 if !ok {361 if !ok {
194 contents = nil362 contents = nil
195 } else {363 } else {
364+ if err := checkSecurity(); err != nil {
365+ if ctx.Err() != nil {
366+ return nil
367+ }
368+ return err
369+ }
196 update(c)370 update(c)
197 }371 }
198 default:372 default:
199 break drain373 break drain
200 }374 }
201 }375 }
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+ }
202 if scene.Off && len(requests) > 0 {385 if scene.Off && len(requests) > 0 {
203 for _, q := range requests {386 for _, q := range requests {
204 capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)387 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
213 continue396 continue
214 }397 }
215 dirty = false398 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 {
217 cursorScale = scene.Scale400 cursorScale = scene.Scale
218 if img, err := opts.LoadCursor(want, scene.Scale, 256); err == nil {401 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+ }
220 } else {405 } else {
221 opts.Log.Warn().Err(err).Msg("cursor")406 opts.Log.Warn().Err(err).Msg("cursor")
222 }407 }
223 }408 }
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+ }
224 start := time.Now()418 start := time.Now()
225 // Clean requests of a session the scene does not carry are failed419 // Clean requests of a session the scene does not carry are failed
226 // here. The others draw the clean frame first (same renderer, queue420 // 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
230 if len(clean) > 0 && scene.CaptureScene != nil {424 if len(clean) > 0 && scene.CaptureScene != nil {
231 // A hidden workspace: a child renderer draws it. The display's425 // A hidden workspace: a child renderer draws it. The display's
232 // fences do not cover the child, so wait for it before reporting.426 // 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+ }
233 pipeline.SubmitHidden(scene, surfaces, clean)431 pipeline.SubmitHidden(scene, surfaces, clean)
234 clear(clean)432 clear(clean)
235 // A slow child may outlive Wayland's stale-report timeout even433 // 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
252 return fmt.Errorf("workspace frame fence: %w", err)450 return fmt.Errorf("workspace frame fence: %w", err)
253 }451 }
254 } else if len(clean) > 0 {452 } else if len(clean) > 0 {
255- cdone, err := r.Render(pipeline.CleanScene(scene), surfaces)453+ if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
256- if err != nil {454+ failRequests("security epoch changed")
257- return fmt.Errorf("render clean frame: %w", err)455+ continue
258 }456 }
457+ cdone, renderErr := r.Render(pipeline.CleanScene(scene), surfaces)
259 if cdone != nil {458 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
261 }477 }
262- pipeline.Submit(r, clean)478+ pipeline.SubmitScoped(scene.Security, r, clean)
263 clear(clean)479 clear(clean)
264 }480 }
481+ if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
482+ failRequests("security epoch changed")
483+ continue
484+ }
265 done, err := r.Render(scene, surfaces)485 done, err := r.Render(scene, surfaces)
266 if err != nil {486 if err != nil {
267 return fmt.Errorf("render frame: %w", err)487 return fmt.Errorf("render frame: %w", err)
@@ -281,24 +501,36 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
281 if len(normal) > 0 {501 if len(normal) > 0 {
282 opts.Log.Debug().Str("output", opts.Name).Int("captures", len(normal)).Msg("capture frame composed")502 opts.Log.Debug().Str("output", opts.Name).Int("captures", len(normal)).Msg("capture frame composed")
283 }503 }
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)
285 clear(requests)511 clear(requests)
286 requests = requestStorage[:0]512 requests = requestStorage[:0]
287 frame++513 frame++
288 pending = opts.flipped(pending, seen, scene, surfaces)514 pending = opts.flipped(pending, seen, scene, surfaces)
289- if opts.ScreenshotDir != "" {515+ if opts.ScreenshotDir != "" && !opts.protected() {
290 shot := r.Pixels()516 shot := r.Pixels()
517+ if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
518+ continue
519+ }
291 if shot == nil {520 if shot == nil {
292 opts.Log.Debug().Str("component", "headless").Str("output", opts.Name).Int("frame", frame).Msg("screenshot readback unavailable; frame skipped")521 opts.Log.Debug().Str("component", "headless").Str("output", opts.Name).Int("frame", frame).Msg("screenshot readback unavailable; frame skipped")
293 continue522 continue
294 }523 }
295 if opts.Cursor != nil {524 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
297 }529 }
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 {
299 return fmt.Errorf("screenshot: %w", err)531 return fmt.Errorf("screenshot: %w", err)
300 }532 }
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 {
302 return fmt.Errorf("latest screenshot: %w", err)534 return fmt.Errorf("latest screenshot: %w", err)
303 }535 }
304 }536 }
@@ -324,7 +556,14 @@ func (h *holdSnapshots) report(output string, seen, reads map[ports.WindowID]uin
324 return ports.OutputPresented{Output: output, Seen: h.seen, ChildReads: h.reads}556 return ports.OutputPresented{Output: output, Seen: h.seen, ChildReads: h.reads}
325 }557 }
326 558
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+ }
328 f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png")567 f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png")
329 if err != nil {568 if err != nil {
330 return err569 return err
@@ -337,6 +576,9 @@ func writePNG(path string, img *image.RGBA) error {
337 if err := f.Close(); err != nil {576 if err := f.Close(); err != nil {
338 return err577 return err
339 }578 }
579+ if security != nil && security.Snapshot() != state {
580+ return nil
581+ }
340 return os.Rename(f.Name(), path)582 return os.Rename(f.Name(), path)
341 }583 }
342 584
M internal/adapters/headless/output_test.go +1 -1
@@ -193,7 +193,7 @@ func TestWritePNGAtomic(t *testing.T) {
193 img := image.NewRGBA(image.Rect(0, 0, 1, 1))193 img := image.NewRGBA(image.Rect(0, 0, 1, 1))
194 for _, name := range []string{"frame-000001.png", "latest.png"} {194 for _, name := range []string{"frame-000001.png", "latest.png"} {
195 path := filepath.Join(dir, name)195 path := filepath.Join(dir, name)
196- if err := writePNG(path, img); err != nil {196+ if err := writePNGSecure(path, img, nil, ports.SecurityState{}); err != nil {
197 t.Fatal(err)197 t.Fatal(err)
198 }198 }
199 f, err := os.Open(path)199 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 (
20 // software cursor of the output under them (physical position on it), using20 // software cursor of the output under them (physical position on it), using
21 // the latest layout from layouts.21 // the latest layout from layouts.
22 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 {22 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+ }
23 var layout ports.Layout51 var layout ports.Layout
24 defer func() { km.Close() }()52 defer func() { km.Close() }()
25 emit := func(code uint32, down bool) error {53 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)
27 select {70 select {
28 case <-ctx.Done():71 case <-ctx.Done():
29 return ctx.Err()72 return ctx.Err()
@@ -41,7 +84,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
41 stroke := func(name string, mods []uint32) error {84 stroke := func(name string, mods []uint32) error {
42 code, shift, ok := km.KeycodeFor(name)85 code, shift, ok := km.KeycodeFor(name)
43 if !ok {86 if !ok {
44- log.Warn().Str("key", name).Msg("unknown key")87+ warnInput("key", name, "unknown key")
45 return nil88 return nil
46 }89 }
47 if shift {90 if shift {
@@ -79,6 +122,9 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
79 case <-ctx.Done():122 case <-ctx.Done():
80 return ctx.Err()123 return ctx.Err()
81 case next := <-keymaps:124 case next := <-keymaps:
125+ if security != nil {
126+ next.QuarantineFrom(km)
127+ }
82 km.Close()128 km.Close()
83 km = next129 km = next
84 log.Info().Msg("keymap replaced")130 log.Info().Msg("keymap replaced")
@@ -89,6 +135,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
89 }135 }
90 fields := strings.Fields(line)136 fields := strings.Fields(line)
91 sendPointer := func(ev ports.InputEvent) error {137 sendPointer := func(ev ports.InputEvent) error {
138+ ev = stamp(ev, snapshot())
92 select {139 select {
93 case <-ctx.Done():140 case <-ctx.Done():
94 return ctx.Err()141 return ctx.Err()
@@ -122,7 +169,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
122 continue169 continue
123 }170 }
124 }171 }
125- log.Warn().Str("line", line).Msg("invalid move")172+ warnInput("line", line, "invalid move")
126 continue173 continue
127 }174 }
128 if len(fields) > 0 && (fields[0] == "swipe" || fields[0] == "swipe4") {175 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
132 d, ok = dirs[fields[1]]179 d, ok = dirs[fields[1]]
133 }180 }
134 if !ok {181 if !ok {
135- log.Warn().Str("line", line).Msg("invalid swipe")182+ warnInput("line", line, "invalid swipe")
136 continue183 continue
137 }184 }
138 fingers := 3185 fingers := 3
@@ -153,7 +200,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
153 }200 }
154 code := buttonCode(name)201 code := buttonCode(name)
155 if len(fields) > 2 || (len(fields) != 2 && fields[0] != "click") || code == 0 {202 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")
157 continue204 continue
158 }205 }
159 if fields[0] != "up" {206 if fields[0] != "up" {
@@ -176,7 +223,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
176 name = string(r)223 name = string(r)
177 }224 }
178 if name == "" {225 if name == "" {
179- log.Warn().Str("rune", string(r)).Msg("unknown rune")226+ warnInput("rune", string(r), "unknown rune")
180 continue227 continue
181 }228 }
182 if err := stroke(name, nil); err != nil {229 if err := stroke(name, nil); err != nil {
@@ -202,7 +249,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
202 }249 }
203 }250 }
204 if !valid {251 if !valid {
205- log.Warn().Str("combo", line).Msg("unknown modifier")252+ warnInput("combo", line, "unknown modifier")
206 continue253 continue
207 }254 }
208 if err := stroke(parts[len(parts)-1], mods); err != nil {255 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
211 case strings.HasPrefix(line, "sleep "):258 case strings.HasPrefix(line, "sleep "):
212 d, err := time.ParseDuration(strings.TrimPrefix(line, "sleep "))259 d, err := time.ParseDuration(strings.TrimPrefix(line, "sleep "))
213 if err != nil || d < 0 {260 if err != nil || d < 0 {
214- log.Warn().Str("line", line).Msg("invalid sleep")261+ warnInput("line", line, "invalid sleep")
215 continue262 continue
216 }263 }
217 timer := time.NewTimer(d)264 timer := time.NewTimer(d)
@@ -222,7 +269,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
222 case <-timer.C:269 case <-timer.C:
223 }270 }
224 default:271 default:
225- log.Warn().Str("line", line).Msg("unknown script line")272+ warnInput("line", line, "unknown script line")
226 }273 }
227 }274 }
228 }275 }
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 (
16 )16 )
17 17
18 type Launcher struct {18 type Launcher struct {
19- env []string19+ // Security is the optional defensive admission gate. Set before Run.
20- log zerowrap.Logger20+ Security ports.SessionSecurity
21+ env []string
22+ log zerowrap.Logger
21 }23 }
22 24
23 func New(env []string, log zerowrap.Logger) *Launcher {25 func New(env []string, log zerowrap.Logger) *Launcher {
@@ -64,6 +66,9 @@ func (l *Launcher) Run(ctx context.Context, reqs <-chan ports.SpawnRequest) erro
64 if !ok {66 if !ok {
65 return nil67 return nil
66 }68 }
69+ if !l.admitted(req) {
70+ continue
71+ }
67 if len(req.Argv) == 0 {72 if len(req.Argv) == 0 {
68 l.log.Warn().Msg("empty spawn argv")73 l.log.Warn().Msg("empty spawn argv")
69 continue74 continue
@@ -79,6 +84,12 @@ func (l *Launcher) Run(ctx context.Context, reqs <-chan ports.SpawnRequest) erro
79 cmd.Stdout = nil84 cmd.Stdout = nil
80 cmd.Stderr = nil85 cmd.Stderr = nil
81 cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}86 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+ }
82 if err := cmd.Start(); err != nil {93 if err := cmd.Start(); err != nil {
83 l.log.Warn().Err(err).Str("binary", req.Argv[0]).Msg("spawn failed")94 l.log.Warn().Err(err).Str("binary", req.Argv[0]).Msg("spawn failed")
84 continue95 continue
@@ -89,6 +100,14 @@ func (l *Launcher) Run(ctx context.Context, reqs <-chan ports.SpawnRequest) erro
89 }100 }
90 }101 }
91 102
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+
92 func childLookPath(name string, env []string) (string, error) {111 func childLookPath(name string, env []string) (string, error) {
93 if strings.Contains(name, "/") {112 if strings.Contains(name, "/") {
94 return name, nil113 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 {
42 // push queues ev without blocking.42 // push queues ev without blocking.
43 func (f *forwarder) push(ev ports.InputEvent) {43 func (f *forwarder) push(ev ports.InputEvent) {
44 f.mu.Lock()44 f.mu.Lock()
45- if m, ok := ev.(ports.PointerMotion); ok {45+ if m, state, wrapped, ok := queuedMotion(ev); ok {
46 f.stats.Motions++46 f.stats.Motions++
47 if n := len(f.queue); n > 0 {47 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 {
49 // Relative deltas add up: games read them, not the position.49 // Relative deltas add up: games read them, not the position.
50 m.DX += prev.DX50 m.DX += prev.DX
51 m.DY += prev.DY51 m.DY += prev.DY
52 m.UnaccelDX += prev.UnaccelDX52 m.UnaccelDX += prev.UnaccelDX
53 m.UnaccelDY += prev.UnaccelDY53 m.UnaccelDY += prev.UnaccelDY
54- f.queue[n-1] = m54+ if wrapped {
55+ f.queue[n-1] = ports.SecurityInput{State: state, Event: m}
56+ } else {
57+ f.queue[n-1] = m
58+ }
55 f.stats.Coalesced++59 f.stats.Coalesced++
56 f.mu.Unlock()60 f.mu.Unlock()
57 return61 return
@@ -70,6 +74,18 @@ func (f *forwarder) push(ev ports.InputEvent) {
70 }74 }
71 }75 }
72 76
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+
73 func (f *forwarder) pop() (ports.InputEvent, bool) {89 func (f *forwarder) pop() (ports.InputEvent, bool) {
74 f.mu.Lock()90 f.mu.Lock()
75 defer f.mu.Unlock()91 defer f.mu.Unlock()
M internal/adapters/libinput/forward_test.go +36 -0
@@ -139,3 +139,39 @@ func (l *lockedBuffer) String() string {
139 defer l.mu.Unlock()139 defer l.mu.Unlock()
140 return l.b.String()140 return l.b.String()
141 }141 }
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 {
184 184
185 // Options configures the input adapter.185 // Options configures the input adapter.
186 type Options struct {186 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
187 Seat ports.Seat191 Seat ports.Seat
188 SeatName string192 SeatName string
189 Keymap *xkb.Keymap193 Keymap *xkb.Keymap
@@ -266,6 +270,9 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
266 }270 }
267 opts.Log.Info().Bool("active", on).Msg("input")271 opts.Log.Info().Bool("active", on).Msg("input")
268 case km := <-opts.Keymaps:272 case km := <-opts.Keymaps:
273+ if opts.Security != nil {
274+ km.QuarantineFrom(opts.Keymap)
275+ }
269 opts.Keymap.Close()276 opts.Keymap.Close()
270 opts.Keymap = km277 opts.Keymap = km
271 opts.Log.Info().Msg("keymap replaced")278 opts.Log.Info().Msg("keymap replaced")
@@ -418,7 +425,28 @@ func msec(v uint64) uint32 { return uint32(v / 1000) }
418 // translate converts one libinput event. Timestamps are the device's425 // translate converts one libinput event. Timestamps are the device's
419 // (CLOCK_MONOTONIC), so clients measure speeds (kinetic scrolling) on426 // (CLOCK_MONOTONIC), so clients measure speeds (kinetic scrolling) on
420 // when events happened, not when they were read.427 // when events happened, not when they were read.
428+// It snapshots admission before translating and queueing any input kind.
421 func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.InputEvent, error) {429 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) {
422 log := opts.Log450 log := opts.Log
423 switch eventType(ev) {451 switch eventType(ev) {
424 case evDeviceAdded:452 case evDeviceAdded:
@@ -449,19 +477,7 @@ func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.Inpu
449 case evKeyboardKey:477 case evKeyboardKey:
450 k := keyboardEvent(ev)478 k := keyboardEvent(ev)
451 code, pressed := keyboardKey(k), keyboardState(k) == 1479 code, pressed := keyboardKey(k), keyboardState(k) == 1
452- ke := opts.Keymap.Key(code, pressed, msec(keyboardUsec(k)))480+ return translateKeyboard(code, pressed, msec(keyboardUsec(k)), opts, state)
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
465 case evPointerMotion:481 case evPointerMotion:
466 pe := pointerEvent(ev)482 pe := pointerEvent(ev)
467 m := p.move(pointerDX(pe), pointerDY(pe))483 m := p.move(pointerDX(pe), pointerDY(pe))
@@ -516,6 +532,41 @@ func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.Inpu
516 return nil, nil532 return nil, nil
517 }533 }
518 534
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+
519 type hotkeyAction int570 type hotkeyAction int
520 571
521 const (572 const (
@@ -524,8 +575,9 @@ const (
524 hotkeyVT575 hotkeyVT
525 )576 )
526 577
527-// hotkey detects compositor-reserved keys on press: Ctrl+Alt+Backspace and Ctrl+Alt+F1..F12.578+// hotkey detects compositor-reserved keys on press: Ctrl+Alt+Backspace and
528-func hotkey(ke ports.KeyEvent) (hotkeyAction, int) {579+// Ctrl+Alt+F1..F12. Protection disables emergency quit, but not VT switching.
580+func hotkey(ke ports.KeyEvent, protected bool) (hotkeyAction, int) {
529 if !ke.Pressed {581 if !ke.Pressed {
530 return hotkeyNone, 0582 return hotkeyNone, 0
531 }583 }
@@ -540,7 +592,9 @@ func hotkey(ke ports.KeyEvent) (hotkeyAction, int) {
540 }592 }
541 switch {593 switch {
542 case ke.Keysym == "BackSpace" || ke.Keycode == 14:594 case ke.Keysym == "BackSpace" || ke.Keycode == 14:
543- return hotkeyQuit, 0595+ if !protected {
596+ return hotkeyQuit, 0
597+ }
544 case ke.Keycode >= 59 && ke.Keycode <= 68: // F1..F10598 case ke.Keycode >= 59 && ke.Keycode <= 68: // F1..F10
545 return hotkeyVT, int(ke.Keycode) - 58599 return hotkeyVT, int(ke.Keycode) - 58
546 case ke.Keycode == 87 || ke.Keycode == 88: // F11, F12600 case ke.Keycode == 87 || ke.Keycode == 88: // F11, F12
M internal/adapters/libinput/libinput_test.go +165 -3
@@ -1,10 +1,16 @@
1 package libinput1 package libinput
2 2
3 import (3 import (
4+ "bytes"
5+ "errors"
6+ "strings"
4 "testing"7 "testing"
5 "time"8 "time"
6 9
10+ "github.com/bnema/neferwl/internal/adapters/xkb"
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
7 "github.com/bnema/neferwl/internal/ports"12 "github.com/bnema/neferwl/internal/ports"
13+ "github.com/bnema/zerowrap"
8 )14 )
9 15
10 func TestHotkey(t *testing.T) {16 func TestHotkey(t *testing.T) {
@@ -23,9 +29,15 @@ func TestHotkey(t *testing.T) {
23 {"ctrl alt f12 raw", ports.KeyEvent{Keysym: "F12", Keycode: 88, Mods: ca, Pressed: true}, hotkeyVT, 12},29 {"ctrl alt f12 raw", ports.KeyEvent{Keysym: "F12", Keycode: 88, Mods: ca, Pressed: true}, hotkeyVT, 12},
24 {"plain f2", ports.KeyEvent{Keysym: "F2", Keycode: 60, Pressed: true}, hotkeyNone, 0},30 {"plain f2", ports.KeyEvent{Keysym: "F2", Keycode: 60, Pressed: true}, hotkeyNone, 0},
25 } {31 } {
26- a, vt := hotkey(tc.ev)32+ for _, protected := range []bool{false, true} {
27- if a != tc.action || vt != tc.vt {33+ want := tc.action
28- t.Errorf("%s: got %v %d", tc.name, a, vt)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+ }
29 }41 }
30 }42 }
31 }43 }
@@ -175,3 +187,153 @@ func TestPointerConstraints(t *testing.T) {
175 t.Fatal(p.x, p.y)187 t.Fatal(p.x, p.y)
176 }188 }
177 }189 }
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) {
377 return nil, nil377 return nil, nil
378 }378 }
379 379
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+
380 // BeginCapture submits a GPU copy of the last frame and leases its slot.386 // BeginCapture submits a GPU copy of the last frame and leases its slot.
381 // The lease is valid until EndCapture and no longer: the same object387 // The lease is valid until EndCapture and no longer: the same object
382 // serves the slot's next lease.388 // 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 *
55 state := s.surfaceOf(surf)55 state := s.surfaceOf(surf)
56 reason := ""56 reason := ""
57 switch {57 switch {
58+ case s.protected():
59+ reason = "session_protected"
58 case !ok:60 case !ok:
59 reason = "unknown_token"61 reason = "unknown_token"
60 case time.Since(t.at) > tokenLifetime:62 case time.Since(t.at) > tokenLifetime:
@@ -105,7 +107,7 @@ func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) {
105 fresh := time.Since(s.seat.pressAt) <= tokenLifetime && !s.seat.pressAt.Before(s.seat.focusAt)107 fresh := time.Since(s.seat.pressAt) <= tokenLifetime && !s.seat.pressAt.Before(s.seat.focusAt)
106 valid := s.focusClient() == c || (t.hasSerial && t.serial == s.seat.press && s.seat.pressClient == c && fresh)108 valid := s.focusClient() == c || (t.hasSerial && t.serial == s.seat.press && s.seat.pressClient == c && fresh)
107 token := rand.Text()109 token := rand.Text()
108- if valid {110+ if valid && !s.protected() {
109 s.pruneTokens()111 s.pruneTokens()
110 s.tokens[token] = activationToken{client: c, at: time.Now()}112 s.tokens[token] = activationToken{client: c, at: time.Now()}
111 }113 }
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
24 id := c.AllocateID()24 id := c.AllocateID()
25 p := &tokenProxy{tokens: make(chan string, 1)}25 p := &tokenProxy{tokens: make(chan string, 1)}
26 p.SetID(id)26 p.SetID(id)
27- c.Context().Register(p)27+ registerWireProxy(c, p)
28 requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestGetActivationToken, id)28 requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestGetActivationToken, id)
29 if len(serialSeat) == 2 {29 if len(serialSeat) == 2 {
30 requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestSetSerial, serialSeat[0], serialSeat[1])30 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 {
79 if !ok {79 if !ok {
80 t.Fatal("output missing")80 t.Fatal("output missing")
81 }81 }
82- out, err := c.Registry().BindID(g.Name, g.Interface, 4)82+ out, err := bindWireID(c, g.Name, g.Interface, 4)
83 if err != nil {83 if err != nil {
84 t.Fatal(err)84 t.Fatal(err)
85 }85 }
@@ -93,7 +93,7 @@ func (a *admissionClient) newFrame() (uint32, *captureEvents) {
93 frame := a.c.AllocateID()93 frame := a.c.AllocateID()
94 ev := &captureEvents{events: make(chan uint16, 8)}94 ev := &captureEvents{events: make(chan uint16, 8)}
95 ev.SetID(frame)95 ev.SetID(frame)
96- a.c.Context().Register(ev)96+ registerWireProxy(a.c, ev)
97 return frame, ev97 return frame, ev
98 }98 }
99 99
M internal/adapters/wayland/capture_session.go +3 -3
@@ -121,7 +121,7 @@ type captureManager struct {
121 // update sends the workspaces that are new or changed, and the ones gone.121 // update sends the workspaces that are new or changed, and the ones gone.
122 // Untrusted peers get no inventory.122 // Untrusted peers get no inventory.
123 func (m *captureManager) update(snapshot ports.Workspaces) {123 func (m *captureManager) update(snapshot ports.Workspaces) {
124- if !m.trusted || !m.res.Alive() {124+ if m.s.protected() || !m.trusted || !m.res.Alive() {
125 return125 return
126 }126 }
127 now := map[uint64]privateWorkspace{}127 now := map[uint64]privateWorkspace{}
@@ -176,7 +176,7 @@ func (m *captureManager) BeginSession(r *capturesession.NeferwlCaptureManagerV1,
176 stop := func(reason capturesession.NeferwlCaptureSessionV1StopReason) { res.SendStopped(uint32(reason)) }176 stop := func(reason capturesession.NeferwlCaptureSessionV1StopReason) { res.SendStopped(uint32(reason)) }
177 o := s.outputOf(out)177 o := s.outputOf(out)
178 switch {178 switch {
179- case !m.trusted:179+ case s.protected() || !m.trusted:
180 s.log.Info().Str("reason", string(ports.CaptureReasonUnauthorized)).Msg("capture session refused")180 s.log.Info().Str("reason", string(ports.CaptureReasonUnauthorized)).Msg("capture session refused")
181 stop(capturesession.NeferwlCaptureSessionV1StopReasonUnauthorized)181 stop(capturesession.NeferwlCaptureSessionV1StopReasonUnauthorized)
182 return182 return
@@ -221,7 +221,7 @@ func (m *captureManager) AttachSurface(r *capturesession.NeferwlCaptureManagerV1
221 // the same check): nothing else can attach.221 // the same check): nothing else can attach.
222 ps := s.private.session222 ps := s.private.session
223 switch {223 switch {
224- case !m.trusted: // the UID of this connection, checked once at bind224+ case s.protected() || !m.trusted: // DISPLAY protection and bind-time UID
225 att.fail(capturesession.NeferwlCaptureLayerV1FailureUnauthorized)225 att.fail(capturesession.NeferwlCaptureLayerV1FailureUnauthorized)
226 return226 return
227 case ps == nil || subtle.ConstantTimeCompare([]byte(token), []byte(ps.token)) != 1:227 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 {
133 if !ok {133 if !ok {
134 t.Fatal("output missing")134 t.Fatal("output missing")
135 }135 }
136- out, err := c.Registry().BindID(g.Name, g.Interface, 4)136+ out, err := bindWireID(c, g.Name, g.Interface, 4)
137 if err != nil {137 if err != nil {
138 t.Fatal(err)138 t.Fatal(err)
139 }139 }
140 registerProtocol(t, c, out)140 registerProtocol(t, c, out)
141 pc := &privateClient{c: c, output: out, manager: bindProtocol(t, c, "neferwl_capture_manager_v1"), mgr: &managerEvents{msgs: make(chan sessionMsg, 32)}}141 pc := &privateClient{c: c, output: out, manager: bindProtocol(t, c, "neferwl_capture_manager_v1"), mgr: &managerEvents{msgs: make(chan sessionMsg, 32)}}
142 pc.mgr.SetID(pc.manager)142 pc.mgr.SetID(pc.manager)
143- c.Context().Register(pc.mgr)143+ registerWireProxy(c, pc.mgr)
144 return pc144 return pc
145 }145 }
146 146
@@ -150,7 +150,7 @@ func (pc *privateClient) begin(t *testing.T, x, y, w, h int32, workspace uint64,
150 id := pc.c.AllocateID()150 id := pc.c.AllocateID()
151 ev := &sessionEvents{msgs: make(chan sessionMsg, 32)}151 ev := &sessionEvents{msgs: make(chan sessionMsg, 32)}
152 ev.SetID(id)152 ev.SetID(id)
153- pc.c.Context().Register(ev)153+ registerWireProxy(pc.c, ev)
154 requestProtocol(t, pc.c, pc.manager, capturesession.NeferwlCaptureManagerV1RequestBeginSession, id, pc.output, x, y, w, h, uint32(workspace>>32), uint32(workspace), record)154 requestProtocol(t, pc.c, pc.manager, capturesession.NeferwlCaptureManagerV1RequestBeginSession, id, pc.output, x, y, w, h, uint32(workspace>>32), uint32(workspace), record)
155 if err := pc.c.Roundtrip(); err != nil {155 if err := pc.c.Roundtrip(); err != nil {
156 t.Fatal(err)156 t.Fatal(err)
@@ -348,7 +348,7 @@ func (hd *hud) attachSurface(t *testing.T, token string, surface uint32) chan se
348 id := hd.c.AllocateID()348 id := hd.c.AllocateID()
349 ev := &attachEvents{msgs: make(chan sessionMsg, 8)}349 ev := &attachEvents{msgs: make(chan sessionMsg, 8)}
350 ev.SetID(id)350 ev.SetID(id)
351- hd.c.Context().Register(ev)351+ registerWireProxy(hd.c, ev)
352 requestProtocol(t, hd.c, hd.manager, capturesession.NeferwlCaptureManagerV1RequestAttachSurface, id, token, surface)352 requestProtocol(t, hd.c, hd.manager, capturesession.NeferwlCaptureManagerV1RequestAttachSurface, id, token, surface)
353 if err := hd.c.Roundtrip(); err != nil {353 if err := hd.c.Roundtrip(); err != nil {
354 t.Fatal(err)354 t.Fatal(err)
@@ -452,7 +452,7 @@ func mapLayer(t *testing.T, hd *hud) {
452 requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit)452 requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit)
453 cfg := &layerProxy{configured: make(chan [3]uint32, 4)}453 cfg := &layerProxy{configured: make(chan [3]uint32, 4)}
454 cfg.SetID(hd.layer)454 cfg.SetID(hd.layer)
455- hd.c.Context().Register(cfg)455+ registerWireProxy(hd.c, cfg)
456 if err := hd.c.Roundtrip(); err != nil {456 if err := hd.c.Roundtrip(); err != nil {
457 t.Fatal(err)457 t.Fatal(err)
458 }458 }
@@ -480,7 +480,7 @@ func TestPrivateCaptureStopClosesLayers(t *testing.T) {
480 closed := make(chan sessionMsg, 4)480 closed := make(chan sessionMsg, 4)
481 lp := &layerClosedProxy{msgs: closed}481 lp := &layerClosedProxy{msgs: closed}
482 lp.SetID(hd.layer)482 lp.SetID(hd.layer)
483- hd.c.Context().Register(lp)483+ registerWireProxy(hd.c, lp)
484 confirmed := hd.attach(t, token)484 confirmed := hd.attach(t, token)
485 layer := nextEvent[ports.CaptureSessionLayer](t, h.events)485 layer := nextEvent[ports.CaptureSessionLayer](t, h.events)
486 h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 2, Layers: []ports.WindowID{layer.Layer}})486 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
611 frame := pc.c.AllocateID()611 frame := pc.c.AllocateID()
612 ev := &captureEvents{events: make(chan uint16, 16)}612 ev := &captureEvents{events: make(chan uint16, 16)}
613 ev.SetID(frame)613 ev.SetID(frame)
614- pc.c.Context().Register(ev)614+ registerWireProxy(pc.c, ev)
615 requestProtocol(t, pc.c, mgr, wlr.ZwlrScreencopyManagerV1RequestCaptureOutput, frame, int32(0), pc.output)615 requestProtocol(t, pc.c, mgr, wlr.ZwlrScreencopyManagerV1RequestCaptureOutput, frame, int32(0), pc.output)
616 requestProtocol(t, pc.c, frame, wlr.ZwlrScreencopyFrameV1RequestCopy, buf)616 requestProtocol(t, pc.c, frame, wlr.ZwlrScreencopyFrameV1RequestCopy, buf)
617 if err := pc.c.Roundtrip(); err != nil {617 if err := pc.c.Roundtrip(); err != nil {
@@ -761,7 +761,7 @@ func TestPrivateCaptureLayerMovedBelowTop(t *testing.T) {
761 closed := make(chan sessionMsg, 4)761 closed := make(chan sessionMsg, 4)
762 lp := &layerClosedProxy{msgs: closed}762 lp := &layerClosedProxy{msgs: closed}
763 lp.SetID(hd.layer)763 lp.SetID(hd.layer)
764- hd.c.Context().Register(lp)764+ registerWireProxy(hd.c, lp)
765 requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetLayer, uint32(ports.LayerBottom))765 requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetLayer, uint32(ports.LayerBottom))
766 requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit)766 requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit)
767 if err := hd.c.Roundtrip(); err != nil {767 if err := hd.c.Roundtrip(); err != nil {
M internal/adapters/wayland/clipboard.go +22 -3
@@ -15,6 +15,11 @@ import (
15 // both without focus. Like wlroots, any client may bind it. Pasting hands the reader's pipe to the source client:15 // both without focus. Like wlroots, any client may bind it. Pasting hands the reader's pipe to the source client:
16 // the data never goes through neferwl. Drag and drop is not implemented.16 // the data never goes through neferwl. Drag and drop is not implemented.
17 17
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+
18 const (23 const (
19 selClipboard = iota24 selClipboard = iota
20 selPrimary25 selPrimary
@@ -89,6 +94,12 @@ func (s *Server) focusedClient() (server.Client, bool) {
89 // setSelection makes src the selection of kind and announces it. A94 // setSelection makes src the selection of kind and announces it. A
90 // replaced source is cancelled.95 // replaced source is cancelled.
91 func (s *Server) setSelection(kind int, src *clipSource) {96 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+ }
92 old := s.selections[kind]103 old := s.selections[kind]
93 if old == src {104 if old == src {
94 return105 return
@@ -109,6 +120,9 @@ func (s *Server) announceAll(kind int) {
109 120
110 // announce sends the selection of kind to the devices matching want.121 // announce sends the selection of kind to the devices matching want.
111 func (s *Server) announce(kind int, want func(*clipDevice) bool) {122 func (s *Server) announce(kind int, want func(*clipDevice) bool) {
123+ if s.protected() {
124+ return
125+ }
112 src := s.selections[kind]126 src := s.selections[kind]
113 if src != nil && !src.alive() {127 if src != nil && !src.alive() {
114 src, s.selections[kind] = nil, nil128 src, s.selections[kind] = nil, nil
@@ -142,6 +156,9 @@ func (s *Server) focusSelections() {
142 // addClipDevice tracks a device and sends it the selections it may see.156 // addClipDevice tracks a device and sends it the selections it may see.
143 func (s *Server) addClipDevice(d *clipDevice) {157 func (s *Server) addClipDevice(d *clipDevice) {
144 s.clipDevices = append(s.clipDevices, d)158 s.clipDevices = append(s.clipDevices, d)
159+ if s.protected() {
160+ return
161+ }
145 c, ok := s.focusedClient()162 c, ok := s.focusedClient()
146 if !d.control && (!ok || d.client != c) {163 if !d.control && (!ok || d.client != c) {
147 return164 return
@@ -170,7 +187,7 @@ func (s *Server) sourceGone(src *clipSource) {
170 // focused one. Any client holding an offer may read it.187 // focused one. Any client holding an offer may read it.
171 func (s *Server) maySet(c server.Client) bool {188 func (s *Server) maySet(c server.Client) bool {
172 f, ok := s.focusedClient()189 f, ok := s.focusedClient()
173- return ok && f == c190+ return !s.protected() && ok && f == c
174 }191 }
175 192
176 // setFocused sets a selection from a focus-bound device. A used source is193 // 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) {
193 }210 }
194 211
195 // receive hands the reader's pipe to the source if it is still the212 // 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.
197 func (s *Server) receive(kind int, src *clipSource, mime string, fd int) {216 func (s *Server) receive(kind int, src *clipSource, mime string, fd int) {
198 defer unix.Close(fd)217 defer unix.Close(fd)
199- if s.selections[kind] != src || !src.alive() {218+ if s.protected() || s.selections[kind] != src || !src.alive() {
200 return219 return
201 }220 }
202 for _, m := range src.mimes {221 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) {
52 func registerOffer(c *wlturbo.Display, id uint32) {52 func registerOffer(c *wlturbo.Display, id uint32) {
53 p := &protocolProxy{}53 p := &protocolProxy{}
54 p.SetID(id)54 p.SetID(id)
55- c.Context().Register(p)55+ registerWireProxy(c, p)
56 }56 }
57 57
58 // clipClient is a client with a toplevel, a data device and a pointer to58 // 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
70 seat := bindProtocol(t, c, "wl_seat")70 seat := bindProtocol(t, c, "wl_seat")
71 registerProtocol(t, c, seat)71 registerProtocol(t, c, seat)
72 g, _ := c.Registry().FindGlobal("wl_data_device_manager")72 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)
74 if err != nil {74 if err != nil {
75 t.Fatal(err)75 t.Fatal(err)
76 }76 }
77 device := &clipEvents{c: c, offers: make(chan uint32, 8)}77 device := &clipEvents{c: c, offers: make(chan uint32, 8)}
78 device.SetID(c.AllocateID())78 device.SetID(c.AllocateID())
79- c.Context().Register(device)79+ registerWireProxy(c, device)
80 requestProtocol(t, c, manager, wayland.DataDeviceManagerRequestGetDataDevice, device.ID(), seat)80 requestProtocol(t, c, manager, wayland.DataDeviceManagerRequestGetDataDevice, device.ID(), seat)
81 w := toplevelMapper(t, c, events)()81 w := toplevelMapper(t, c, events)()
82 return &clipClient{c: c, manager: manager, device: device, window: w.ID}82 return &clipClient{c: c, manager: manager, device: device, window: w.ID}
@@ -100,7 +100,7 @@ func (cc *clipClient) copy(t *testing.T) *sourceEvents {
100 t.Helper()100 t.Helper()
101 src := &sourceEvents{sends: make(chan int, 4), cancelled: make(chan struct{}, 4)}101 src := &sourceEvents{sends: make(chan int, 4), cancelled: make(chan struct{}, 4)}
102 src.SetID(cc.c.AllocateID())102 src.SetID(cc.c.AllocateID())
103- cc.c.Context().Register(src)103+ registerWireProxy(cc.c, src)
104 requestProtocol(t, cc.c, cc.manager, wayland.DataDeviceManagerRequestCreateDataSource, src.ID())104 requestProtocol(t, cc.c, cc.manager, wayland.DataDeviceManagerRequestCreateDataSource, src.ID())
105 requestProtocol(t, cc.c, src.ID(), wayland.DataSourceRequestOffer, "text/plain")105 requestProtocol(t, cc.c, src.ID(), wayland.DataSourceRequestOffer, "text/plain")
106 requestProtocol(t, cc.c, cc.device.ID(), wayland.DataDeviceRequestSetSelection, src.ID(), uint32(0))106 requestProtocol(t, cc.c, cc.device.ID(), wayland.DataDeviceRequestSetSelection, src.ID(), uint32(0))
@@ -161,7 +161,7 @@ func TestClipboardFollowsFocus(t *testing.T) {
161 t.Fatal(err)161 t.Fatal(err)
162 }162 }
163 defer unix.Close(fds[0])163 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 {
165 t.Fatal(err)165 t.Fatal(err)
166 }166 }
167 _ = unix.Close(fds[1])167 _ = unix.Close(fds[1])
@@ -208,7 +208,7 @@ func TestDataControlSeesSelection(t *testing.T) {
208 offers := make(chan uint32, 8)208 offers := make(chan uint32, 8)
209 dev := &controlEvents{c: m, offers: offers}209 dev := &controlEvents{c: m, offers: offers}
210 dev.SetID(m.AllocateID())210 dev.SetID(m.AllocateID())
211- m.Context().Register(dev)211+ registerWireProxy(m, dev)
212 requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), seat)212 requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), seat)
213 next := func() uint32 {213 next := func() uint32 {
214 t.Helper()214 t.Helper()
@@ -294,7 +294,7 @@ func TestDataControlUsedSource(t *testing.T) {
294 manager := bindProtocol(t, m, "ext_data_control_manager_v1")294 manager := bindProtocol(t, m, "ext_data_control_manager_v1")
295 dev := &controlEvents{c: m, offers: make(chan uint32, 8)}295 dev := &controlEvents{c: m, offers: make(chan uint32, 8)}
296 dev.SetID(m.AllocateID())296 dev.SetID(m.AllocateID())
297- m.Context().Register(dev)297+ registerWireProxy(m, dev)
298 requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), seat)298 requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), seat)
299 src := m.AllocateID()299 src := m.AllocateID()
300 registerProtocol(t, m, src)300 registerProtocol(t, m, src)
@@ -330,7 +330,7 @@ func TestPrimarySelectionFromManager(t *testing.T) {
330 registerProtocol(t, a.c, seat)330 registerProtocol(t, a.c, seat)
331 pdev := &primaryEvents{c: a.c, offers: make(chan uint32, 8)}331 pdev := &primaryEvents{c: a.c, offers: make(chan uint32, 8)}
332 pdev.SetID(a.c.AllocateID())332 pdev.SetID(a.c.AllocateID())
333- a.c.Context().Register(pdev)333+ registerWireProxy(a.c, pdev)
334 requestProtocol(t, a.c, pm, primaryselection.ZwpPrimarySelectionDeviceManagerV1RequestGetDevice, pdev.ID(), seat)334 requestProtocol(t, a.c, pm, primaryselection.ZwpPrimarySelectionDeviceManagerV1RequestGetDevice, pdev.ID(), seat)
335 a.focus(t, commands)335 a.focus(t, commands)
336 336
@@ -340,7 +340,7 @@ func TestPrimarySelectionFromManager(t *testing.T) {
340 manager := bindProtocol(t, m, "ext_data_control_manager_v1")340 manager := bindProtocol(t, m, "ext_data_control_manager_v1")
341 dev := &controlEvents{c: m, offers: make(chan uint32, 8)}341 dev := &controlEvents{c: m, offers: make(chan uint32, 8)}
342 dev.SetID(m.AllocateID())342 dev.SetID(m.AllocateID())
343- m.Context().Register(dev)343+ registerWireProxy(m, dev)
344 requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), mseat)344 requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), mseat)
345 src := m.AllocateID()345 src := m.AllocateID()
346 registerProtocol(t, m, src)346 registerProtocol(t, m, src)
M internal/adapters/wayland/colormanagement_errors_test.go +1 -1
@@ -72,7 +72,7 @@ func TestColorSurfaceErrors(t *testing.T) {
72 global = name72 global = name
73 }73 }
74 }74 }
75- out, err := c.Registry().BindID(global, "wl_output", 4)75+ out, err := bindWireID(c, global, "wl_output", 4)
76 if err != nil {76 if err != nil {
77 t.Fatal(err)77 t.Fatal(err)
78 }78 }
M internal/adapters/wayland/colormanagement_test.go +2 -2
@@ -31,7 +31,7 @@ func (p *colorEvent) Dispatch(e *wlturbo.Event) {
31 func watchColor(c *wlturbo.Display, id uint32) *colorEvent {31 func watchColor(c *wlturbo.Display, id uint32) *colorEvent {
32 p := &colorEvent{}32 p := &colorEvent{}
33 p.SetID(id)33 p.SetID(id)
34- c.Context().Register(p)34+ registerWireProxy(c, p)
35 return p35 return p
36 }36 }
37 func colorServer(t *testing.T) (*Server, *wlturbo.Display, chan ports.OutputFormats) {37 func colorServer(t *testing.T) (*Server, *wlturbo.Display, chan ports.OutputFormats) {
@@ -205,7 +205,7 @@ func TestColorOutputAndFeedback(t *testing.T) {
205 name = id205 name = id
206 }206 }
207 }207 }
208- out, err := c.Registry().BindID(name, "wl_output", 4)208+ out, err := bindWireID(c, name, "wl_output", 4)
209 if err != nil {209 if err != nil {
210 t.Fatal(err)210 t.Fatal(err)
211 }211 }
M internal/adapters/wayland/commit_hotpath_bench_test.go +8 -5
@@ -31,7 +31,10 @@ func BenchmarkProtocolQueuedCommit(b *testing.B) {
31 if err != nil {31 if err != nil {
32 b.Fatal(err)32 b.Fatal(err)
33 }33 }
34- defer c.Close()34+ defer func() {
35+ _ = c.Close()
36+ forgetWireClient(c)
37+ }()
35 if err := c.Roundtrip(); err != nil {38 if err := c.Roundtrip(); err != nil {
36 b.Fatal(err)39 b.Fatal(err)
37 }40 }
@@ -40,14 +43,14 @@ func BenchmarkProtocolQueuedCommit(b *testing.B) {
40 if !ok {43 if !ok {
41 b.Fatalf("missing %s", name)44 b.Fatalf("missing %s", name)
42 }45 }
43- id, err := c.Registry().BindID(g.Name, g.Interface, 1)46+ id, err := bindWireID(c, g.Name, g.Interface, 1)
44 if err != nil {47 if err != nil {
45 b.Fatal(err)48 b.Fatal(err)
46 }49 }
47 return id50 return id
48 }51 }
49 send := func(id, op uint32, args ...any) {52 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 {
51 b.Fatal(err)54 b.Fatal(err)
52 }55 }
53 }56 }
@@ -56,7 +59,7 @@ func BenchmarkProtocolQueuedCommit(b *testing.B) {
56 register := func(id uint32) {59 register := func(id uint32) {
57 p := &protocolProxy{}60 p := &protocolProxy{}
58 p.SetID(id)61 p.SetID(id)
59- c.Context().Register(p)62+ registerWireProxy(c, p)
60 }63 }
61 register(shm)64 register(shm)
62 surf := c.AllocateID()65 surf := c.AllocateID()
@@ -80,7 +83,7 @@ func BenchmarkProtocolQueuedCommit(b *testing.B) {
80 pool, buf := c.AllocateID(), c.AllocateID()83 pool, buf := c.AllocateID(), c.AllocateID()
81 register(pool)84 register(pool)
82 register(buf)85 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 {
84 b.Fatal(err)87 b.Fatal(err)
85 }88 }
86 send(pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(0))89 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 }
52 func (pointerWarp) Destroy(*pointerwarp.WpPointerWarpV1) {}52 func (pointerWarp) Destroy(*pointerwarp.WpPointerWarpV1) {}
53 func (h pointerWarp) WarpPointer(r *pointerwarp.WpPointerWarpV1, surf *wayland.Surface, p *wayland.Pointer, x, y server.Fixed, serial uint32) {53 func (h pointerWarp) WarpPointer(r *pointerwarp.WpPointerWarpV1, surf *wayland.Surface, p *wayland.Pointer, x, y server.Fixed, serial uint32) {
54 s := h.server54 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() {
56 return56 return
57 }57 }
58 if enter, ok := s.seat.enters[p.Resource]; !ok || enter != serial {58 if enter, ok := s.seat.enters[p.Resource]; !ok || enter != serial {
@@ -75,7 +75,7 @@ func (relativeHandler) Destroy(*relativepointer.ZwpRelativePointerV1) {}
75 // pointers. Absolute devices have no deltas and send nothing.75 // pointers. Absolute devices have no deltas and send nothing.
76 // It reports whether any event was sent.76 // It reports whether any event was sent.
77 func (s *Server) relativeMotion(w *window, c ports.PointerMotionTo) bool {77 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 {
79 return false79 return false
80 }80 }
81 if !w.mapped || !w.xdg.resource.Resource.Alive() {81 if !w.mapped || !w.xdg.resource.Resource.Alive() {
@@ -142,7 +142,7 @@ func (m constraintManager) ConfinePointer(r *pointerconstraints.ZwpPointerConstr
142 // create validates the request and registers the constraint on the surface.142 // create validates the request and registers the constraint on the surface.
143 func (m constraintManager) create(r *pointerconstraints.ZwpPointerConstraintsV1, surf *wayland.Surface, reg *wayland.Region, lifetime uint32) *constraint {143 func (m constraintManager) create(r *pointerconstraints.ZwpPointerConstraintsV1, surf *wayland.Surface, reg *wayland.Region, lifetime uint32) *constraint {
144 s := m.server144 s := m.server
145- if surf == nil {145+ if s.protected() || surf == nil {
146 return nil146 return nil
147 }147 }
148 state := s.surfaces[surf.Resource]148 state := s.surfaces[surf.Resource]
@@ -263,6 +263,10 @@ func (s *Server) deactivateConstraint() {
263 // updateConstraint activates the constraint of the window holding both263 // updateConstraint activates the constraint of the window holding both
264 // pointer and keyboard focus, and deactivates any other.264 // pointer and keyboard focus, and deactivates any other.
265 func (s *Server) updateConstraint() {265 func (s *Server) updateConstraint() {
266+ if s.protected() {
267+ s.deactivateConstraint()
268+ return
269+ }
266 var want *constraint270 var want *constraint
267 if w := s.windows[s.seat.pointerFocus]; w != nil && w.mapped && s.seat.pointerFocus == s.seat.focused {271 if w := s.windows[s.seat.pointerFocus]; w != nil && w.mapped && s.seat.pointerFocus == s.seat.focused {
268 if c := s.constraints[w.xdg.surface]; c != nil && !c.defunct && (c.active || c.contains(w)) {272 if c := s.constraints[w.xdg.surface]; c != nil && !c.defunct && (c.active || c.contains(w)) {
@@ -288,6 +292,9 @@ func (s *Server) updateConstraint() {
288 292
289 // emitConstraint tells core about the active constraint, window-local.293 // emitConstraint tells core about the active constraint, window-local.
290 func (s *Server) emitConstraint(c *constraint) {294 func (s *Server) emitConstraint(c *constraint) {
295+ if s.protected() {
296+ return
297+ }
291 x := c.surface.xdg298 x := c.surface.xdg
292 if x == nil || x.window == nil {299 if x == nil || x.window == nil {
293 return300 return
@@ -353,5 +360,5 @@ func (c *constraint) contains(w *window) bool {
353 360
354 // locked reports whether the window's pointer is locked.361 // locked reports whether the window's pointer is locked.
355 func (s *Server) locked(w *window) bool {362 func (s *Server) locked(w *window) bool {
356- return s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.xdg.surface363+ return !s.protected() && s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.xdg.surface
357 }364 }
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 {
39 t.Helper()39 t.Helper()
40 p := &eventLog{events: make(chan []float64, 64)}40 p := &eventLog{events: make(chan []float64, 64)}
41 p.SetID(id)41 p.SetID(id)
42- c.Context().Register(p)42+ registerWireProxy(c, p)
43 return p.events43 return p.events
44 }44 }
45 45
@@ -80,7 +80,7 @@ func TestPointerWarp(t *testing.T) {
80 pointer := c.AllocateID()80 pointer := c.AllocateID()
81 p := &eventLog{events: make(chan []float64, 64), pointer: true}81 p := &eventLog{events: make(chan []float64, 64), pointer: true}
82 p.SetID(pointer)82 p.SetID(pointer)
83- c.Context().Register(p)83+ registerWireProxy(c, p)
84 requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)84 requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
85 w, surf, xdgID := surfaceMapper(t, c, events)()85 w, surf, xdgID := surfaceMapper(t, c, events)()
86 requestProtocol(t, c, xdgID, xdgshell.SurfaceRequestSetWindowGeometry, int32(4), int32(6), int32(50), int32(50))86 requestProtocol(t, c, xdgID, xdgshell.SurfaceRequestSetWindowGeometry, int32(4), int32(6), int32(50), int32(50))
@@ -100,7 +100,7 @@ func TestPointerWarp(t *testing.T) {
100 pointer2 := c.AllocateID()100 pointer2 := c.AllocateID()
101 p2 := &eventLog{events: make(chan []float64, 64), pointer: true}101 p2 := &eventLog{events: make(chan []float64, 64), pointer: true}
102 p2.SetID(pointer2)102 p2.SetID(pointer2)
103- c.Context().Register(p2)103+ registerWireProxy(c, p2)
104 requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer2)104 requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer2)
105 requestProtocol(t, c, warp, pointerwarp.WpPointerWarpV1RequestWarpPointer, surf, pointer2, wlturbo.Fixed(10*256), wlturbo.Fixed(10*256), serial)105 requestProtocol(t, c, warp, pointerwarp.WpPointerWarpV1RequestWarpPointer, surf, pointer2, wlturbo.Fixed(10*256), wlturbo.Fixed(10*256), serial)
106 // A stale serial is ignored; the enter serial moves the pointer.106 // 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
59 return59 return
60 }60 }
61 s := d.server61 s := d.server
62- if !s.hasPointerFocus(r.Client()) {62+ if s.protected() || !s.hasPointerFocus(r.Client()) {
63 return63 return
64 }64 }
65 s.cursorSurface = nil65 s.cursorSurface = nil
@@ -70,6 +70,9 @@ type pointer struct{ server *Server }
70 70
71 func (p pointer) SetCursor(r *wayland.Pointer, _ uint32, surf *wayland.Surface, hotX, hotY int32) {71 func (p pointer) SetCursor(r *wayland.Pointer, _ uint32, surf *wayland.Surface, hotX, hotY int32) {
72 s := p.server72 s := p.server
73+ if s.protected() {
74+ return
75+ }
73 var state *surface76 var state *surface
74 if surf != nil {77 if surf != nil {
75 state = s.surfaces[surf.Resource]78 state = s.surfaces[surf.Resource]
@@ -137,7 +140,7 @@ func (s *surface) cursorImage() ports.CursorChange {
137 140
138 // setCursor queues a cursor change; only the latest is delivered.141 // setCursor queues a cursor change; only the latest is delivered.
139 func (s *Server) setCursor(c ports.CursorChange) {142 func (s *Server) setCursor(c ports.CursorChange) {
140- if s.channels.Cursors == nil {143+ if s.protected() || s.channels.Cursors == nil {
141 return144 return
142 }145 }
143 s.cursorMu.Lock()146 s.cursorMu.Lock()
@@ -163,7 +166,7 @@ func (s *Server) forwardCursors(ctx context.Context) {
163 c, ok := s.cursorLatest, s.cursorQueued166 c, ok := s.cursorLatest, s.cursorQueued
164 s.cursorQueued = false167 s.cursorQueued = false
165 s.cursorMu.Unlock()168 s.cursorMu.Unlock()
166- if !ok {169+ if !ok || s.protected() {
167 continue170 continue
168 }171 }
169 select {172 select {
M internal/adapters/wayland/cursor_test.go +1 -1
@@ -147,7 +147,7 @@ func TestSetCursorSurface(t *testing.T) {
147 pool, buf := c.AllocateID(), c.AllocateID()147 pool, buf := c.AllocateID(), c.AllocateID()
148 registerProtocol(t, c, pool)148 registerProtocol(t, c, pool)
149 registerProtocol(t, c, buf)149 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 {
151 t.Fatal(err)151 t.Fatal(err)
152 }152 }
153 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(2), int32(1), int32(12), uint32(wayland.ShmFormatArgb8888))153 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) {
26 serials := make(chan uint32, 8)26 serials := make(chan uint32, 8)
27 xp := &configureProxy{serial: serials}27 xp := &configureProxy{serial: serials}
28 xp.SetID(xdg)28 xp.SetID(xdg)
29- c.Context().Register(xp)29+ registerWireProxy(c, xp)
30 registerProtocol(t, c, top)30 registerProtocol(t, c, top)
31 requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)31 requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
32 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)32 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
@@ -44,7 +44,7 @@ func TestDamageHistory(t *testing.T) {
44 }44 }
45 pool := c.AllocateID()45 pool := c.AllocateID()
46 registerProtocol(t, c, pool)46 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 {
48 t.Fatal(err)48 t.Fatal(err)
49 }49 }
50 bufs := [2]uint32{c.AllocateID(), c.AllocateID()}50 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) {
31 deco := c.AllocateID()31 deco := c.AllocateID()
32 proxy := &kdeModeProxy{modes: make(chan uint32, 8)}32 proxy := &kdeModeProxy{modes: make(chan uint32, 8)}
33 proxy.SetID(deco)33 proxy.SetID(deco)
34- c.Context().Register(proxy)34+ registerWireProxy(c, proxy)
35 requestProtocol(t, c, mgr, kdedecoration.OrgKdeKwinServerDecorationManagerRequestCreate, deco, surf)35 requestProtocol(t, c, mgr, kdedecoration.OrgKdeKwinServerDecorationManagerRequestCreate, deco, surf)
36 requestProtocol(t, c, deco, kdedecoration.OrgKdeKwinServerDecorationRequestRequestMode, uint32(kdedecoration.OrgKdeKwinServerDecorationModeClient))36 requestProtocol(t, c, deco, kdedecoration.OrgKdeKwinServerDecorationRequestRequestMode, uint32(kdedecoration.OrgKdeKwinServerDecorationModeClient))
37 // An undefined mode gets no answer.37 // An undefined mode gets no answer.
M internal/adapters/wayland/dmabuf_test.go +8 -8
@@ -66,7 +66,7 @@ func TestDMABufParams(t *testing.T) {
66 if !ok || g.Version != dmabufVersion {66 if !ok || g.Version != dmabufVersion {
67 t.Fatalf("global %+v", g)67 t.Fatalf("global %+v", g)
68 }68 }
69- dm, err := c.Registry().BindID(g.Name, g.Interface, dmabufVersion)69+ dm, err := bindWireID(c, g.Name, g.Interface, dmabufVersion)
70 if err != nil {70 if err != nil {
71 t.Fatal(err)71 t.Fatal(err)
72 }72 }
@@ -83,8 +83,8 @@ func TestDMABufParams(t *testing.T) {
83 requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, p)83 requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, p)
84 proxy := &paramsProxy{result: make(chan uint16, 2)}84 proxy := &paramsProxy{result: make(chan uint16, 2)}
85 proxy.SetID(p)85 proxy.SetID(p)
86- c.Context().Register(proxy)86+ registerWireProxy(c, proxy)
87- if err := c.SendRequestWithFDs(p, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, uint32(0), uint32(0), uint32(256), uint32(0), uint32(0)); err != nil {87+ if err := wireRequest(c, p, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, uint32(0), uint32(0), uint32(256), uint32(0), uint32(0)); err != nil {
88 t.Fatal(err)88 t.Fatal(err)
89 }89 }
90 requestProtocol(t, c, p, linuxdmabuf.ZwpLinuxBufferParamsV1RequestCreate, int32(64), height, format, uint32(0))90 requestProtocol(t, c, p, linuxdmabuf.ZwpLinuxBufferParamsV1RequestCreate, int32(64), height, format, uint32(0))
@@ -106,7 +106,7 @@ func TestDMABufParams(t *testing.T) {
106 // Taller than the file: out of bounds.106 // Taller than the file: out of bounds.
107 big := c.AllocateID()107 big := c.AllocateID()
108 requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, big)108 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 {
110 t.Fatal(err)110 t.Fatal(err)
111 }111 }
112 requestProtocol(t, c, big, linuxdmabuf.ZwpLinuxBufferParamsV1RequestCreate, int32(64), int32(17), linearARGB.Format, uint32(0))112 requestProtocol(t, c, big, linuxdmabuf.ZwpLinuxBufferParamsV1RequestCreate, int32(64), int32(17), linearARGB.Format, uint32(0))
@@ -118,7 +118,7 @@ func TestDMABufParamsReuse(t *testing.T) {
118 s, _, _, dir := dmabufServer(t, ports.DMABufSupport{Formats: []ports.DMABufFormat{linearARGB}})118 s, _, _, dir := dmabufServer(t, ports.DMABufSupport{Formats: []ports.DMABufFormat{linearARGB}})
119 c := protocolClient(t, s, dir)119 c := protocolClient(t, s, dir)
120 g, _ := c.Registry().FindGlobal("zwp_linux_dmabuf_v1")120 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)
122 if err != nil {122 if err != nil {
123 t.Fatal(err)123 t.Fatal(err)
124 }124 }
@@ -134,8 +134,8 @@ func TestDMABufParamsReuse(t *testing.T) {
134 requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, p)134 requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, p)
135 proxy := &paramsProxy{result: make(chan uint16, 2)}135 proxy := &paramsProxy{result: make(chan uint16, 2)}
136 proxy.SetID(p)136 proxy.SetID(p)
137- c.Context().Register(proxy)137+ registerWireProxy(c, proxy)
138- if err := c.SendRequestWithFDs(p, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, uint32(0), uint32(0), uint32(256), uint32(0), uint32(0)); err != nil {138+ if err := wireRequest(c, p, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, uint32(0), uint32(0), uint32(256), uint32(0), uint32(0)); err != nil {
139 t.Fatal(err)139 t.Fatal(err)
140 }140 }
141 requestProtocol(t, c, p, linuxdmabuf.ZwpLinuxBufferParamsV1RequestCreate, int32(64), int32(16), linearARGB.Format, uint32(0))141 requestProtocol(t, c, p, linuxdmabuf.ZwpLinuxBufferParamsV1RequestCreate, int32(64), int32(16), linearARGB.Format, uint32(0))
@@ -232,7 +232,7 @@ func TestDMABufScanoutTranche(t *testing.T) {
232 fb := c.AllocateID()232 fb := c.AllocateID()
233 proxy := &feedbackProxy{rounds: make(chan []tranche, 8)}233 proxy := &feedbackProxy{rounds: make(chan []tranche, 8)}
234 proxy.SetID(fb)234 proxy.SetID(fb)
235- c.Context().Register(proxy)235+ registerWireProxy(c, proxy)
236 requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestGetSurfaceFeedback, fb, surf)236 requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestGetSurfaceFeedback, fb, surf)
237 round := func(what string) []tranche {237 round := func(what string) []tranche {
238 t.Helper()238 t.Helper()
M internal/adapters/wayland/dmabuf_yuv_test.go +2 -2
@@ -32,7 +32,7 @@ func TestYUVParams(t *testing.T) {
32 requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, p)32 requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, p)
33 proxy := &paramsProxy{result: make(chan uint16, 2)}33 proxy := &paramsProxy{result: make(chan uint16, 2)}
34 proxy.SetID(p)34 proxy.SetID(p)
35- c.Context().Register(proxy)35+ registerWireProxy(c, proxy)
36 fd, err := unix.MemfdCreate("yuv-params", 0)36 fd, err := unix.MemfdCreate("yuv-params", 0)
37 if err != nil {37 if err != nil {
38 t.Fatal(err)38 t.Fatal(err)
@@ -43,7 +43,7 @@ func TestYUVParams(t *testing.T) {
43 }43 }
44 add := func(idx, offset, stride, mod uint32) {44 add := func(idx, offset, stride, mod uint32) {
45 t.Helper()45 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 {
47 t.Fatal(err)47 t.Fatal(err)
48 }48 }
49 }49 }
M internal/adapters/wayland/drmlease.go +8 -1
@@ -90,13 +90,16 @@ func (s *Server) applyLeaseEvent(event ports.LeaseMessage) {
90 card := s.pendingLeaseCards[v.ID]90 card := s.pendingLeaseCards[v.ID]
91 delete(s.pendingLeases, v.ID)91 delete(s.pendingLeases, v.ID)
92 delete(s.pendingLeaseCards, v.ID)92 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 {
94 if v.FD != nil {94 if v.FD != nil {
95 v.FD.Close()95 v.FD.Close()
96 }96 }
97 if v.LeaseID != 0 && card != "" {97 if v.LeaseID != 0 && card != "" {
98 s.sendLeaseRequest(ports.LeaseRevoke{Card: card, LeaseID: v.LeaseID})98 s.sendLeaseRequest(ports.LeaseRevoke{Card: card, LeaseID: v.LeaseID})
99 }99 }
100+ if obj != nil {
101+ obj.finish()
102+ }
100 return103 return
101 }104 }
102 if v.Err != nil || v.FD == nil {105 if v.Err != nil || v.FD == nil {
@@ -316,6 +319,10 @@ func (r *leaseRequestHandler) Submit(self *drmlease.WpDrmLeaseRequestV1, id uint
316 }319 }
317 obj.res = res320 obj.res = res
318 res.OnDestroy = func() { obj.destroy() }321 res.OnDestroy = func() { obj.destroy() }
322+ if s.security != nil && s.security.Snapshot().Protected {
323+ obj.finish()
324+ return
325+ }
319 for _, name := range r.names {326 for _, name := range r.names {
320 if _, ok := r.bind.device.connectors[name]; !ok || r.withdrawn {327 if _, ok := r.bind.device.connectors[name]; !ok || r.withdrawn {
321 obj.finish()328 obj.finish()
M internal/adapters/wayland/drmlease_test.go +10 -10
@@ -31,7 +31,7 @@ func (p *leaseDeviceEvents) Dispatch(e *wlturbo.Event) {
31 p.connectors = append(p.connectors, id)31 p.connectors = append(p.connectors, id)
32 child := &leaseConnectorEvents{}32 child := &leaseConnectorEvents{}
33 child.SetID(id)33 child.SetID(id)
34- p.c.Context().Register(child)34+ registerWireProxy(p.c, child)
35 }35 }
36 }36 }
37 37
@@ -117,7 +117,7 @@ func TestDRMLeaseFlow(t *testing.T) {
117 dev := bindProtocol(t, c, "wp_drm_lease_device_v1")117 dev := bindProtocol(t, c, "wp_drm_lease_device_v1")
118 p := &leaseDeviceEvents{c: c}118 p := &leaseDeviceEvents{c: c}
119 p.SetID(dev)119 p.SetID(dev)
120- c.Context().Register(p)120+ registerWireProxy(c, p)
121 if err := c.Roundtrip(); err != nil {121 if err := c.Roundtrip(); err != nil {
122 t.Fatal(err)122 t.Fatal(err)
123 }123 }
@@ -133,7 +133,7 @@ func TestDRMLeaseFlow(t *testing.T) {
133 lease := c.AllocateID()133 lease := c.AllocateID()
134 l := &leaseResultEvents{}134 l := &leaseResultEvents{}
135 l.SetID(lease)135 l.SetID(lease)
136- c.Context().Register(l)136+ registerWireProxy(c, l)
137 requestProtocol(t, c, req, dl.WpDrmLeaseRequestV1RequestSubmit, lease)137 requestProtocol(t, c, req, dl.WpDrmLeaseRequestV1RequestSubmit, lease)
138 if err := c.Roundtrip(); err != nil {138 if err := c.Roundtrip(); err != nil {
139 t.Fatal(err)139 t.Fatal(err)
@@ -194,7 +194,7 @@ func TestDRMLeaseErrors(t *testing.T) {
194 dev := bindProtocol(t, c, "wp_drm_lease_device_v1")194 dev := bindProtocol(t, c, "wp_drm_lease_device_v1")
195 p := &leaseDeviceEvents{c: c}195 p := &leaseDeviceEvents{c: c}
196 p.SetID(dev)196 p.SetID(dev)
197- c.Context().Register(p)197+ registerWireProxy(c, p)
198 if err := c.Roundtrip(); err != nil {198 if err := c.Roundtrip(); err != nil {
199 t.Fatal(err)199 t.Fatal(err)
200 }200 }
@@ -222,7 +222,7 @@ func TestDRMLeaseFinished(t *testing.T) {
222 dev := bindProtocol(t, c, "wp_drm_lease_device_v1")222 dev := bindProtocol(t, c, "wp_drm_lease_device_v1")
223 p := &leaseDeviceEvents{c: c}223 p := &leaseDeviceEvents{c: c}
224 p.SetID(dev)224 p.SetID(dev)
225- c.Context().Register(p)225+ registerWireProxy(c, p)
226 if err := c.Roundtrip(); err != nil {226 if err := c.Roundtrip(); err != nil {
227 t.Fatal(err)227 t.Fatal(err)
228 }228 }
@@ -234,7 +234,7 @@ func TestDRMLeaseFinished(t *testing.T) {
234 lease := c.AllocateID()234 lease := c.AllocateID()
235 l := &leaseResultEvents{}235 l := &leaseResultEvents{}
236 l.SetID(lease)236 l.SetID(lease)
237- c.Context().Register(l)237+ registerWireProxy(c, l)
238 requestProtocol(t, c, req, dl.WpDrmLeaseRequestV1RequestSubmit, lease)238 requestProtocol(t, c, req, dl.WpDrmLeaseRequestV1RequestSubmit, lease)
239 if err := c.Roundtrip(); err != nil {239 if err := c.Roundtrip(); err != nil {
240 t.Fatal(err)240 t.Fatal(err)
@@ -347,7 +347,7 @@ func TestLeaseIDsAreScopedToCard(t *testing.T) {
347 var devices []uint32347 var devices []uint32
348 for _, g := range c.Registry().GetGlobals() {348 for _, g := range c.Registry().GetGlobals() {
349 if g.Interface == "wp_drm_lease_device_v1" {349 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)
351 if err != nil {351 if err != nil {
352 t.Fatal(err)352 t.Fatal(err)
353 }353 }
@@ -361,7 +361,7 @@ func TestLeaseIDsAreScopedToCard(t *testing.T) {
361 for _, dev := range devices {361 for _, dev := range devices {
362 p := &leaseDeviceEvents{c: c}362 p := &leaseDeviceEvents{c: c}
363 p.SetID(dev)363 p.SetID(dev)
364- c.Context().Register(p)364+ registerWireProxy(c, p)
365 bound = append(bound, p)365 bound = append(bound, p)
366 }366 }
367 if err := c.Roundtrip(); err != nil {367 if err := c.Roundtrip(); err != nil {
@@ -380,7 +380,7 @@ func TestLeaseIDsAreScopedToCard(t *testing.T) {
380 id := c.AllocateID()380 id := c.AllocateID()
381 l := &leaseResultEvents{}381 l := &leaseResultEvents{}
382 l.SetID(id)382 l.SetID(id)
383- c.Context().Register(l)383+ registerWireProxy(c, l)
384 requestProtocol(t, c, req, dl.WpDrmLeaseRequestV1RequestSubmit, id)384 requestProtocol(t, c, req, dl.WpDrmLeaseRequestV1RequestSubmit, id)
385 }385 }
386 if err := c.Roundtrip(); err != nil {386 if err := c.Roundtrip(); err != nil {
@@ -447,7 +447,7 @@ func TestLeaseDestroyPendingRevokesLateReply(t *testing.T) {
447 dev := bindProtocol(t, c, "wp_drm_lease_device_v1")447 dev := bindProtocol(t, c, "wp_drm_lease_device_v1")
448 p := &leaseDeviceEvents{c: c}448 p := &leaseDeviceEvents{c: c}
449 p.SetID(dev)449 p.SetID(dev)
450- c.Context().Register(p)450+ registerWireProxy(c, p)
451 if err := c.Roundtrip(); err != nil {451 if err := c.Roundtrip(); err != nil {
452 t.Fatal(err)452 t.Fatal(err)
453 }453 }
M internal/adapters/wayland/fifo.go +92 -13
@@ -217,12 +217,14 @@ type update struct {
217 region *ports.Rect217 region *ports.Rect
218 layer *layerSurface218 layer *layerSurface
219 layerNext layerState219 layerNext layerState
220+ lockAck lockConfigure
220 readyGeneration uint64221 readyGeneration uint64
221 readyResult bool222 readyResult bool
222 // refs counts incoming deps and prev links. Retired updates remain readable223 // refs counts incoming deps and prev links. Retired updates remain readable
223 // until their last incoming link goes away, even after dropQueue.224 // until their last incoming link goes away, even after dropQueue.
224 refs int225 refs int
225 retired bool226 retired bool
227+ failed bool // acquire observation failed: discard, never publish
226 }228 }
227 229
228 // updatePool contains only retired updates with no incoming graph links. A230 // updatePool contains only retired updates with no incoming graph links. A
@@ -308,6 +310,9 @@ func (s *surface) takePending() update {
308 if s.layer != nil {310 if s.layer != nil {
309 u.layer, u.layerNext = s.layer, s.layer.pending311 u.layer, u.layerNext = s.layer, s.layer.pending
310 }312 }
313+ if s.lock != nil {
314+ u.lockAck = s.lock.ack
315+ }
311 return u316 return u
312 }317 }
313 318
@@ -349,7 +354,8 @@ func (s *surface) queueUpdate() {
349 if b, ok := s.server.buffers[u.buffer.Resource].(*dmabufBuffer); ok {354 if b, ok := s.server.buffers[u.buffer.Resource].(*dmabufBuffer); ok {
350 err := s.server.syncWait.watchImplicit(b.buf, &u.implicit)355 err := s.server.syncWait.watchImplicit(b.buf, &u.implicit)
351 if err != nil {356 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
353 s.server.log.Warn().Str("component", "wayland").Err(err).Msg("implicit buffer wait")359 s.server.log.Warn().Str("component", "wayland").Err(err).Msg("implicit buffer wait")
354 }360 }
355 }361 }
@@ -396,7 +402,7 @@ func (s *surface) applyUpdate(u *update) {
396 u.transform = s.transform402 u.transform = s.transform
397 // The viewport change was made for that buffer: keep the crop that403 // The viewport change was made for that buffer: keep the crop that
398 // matches the retained content instead of validating a mismatch.404 // 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()) {
400 v := &s.committedViewport406 v := &s.committedViewport
401 u.vpW, u.vpH, u.vpSet, u.vpSrc, u.vpCrop = v.destW, v.destH, v.dest, v.src, v.crop407 u.vpW, u.vpH, u.vpSet, u.vpSrc, u.vpCrop = v.destW, v.destH, v.dest, v.src, v.crop
402 }408 }
@@ -408,8 +414,19 @@ func (s *surface) applyUpdate(u *update) {
408 // dropQueue discards the queued updates of a destroyed surface or role.414 // dropQueue discards the queued updates of a destroyed surface or role.
409 // A live surface still gets its queued frame callbacks.415 // A live surface still gets its queued frame callbacks.
410 func (s *surface) dropQueue() {416 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:]
411 released := map[*server.Resource]bool{}428 released := map[*server.Resource]bool{}
412- for _, u := range s.queue {429+ for _, u := range queue {
413 if s.destroyed {430 if s.destroyed {
414 for _, cb := range u.callbacks {431 for _, cb := range u.callbacks {
415 cb.Destroy()432 cb.Destroy()
@@ -432,20 +449,22 @@ func (s *surface) dropQueue() {
432 u.buffer.SendRelease()449 u.buffer.SendRelease()
433 }450 }
434 }451 }
435- for i, u := range s.queue {452+ for i, u := range queue {
436- s.queue[i] = nil // do not retain retired objects in the queue backing array453+ queue[i] = nil // do not retain retired objects in the queue backing array
437 u.retireUpdate()454 u.retireUpdate()
438 }455 }
439- s.queue = s.queue[:0]456+ if len(s.queue) == 0 {
440- delete(s.server.fifoSurfaces, s)457+ s.queue = queue[:0] // preserve steady-state capacity when all were retired
458+ delete(s.server.fifoSurfaces, s)
459+ }
441 }460 }
442 461
443 func (s *surface) bufferQueuedElsewhere(b *wayland.Buffer) bool {462 func (s *surface) bufferQueuedElsewhere(b *wayland.Buffer) bool {
444 for _, other := range s.server.surfaces {463 for _, other := range s.server.surfaces {
445- if other == s {464+ if sameBuffer(other.next.buffer, b) {
446- continue465+ return true
447 }466 }
448- if sameBuffer(other.current, b) {467+ if other != s && sameBuffer(other.current, b) {
449 return true468 return true
450 }469 }
451 for _, u := range other.queue {470 for _, u := range other.queue {
@@ -611,16 +630,32 @@ func (u *update) graphReady(now time.Time) bool {
611 u.implicit[i] = nil630 u.implicit[i] = nil
612 }631 }
613 }632 }
614- } else {633+ } else if !u.failed {
615 if !s.syncReady(u.sync) {634 if !s.syncReady(u.sync) {
616 return false635 return false
617 }636 }
637+ if u.sync != nil && u.sync.wait != nil && u.sync.wait.failed {
638+ u.failed = true
639+ }
640+ waiting := false
618 for _, w := range u.implicit {641 for _, w := range u.implicit {
619- if w != nil && !s.server.syncWait.fired(w) {642+ if w == nil {
620- return false643+ continue
621 }644 }
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
622 }653 }
623 }654 }
655+ if u.failed {
656+ u.readyResult = true // ready only to retire, never to apply
657+ return true
658+ }
624 for _, dep := range u.deps {659 for _, dep := range u.deps {
625 if !dep.owner.destroyed && !dep.graphReady(now) {660 if !dep.owner.destroyed && !dep.graphReady(now) {
626 return false661 return false
@@ -635,6 +670,24 @@ func (u *update) applyGraph() {
635 return670 return
636 }671 }
637 s := u.owner672 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+ }
638 for len(s.queue) > 0 && s.queue[0] != u {691 for len(s.queue) > 0 && s.queue[0] != u {
639 s.queue[0].applyGraph()692 s.queue[0].applyGraph()
640 }693 }
@@ -661,6 +714,32 @@ func (u *update) applyGraph() {
661 u.retireUpdate()714 u.retireUpdate()
662 }715 }
663 716
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+
664 // sameBuffer reports whether two wrappers are the same wl_buffer.743 // sameBuffer reports whether two wrappers are the same wl_buffer.
665 func sameBuffer(a, b *wayland.Buffer) bool {744 func sameBuffer(a, b *wayland.Buffer) bool {
666 return a != nil && b != nil && a.Resource == b.Resource745 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 {
44 pool, buffer := c.AllocateID(), c.AllocateID()44 pool, buffer := c.AllocateID(), c.AllocateID()
45 registerProtocol(t, c, pool)45 registerProtocol(t, c, pool)
46 registerProtocol(t, c, buffer)46 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 {
48 t.Fatal(err)48 t.Fatal(err)
49 }49 }
50 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))50 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) {
62 62
63 // refresh announces w or sends what changed since the last done.63 // refresh announces w or sends what changed since the last done.
64 func (m *toplevelManager) refresh(w *window) {64 func (m *toplevelManager) refresh(w *window) {
65- if !m.res.Alive() {65+ if m.s.protected() || !m.res.Alive() {
66 return66 return
67 }67 }
68 h := m.handles[w.id]68 h := m.handles[w.id]
69+ if h != nil && h.req.closed {
70+ m.closed(w.id)
71+ h = nil
72+ }
69 fresh := h == nil73 fresh := h == nil
70 if fresh {74 if fresh {
71 req := &toplevelRequests{s: m.s, id: w.id}75 req := &toplevelRequests{s: m.s, id: w.id}
@@ -110,9 +114,14 @@ func (m *toplevelManager) closed(id ports.WindowID) {
110 if h == nil {114 if h == nil {
111 return115 return
112 }116 }
113- delete(m.handles, id)117+ // A remapped window keeps its ID but gets a new handle. Mark requests
114- // A remapped window keeps its ID but gets a new handle.118+ // inert immediately, but defer the wire event to avoid leaking lock-time
119+ // unmap/disconnect timing. Keep the handle for the unlock refresh.
115 h.req.closed = true120 h.req.closed = true
121+ if m.s.protected() {
122+ return
123+ }
124+ delete(m.handles, id)
116 if h.res.Alive() {125 if h.res.Alive() {
117 h.res.SendClosed()126 h.res.SendClosed()
118 }127 }
@@ -192,15 +201,30 @@ func (s *Server) toplevelClosed(id ports.WindowID) {
192 }201 }
193 }202 }
194 203
195-// refreshToplevels resends the output events of every handle after a204+// refreshToplevels reconciles inventory after output changes or unlock:
196-// wl_output was bound or an output removed.205+// retire handles closed during protection, then announce current windows,
206+// including those mapped (or managers bound) while inventory was suppressed.
197 func (s *Server) refreshToplevels() {207 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)
198 for _, m := range s.toplevelManagers {218 for _, m := range s.toplevelManagers {
199- for id := range m.handles {219+ for id, h := range m.handles {
200- if w := s.windows[id]; w != nil {220+ w := s.windows[id]
201- m.refresh(w)221+ if h.req.closed || w == nil || w.toplevel == nil || !w.mapped {
222+ m.closed(id)
202 }223 }
203 }224 }
225+ for _, id := range ids {
226+ m.refresh(s.windows[id])
227+ }
204 }228 }
205 }229 }
206 230
@@ -213,7 +237,7 @@ type toplevelRequests struct {
213 }237 }
214 238
215 func (t *toplevelRequests) window() *window {239 func (t *toplevelRequests) window() *window {
216- if t.closed {240+ if t.s.protected() || t.closed {
217 return nil241 return nil
218 }242 }
219 if w := t.s.windows[t.id]; w != nil && w.toplevel != nil && w.mapped && w.toplevel.Resource.Alive() {243 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) {
29 id := e.Uint32()29 id := e.Uint32()
30 h := &toplevelHandleEvents{events: p.events}30 h := &toplevelHandleEvents{events: p.events}
31 h.SetID(id)31 h.SetID(id)
32- p.client.Context().Register(h)32+ registerWireProxy(p.client, h)
33 p.handles <- id33 p.handles <- id
34 }34 }
35 35
@@ -105,7 +105,7 @@ func TestForeignToplevel(t *testing.T) {
105 manager := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 3)105 manager := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 3)
106 p := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)}106 p := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)}
107 p.SetID(manager)107 p.SetID(manager)
108- c.Context().Register(p)108+ registerWireProxy(c, p)
109 if got, want := takeEvents(t, c, p.events), []string{"title editor", "app_id app.editor", "state []", "done"}; !slices.Equal(got, want) {109 if got, want := takeEvents(t, c, p.events), []string{"title editor", "app_id app.editor", "state []", "done"}; !slices.Equal(got, want) {
110 t.Fatalf("announce = %q, want %q", got, want)110 t.Fatalf("announce = %q, want %q", got, want)
111 }111 }
@@ -154,7 +154,7 @@ func TestForeignToplevel(t *testing.T) {
154 serials := make(chan uint32, 8)154 serials := make(chan uint32, 8)
155 xp := &configureProxy{serial: serials}155 xp := &configureProxy{serial: serials}
156 xp.SetID(xdg)156 xp.SetID(xdg)
157- c.Context().Register(xp)157+ registerWireProxy(c, xp)
158 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)158 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
159 ackAndAttach(t, c, surf, xdg, shmBuffer(t, c), serials)159 ackAndAttach(t, c, surf, xdg, shmBuffer(t, c), serials)
160 if again := mapped(t, events, 2*time.Second); again.ID != w.ID {160 if again := mapped(t, events, 2*time.Second); again.ID != w.ID {
@@ -200,7 +200,7 @@ func TestForeignToplevelV1HidesFullscreen(t *testing.T) {
200 manager := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 1)200 manager := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 1)
201 p := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)}201 p := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)}
202 p.SetID(manager)202 p.SetID(manager)
203- c.Context().Register(p)203+ registerWireProxy(c, p)
204 takeEvents(t, c, p.events)204 takeEvents(t, c, p.events)
205 commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Activated: true}205 commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Activated: true}
206 want := []string{fmt.Sprint("state ", []uint32{uint32(wlr.ZwlrForeignToplevelHandleV1StateActivated)}), "done"}206 want := []string{fmt.Sprint("state ", []uint32{uint32(wlr.ZwlrForeignToplevelHandleV1StateActivated)}), "done"}
@@ -216,7 +216,7 @@ func TestForeignToplevelInvalidRectangle(t *testing.T) {
216 manager := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 3)216 manager := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 3)
217 p := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)}217 p := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)}
218 p.SetID(manager)218 p.SetID(manager)
219- c.Context().Register(p)219+ registerWireProxy(c, p)
220 takeEvents(t, c, p.events)220 takeEvents(t, c, p.events)
221 handle := <-p.handles221 handle := <-p.handles
222 requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestSetRectangle, surf, int32(0), int32(0), int32(-1), int32(1))222 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 {
149 return surf.xdg.window.last.Output149 return surf.xdg.window.last.Output
150 case surf.layer != nil && surf.layer.output != nil:150 case surf.layer != nil && surf.layer.output != nil:
151 return surf.layer.output.name()151 return surf.layer.output.name()
152+ case surf.lock != nil && surf.lock.output != nil && !surf.lock.closed:
153+ return surf.lock.output.name()
152 }154 }
153 return ""155 return ""
154 }156 }
M internal/adapters/wayland/globals.go +9 -0
@@ -12,6 +12,15 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
12 for _, register := range []func() error{12 for _, register := range []func() error{
13 func() error { return registerXDG(d, s) },13 func() error { return registerXDG(d, s) },
14 func() error { return registerLayer(d, s) },14 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+ },
15 func() error { return registerXDGOutput(d, s) },24 func() error { return registerXDGOutput(d, s) },
16 func() error { return registerOutputManagement(d, s) },25 func() error { return registerOutputManagement(d, s) },
17 func() error { return registerWorkspaces(d, s) },26 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) {
33 id := c.AllocateID()33 id := c.AllocateID()
34 p := &eventProxy{events: make(chan [2]uint32, 16)}34 p := &eventProxy{events: make(chan [2]uint32, 16)}
35 p.SetID(id)35 p.SetID(id)
36- c.Context().Register(p)36+ registerWireProxy(c, p)
37 return id, p37 return id, p
38 }38 }
39 39
M internal/adapters/wayland/inhibit_test.go +1 -1
@@ -47,7 +47,7 @@ func TestShortcutsInhibitor(t *testing.T) {
47 in := c.AllocateID()47 in := c.AllocateID()
48 p := &inhibitorProxy{states: make(chan bool, 4)}48 p := &inhibitorProxy{states: make(chan bool, 4)}
49 p.SetID(in)49 p.SetID(in)
50- c.Context().Register(p)50+ registerWireProxy(c, p)
51 requestProtocol(t, c, mgr, keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitManagerV1RequestInhibitShortcuts, in, surf, seat)51 requestProtocol(t, c, mgr, keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitManagerV1RequestInhibitShortcuts, in, surf, seat)
52 if v := clientEvent[ports.ShortcutsInhibit](t, events); v != (ports.ShortcutsInhibit{Window: w.ID, Active: true}) {52 if v := clientEvent[ports.ShortcutsInhibit](t, events); v != (ports.ShortcutsInhibit{Window: w.ID, Active: true}) {
53 t.Fatal(v)53 t.Fatal(v)
M internal/adapters/wayland/input_region_test.go +1 -1
@@ -90,7 +90,7 @@ func TestSubsurfaceInputRegionOwnCommitAndOffset(t *testing.T) {
90 pool, buffer := c.AllocateID(), c.AllocateID()90 pool, buffer := c.AllocateID(), c.AllocateID()
91 registerProtocol(t, c, pool)91 registerProtocol(t, c, pool)
92 registerProtocol(t, c, buffer)92 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 {
94 t.Fatal(err)94 t.Fatal(err)
95 }95 }
96 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(4), int32(4), int32(16), uint32(0))96 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) {
109 id := c.AllocateID()109 id := c.AllocateID()
110 proxy := &keymapProxy{result: make(chan error, 1)}110 proxy := &keymapProxy{result: make(chan error, 1)}
111 proxy.SetID(id)111 proxy.SetID(id)
112- c.Context().Register(proxy)112+ registerWireProxy(c, proxy)
113 requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id)113 requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id)
114 if err := c.Roundtrip(); err != nil {114 if err := c.Roundtrip(); err != nil {
115 t.Fatal(err)115 t.Fatal(err)
@@ -162,7 +162,7 @@ func TestSetKeymap(t *testing.T) {
162 if !ok {162 if !ok {
163 t.Fatal("missing wl_seat")163 t.Fatal("missing wl_seat")
164 }164 }
165- seat, err := c.Registry().BindID(g.Name, g.Interface, 4)165+ seat, err := bindWireID(c, g.Name, g.Interface, 4)
166 if err != nil {166 if err != nil {
167 t.Fatal(err)167 t.Fatal(err)
168 }168 }
@@ -170,7 +170,7 @@ func TestSetKeymap(t *testing.T) {
170 id := c.AllocateID()170 id := c.AllocateID()
171 proxy := &eventsProxy{}171 proxy := &eventsProxy{}
172 proxy.SetID(id)172 proxy.SetID(id)
173- c.Context().Register(proxy)173+ registerWireProxy(c, proxy)
174 requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id)174 requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id)
175 if err := c.Roundtrip(); err != nil {175 if err := c.Roundtrip(); err != nil {
176 t.Fatal(err)176 t.Fatal(err)
@@ -212,7 +212,7 @@ func TestSetKeymap(t *testing.T) {
212 id2 := c.AllocateID()212 id2 := c.AllocateID()
213 late := &eventsProxy{}213 late := &eventsProxy{}
214 late.SetID(id2)214 late.SetID(id2)
215- c.Context().Register(late)215+ registerWireProxy(c, late)
216 requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id2)216 requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id2)
217 if err := c.Roundtrip(); err != nil {217 if err := c.Roundtrip(); err != nil {
218 t.Fatal(err)218 t.Fatal(err)
M internal/adapters/wayland/layer_scale_test.go +4 -4
@@ -56,7 +56,7 @@ func TestLayerTargetScaleBeforeConfigure(t *testing.T) {
56 name = first56 name = first
57 }57 }
58 var err error58 var err error
59- out, err = c.Registry().BindID(name, "wl_output", 4)59+ out, err = bindWireID(c, name, "wl_output", 4)
60 if err != nil {60 if err != nil {
61 t.Fatal(err)61 t.Fatal(err)
62 }62 }
@@ -73,13 +73,13 @@ func TestLayerTargetScaleBeforeConfigure(t *testing.T) {
73 integers := make(chan int32, 4)73 integers := make(chan int32, 4)
74 sp := &surfaceScaleProxy{scales: integers}74 sp := &surfaceScaleProxy{scales: integers}
75 sp.SetID(surf)75 sp.SetID(surf)
76- c.Context().Register(sp)76+ registerWireProxy(c, sp)
77 requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)77 requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
78 frac := c.AllocateID()78 frac := c.AllocateID()
79 events := make(chan [3]uint32, 8)79 events := make(chan [3]uint32, 8)
80 fp := &layerScaleOrderProxy{events: events}80 fp := &layerScaleOrderProxy{events: events}
81 fp.SetID(frac)81 fp.SetID(frac)
82- c.Context().Register(fp)82+ registerWireProxy(c, fp)
83 requestProtocol(t, c, fm, fractionalscale.WpFractionalScaleManagerV1RequestGetFractionalScale, frac, surf)83 requestProtocol(t, c, fm, fractionalscale.WpFractionalScaleManagerV1RequestGetFractionalScale, frac, surf)
84 if err := c.Roundtrip(); err != nil {84 if err := c.Roundtrip(); err != nil {
85 t.Fatal(err)85 t.Fatal(err)
@@ -93,7 +93,7 @@ func TestLayerTargetScaleBeforeConfigure(t *testing.T) {
93 layer := c.AllocateID()93 layer := c.AllocateID()
94 lp := &layerScaleOrderProxy{layer: true, events: events}94 lp := &layerScaleOrderProxy{layer: true, events: events}
95 lp.SetID(layer)95 lp.SetID(layer)
96- c.Context().Register(lp)96+ registerWireProxy(c, lp)
97 requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, layer, surf, out, uint32(ports.LayerOverlay), "scale-test")97 requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, layer, surf, out, uint32(ports.LayerOverlay), "scale-test")
98 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(15))98 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(15))
99 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(0))99 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) {
52 surf, layer := layerProtocol(t, c)52 surf, layer := layerProtocol(t, c)
53 proxy := &layerProxy{configured: make(chan [3]uint32, 2)}53 proxy := &layerProxy{configured: make(chan [3]uint32, 2)}
54 proxy.SetID(layer)54 proxy.SetID(layer)
55- c.Context().Register(proxy)55+ registerWireProxy(c, proxy)
56 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(13))56 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(13))
57 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(30))57 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(30))
58 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetExclusiveZone, int32(30))58 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetExclusiveZone, int32(30))
@@ -83,7 +83,7 @@ func TestLayerLifecycle(t *testing.T) {
83 pool, buf := c.AllocateID(), c.AllocateID()83 pool, buf := c.AllocateID(), c.AllocateID()
84 registerProtocol(t, c, pool)84 registerProtocol(t, c, pool)
85 registerProtocol(t, c, buf)85 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 {
87 t.Fatal(err)87 t.Fatal(err)
88 }88 }
89 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1920), int32(30), int32(1920*4), uint32(wayland.ShmFormatArgb8888))89 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) {
199 if !ok {199 if !ok {
200 t.Fatal("missing wl_output")200 t.Fatal("missing wl_output")
201 }201 }
202- output, err := c.Registry().BindID(global.Name, global.Interface, version)202+ output, err := bindWireID(c, global.Name, global.Interface, version)
203 if err != nil {203 if err != nil {
204 t.Fatal(err)204 t.Fatal(err)
205 }205 }
206 op := &doneProxy{opcode: uint16(wayland.OutputEventDone)}206 op := &doneProxy{opcode: uint16(wayland.OutputEventDone)}
207 op.SetID(output)207 op.SetID(output)
208- c.Context().Register(op)208+ registerWireProxy(c, op)
209 global, ok = c.Registry().FindGlobal("zxdg_output_manager_v1")209 global, ok = c.Registry().FindGlobal("zxdg_output_manager_v1")
210 if !ok {210 if !ok {
211 t.Fatal("missing xdg output manager")211 t.Fatal("missing xdg output manager")
212 }212 }
213- manager, err := c.Registry().BindID(global.Name, global.Interface, 3)213+ manager, err := bindWireID(c, global.Name, global.Interface, 3)
214 if err != nil {214 if err != nil {
215 t.Fatal(err)215 t.Fatal(err)
216 }216 }
@@ -222,7 +222,7 @@ func TestXDGOutputDone(t *testing.T) {
222 xdg := c.AllocateID()222 xdg := c.AllocateID()
223 xp := &doneProxy{opcode: 2}223 xp := &doneProxy{opcode: 2}
224 xp.SetID(xdg)224 xp.SetID(xdg)
225- c.Context().Register(xp)225+ registerWireProxy(c, xp)
226 requestProtocol(t, c, manager, 1, xdg, output)226 requestProtocol(t, c, manager, 1, xdg, output)
227 if err := c.Roundtrip(); err != nil {227 if err := c.Roundtrip(); err != nil {
228 t.Fatal(err)228 t.Fatal(err)
@@ -260,7 +260,7 @@ func TestLayerPointerAndPopup(t *testing.T) {
260 pointer := c.AllocateID()260 pointer := c.AllocateID()
261 pp := &layerPointerProxy{enters: make(chan [2]float64, 1), buttons: make(chan uint32, 1)}261 pp := &layerPointerProxy{enters: make(chan [2]float64, 1), buttons: make(chan uint32, 1)}
262 pp.SetID(pointer)262 pp.SetID(pointer)
263- c.Context().Register(pp)263+ registerWireProxy(c, pp)
264 requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)264 requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
265 comp := bindProtocol(t, c, "wl_compositor")265 comp := bindProtocol(t, c, "wl_compositor")
266 wm := bindProtocol(t, c, "xdg_wm_base")266 wm := bindProtocol(t, c, "xdg_wm_base")
@@ -277,7 +277,7 @@ func TestLayerPointerAndPopup(t *testing.T) {
277 pool, buf := c.AllocateID(), c.AllocateID()277 pool, buf := c.AllocateID(), c.AllocateID()
278 registerProtocol(t, c, pool)278 registerProtocol(t, c, pool)
279 registerProtocol(t, c, buf)279 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 {
281 t.Fatal(err)281 t.Fatal(err)
282 }282 }
283 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(0))283 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) {
285 surf, layer := layerProtocol(t, c)285 surf, layer := layerProtocol(t, c)
286 lp := &layerProxy{configured: make(chan [3]uint32, 2)}286 lp := &layerProxy{configured: make(chan [3]uint32, 2)}
287 lp.SetID(layer)287 lp.SetID(layer)
288- c.Context().Register(lp)288+ registerWireProxy(c, lp)
289 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(1), uint32(1))289 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(1), uint32(1))
290 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)290 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
291 if err = c.Roundtrip(); err != nil {291 if err = c.Roundtrip(); err != nil {
@@ -334,7 +334,7 @@ func TestLayerPointerAndPopup(t *testing.T) {
334 popup := c.AllocateID()334 popup := c.AllocateID()
335 proxy := &popupDoneProxy{done: done}335 proxy := &popupDoneProxy{done: done}
336 proxy.SetID(popup)336 proxy.SetID(popup)
337- c.Context().Register(proxy)337+ registerWireProxy(c, proxy)
338 requestProtocol(t, c, pxdg, xdgshell.SurfaceRequestGetPopup, popup, uint32(0), positioner)338 requestProtocol(t, c, pxdg, xdgshell.SurfaceRequestGetPopup, popup, uint32(0), positioner)
339 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestGetPopup, popup)339 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestGetPopup, popup)
340 requestProtocol(t, c, popup, xdgshell.PopupRequestGrab, seat, press)340 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) {
27 if e.Opcode == uint16(wlr.ZwlrOutputManagerV1EventHead) {27 if e.Opcode == uint16(wlr.ZwlrOutputManagerV1EventHead) {
28 h := &managementHeadEvents{client: p.client, modes: p.modes, events: p.headEvents}28 h := &managementHeadEvents{client: p.client, modes: p.modes, events: p.headEvents}
29 h.SetID(msg[1])29 h.SetID(msg[1])
30- p.client.Context().Register(h)30+ registerWireProxy(p.client, h)
31 }31 }
32 p.events <- msg32 p.events <- msg
33 }33 }
@@ -51,7 +51,7 @@ func (p *managementHeadEvents) Dispatch(e *wlturbo.Event) {
51 if e.Opcode == uint16(wlr.ZwlrOutputHeadV1EventMode) {51 if e.Opcode == uint16(wlr.ZwlrOutputHeadV1EventMode) {
52 mode := &protocolProxy{}52 mode := &protocolProxy{}
53 mode.SetID(e.Uint32())53 mode.SetID(e.Uint32())
54- p.client.Context().Register(mode)54+ registerWireProxy(p.client, mode)
55 if p.modes != nil {55 if p.modes != nil {
56 p.modes <- mode.ID()56 p.modes <- mode.ID()
57 }57 }
@@ -118,7 +118,7 @@ func TestOutputManagementStaleSerialAndLayout(t *testing.T) {
118 id := bindVersion(t, c, "zwlr_output_manager_v1", 4)118 id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
119 manager := &managementEvents{events: make(chan [2]uint32, 32), client: c}119 manager := &managementEvents{events: make(chan [2]uint32, 32), client: c}
120 manager.SetID(id)120 manager.SetID(id)
121- c.Context().Register(manager)121+ registerWireProxy(c, manager)
122 if err := c.Roundtrip(); err != nil {122 if err := c.Roundtrip(); err != nil {
123 t.Fatal(err)123 t.Fatal(err)
124 }124 }
@@ -144,7 +144,7 @@ func TestOutputManagementStaleSerialAndLayout(t *testing.T) {
144 configID := c.AllocateID()144 configID := c.AllocateID()
145 proxy := &managementEvents{events: make(chan [2]uint32, 8), client: c}145 proxy := &managementEvents{events: make(chan [2]uint32, 8), client: c}
146 proxy.SetID(configID)146 proxy.SetID(configID)
147- c.Context().Register(proxy)147+ registerWireProxy(c, proxy)
148 requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, first[1])148 requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, first[1])
149 waitManagementEvent(t, c, proxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventCancelled))149 waitManagementEvent(t, c, proxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventCancelled))
150 if !s.display.Do(func() { s.setOutputHeads(ports.OutputHeads{}) }) {150 if !s.display.Do(func() { s.setOutputHeads(ports.OutputHeads{}) }) {
@@ -169,7 +169,7 @@ func TestOutputManagementHeadsAndApply(t *testing.T) {
169 id := bindVersion(t, c, "zwlr_output_manager_v1", 4)169 id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
170 manager := &managementEvents{events: make(chan [2]uint32, 32), client: c}170 manager := &managementEvents{events: make(chan [2]uint32, 32), client: c}
171 manager.SetID(id)171 manager.SetID(id)
172- c.Context().Register(manager)172+ registerWireProxy(c, manager)
173 if err := c.Roundtrip(); err != nil {173 if err := c.Roundtrip(); err != nil {
174 t.Fatal(err)174 t.Fatal(err)
175 }175 }
@@ -187,7 +187,7 @@ func TestOutputManagementHeadsAndApply(t *testing.T) {
187 configID := c.AllocateID()187 configID := c.AllocateID()
188 configProxy := &managementEvents{events: make(chan [2]uint32, 8), client: c}188 configProxy := &managementEvents{events: make(chan [2]uint32, 8), client: c}
189 configProxy.SetID(configID)189 configProxy.SetID(configID)
190- c.Context().Register(configProxy)190+ registerWireProxy(c, configProxy)
191 requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, serial[1])191 requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, serial[1])
192 chID := c.AllocateID()192 chID := c.AllocateID()
193 registerProtocol(t, c, chID)193 registerProtocol(t, c, chID)
@@ -211,7 +211,7 @@ func TestOutputManagementHeadsAndApply(t *testing.T) {
211 configID = c.AllocateID()211 configID = c.AllocateID()
212 configProxy = &managementEvents{events: make(chan [2]uint32, 8), client: c}212 configProxy = &managementEvents{events: make(chan [2]uint32, 8), client: c}
213 configProxy.SetID(configID)213 configProxy.SetID(configID)
214- c.Context().Register(configProxy)214+ registerWireProxy(c, configProxy)
215 requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, serial[1])215 requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, serial[1])
216 chID = c.AllocateID()216 chID = c.AllocateID()
217 registerProtocol(t, c, chID)217 registerProtocol(t, c, chID)
@@ -259,7 +259,7 @@ func TestOutputManagementUnsupportedSettings(t *testing.T) {
259 managerID := bindVersion(t, c, "zwlr_output_manager_v1", 4)259 managerID := bindVersion(t, c, "zwlr_output_manager_v1", 4)
260 manager := &managementEvents{events: make(chan [2]uint32, 32), client: c}260 manager := &managementEvents{events: make(chan [2]uint32, 32), client: c}
261 manager.SetID(managerID)261 manager.SetID(managerID)
262- c.Context().Register(manager)262+ registerWireProxy(c, manager)
263 if err := c.Roundtrip(); err != nil {263 if err := c.Roundtrip(); err != nil {
264 t.Fatal(err)264 t.Fatal(err)
265 }265 }
@@ -271,7 +271,7 @@ func TestOutputManagementUnsupportedSettings(t *testing.T) {
271 cfg := c.AllocateID()271 cfg := c.AllocateID()
272 proxy := &managementEvents{events: make(chan [2]uint32, 8), client: c}272 proxy := &managementEvents{events: make(chan [2]uint32, 8), client: c}
273 proxy.SetID(cfg)273 proxy.SetID(cfg)
274- c.Context().Register(proxy)274+ registerWireProxy(c, proxy)
275 requestProtocol(t, c, managerID, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, done[1])275 requestProtocol(t, c, managerID, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, done[1])
276 ch := c.AllocateID()276 ch := c.AllocateID()
277 registerProtocol(t, c, ch)277 registerProtocol(t, c, ch)
@@ -373,7 +373,7 @@ func TestOutputManagementProtocolErrors(t *testing.T) {
373 managerID := bindVersion(t, c, "zwlr_output_manager_v1", 4)373 managerID := bindVersion(t, c, "zwlr_output_manager_v1", 4)
374 p := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}374 p := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
375 p.SetID(managerID)375 p.SetID(managerID)
376- c.Context().Register(p)376+ registerWireProxy(c, p)
377 if err := c.Roundtrip(); err != nil {377 if err := c.Roundtrip(); err != nil {
378 t.Fatal(err)378 t.Fatal(err)
379 }379 }
@@ -390,7 +390,7 @@ func TestOutputManagementProtocolErrors(t *testing.T) {
390 other := bindVersion(t, c, "zwlr_output_manager_v1", 4)390 other := bindVersion(t, c, "zwlr_output_manager_v1", 4)
391 otherEvents := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}391 otherEvents := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
392 otherEvents.SetID(other)392 otherEvents.SetID(other)
393- c.Context().Register(otherEvents)393+ registerWireProxy(c, otherEvents)
394 if err := c.Roundtrip(); err != nil {394 if err := c.Roundtrip(); err != nil {
395 t.Fatal(err)395 t.Fatal(err)
396 }396 }
@@ -434,7 +434,7 @@ func TestOutputManagementInitialHeadState(t *testing.T) {
434 id := bindVersion(t, c, "zwlr_output_manager_v1", 4)434 id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
435 p := &managementEvents{events: make(chan [2]uint32, 32), headEvents: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}435 p := &managementEvents{events: make(chan [2]uint32, 32), headEvents: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
436 p.SetID(id)436 p.SetID(id)
437- c.Context().Register(p)437+ registerWireProxy(c, p)
438 if err := c.Roundtrip(); err != nil {438 if err := c.Roundtrip(); err != nil {
439 t.Fatal(err)439 t.Fatal(err)
440 }440 }
@@ -465,7 +465,7 @@ func TestOutputManagementStopDestroysManager(t *testing.T) {
465 id := bindVersion(t, c, "zwlr_output_manager_v1", 4)465 id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
466 p := &managementEvents{events: make(chan [2]uint32, 8), client: c}466 p := &managementEvents{events: make(chan [2]uint32, 8), client: c}
467 p.SetID(id)467 p.SetID(id)
468- c.Context().Register(p)468+ registerWireProxy(c, p)
469 requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestStop)469 requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestStop)
470 waitManagementEvent(t, c, p.events, wlr.ZwlrOutputManagerV1EventDone)470 waitManagementEvent(t, c, p.events, wlr.ZwlrOutputManagerV1EventDone)
471 waitManagementEvent(t, c, p.events, wlr.ZwlrOutputManagerV1EventFinished)471 waitManagementEvent(t, c, p.events, wlr.ZwlrOutputManagerV1EventFinished)
@@ -487,7 +487,7 @@ func TestOutputManagementReleasedModeNotReused(t *testing.T) {
487 id := bindVersion(t, c, "zwlr_output_manager_v1", 4)487 id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
488 p := &managementEvents{events: make(chan [2]uint32, 32), headEvents: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}488 p := &managementEvents{events: make(chan [2]uint32, 32), headEvents: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
489 p.SetID(id)489 p.SetID(id)
490- c.Context().Register(p)490+ registerWireProxy(c, p)
491 if err := c.Roundtrip(); err != nil {491 if err := c.Roundtrip(); err != nil {
492 t.Fatal(err)492 t.Fatal(err)
493 }493 }
@@ -531,7 +531,7 @@ func managementSetup(t *testing.T) (*Server, *wlturbo.Display, uint32, uint32, u
531 id := bindVersion(t, c, "zwlr_output_manager_v1", 4)531 id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
532 p := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}532 p := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
533 p.SetID(id)533 p.SetID(id)
534- c.Context().Register(p)534+ registerWireProxy(c, p)
535 if err := c.Roundtrip(); err != nil {535 if err := c.Roundtrip(); err != nil {
536 t.Fatal(err)536 t.Fatal(err)
537 }537 }
@@ -585,6 +585,7 @@ func TestOutputManagementDestroyConfigurationChildren(t *testing.T) {
585 t.Fatal("no child resource")585 t.Fatal("no child resource")
586 }586 }
587 requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestDestroy)587 requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestDestroy)
588+ retireWireObject(c, ch) // the parent destructor also destroys its configuration heads
588 if err := c.Roundtrip(); err != nil {589 if err := c.Roundtrip(); err != nil {
589 t.Fatal(err)590 t.Fatal(err)
590 }591 }
@@ -602,7 +603,7 @@ func TestOutputManagementStaleConfigurationSingleTerminal(t *testing.T) {
602 cfg := c.AllocateID()603 cfg := c.AllocateID()
603 p := &managementEvents{events: make(chan [2]uint32, 8), client: c}604 p := &managementEvents{events: make(chan [2]uint32, 8), client: c}
604 p.SetID(cfg)605 p.SetID(cfg)
605- c.Context().Register(p)606+ registerWireProxy(c, p)
606 requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, serial-1)607 requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, serial-1)
607 waitManagementEvent(t, c, p.events, wlr.ZwlrOutputConfigurationV1EventCancelled)608 waitManagementEvent(t, c, p.events, wlr.ZwlrOutputConfigurationV1EventCancelled)
608 requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestApply)609 requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestApply)
@@ -634,6 +635,7 @@ func TestOutputManagementDestroyDropsPendingReply(t *testing.T) {
634 t.Fatal("display stopped")635 t.Fatal("display stopped")
635 }636 }
636 requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestDestroy)637 requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestDestroy)
638+ retireWireObject(c, ch) // the parent destructor also destroys its configuration heads
637 if err := c.Roundtrip(); err != nil {639 if err := c.Roundtrip(); err != nil {
638 t.Fatal(err)640 t.Fatal(err)
639 }641 }
M internal/adapters/wayland/outputs.go +14 -0
@@ -193,6 +193,11 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
193 l.close()193 l.close()
194 }194 }
195 }195 }
196+ for _, l := range s.lockSurfaces {
197+ if l.output == o {
198+ l.unmap()
199+ }
200+ }
196 for _, surf := range s.surfaces {201 for _, surf := range s.surfaces {
197 surf.leave(o)202 surf.leave(o)
198 }203 }
@@ -211,7 +216,16 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
211 }216 }
212 scaleChanged := o.place.Scale != p.Scale217 scaleChanged := o.place.Scale != p.Scale
213 sizeChanged := o.place.Info.Width != p.Info.Width || o.place.Info.Height != p.Info.Height218 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
214 o.place = p220 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+ }
215 if sizeChanged {229 if sizeChanged {
216 for session := range s.captureSessions {230 for session := range s.captureSessions {
217 if session.o == o && !session.stopped {231 if session.o == o && !session.stopped {
M internal/adapters/wayland/outputs_test.go +4 -2
@@ -92,6 +92,8 @@ func TestFixesAckAndDestroyRegistry(t *testing.T) {
92 waitOutputs(t, c, 1)92 waitOutputs(t, c, 1)
93 requestProtocol(t, c, fixes, wayland.FixesRequestAckGlobalRemove, c.Registry().ID(), name)93 requestProtocol(t, c, fixes, wayland.FixesRequestAckGlobalRemove, c.Registry().ID(), name)
94 requestProtocol(t, c, fixes, wayland.FixesRequestDestroyRegistry, c.Registry().ID())94 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())
95 if err := c.Roundtrip(); err != nil {97 if err := c.Roundtrip(); err != nil {
96 t.Fatalf("valid ack and destroy: %v", err)98 t.Fatalf("valid ack and destroy: %v", err)
97 }99 }
@@ -121,7 +123,7 @@ func TestOutputHotplugAndLayerOutput(t *testing.T) {
121 name = n123 name = n
122 }124 }
123 }125 }
124- out, err := c.Registry().BindID(name, "wl_output", 4)126+ out, err := bindWireID(c, name, "wl_output", 4)
125 if err != nil {127 if err != nil {
126 t.Fatal(err)128 t.Fatal(err)
127 }129 }
@@ -134,7 +136,7 @@ func TestOutputHotplugAndLayerOutput(t *testing.T) {
134 layer := c.AllocateID()136 layer := c.AllocateID()
135 proxy := &closedProxy{configured: make(chan [3]uint32, 2), closed: make(chan struct{})}137 proxy := &closedProxy{configured: make(chan [3]uint32, 2), closed: make(chan struct{})}
136 proxy.SetID(layer)138 proxy.SetID(layer)
137- c.Context().Register(proxy)139+ registerWireProxy(c, proxy)
138 requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, layer, surf, out, uint32(ports.LayerTop), "bar")140 requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, layer, surf, out, uint32(ports.LayerTop), "bar")
139 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(13))141 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(13))
140 requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(30))142 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) {
109 fb := c.AllocateID()109 fb := c.AllocateID()
110 p := &feedbackEvents{out: make(chan presentedEvent, 4)}110 p := &feedbackEvents{out: make(chan presentedEvent, 4)}
111 p.SetID(fb)111 p.SetID(fb)
112- c.Context().Register(p)112+ registerWireProxy(c, p)
113 requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, surf, fb)113 requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, surf, fb)
114 return p114 return p
115 }115 }
@@ -142,7 +142,7 @@ func TestPresentationFeedback(t *testing.T) {
142 }142 }
143 pool := c.AllocateID()143 pool := c.AllocateID()
144 registerProtocol(t, c, pool)144 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 {
146 t.Fatal(err)146 t.Fatal(err)
147 }147 }
148 commit := func() {148 commit := func() {
@@ -218,7 +218,7 @@ func TestPresentationFeedback(t *testing.T) {
218 cfb := c.AllocateID()218 cfb := c.AllocateID()
219 cp := &feedbackEvents{out: make(chan presentedEvent, 1)}219 cp := &feedbackEvents{out: make(chan presentedEvent, 1)}
220 cp.SetID(cfb)220 cp.SetID(cfb)
221- c.Context().Register(cp)221+ registerWireProxy(c, cp)
222 requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, child, cfb)222 requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, child, cfb)
223 requestProtocol(t, c, child, wayland.SurfaceRequestCommit)223 requestProtocol(t, c, child, wayland.SurfaceRequestCommit)
224 commit() // the parent applies the subsurface224 commit() // the parent applies the subsurface
@@ -248,7 +248,7 @@ func TestPresentationHiddenDiscarded(t *testing.T) {
248 fb := c.AllocateID()248 fb := c.AllocateID()
249 p := &feedbackEvents{out: make(chan presentedEvent, 1)}249 p := &feedbackEvents{out: make(chan presentedEvent, 1)}
250 p.SetID(fb)250 p.SetID(fb)
251- c.Context().Register(p)251+ registerWireProxy(c, p)
252 requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, surf, fb)252 requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, surf, fb)
253 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)253 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
254 if err := c.Roundtrip(); err != nil {254 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 {
23 if err != nil {23 if err != nil {
24 t.Fatal(err)24 t.Fatal(err)
25 }25 }
26- t.Cleanup(func() { _ = c.Close() })26+ t.Cleanup(func() { _ = c.Close(); forgetWireClient(c) })
27 if err := c.Roundtrip(); err != nil {27 if err := c.Roundtrip(); err != nil {
28 t.Fatal(err)28 t.Fatal(err)
29 }29 }
@@ -41,7 +41,7 @@ func bindVersion(t *testing.T, c *wlturbo.Display, iface string, version uint32)
41 if !ok {41 if !ok {
42 t.Fatalf("missing %s", iface)42 t.Fatalf("missing %s", iface)
43 }43 }
44- id, err := c.Registry().BindID(g.Name, g.Interface, version)44+ id, err := bindWireID(c, g.Name, g.Interface, version)
45 if err != nil {45 if err != nil {
46 t.Fatal(err)46 t.Fatal(err)
47 }47 }
@@ -50,7 +50,7 @@ func bindVersion(t *testing.T, c *wlturbo.Display, iface string, version uint32)
50 50
51 func requestProtocol(t *testing.T, c *wlturbo.Display, id uint32, op uint32, args ...any) {51 func requestProtocol(t *testing.T, c *wlturbo.Display, id uint32, op uint32, args ...any) {
52 t.Helper()52 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 {
54 t.Fatal(err)54 t.Fatal(err)
55 }55 }
56 }56 }
@@ -89,7 +89,7 @@ func registerProtocol(t *testing.T, c *wlturbo.Display, id uint32) {
89 t.Helper()89 t.Helper()
90 p := &protocolProxy{}90 p := &protocolProxy{}
91 p.SetID(id)91 p.SetID(id)
92- c.Context().Register(p)92+ registerWireProxy(c, p)
93 }93 }
94 94
95 func TestXDGProtocolErrors(t *testing.T) {95 func TestXDGProtocolErrors(t *testing.T) {
@@ -134,7 +134,7 @@ func TestXDGProtocolErrors(t *testing.T) {
134 }134 }
135 pool := c.AllocateID()135 pool := c.AllocateID()
136 registerProtocol(t, c, shm)136 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 {
138 t.Fatal(err)138 t.Fatal(err)
139 }139 }
140 buffer := c.AllocateID()140 buffer := c.AllocateID()
@@ -199,7 +199,7 @@ func TestRecreatedToplevelRequiresNewBuffer(t *testing.T) {
199 serials := make(chan uint32, 4)199 serials := make(chan uint32, 4)
200 proxy := &configureProxy{serial: serials}200 proxy := &configureProxy{serial: serials}
201 proxy.SetID(xdg)201 proxy.SetID(xdg)
202- c.Context().Register(proxy)202+ registerWireProxy(c, proxy)
203 fd, err := unix.MemfdCreate("recreated-buffer", 0)203 fd, err := unix.MemfdCreate("recreated-buffer", 0)
204 if err != nil {204 if err != nil {
205 t.Fatal(err)205 t.Fatal(err)
@@ -212,7 +212,7 @@ func TestRecreatedToplevelRequiresNewBuffer(t *testing.T) {
212 pool, buffer := c.AllocateID(), c.AllocateID()212 pool, buffer := c.AllocateID(), c.AllocateID()
213 registerProtocol(t, c, pool)213 registerProtocol(t, c, pool)
214 registerProtocol(t, c, buffer)214 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 {
216 t.Fatal(err)216 t.Fatal(err)
217 }217 }
218 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))218 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) {
292 pool := c.AllocateID()292 pool := c.AllocateID()
293 registerProtocol(t, c, shm)293 registerProtocol(t, c, shm)
294 registerProtocol(t, c, pool)294 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 {
296 t.Fatal(err)296 t.Fatal(err)
297 }297 }
298 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, c.AllocateID(), int32(0), int32(2), int32(1), int32(4), uint32(wayland.ShmFormatArgb8888))298 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) {
320 pool := c.AllocateID()320 pool := c.AllocateID()
321 registerProtocol(t, c, shm)321 registerProtocol(t, c, shm)
322 registerProtocol(t, c, pool)322 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 {
324 t.Fatal(err)324 t.Fatal(err)
325 }325 }
326 buffer := c.AllocateID()326 buffer := c.AllocateID()
@@ -390,7 +390,7 @@ func TestSHMBufferReleasedOnReplaceAndDestroy(t *testing.T) {
390 pool := c.AllocateID()390 pool := c.AllocateID()
391 registerProtocol(t, c, shm)391 registerProtocol(t, c, shm)
392 registerProtocol(t, c, pool)392 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 {
394 t.Fatal(err)394 t.Fatal(err)
395 }395 }
396 released := make(chan uint32, 4)396 released := make(chan uint32, 4)
@@ -399,7 +399,7 @@ func TestSHMBufferReleasedOnReplaceAndDestroy(t *testing.T) {
399 bufs[i] = c.AllocateID()399 bufs[i] = c.AllocateID()
400 p := &releaseProxy{released: released}400 p := &releaseProxy{released: released}
401 p.SetID(bufs[i])401 p.SetID(bufs[i])
402- c.Context().Register(p)402+ registerWireProxy(c, p)
403 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, bufs[i], int32(i*32), int32(2), int32(2), int32(8), uint32(wayland.ShmFormatXrgb8888))403 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, bufs[i], int32(i*32), int32(2), int32(2), int32(8), uint32(wayland.ShmFormatXrgb8888))
404 }404 }
405 surf := c.AllocateID()405 surf := c.AllocateID()
@@ -466,7 +466,7 @@ func TestPointerProtocol(t *testing.T) {
466 pointer := c.AllocateID()466 pointer := c.AllocateID()
467 p := &pointerEvents{enters: make(chan [2]float64, 1), buttons: make(chan uint32, 8), axes: make(chan string, 16)}467 p := &pointerEvents{enters: make(chan [2]float64, 1), buttons: make(chan uint32, 8), axes: make(chan string, 16)}
468 p.SetID(pointer)468 p.SetID(pointer)
469- c.Context().Register(p)469+ registerWireProxy(c, p)
470 requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)470 requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
471 comp := bindProtocol(t, c, "wl_compositor")471 comp := bindProtocol(t, c, "wl_compositor")
472 wm := bindProtocol(t, c, "xdg_wm_base")472 wm := bindProtocol(t, c, "xdg_wm_base")
@@ -479,7 +479,7 @@ func TestPointerProtocol(t *testing.T) {
479 serials := make(chan uint32, 1)479 serials := make(chan uint32, 1)
480 xp := &configureProxy{serial: serials}480 xp := &configureProxy{serial: serials}
481 xp.SetID(xdg)481 xp.SetID(xdg)
482- c.Context().Register(xp)482+ registerWireProxy(c, xp)
483 top := c.AllocateID()483 top := c.AllocateID()
484 registerProtocol(t, c, top)484 registerProtocol(t, c, top)
485 requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)485 requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
@@ -499,7 +499,7 @@ func TestPointerProtocol(t *testing.T) {
499 pool, buffer := c.AllocateID(), c.AllocateID()499 pool, buffer := c.AllocateID(), c.AllocateID()
500 registerProtocol(t, c, pool)500 registerProtocol(t, c, pool)
501 registerProtocol(t, c, buffer)501 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 {
503 t.Fatal(err)503 t.Fatal(err)
504 }504 }
505 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))505 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) {
577 keyboard := c.AllocateID()577 keyboard := c.AllocateID()
578 kp := &heldKeyProxy{enters: make(chan []byte, 4)}578 kp := &heldKeyProxy{enters: make(chan []byte, 4)}
579 kp.SetID(keyboard)579 kp.SetID(keyboard)
580- c.Context().Register(kp)580+ registerWireProxy(c, kp)
581 requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, keyboard)581 requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, keyboard)
582 mapWindow := toplevelMapper(t, c, events)582 mapWindow := toplevelMapper(t, c, events)
583 a := mapWindow().ID583 a := mapWindow().ID
@@ -642,7 +642,7 @@ func surfaceMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientE
642 pool, buffer := c.AllocateID(), c.AllocateID()642 pool, buffer := c.AllocateID(), c.AllocateID()
643 registerProtocol(t, c, pool)643 registerProtocol(t, c, pool)
644 registerProtocol(t, c, buffer)644 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 {
646 t.Fatal(err)646 t.Fatal(err)
647 }647 }
648 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))648 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
654 serials := make(chan uint32, 1)654 serials := make(chan uint32, 1)
655 xp := &configureProxy{serial: serials}655 xp := &configureProxy{serial: serials}
656 xp.SetID(xdg)656 xp.SetID(xdg)
657- c.Context().Register(xp)657+ registerWireProxy(c, xp)
658 registerProtocol(t, c, top)658 registerProtocol(t, c, top)
659 requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)659 requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
660 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)660 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
@@ -697,7 +697,7 @@ func xdgWindow(t *testing.T, c *wlturbo.Display, comp, wm, buffer uint32, role f
697 serials = make(chan uint32, 4)697 serials = make(chan uint32, 4)
698 proxy := &configureProxy{serial: serials}698 proxy := &configureProxy{serial: serials}
699 proxy.SetID(xdg)699 proxy.SetID(xdg)
700- c.Context().Register(proxy)700+ registerWireProxy(c, proxy)
701 role(xdg)701 role(xdg)
702 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)702 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
703 if err := c.Roundtrip(); err != nil {703 if err := c.Roundtrip(); err != nil {
@@ -746,7 +746,7 @@ func TestPopupAndFloatingLifecycle(t *testing.T) {
746 pool, buffer := c.AllocateID(), c.AllocateID()746 pool, buffer := c.AllocateID(), c.AllocateID()
747 registerProtocol(t, c, pool)747 registerProtocol(t, c, pool)
748 registerProtocol(t, c, buffer)748 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 {
750 t.Fatal(err)750 t.Fatal(err)
751 }751 }
752 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))752 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) {
777 popup := c.AllocateID()777 popup := c.AllocateID()
778 proxy := &popupDoneProxy{done: done}778 proxy := &popupDoneProxy{done: done}
779 proxy.SetID(popup)779 proxy.SetID(popup)
780- c.Context().Register(proxy)780+ registerWireProxy(c, proxy)
781 requestProtocol(t, c, pxdg, xdgshell.SurfaceRequestGetPopup, popup, xdg, positioner)781 requestProtocol(t, c, pxdg, xdgshell.SurfaceRequestGetPopup, popup, xdg, positioner)
782 })782 })
783 req, ok := waitEvent(t, events, 2*time.Second).(ports.PopupRequest)783 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) {
35 b := shmBuffer(t, c)35 b := shmBuffer(t, c)
36 p := &syncReleaseProxy{released: make(chan struct{}, 4)}36 p := &syncReleaseProxy{released: make(chan struct{}, 4)}
37 p.SetID(b)37 p.SetID(b)
38- c.Context().Register(p)38+ registerWireProxy(c, p)
39 requestProtocol(t, c, child, wayland.SurfaceRequestAttach, b, int32(0), int32(0))39 requestProtocol(t, c, child, wayland.SurfaceRequestAttach, b, int32(0), int32(0))
40 requestProtocol(t, c, child, wayland.SurfaceRequestCommit)40 requestProtocol(t, c, child, wayland.SurfaceRequestCommit)
41 if err := c.Roundtrip(); err != nil {41 if err := c.Roundtrip(); err != nil {
@@ -245,7 +245,7 @@ func TestHeldBufferRequeuedBeforeOutputAck(t *testing.T) {
245 b, other := shmBuffer(t, c), shmBuffer(t, c)245 b, other := shmBuffer(t, c), shmBuffer(t, c)
246 p := &syncReleaseProxy{released: make(chan struct{}, 4)}246 p := &syncReleaseProxy{released: make(chan struct{}, 4)}
247 p.SetID(b)247 p.SetID(b)
248- c.Context().Register(p)248+ registerWireProxy(c, p)
249 attach := func(buf uint32) {249 attach := func(buf uint32) {
250 requestProtocol(t, c, root, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))250 requestProtocol(t, c, root, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
251 requestProtocol(t, c, root, wayland.SurfaceRequestCommit)251 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 {
28 name = surf.xdg.window.last.Output28 name = surf.xdg.window.last.Output
29 case surf.layer != nil && surf.layer.output != nil:29 case surf.layer != nil && surf.layer.output != nil:
30 return surf.layer.output30 return surf.layer.output
31+ case surf.lock != nil:
32+ if !surf.lock.closed {
33+ return surf.lock.output
34+ }
35+ return nil
31 }36 }
32 return s.outputByName(name)37 return s.outputByName(name)
33 }38 }
M internal/adapters/wayland/scale_test.go +7 -7
@@ -60,7 +60,7 @@ func TestFractionalScaleAndViewport(t *testing.T) {
60 c := protocolClient(t, s, dir)60 c := protocolClient(t, s, dir)
61 // wl_surface.preferred_buffer_scale needs wl_compositor v6.61 // wl_surface.preferred_buffer_scale needs wl_compositor v6.
62 g, _ := c.Registry().FindGlobal("wl_compositor")62 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)
64 if err != nil {64 if err != nil {
65 t.Fatal(err)65 t.Fatal(err)
66 }66 }
@@ -74,13 +74,13 @@ func TestFractionalScaleAndViewport(t *testing.T) {
74 bufferScales := make(chan int32, 4)74 bufferScales := make(chan int32, 4)
75 sp := &surfaceScaleProxy{scales: bufferScales}75 sp := &surfaceScaleProxy{scales: bufferScales}
76 sp.SetID(surf)76 sp.SetID(surf)
77- c.Context().Register(sp)77+ registerWireProxy(c, sp)
78 requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)78 requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
79 frac := c.AllocateID()79 frac := c.AllocateID()
80 values := make(chan uint32, 4)80 values := make(chan uint32, 4)
81 proxy := &scaleProxy{values: values}81 proxy := &scaleProxy{values: values}
82 proxy.SetID(frac)82 proxy.SetID(frac)
83- c.Context().Register(proxy)83+ registerWireProxy(c, proxy)
84 requestProtocol(t, c, fm, fractionalscale.WpFractionalScaleManagerV1RequestGetFractionalScale, frac, surf)84 requestProtocol(t, c, fm, fractionalscale.WpFractionalScaleManagerV1RequestGetFractionalScale, frac, surf)
85 vp := c.AllocateID()85 vp := c.AllocateID()
86 requestProtocol(t, c, vpm, viewporter.WpViewporterRequestGetViewport, vp, surf)86 requestProtocol(t, c, vpm, viewporter.WpViewporterRequestGetViewport, vp, surf)
@@ -121,7 +121,7 @@ func TestFractionalScaleAndViewport(t *testing.T) {
121 serials := make(chan uint32, 4)121 serials := make(chan uint32, 4)
122 cp := &configureProxy{serial: serials}122 cp := &configureProxy{serial: serials}
123 cp.SetID(xdg)123 cp.SetID(xdg)
124- c.Context().Register(cp)124+ registerWireProxy(c, cp)
125 top := c.AllocateID()125 top := c.AllocateID()
126 registerProtocol(t, c, top)126 registerProtocol(t, c, top)
127 requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)127 requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
@@ -142,7 +142,7 @@ func TestFractionalScaleAndViewport(t *testing.T) {
142 pool, buffer := c.AllocateID(), c.AllocateID()142 pool, buffer := c.AllocateID(), c.AllocateID()
143 registerProtocol(t, c, pool)143 registerProtocol(t, c, pool)
144 registerProtocol(t, c, buffer)144 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 {
146 t.Fatal(err)146 t.Fatal(err)
147 }147 }
148 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(30), int32(15), int32(120), uint32(0))148 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) {
270 pool, buf := c.AllocateID(), c.AllocateID()270 pool, buf := c.AllocateID(), c.AllocateID()
271 registerProtocol(t, c, pool)271 registerProtocol(t, c, pool)
272 registerProtocol(t, c, buf)272 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 {
274 t.Fatal(err)274 t.Fatal(err)
275 }275 }
276 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(0))276 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) {
321 pool, buf := c.AllocateID(), c.AllocateID()321 pool, buf := c.AllocateID(), c.AllocateID()
322 registerProtocol(t, c, pool)322 registerProtocol(t, c, pool)
323 registerProtocol(t, c, buf)323 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 {
325 t.Fatal(err)325 t.Fatal(err)
326 }326 }
327 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(w), int32(h), int32(w*4), uint32(0))327 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
154 // requestCapture duplicates the pool descriptor, so destroying the wl_buffer154 // requestCapture duplicates the pool descriptor, so destroying the wl_buffer
155 // cannot invalidate a capture in flight.155 // cannot invalidate a capture in flight.
156 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) {156 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+ }
157 if o == nil || b == nil || s.channels.Captures == nil || s.channels.Captured == nil || s.outputByNameExact(o.name()) != o {161 if o == nil || b == nil || s.channels.Captures == nil || s.channels.Captured == nil || s.outputByNameExact(o.name()) != o {
158 return 0, false162 return 0, false
159 }163 }
@@ -220,6 +224,7 @@ func (s *Server) forwardCaptured(ctxDone <-chan struct{}) {
220 delete(s.captureReplies, done.ID)224 delete(s.captureReplies, done.ID)
221 fn(done)225 fn(done)
222 }226 }
227+ s.maybeLocked()
223 }) {228 }) {
224 return229 return
225 }230 }
@@ -264,7 +269,7 @@ func (m copyCaptureManager) CreateSession(r *ext.ExtImageCopyCaptureManagerV1, i
264 m.s.captureSessions[state] = struct{}{}269 m.s.captureSessions[state] = struct{}{}
265 // Frames outlive their session (the spec): each frame drops its own reply.270 // Frames outlive their session (the spec): each frame drops its own reply.
266 res.OnDestroy = func() { delete(m.s.captureSessions, state) }271 res.OnDestroy = func() { delete(m.s.captureSessions, state) }
267- if o == nil {272+ if m.s.protected() || o == nil {
268 res.SendStopped()273 res.SendStopped()
269 state.stopped = true274 state.stopped = true
270 return275 return
@@ -274,6 +279,13 @@ func (m copyCaptureManager) CreateSession(r *ext.ExtImageCopyCaptureManagerV1, i
274 279
275 // sendConstraints sends one complete constraint batch: formats, size, done.280 // sendConstraints sends one complete constraint batch: formats, size, done.
276 func (c *captureSession) sendConstraints(w, h int) {281 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+ }
277 c.res.SendBufferSize(uint32(w), uint32(h))289 c.res.SendBufferSize(uint32(w), uint32(h))
278 c.res.SendShmFormat(uint32(wayland.ShmFormatXrgb8888))290 c.res.SendShmFormat(uint32(wayland.ShmFormatXrgb8888))
279 c.res.SendShmFormat(uint32(wayland.ShmFormatArgb8888))291 c.res.SendShmFormat(uint32(wayland.ShmFormatArgb8888))
@@ -369,7 +381,7 @@ func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) {
369 }381 }
370 f.used = true382 f.used = true
371 c := f.session383 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 {
373 f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonStopped))385 f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonStopped))
374 return386 return
375 }387 }
M internal/adapters/wayland/screencopy_test.go +18 -18
@@ -66,7 +66,7 @@ func TestScreencopyProtocol(t *testing.T) {
66 if out == 0 {66 if out == 0 {
67 t.Fatal("output global missing")67 t.Fatal("output global missing")
68 }68 }
69- outputID, err := c.Registry().BindID(out, "wl_output", 4)69+ outputID, err := bindWireID(c, out, "wl_output", 4)
70 if err != nil {70 if err != nil {
71 t.Fatal(err)71 t.Fatal(err)
72 }72 }
@@ -75,7 +75,7 @@ func TestScreencopyProtocol(t *testing.T) {
75 frame := c.AllocateID()75 frame := c.AllocateID()
76 events := &captureEvents{events: make(chan uint16, 8)}76 events := &captureEvents{events: make(chan uint16, 8)}
77 events.SetID(frame)77 events.SetID(frame)
78- c.Context().Register(events)78+ registerWireProxy(c, events)
79 requestProtocol(t, c, manager, wlr.ZwlrScreencopyManagerV1RequestCaptureOutputRegion, frame, int32(0), outputID, int32(1), int32(1), int32(2), int32(2))79 requestProtocol(t, c, manager, wlr.ZwlrScreencopyManagerV1RequestCaptureOutputRegion, frame, int32(0), outputID, int32(1), int32(1), int32(2), int32(2))
80 if err := c.Roundtrip(); err != nil {80 if err := c.Roundtrip(); err != nil {
81 t.Fatal(err)81 t.Fatal(err)
@@ -99,7 +99,7 @@ func TestScreencopyProtocol(t *testing.T) {
99 pool, buf := c.AllocateID(), c.AllocateID()99 pool, buf := c.AllocateID(), c.AllocateID()
100 registerProtocol(t, c, pool)100 registerProtocol(t, c, pool)
101 registerProtocol(t, c, buf)101 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 {
103 t.Fatal(err)103 t.Fatal(err)
104 }104 }
105 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(2), int32(2), int32(8), uint32(wayland.ShmFormatXrgb8888))105 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) {
153 if !ok {153 if !ok {
154 t.Fatal("wl_output missing")154 t.Fatal("wl_output missing")
155 }155 }
156- out, err := c.Registry().BindID(g.Name, g.Interface, 4)156+ out, err := bindWireID(c, g.Name, g.Interface, 4)
157 if err != nil {157 if err != nil {
158 t.Fatal(err)158 t.Fatal(err)
159 }159 }
@@ -173,7 +173,7 @@ func captureTestExt(t *testing.T, c *wlturbo.Display, out uint32) (uint32, uint3
173 requestProtocol(t, c, sourceManager, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, out)173 requestProtocol(t, c, sourceManager, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, out)
174 events := &captureEvents{events: make(chan uint16, 16)}174 events := &captureEvents{events: make(chan uint16, 16)}
175 events.SetID(session)175 events.SetID(session)
176- c.Context().Register(events)176+ registerWireProxy(c, events)
177 requestProtocol(t, c, manager, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0))177 requestProtocol(t, c, manager, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0))
178 if err := c.Roundtrip(); err != nil {178 if err := c.Roundtrip(); err != nil {
179 t.Fatal(err)179 t.Fatal(err)
@@ -228,7 +228,7 @@ func TestCaptureProtocolErrors(t *testing.T) {
228 registerProtocol(t, c, pool)228 registerProtocol(t, c, pool)
229 registerProtocol(t, c, buf)229 registerProtocol(t, c, buf)
230 registerProtocol(t, c, shm)230 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 {
232 t.Fatal(err)232 t.Fatal(err)
233 }233 }
234 w := int32(1)234 w := int32(1)
@@ -282,7 +282,7 @@ func TestCaptureProtocolErrors(t *testing.T) {
282 frame := c.AllocateID()282 frame := c.AllocateID()
283 frameEvents := &captureDetails{events: make(chan []uint32, 4)}283 frameEvents := &captureDetails{events: make(chan []uint32, 4)}
284 frameEvents.SetID(frame)284 frameEvents.SetID(frame)
285- c.Context().Register(frameEvents)285+ registerWireProxy(c, frameEvents)
286 requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame)286 requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame)
287 switch tc.object {287 switch tc.object {
288 case "frame":288 case "frame":
@@ -301,7 +301,7 @@ func TestCaptureProtocolErrors(t *testing.T) {
301 pool, buf := c.AllocateID(), c.AllocateID()301 pool, buf := c.AllocateID(), c.AllocateID()
302 registerProtocol(t, c, pool)302 registerProtocol(t, c, pool)
303 registerProtocol(t, c, buf)303 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 {
305 t.Fatal(err)305 t.Fatal(err)
306 }306 }
307 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(wayland.ShmFormatXrgb8888))307 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) {
436 if !ok {436 if !ok {
437 t.Fatal("output missing")437 t.Fatal("output missing")
438 }438 }
439- out, err := c.Registry().BindID(g.Name, g.Interface, 4)439+ out, err := bindWireID(c, g.Name, g.Interface, 4)
440 if err != nil {440 if err != nil {
441 t.Fatal(err)441 t.Fatal(err)
442 }442 }
@@ -447,7 +447,7 @@ func TestExtCaptureLifecycle(t *testing.T) {
447 registerProtocol(t, c, src)447 registerProtocol(t, c, src)
448 p := &captureDetails{events: make(chan []uint32, 16)}448 p := &captureDetails{events: make(chan []uint32, 16)}
449 p.SetID(session)449 p.SetID(session)
450- c.Context().Register(p)450+ registerWireProxy(c, p)
451 requestProtocol(t, c, srcMgr, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, out)451 requestProtocol(t, c, srcMgr, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, out)
452 requestProtocol(t, c, mgr, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0))452 requestProtocol(t, c, mgr, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0))
453 if err := c.Roundtrip(); err != nil {453 if err := c.Roundtrip(); err != nil {
@@ -473,14 +473,14 @@ func TestExtCaptureLifecycle(t *testing.T) {
473 pool, buf := c.AllocateID(), c.AllocateID()473 pool, buf := c.AllocateID(), c.AllocateID()
474 registerProtocol(t, c, pool)474 registerProtocol(t, c, pool)
475 registerProtocol(t, c, buf)475 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 {
477 t.Fatal(err)477 t.Fatal(err)
478 }478 }
479 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(4), int32(4), int32(16), uint32(wayland.ShmFormatXrgb8888))479 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(4), int32(4), int32(16), uint32(wayland.ShmFormatXrgb8888))
480 frame := c.AllocateID()480 frame := c.AllocateID()
481 fp := &captureDetails{events: make(chan []uint32, 16)}481 fp := &captureDetails{events: make(chan []uint32, 16)}
482 fp.SetID(frame)482 fp.SetID(frame)
483- c.Context().Register(fp)483+ registerWireProxy(c, fp)
484 requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame)484 requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame)
485 requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf)485 requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf)
486 requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestCapture)486 requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestCapture)
@@ -534,7 +534,7 @@ func TestExtCaptureLifecycle(t *testing.T) {
534 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf2, int32(0), int32(5), int32(4), int32(20), uint32(wayland.ShmFormatXrgb8888))534 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf2, int32(0), int32(5), int32(4), int32(20), uint32(wayland.ShmFormatXrgb8888))
535 fp2 := &captureDetails{events: make(chan []uint32, 16)}535 fp2 := &captureDetails{events: make(chan []uint32, 16)}
536 fp2.SetID(frame2)536 fp2.SetID(frame2)
537- c.Context().Register(fp2)537+ registerWireProxy(c, fp2)
538 requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame2)538 requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame2)
539 requestProtocol(t, c, frame2, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf2)539 requestProtocol(t, c, frame2, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf2)
540 requestProtocol(t, c, frame2, ext.ExtImageCopyCaptureFrameV1RequestCapture)540 requestProtocol(t, c, frame2, ext.ExtImageCopyCaptureFrameV1RequestCapture)
@@ -592,7 +592,7 @@ func TestExtCaptureStoppedOnOutputRemoval(t *testing.T) {
592 if !ok {592 if !ok {
593 t.Fatal("output missing")593 t.Fatal("output missing")
594 }594 }
595- out, err := c.Registry().BindID(g.Name, g.Interface, 4)595+ out, err := bindWireID(c, g.Name, g.Interface, 4)
596 if err != nil {596 if err != nil {
597 t.Fatal(err)597 t.Fatal(err)
598 }598 }
@@ -603,7 +603,7 @@ func TestExtCaptureStoppedOnOutputRemoval(t *testing.T) {
603 registerProtocol(t, c, src)603 registerProtocol(t, c, src)
604 p := &captureDetails{events: make(chan []uint32, 16)}604 p := &captureDetails{events: make(chan []uint32, 16)}
605 p.SetID(session)605 p.SetID(session)
606- c.Context().Register(p)606+ registerWireProxy(c, p)
607 requestProtocol(t, c, srcMgr, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, out)607 requestProtocol(t, c, srcMgr, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, out)
608 requestProtocol(t, c, mgr, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0))608 requestProtocol(t, c, mgr, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0))
609 if err := c.Roundtrip(); err != nil {609 if err := c.Roundtrip(); err != nil {
@@ -647,7 +647,7 @@ func TestWlrOutputGoneAndDestroyedBeforeReply(t *testing.T) {
647 if !ok {647 if !ok {
648 t.Fatal("output missing")648 t.Fatal("output missing")
649 }649 }
650- out, err := c.Registry().BindID(g.Name, g.Interface, 4)650+ out, err := bindWireID(c, g.Name, g.Interface, 4)
651 if err != nil {651 if err != nil {
652 t.Fatal(err)652 t.Fatal(err)
653 }653 }
@@ -656,7 +656,7 @@ func TestWlrOutputGoneAndDestroyedBeforeReply(t *testing.T) {
656 frame := c.AllocateID()656 frame := c.AllocateID()
657 events := &captureEvents{events: make(chan uint16, 8)}657 events := &captureEvents{events: make(chan uint16, 8)}
658 events.SetID(frame)658 events.SetID(frame)
659- c.Context().Register(events)659+ registerWireProxy(c, events)
660 requestProtocol(t, c, manager, wlr.ZwlrScreencopyManagerV1RequestCaptureOutput, frame, int32(0), out)660 requestProtocol(t, c, manager, wlr.ZwlrScreencopyManagerV1RequestCaptureOutput, frame, int32(0), out)
661 if err := c.Roundtrip(); err != nil {661 if err := c.Roundtrip(); err != nil {
662 t.Fatal(err)662 t.Fatal(err)
@@ -732,7 +732,7 @@ func captureSmallBuffer(t *testing.T, c *wlturbo.Display) (uint32, uint32, int)
732 pool, buf := c.AllocateID(), c.AllocateID()732 pool, buf := c.AllocateID(), c.AllocateID()
733 registerProtocol(t, c, pool)733 registerProtocol(t, c, pool)
734 registerProtocol(t, c, buf)734 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 {
736 t.Fatal(err)736 t.Fatal(err)
737 }737 }
738 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(2), int32(2), int32(8), uint32(wayland.ShmFormatXrgb8888))738 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 {
47 47
48 // applyInput delivers a core input command to the focused client.48 // applyInput delivers a core input command to the focused client.
49 func (s *Server) applyInput(cmd ports.ClientCommand) {49 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+ }
50 switch c := cmd.(type) {72 switch c := cmd.(type) {
51 case ports.PointerFocus:73 case ports.PointerFocus:
52 s.changePointerFocus(c.ID, c.X, c.Y)74 s.changePointerFocus(c.ID, c.X, c.Y)
53 case ports.PointerMotionTo:75 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+ }
54 if l := s.layers[c.ID]; l != nil && c.ID == s.seat.pointerFocus {84 if l := s.layers[c.ID]; l != nil && c.ID == s.seat.pointerFocus {
55 s.seat.pointerX, s.seat.pointerY = c.X, c.Y85 s.seat.pointerX, s.seat.pointerY = c.X, c.Y
56 for _, p := range s.layerPointers(l) {86 for _, p := range s.layerPointers(l) {
@@ -111,7 +141,7 @@ func (s *Server) applyInput(cmd ports.ClientCommand) {
111 s.changeFocus(c.ID)141 s.changeFocus(c.ID)
112 }142 }
113 case ports.ForwardKey:143 case ports.ForwardKey:
114- if s.grabKey(c) {144+ if !(s.security != nil && s.security.Snapshot().Protected) && s.grabKey(c) {
115 return145 return
116 }146 }
117 _, keyboards := s.focusTarget(c.ID)147 _, keyboards := s.focusTarget(c.ID)
@@ -170,6 +200,11 @@ func (s *Server) clientKeyboards(c server.Client) []*wayland.Keyboard {
170 200
171 // focusTarget resolves a window or layer ID to its wl_surface and keyboards.201 // focusTarget resolves a window or layer ID to its wl_surface and keyboards.
172 func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Keyboard) {202 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+ }
173 if w := s.windows[id]; w != nil && w.mapped {208 if w := s.windows[id]; w != nil && w.mapped {
174 if surf := w.xdg.surfaceResource(); surf != nil {209 if surf := w.xdg.surfaceResource(); surf != nil {
175 return surf, s.windowKeyboards(w)210 return surf, s.windowKeyboards(w)
@@ -186,6 +221,9 @@ func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Ke
186 // pointerTarget resolves a mapped window or layer ID to its client and221 // pointerTarget resolves a mapped window or layer ID to its client and
187 // live pointers.222 // live pointers.
188 func (s *Server) pointerTarget(id ports.WindowID) (server.Client, []*wayland.Pointer, bool) {223 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+ }
189 if w := s.windows[id]; w != nil && w.mapped && w.xdg.resource.Resource.Alive() {227 if w := s.windows[id]; w != nil && w.mapped && w.xdg.resource.Resource.Alive() {
190 return w.xdg.resource.Client(), s.windowPointers(w), true228 return w.xdg.resource.Client(), s.windowPointers(w), true
191 }229 }
@@ -198,6 +236,9 @@ func (s *Server) pointerTarget(id ports.WindowID) (server.Client, []*wayland.Poi
198 // pointerSurface is the wl_surface of a mapped window or layer and the236 // pointerSurface is the wl_surface of a mapped window or layer and the
199 // point in its surface coordinates.237 // point in its surface coordinates.
200 func (s *Server) pointerSurface(id ports.WindowID, x, y float64) (*wayland.Surface, []*wayland.Pointer, float64, float64) {238 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+ }
201 if w := s.windows[id]; w != nil {242 if w := s.windows[id]; w != nil {
202 if surf := w.xdg.surfaceResource(); surf != nil && surf.Resource.Alive() {243 if surf := w.xdg.surfaceResource(); surf != nil && surf.Resource.Alive() {
203 x, y := w.surfacePoint(x, y)244 x, y := w.surfacePoint(x, y)
@@ -285,6 +326,9 @@ func (s *Server) sendModifiers(k *wayland.Keyboard) {
285 }326 }
286 327
287 func (s *Server) changeFocus(id ports.WindowID) {328 func (s *Server) changeFocus(id ports.WindowID) {
329+ if s.security != nil && s.security.Snapshot().Protected && id != 0 && !s.lockInputTarget(id) {
330+ return
331+ }
288 // Enter carries seat held keys and modifiers: they need the seat keymap.332 // Enter carries seat held keys and modifiers: they need the seat keymap.
289 s.useKeymap(nil)333 s.useKeymap(nil)
290 old := s.focusClient()334 old := s.focusClient()
@@ -421,6 +465,9 @@ func (s *Server) clientPointers(c server.Client) []*wayland.Pointer {
421 return result465 return result
422 }466 }
423 func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) {467 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+ }
424 if id == s.seat.pointerFocus {471 if id == s.seat.pointerFocus {
425 return472 return
426 }473 }
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 (
11 11
12 "github.com/bnema/neferwl/internal/adapters/clock"12 "github.com/bnema/neferwl/internal/adapters/clock"
13 "github.com/bnema/neferwl/internal/ports"13 "github.com/bnema/neferwl/internal/ports"
14+ "github.com/bnema/neferwl/internal/sessionlock"
14 "github.com/bnema/purego-libwayland/protocol/fractionalscale"15 "github.com/bnema/purego-libwayland/protocol/fractionalscale"
15 "github.com/bnema/purego-libwayland/protocol/wayland"16 "github.com/bnema/purego-libwayland/protocol/wayland"
16 "github.com/bnema/purego-libwayland/server"17 "github.com/bnema/purego-libwayland/server"
@@ -33,18 +34,25 @@ type Options struct {
33 RepeatRate, RepeatDelay int34 RepeatRate, RepeatDelay int
34 // Clock runs idle notification timers; nil is the system clock.35 // Clock runs idle notification timers; nil is the system clock.
35 Clock ports.Clock36 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
36 // syncDev replaces the render node's syncobj interface (tests).40 // syncDev replaces the render node's syncobj interface (tests).
37 syncDev syncobjDevice41 syncDev syncobjDevice
38 }42 }
39 43
40 // Channels carries client notifications and commands. Events may be unbuffered.44 // Channels carries client notifications and commands. Events may be unbuffered.
41 type Channels struct {45 type Channels struct {
42- LeaseRequests chan<- ports.LeaseMessage46+ // SecurityChanges requests ordered backend protection/release. The
43- LeaseEvents <-chan ports.LeaseMessage47+ // backend echoes actual inventory/proofs/barriers on SecurityEvents.
44- Events chan<- ports.ClientEvent48+ SecurityChanges chan<- ports.SecurityState
45- Contents chan<- ports.SurfaceContent49+ SecurityEvents <-chan ports.SecurityBackendEvent
46- Commands <-chan ports.ClientCommand50+ LeaseRequests chan<- ports.LeaseMessage
47- Workspaces <-chan ports.Workspaces51+ LeaseEvents <-chan ports.LeaseMessage
52+ Events chan<- ports.ClientEvent
53+ Contents chan<- ports.SurfaceContent
54+ Commands <-chan ports.ClientCommand
55+ Workspaces <-chan ports.Workspaces
48 // Cursors receives the cursor the client under the pointer asks for.56 // Cursors receives the cursor the client under the pointer asks for.
49 Cursors chan<- ports.CursorChange57 Cursors chan<- ports.CursorChange
50 // Presented paces frame callbacks on the outputs' page flips; outputs58 // Presented paces frame callbacks on the outputs' page flips; outputs
@@ -65,17 +73,25 @@ type Channels struct {
65 OutputApply chan<- ports.OutputApply73 OutputApply chan<- ports.OutputApply
66 OutputApplied <-chan ports.OutputApplied74 OutputApplied <-chan ports.OutputApplied
67 }75 }
76+
68 type Server struct {77 type Server struct {
69- leaseDevices map[string]*leaseDevice78+ security ports.SessionSecurity
70- pendingLeases map[uint64]*leaseObject79+ securityController ports.SessionSecurityController
71- pendingLeaseCards map[uint64]string80+ securityPending []ports.SecurityState // display-owned ordered backend outbox
72- activeLeases map[leaseKey]*leaseObject81+ securityWake chan struct{}
73- leaseMu sync.Mutex // only the outbound lease queue; never window state82+ sessionLock *sessionLock
74- leaseOut []ports.LeaseMessage83+ lockReadiness sessionlock.Readiness
75- leaseReady chan struct{}84+ lockSurfaces map[ports.WindowID]*lockSurface
76- nextLeaseID uint6485+ leaseDevices map[string]*leaseDevice
77- nextCapture uint6486+ pendingLeases map[uint64]*leaseObject
78- captureReplies map[uint64]func(ports.CaptureDone)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)
79 // captureInflight counts accepted captures until the backend completes95 // captureInflight counts accepted captures until the backend completes
80 // them, independent of client resources; only the display loop touches it.96 // them, independent of client resources; only the display loop touches it.
81 captureInflight map[uint64]struct{}97 captureInflight map[uint64]struct{}
@@ -237,7 +253,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
237 d.Close()253 d.Close()
238 return nil, err254 return nil, err
239 }255 }
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}}
241 s.leaseDevices = map[string]*leaseDevice{}257 s.leaseDevices = map[string]*leaseDevice{}
242 s.pendingLeases = map[uint64]*leaseObject{}258 s.pendingLeases = map[uint64]*leaseObject{}
243 s.pendingLeaseCards = map[uint64]string{}259 s.pendingLeaseCards = map[uint64]string{}
@@ -336,7 +352,9 @@ func (s *Server) Run(ctx context.Context) error {
336 s.started = time.Now()352 s.started = time.Now()
337 s.ctx = ctx353 s.ctx = ctx
338 var wg sync.WaitGroup354 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) }()
340 go func() { defer wg.Done(); s.forwardLeases(ctx) }()358 go func() { defer wg.Done(); s.forwardLeases(ctx) }()
341 go func() { defer wg.Done(); s.forwardLeaseRequests(ctx) }()359 go func() { defer wg.Done(); s.forwardLeaseRequests(ctx) }()
342 go func() { defer wg.Done(); s.forwardWorkspaces(ctx) }()360 go func() { defer wg.Done(); s.forwardWorkspaces(ctx) }()
@@ -609,6 +627,19 @@ func drainCommands(first ports.ClientCommand, cmds <-chan ports.ClientCommand) (
609 }627 }
610 628
611 func (s *Server) apply(cmd ports.ClientCommand) {629 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+ }
612 switch c := cmd.(type) {643 switch c := cmd.(type) {
613 case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey:644 case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey:
614 s.applyInput(c)645 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) {
12 env := newMockprocEnv(t)12 env := newMockprocEnv(t)
13 // The test client runs in this process: its PID is ours.13 // The test client runs in this process: its PID is ours.
14 env.EXPECT().Lookup(os.Getpid(), ports.SlotEnv).Return("7", true).Once()14 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 })
16 mapWindow := toplevelMapper(t, protocolClient(t, s, dir), events)16 mapWindow := toplevelMapper(t, protocolClient(t, s, dir), events)
17 // No slot pending: /proc is not read (the mock would fail on a call).17 // No slot pending: /proc is not read (the mock would fail on a call).
18 if w := mapWindow(); w.Slot != "" || w.PID != os.Getpid() {18 if w := mapWindow(); w.Slot != "" || w.PID != os.Getpid() {
19 t.Fatalf("slot %q pid %d", w.Slot, w.PID)19 t.Fatalf("slot %q pid %d", w.Slot, w.PID)
20 }20 }
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+ }
22 if w := mapWindow(); w.Slot != "7" {26 if w := mapWindow(); w.Slot != "7" {
23 t.Fatalf("slot %q", w.Slot)27 t.Fatalf("slot %q", w.Slot)
24 }28 }
M internal/adapters/wayland/subsurface_test.go +4 -4
@@ -171,7 +171,7 @@ func TestSubsurfaceSupersededBufferReleased(t *testing.T) {
171 first := shmBuffer(t, c)171 first := shmBuffer(t, c)
172 p := &syncReleaseProxy{released: make(chan struct{}, 4)}172 p := &syncReleaseProxy{released: make(chan struct{}, 4)}
173 p.SetID(first)173 p.SetID(first)
174- c.Context().Register(p)174+ registerWireProxy(c, p)
175 for _, b := range []uint32{first, shmBuffer(t, c)} {175 for _, b := range []uint32{first, shmBuffer(t, c)} {
176 requestProtocol(t, c, child, wayland.SurfaceRequestAttach, b, int32(0), int32(0))176 requestProtocol(t, c, child, wayland.SurfaceRequestAttach, b, int32(0), int32(0))
177 requestProtocol(t, c, child, wayland.SurfaceRequestCommit)177 requestProtocol(t, c, child, wayland.SurfaceRequestCommit)
@@ -479,7 +479,7 @@ func TestSubsurfaceContent(t *testing.T) {
479 registerProtocol(t, c, pool)479 registerProtocol(t, c, pool)
480 registerProtocol(t, c, big)480 registerProtocol(t, c, big)
481 registerProtocol(t, c, small)481 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 {
483 t.Fatal(err)483 t.Fatal(err)
484 }484 }
485 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, big, int32(0), int32(4), int32(4), int32(16), uint32(0))485 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) {
498 serials := make(chan uint32, 4)498 serials := make(chan uint32, 4)
499 xp := &configureProxy{serial: serials}499 xp := &configureProxy{serial: serials}
500 xp.SetID(xdg)500 xp.SetID(xdg)
501- c.Context().Register(xp)501+ registerWireProxy(c, xp)
502 top := c.AllocateID()502 top := c.AllocateID()
503 registerProtocol(t, c, top)503 registerProtocol(t, c, top)
504 requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)504 requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
@@ -615,7 +615,7 @@ func TestSubsurfaceGraphSupersededFeedback(t *testing.T) {
615 first := &feedbackEvents{out: make(chan presentedEvent, 2)}615 first := &feedbackEvents{out: make(chan presentedEvent, 2)}
616 id := c.AllocateID()616 id := c.AllocateID()
617 first.SetID(id)617 first.SetID(id)
618- c.Context().Register(first)618+ registerWireProxy(c, first)
619 requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, child, id)619 requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, child, id)
620 requestProtocol(t, c, child, wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0))620 requestProtocol(t, c, child, wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0))
621 requestProtocol(t, c, child, wayland.SurfaceRequestCommit)621 requestProtocol(t, c, child, wayland.SurfaceRequestCommit)
M internal/adapters/wayland/surface.go +32 -6
@@ -3,6 +3,7 @@ package wayland
3 import (3 import (
4 "image"4 "image"
5 "math"5 "math"
6+ "os"
6 "slices"7 "slices"
7 "time"8 "time"
8 9
@@ -23,6 +24,7 @@ const (
23 roleLayer24 roleLayer
24 roleCursor25 roleCursor
25 roleInputPopup26 roleInputPopup
27+ roleSessionLock
26 )28 )
27 29
28 type surface struct {30 type surface struct {
@@ -40,11 +42,14 @@ type surface struct {
40 kind roleKind42 kind roleKind
41 xdg *xdgSurface43 xdg *xdgSurface
42 layer *layerSurface44 layer *layerSurface
43- server *Server45+ lock *lockSurface
44- current *wayland.Buffer46+ // bufferCommitted is permanent, including commits later discarded by FIFO.
45- lastW, lastH int // last logged buffer size47+ bufferCommitted bool
46- role func(bool)48+ server *Server
47- destroyed bool49+ current *wayland.Buffer
50+ lastW, lastH int // last logged buffer size
51+ role func(bool)
52+ destroyed bool
48 // hotX, hotY is the click point of a cursor surface (logical).53 // hotX, hotY is the click point of a cursor surface (logical).
49 hotX, hotY int54 hotX, hotY int
50 // on is the output the surface entered; scale is the last scale sent.55 // on is the output the surface entered; scale is the last scale sent.
@@ -135,6 +140,7 @@ type pendingCommit struct {
135 type commitSync struct {140 type commitSync struct {
136 acquire, release syncPoint141 acquire, release syncPoint
137 wait *syncWait142 wait *syncWait
143+ fence *os.File // exported before application; owned until apply/drop
138 }144 }
139 145
140 // damage is what one commit changed in a surface's buffer.146 // damage is what one commit changed in a surface's buffer.
@@ -173,6 +179,9 @@ func (s *surface) windowID() ports.WindowID {
173 if s.layer != nil && s.layer.mapped {179 if s.layer != nil && s.layer.mapped {
174 return s.layer.id180 return s.layer.id
175 }181 }
182+ if s.lock != nil && s.lock.mapped {
183+ return s.lock.id
184+ }
176 return 0185 return 0
177 }186 }
178 187
@@ -332,6 +341,12 @@ func (s *surface) Frame(r *wayland.Surface, id uint32) {
332 }341 }
333 }342 }
334 func (s *surface) Commit(*wayland.Surface) {343 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+ }
335 if s.layer != nil && s.next.attached && s.next.buffer != nil && !s.layer.acked {350 if s.layer != nil && s.next.attached && s.next.buffer != nil && !s.layer.acked {
336 s.layer.resource.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSurfaceState), "buffer before configure ack")351 s.layer.resource.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSurfaceState), "buffer before configure ack")
337 return352 return
@@ -388,7 +403,10 @@ func (s *surface) applyCommit(u *update) {
388 s.async = u.async403 s.async = u.async
389 s.color = u.color404 s.color = u.color
390 s.representation = u.representation405 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()) {
392 s.committedViewport.destW, s.committedViewport.destH, s.committedViewport.dest = u.vpW, u.vpH, u.vpSet410 s.committedViewport.destW, s.committedViewport.destH, s.committedViewport.dest = u.vpW, u.vpH, u.vpSet
393 s.committedViewport.src, s.committedViewport.crop = u.vpSrc, u.vpCrop411 s.committedViewport.src, s.committedViewport.crop = u.vpSrc, u.vpCrop
394 } else {412 } else {
@@ -413,6 +431,9 @@ func (s *surface) applyCommit(u *update) {
413 if len(u.callbacks) > 0 {431 if len(u.callbacks) > 0 {
414 s.server.queueFrames(s, u.callbacks)432 s.server.queueFrames(s, u.callbacks)
415 }433 }
434+ if s.lock != nil && !s.commitSkipped && !s.lock.checkCaptured(u) {
435+ return
436+ }
416 if s.role != nil {437 if s.role != nil {
417 if u.layer != nil && s.layer == u.layer {438 if u.layer != nil && s.layer == u.layer {
418 u.layer.commitState(u.layerNext, s.current != nil)439 u.layer.commitState(u.layerNext, s.current != nil)
@@ -488,6 +509,11 @@ func (s *surface) applyCommit(u *update) {
488 if s.xdg != nil && s.xdg.window != nil {509 if s.xdg != nil && s.xdg.window != nil {
489 s.xdg.window.afterCommit()510 s.xdg.window.afterCommit()
490 }511 }
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+ }
491 reshaped := s.has && (s.content.LogicalW != oldW || s.content.LogicalH != oldH || s.content.Source != oldSource || s.content.Transform != oldTransform)517 reshaped := s.has && (s.content.LogicalW != oldW || s.content.LogicalH != oldH || s.content.Source != oldSource || s.content.Transform != oldTransform)
492 // Opacity changes how every pixel blends, so it repaints the whole surface.518 // Opacity changes how every pixel blends, so it repaints the whole surface.
493 opacity := s.has && (s.content.Opaque != oldOpaque || s.content.Fade != oldFade)519 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 {
192 // syncWaiter watches acquire points: each gets an eventfd the kernel192 // syncWaiter watches acquire points: each gets an eventfd the kernel
193 // writes once the point is signalled, and the pacer is woken to apply the193 // writes once the point is signalled, and the pacer is woken to apply the
194 // commits that waited for it.194 // 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+
195 type syncWaiter struct {205 type syncWaiter struct {
206+ poller acquirePoller
207+ terminal bool // polling ended in failure; protected by mu
196 mu sync.Mutex208 mu sync.Mutex
197 ready map[*syncWait]bool209 ready map[*syncWait]bool
198 wake func()210 wake func()
@@ -207,6 +219,7 @@ type syncWaiter struct {
207 type syncWait struct {219 type syncWait struct {
208 efd int220 efd int
209 done bool // the eventfd fired or the wait was cancelled (pacer, under Do)221 done bool // the eventfd fired or the wait was cancelled (pacer, under Do)
222+ failed bool // observation failed; done must not authorize publication
210 cancelled atomic.Bool // run stops polling it and closes efd223 cancelled atomic.Bool // run stops polling it and closes efd
211 }224 }
212 225
@@ -215,28 +228,38 @@ func newSyncWaiter(wake func()) (*syncWaiter, error) {
215 if err != nil {228 if err != nil {
216 return nil, err229 return nil, err
217 }230 }
218- return &syncWaiter{ready: map[*syncWait]bool{}, wake: wake, pipeR: r, pipeW: w, log: zerowrap.Default()}, nil231+ return &syncWaiter{poller: unixAcquirePoller{}, ready: map[*syncWait]bool{}, wake: wake, pipeR: r, pipeW: w, log: zerowrap.Default()}, nil
219 }232 }
220 233
221 // watch registers an eventfd for p; the wait is ready once it fires.234 // watch registers an eventfd for p; the wait is ready once it fires.
222 func (sw *syncWaiter) watch(p syncPoint) (*syncWait, error) {235 func (sw *syncWaiter) watch(p syncPoint) (*syncWait, error) {
236+ if sw.stopped() {
237+ return &syncWait{efd: -1, done: true, failed: true}, unix.EIO
238+ }
223 ready, err := p.tl.dev.signalled(p.tl.handle, p.point)239 ready, err := p.tl.dev.signalled(p.tl.handle, p.point)
224 if err != nil || ready {240 if err != nil || ready {
225- return &syncWait{efd: -1, done: ready}, err241+ return &syncWait{efd: -1, done: true, failed: err != nil}, err
226 }242 }
227 efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)243 efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
228 if err != nil {244 if err != nil {
229- return nil, err245+ return &syncWait{efd: -1, done: true, failed: true}, err
230 }246 }
231 if err := p.tl.dev.eventfd(p.tl.handle, p.point, efd); err != nil {247 if err := p.tl.dev.eventfd(p.tl.handle, p.point, efd); err != nil {
232 unix.Close(efd)248 unix.Close(efd)
233- return nil, err249+ return &syncWait{efd: -1, done: true, failed: true}, err
234 }250 }
235- return sw.add(efd), nil251+ w := sw.add(efd)
252+ if w.failed {
253+ return w, unix.EIO
254+ }
255+ return w, nil
236 }256 }
237 257
238 // watchImplicit waits for a dma-buf's exclusive fences without blocking dispatch.258 // watchImplicit waits for a dma-buf's exclusive fences without blocking dispatch.
239 func (sw *syncWaiter) watchImplicit(b *ports.DMABuf, waits *[4]*syncWait) error {259 func (sw *syncWaiter) watchImplicit(b *ports.DMABuf, waits *[4]*syncWait) error {
260+ if sw.stopped() {
261+ return unix.EIO
262+ }
240 var fds [4]unix.PollFd263 var fds [4]unix.PollFd
241 for i, p := range b.Planes {264 for i, p := range b.Planes {
242 if i >= len(fds) {265 if i >= len(fds) {
@@ -258,7 +281,7 @@ func (sw *syncWaiter) watchImplicit(b *ports.DMABuf, waits *[4]*syncWait) error
258 }281 }
259 if fd.Revents&(unix.POLLERR|unix.POLLNVAL|unix.POLLHUP) != 0 {282 if fd.Revents&(unix.POLLERR|unix.POLLNVAL|unix.POLLHUP) != 0 {
260 sw.warnPollError()283 sw.warnPollError()
261- continue284+ return unix.EIO
262 }285 }
263 dup, err := unix.FcntlInt(uintptr(fd.Fd), unix.F_DUPFD_CLOEXEC, 0)286 dup, err := unix.FcntlInt(uintptr(fd.Fd), unix.F_DUPFD_CLOEXEC, 0)
264 if err != nil {287 if err != nil {
@@ -269,6 +292,15 @@ func (sw *syncWaiter) watchImplicit(b *ports.DMABuf, waits *[4]*syncWait) error
269 return err292 return err
270 }293 }
271 waits[i] = sw.add(dup)294 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+ }
272 }304 }
273 return nil305 return nil
274 }306 }
@@ -282,6 +314,11 @@ func (sw *syncWaiter) warnPollError() {
282 func (sw *syncWaiter) add(fd int) *syncWait {314 func (sw *syncWaiter) add(fd int) *syncWait {
283 w := &syncWait{efd: fd}315 w := &syncWait{efd: fd}
284 sw.mu.Lock()316 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+ }
285 sw.added = append(sw.added, w)322 sw.added = append(sw.added, w)
286 sw.mu.Unlock()323 sw.mu.Unlock()
287 _, _ = sw.pipeW.Write([]byte{0})324 _, _ = sw.pipeW.Write([]byte{0})
@@ -292,9 +329,9 @@ func (sw *syncWaiter) add(fd int) *syncWait {
292 func (sw *syncWaiter) fired(w *syncWait) bool {329 func (sw *syncWaiter) fired(w *syncWait) bool {
293 sw.mu.Lock()330 sw.mu.Lock()
294 defer sw.mu.Unlock()331 defer sw.mu.Unlock()
295- if sw.ready[w] {332+ if ready, ok := sw.ready[w]; ok {
296 delete(sw.ready, w)333 delete(sw.ready, w)
297- w.done = true334+ w.done, w.failed = true, !ready
298 }335 }
299 return w.done336 return w.done
300 }337 }
@@ -312,6 +349,28 @@ func (sw *syncWaiter) cancel(w *syncWait) {
312 _, _ = sw.pipeW.Write([]byte{0}) // run closes its eventfd349 _, _ = sw.pipeW.Write([]byte{0}) // run closes its eventfd
313 }350 }
314 351
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+
315 // run polls every watched eventfd; it owns and closes them.374 // run polls every watched eventfd; it owns and closes them.
316 func (sw *syncWaiter) run(ctx context.Context) {375 func (sw *syncWaiter) run(ctx context.Context) {
317 defer sw.pipeR.Close()376 defer sw.pipeR.Close()
@@ -339,8 +398,9 @@ func (sw *syncWaiter) run(ctx context.Context) {
339 for _, w := range waits {398 for _, w := range waits {
340 fds = append(fds, unix.PollFd{Fd: int32(w.efd), Events: unix.POLLIN})399 fds = append(fds, unix.PollFd{Fd: int32(w.efd), Events: unix.POLLIN})
341 }400 }
342- n, err := unix.Poll(fds, 100)401+ n, err := sw.poller.poll(fds, 100)
343 if err != nil && !errors.Is(err, unix.EINTR) {402 if err != nil && !errors.Is(err, unix.EINTR) {
403+ sw.fail(waits)
344 return404 return
345 }405 }
346 if n <= 0 {406 if n <= 0 {
@@ -364,7 +424,7 @@ func (sw *syncWaiter) run(ctx context.Context) {
364 unix.Close(w.efd)424 unix.Close(w.efd)
365 sw.mu.Lock()425 sw.mu.Lock()
366 if !w.cancelled.Load() {426 if !w.cancelled.Load() {
367- sw.ready[w] = true427+ sw.ready[w] = revents&unix.POLLIN != 0
368 fired = true428 fired = true
369 }429 }
370 sw.mu.Unlock()430 sw.mu.Unlock()
@@ -432,12 +492,11 @@ func (s *surface) takeSyncPoints() {
432 cs.release.tl.uses++492 cs.release.tl.uses++
433 w, err := s.server.syncWait.watch(cs.acquire)493 w, err := s.server.syncWait.watch(cs.acquire)
434 if err != nil {494 if err != nil {
435- // The point cannot be watched: apply now, readers wait on the495+ // Keep points owned by the captured commit for safe discard. An
436- // fence (or its absence) when they read the buffer.496+ // unobservable acquire must never reach native rendering.
437 s.server.log.Warn().Str("component", "wayland").Err(err).Msg("acquire point")497 s.server.log.Warn().Str("component", "wayland").Err(err).Msg("acquire point")
438- } else {
439- cs.wait = w
440 }498 }
499+ cs.wait = w
441 s.next.sync = cs500 s.next.sync = cs
442 }501 }
443 502
@@ -453,7 +512,11 @@ func (s *surface) dropSync(cs *commitSync) {
453 if cs == nil {512 if cs == nil {
454 return513 return
455 }514 }
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
457 if cs.wait != nil {520 if cs.wait != nil {
458 s.server.syncWait.cancel(cs.wait)521 s.server.syncWait.cancel(cs.wait)
459 }522 }
@@ -467,6 +530,24 @@ func (s *surface) dropSync(cs *commitSync) {
467 s.server.dropTimeline(cs.release.tl)530 s.server.dropTimeline(cs.release.tl)
468 }531 }
469 532
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+
470 // applySync makes a commit's points the current buffer's: its acquire551 // applySync makes a commit's points the current buffer's: its acquire
471 // fence goes with the content, its release point waits for release.552 // fence goes with the content, its release point waits for release.
472 func (s *surface) applySync(cs *commitSync) {553 func (s *surface) applySync(cs *commitSync) {
@@ -476,11 +557,8 @@ func (s *surface) applySync(cs *commitSync) {
476 if cs.wait != nil {557 if cs.wait != nil {
477 s.server.syncWait.cancel(cs.wait)558 s.server.syncWait.cancel(cs.wait)
478 }559 }
479- f, err := cs.acquire.tl.dev.exportSyncFile(cs.acquire.tl.handle, cs.acquire.point)560+ f := cs.fence
480- if err != nil {561+ cs.fence = nil
481- s.server.log.Warn().Str("component", "wayland").Err(err).Msg("acquire fence")
482- f = nil
483- }
484 cs.acquire.tl.uses--562 cs.acquire.tl.uses--
485 s.server.dropTimeline(cs.acquire.tl)563 s.server.dropTimeline(cs.acquire.tl)
486 s.hold = syncHold{acquire: f, release: cs.release}564 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 {
135 defer unix.Close(fd)135 defer unix.Close(fd)
136 h.timeline = h.c.AllocateID()136 h.timeline = h.c.AllocateID()
137 registerProtocol(t, h.c, h.timeline)137 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 {
139 t.Fatal(err)139 t.Fatal(err)
140 }140 }
141 for deadline := time.Now().Add(2 * time.Second); ; {141 for deadline := time.Now().Add(2 * time.Second); ; {
@@ -167,13 +167,13 @@ func (h *syncHarness) dmabuf() uint32 {
167 p := c.AllocateID()167 p := c.AllocateID()
168 requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, p)168 requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, p)
169 registerProtocol(t, c, p)169 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 {
171 t.Fatal(err)171 t.Fatal(err)
172 }172 }
173 b := c.AllocateID()173 b := c.AllocateID()
174 rel := &syncReleaseProxy{released: make(chan struct{}, 4)}174 rel := &syncReleaseProxy{released: make(chan struct{}, 4)}
175 rel.SetID(b)175 rel.SetID(b)
176- c.Context().Register(rel)176+ registerWireProxy(c, rel)
177 requestProtocol(t, c, p, linuxdmabuf.ZwpLinuxBufferParamsV1RequestCreateImmed, b, int32(64), int32(16), linearARGB.Format, uint32(0))177 requestProtocol(t, c, p, linuxdmabuf.ZwpLinuxBufferParamsV1RequestCreateImmed, b, int32(64), int32(16), linearARGB.Format, uint32(0))
178 h.releases[b] = rel178 h.releases[b] = rel
179 return b179 return b
@@ -479,7 +479,7 @@ func TestSyncobjInvalidTimeline(t *testing.T) {
479 mgr := bindProtocol(t, c, "wp_linux_drm_syncobj_manager_v1")479 mgr := bindProtocol(t, c, "wp_linux_drm_syncobj_manager_v1")
480 fd, _ := unix.MemfdCreate("bad", 0)480 fd, _ := unix.MemfdCreate("bad", 0)
481 defer unix.Close(fd)481 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 {
483 t.Fatal(err)483 t.Fatal(err)
484 }484 }
485 expectProtocolError(t, c, mgr, uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjManagerV1ErrorInvalidTimeline))485 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) {
27 }27 }
28 }28 }
29 29
30-// An invalid poll result is not readiness; it must not hold the surface.30+// An invalid poll result is an error, never permission to publish.
31-func TestImplicitPollErrorReadyAndWarnOnce(t *testing.T) {31+func TestImplicitPollErrorRejectedAndWarnOnce(t *testing.T) {
32 sw, err := newSyncWaiter(func() {})32 sw, err := newSyncWaiter(func() {})
33 if err != nil {33 if err != nil {
34 t.Fatal(err)34 t.Fatal(err)
@@ -42,8 +42,8 @@ func TestImplicitPollErrorReadyAndWarnOnce(t *testing.T) {
42 b := &ports.DMABuf{Planes: []ports.DMABufPlane{{File: os.NewFile(uintptr(math.MaxInt32), "invalid")}}}42 b := &ports.DMABuf{Planes: []ports.DMABufPlane{{File: os.NewFile(uintptr(math.MaxInt32), "invalid")}}}
43 var waits [4]*syncWait43 var waits [4]*syncWait
44 for range 2 {44 for range 2 {
45- if err := sw.watchImplicit(b, &waits); err != nil || waits[0] != nil {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)46+ t.Fatalf("poll error should reject without registering wait: %v %+v", err, waits)
47 }47 }
48 }48 }
49 if n := bytes.Count(output.Bytes(), []byte("implicit fence poll error")); n != 1 {49 if n := bytes.Count(output.Bytes(), []byte("implicit fence poll error")); n != 1 {
@@ -76,8 +76,8 @@ func TestImplicitPollHangupWakes(t *testing.T) {
76 for !sw.fired(waits[0]) && time.Now().Before(deadline) {76 for !sw.fired(waits[0]) && time.Now().Before(deadline) {
77 time.Sleep(time.Millisecond)77 time.Sleep(time.Millisecond)
78 }78 }
79- if !sw.fired(waits[0]) {79+ if !sw.fired(waits[0]) || !waits[0].failed {
80- t.Fatal("hangup never woke waiter")80+ t.Fatal("hangup must wake waiter as failed, not ready to publish")
81 }81 }
82 }82 }
83 83
M internal/adapters/wayland/textinput.go +41 -6
@@ -87,6 +87,9 @@ func (t *textInput) Disable(*textinput.ZwpTextInputV3) {
87 // SetSurroundingText ignores text past the spec limit or a cursor or87 // SetSurroundingText ignores text past the spec limit or a cursor or
88 // anchor outside it.88 // anchor outside it.
89 func (t *textInput) SetSurroundingText(_ *textinput.ZwpTextInputV3, text string, cursor, anchor int32) {89 func (t *textInput) SetSurroundingText(_ *textinput.ZwpTextInputV3, text string, cursor, anchor int32) {
90+ if t.server.protected() {
91+ return
92+ }
90 if len(text) > maxIMEText || cursor < 0 || anchor < 0 || int(cursor) > len(text) || int(anchor) > len(text) {93 if len(text) > maxIMEText || cursor < 0 || anchor < 0 || int(cursor) > len(text) || int(anchor) > len(text) {
91 t.server.log.Debug().Int("bytes", len(text)).Msg("surrounding text ignored")94 t.server.log.Debug().Int("bytes", len(text)).Msg("surrounding text ignored")
92 return95 return
@@ -111,6 +114,10 @@ func (t *textInput) SetCursorRectangle(_ *textinput.ZwpTextInputV3, x, y, w, h i
111 // serial, but after leave the state is ignored until the next enter.114 // serial, but after leave the state is ignored until the next enter.
112 func (t *textInput) Commit(*textinput.ZwpTextInputV3) {115 func (t *textInput) Commit(*textinput.ZwpTextInputV3) {
113 t.serial++116 t.serial++
117+ if t.server.protected() {
118+ t.pending, t.current, t.toggled = textState{}, textState{}, false
119+ return
120+ }
114 if t.focus == nil {121 if t.focus == nil {
115 return122 return
116 }123 }
@@ -128,6 +135,9 @@ func (*textInput) HideInputPanel(*textinput.ZwpTextInputV3) {}
128 135
129 // enter focuses the text input on surf; its state starts over.136 // enter focuses the text input on surf; its state starts over.
130 func (t *textInput) enter(surf *wayland.Surface) {137 func (t *textInput) enter(surf *wayland.Surface) {
138+ if t.server.protected() {
139+ return
140+ }
131 t.focus = surf141 t.focus = surf
132 t.pending, t.current, t.toggled = textState{}, textState{}, false142 t.pending, t.current, t.toggled = textState{}, textState{}, false
133 t.res.SendEnter(surf)143 t.res.SendEnter(surf)
@@ -149,6 +159,9 @@ func (t *textInput) leave() {
149 // textCommitted activates, updates or deactivates the input method for a159 // textCommitted activates, updates or deactivates the input method for a
150 // committed text input. Enabling a second text input is ignored.160 // committed text input. Enabling a second text input is ignored.
151 func (s *Server) textCommitted(t *textInput, reenabled bool) {161 func (s *Server) textCommitted(t *textInput, reenabled bool) {
162+ if s.protected() {
163+ return
164+ }
152 switch {165 switch {
153 case t.current.enabled && s.activeText == nil:166 case t.current.enabled && s.activeText == nil:
154 s.activeText = t167 s.activeText = t
@@ -198,7 +211,7 @@ func (s *Server) deactivateText() {
198 // activateIM sends activate and the active text input's state.211 // activateIM sends activate and the active text input's state.
199 func (s *Server) activateIM() {212 func (s *Server) activateIM() {
200 im := s.inputMethod213 im := s.inputMethod
201- if im == nil || s.activeText == nil {214+ if s.protected() || im == nil || s.activeText == nil {
202 return215 return
203 }216 }
204 im.pending = imState{}217 im.pending = imState{}
@@ -210,7 +223,7 @@ func (s *Server) activateIM() {
210 // sendIMState sends the active text input's state, then done.223 // sendIMState sends the active text input's state, then done.
211 func (s *Server) sendIMState() {224 func (s *Server) sendIMState() {
212 im, t := s.inputMethod, s.activeText225 im, t := s.inputMethod, s.activeText
213- if im == nil || t == nil {226+ if s.protected() || im == nil || t == nil {
214 return227 return
215 }228 }
216 c := t.current229 c := t.current
@@ -304,14 +317,14 @@ func (im *inputMethod) done() {
304 }317 }
305 318
306 func (im *inputMethod) CommitString(_ *inputmethod.ZwpInputMethodV2, text string) {319 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) {
308 return321 return
309 }322 }
310 im.pending.commit, im.pending.hasCommit = text, true323 im.pending.commit, im.pending.hasCommit = text, true
311 }324 }
312 325
313 func (im *inputMethod) SetPreeditString(_ *inputmethod.ZwpInputMethodV2, text string, begin, end int32) {326 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) {
315 return328 return
316 }329 }
317 p := &im.pending330 p := &im.pending
@@ -327,7 +340,7 @@ func (im *inputMethod) fits(text string) bool {
327 }340 }
328 341
329 func (im *inputMethod) DeleteSurroundingText(_ *inputmethod.ZwpInputMethodV2, before, after uint32) {342 func (im *inputMethod) DeleteSurroundingText(_ *inputmethod.ZwpInputMethodV2, before, after uint32) {
330- if im.inert {343+ if im.server.protected() || im.inert {
331 return344 return
332 }345 }
333 im.pending.before, im.pending.after = before, after346 im.pending.before, im.pending.after = before, after
@@ -345,6 +358,9 @@ func (im *inputMethod) Commit(_ *inputmethod.ZwpInputMethodV2, serial uint32) {
345 st := im.pending358 st := im.pending
346 im.pending = imState{}359 im.pending = imState{}
347 s := im.server360 s := im.server
361+ if s.protected() {
362+ return
363+ }
348 t := s.activeText364 t := s.activeText
349 if t == nil {365 if t == nil {
350 return366 return
@@ -406,7 +422,7 @@ func (im *inputMethod) GetInputPopupSurface(r *inputmethod.ZwpInputMethodV2, id
406 func (im *inputMethod) GrabKeyboard(r *inputmethod.ZwpInputMethodV2, id uint32) {422 func (im *inputMethod) GrabKeyboard(r *inputmethod.ZwpInputMethodV2, id uint32) {
407 g := &keyboardGrab{}423 g := &keyboardGrab{}
408 res, err := inputmethod.NewZwpInputMethodKeyboardGrabV2(r.Client(), r.Version(), id, g)424 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 {
410 return426 return
411 }427 }
412 g.res, im.grab = res, g428 g.res, im.grab = res, g
@@ -439,6 +455,9 @@ func (*inputPopup) Destroy(*inputmethod.ZwpInputPopupSurfaceV2) {}
439 455
440 // sendRect tells the popup where the active text cursor is.456 // sendRect tells the popup where the active text cursor is.
441 func (p *inputPopup) sendRect() {457 func (p *inputPopup) sendRect() {
458+ if p.im.server.protected() {
459+ return
460+ }
442 t := p.im.server.activeText461 t := p.im.server.activeText
443 if t == nil || !t.current.hasRect {462 if t == nil || !t.current.hasRect {
444 return463 return
@@ -455,6 +474,9 @@ func (*keyboardGrab) Release(*inputmethod.ZwpInputMethodKeyboardGrabV2) {}
455 474
456 // grab is the live keyboard grab, nil without one.475 // grab is the live keyboard grab, nil without one.
457 func (s *Server) grab() *keyboardGrab {476 func (s *Server) grab() *keyboardGrab {
477+ if s.protected() {
478+ return nil
479+ }
458 if im := s.inputMethod; im != nil {480 if im := s.inputMethod; im != nil {
459 return im.grab481 return im.grab
460 }482 }
@@ -467,6 +489,13 @@ func (s *Server) grab() *keyboardGrab {
467 // does, even after the grab ends.489 // does, even after the grab ends.
468 func (s *Server) grabKey(c ports.ForwardKey) bool {490 func (s *Server) grabKey(c ports.ForwardKey) bool {
469 code := c.Key.Keycode491 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+ }
470 g := s.grab()499 g := s.grab()
471 owned := !c.Key.Pressed && s.seat.grabKeys[code]500 owned := !c.Key.Pressed && s.seat.grabKeys[code]
472 if owned {501 if owned {
@@ -500,6 +529,9 @@ func (s *Server) grabKey(c ports.ForwardKey) bool {
500 // grabReleaseDropped keeps the focused client's modifiers right when the529 // grabReleaseDropped keeps the focused client's modifiers right when the
501 // release of a grab-owned key is dropped.530 // release of a grab-owned key is dropped.
502 func (s *Server) grabReleaseDropped(m ports.ModState) {531 func (s *Server) grabReleaseDropped(m ports.ModState) {
532+ if s.protected() {
533+ return
534+ }
503 if m == s.seat.modState {535 if m == s.seat.modState {
504 return536 return
505 }537 }
@@ -512,6 +544,9 @@ func (s *Server) grabReleaseDropped(m ports.ModState) {
512 544
513 // grabEnded gives the focused client the seat modifiers it missed.545 // grabEnded gives the focused client the seat modifiers it missed.
514 func (s *Server) grabEnded() {546 func (s *Server) grabEnded() {
547+ if s.protected() {
548+ return
549+ }
515 s.serial++550 s.serial++
516 for _, k := range s.clientKeyboards(s.focusClient()) {551 for _, k := range s.clientKeyboards(s.focusClient()) {
517 s.sendModifiers(k)552 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 {
63 l.sigs = append(l.sigs, struct{ name, args string }{name, args})63 l.sigs = append(l.sigs, struct{ name, args string }{name, args})
64 }64 }
65 l.SetID(id)65 l.SetID(id)
66- c.Context().Register(l)66+ registerWireProxy(c, l)
67 return l67 return l
68 }68 }
69 69
@@ -90,7 +90,7 @@ func newTextApp(t *testing.T, s *Server, events chan ports.ClientEvent, commands
90 registerProtocol(t, c, seat)90 registerProtocol(t, c, seat)
91 keys := &eventsProxy{}91 keys := &eventsProxy{}
92 keys.SetID(c.AllocateID())92 keys.SetID(c.AllocateID())
93- c.Context().Register(keys)93+ registerWireProxy(c, keys)
94 requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, keys.ID())94 requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, keys.ID())
95 w, surf, xdg := surfaceMapper(t, c, events)()95 w, surf, xdg := surfaceMapper(t, c, events)()
96 manager := bindProtocol(t, c, "zwp_text_input_manager_v3")96 manager := bindProtocol(t, c, "zwp_text_input_manager_v3")
@@ -459,7 +459,7 @@ func TestTextInputLeaveBeforeEnter(t *testing.T) {
459 for _, tag := range []string{"t1", "t2"} {459 for _, tag := range []string{"t1", "t2"} {
460 p := &tagged{tag: tag, into: log}460 p := &tagged{tag: tag, into: log}
461 p.SetID(c.AllocateID())461 p.SetID(c.AllocateID())
462- c.Context().Register(p)462+ registerWireProxy(c, p)
463 requestProtocol(t, c, manager, textinput.ZwpTextInputManagerV3RequestGetTextInput, p.ID(), seat)463 requestProtocol(t, c, manager, textinput.ZwpTextInputManagerV3RequestGetTextInput, p.ID(), seat)
464 }464 }
465 commands <- ports.FocusWindow{ID: a.ID}465 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) {
47 }47 }
48 pool := c.AllocateID()48 pool := c.AllocateID()
49 registerProtocol(t, c, pool)49 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 {
51 t.Fatal(err)51 t.Fatal(err)
52 }52 }
53 children := make([]uint32, tiles+1)53 children := make([]uint32, tiles+1)
@@ -112,7 +112,7 @@ func TestHeadlessTiledSHMCallbackAndCopyBudget(t *testing.T) {
112 cb := c.AllocateID()112 cb := c.AllocateID()
113 p := &callbackBudgetProxy{done: done}113 p := &callbackBudgetProxy{done: done}
114 p.SetID(cb)114 p.SetID(cb)
115- c.Context().Register(p)115+ registerWireProxy(c, p)
116 requestProtocol(t, c, children[tiles], wayland.SurfaceRequestFrame, cb)116 requestProtocol(t, c, children[tiles], wayland.SurfaceRequestFrame, cb)
117 requestProtocol(t, c, children[tiles], wayland.SurfaceRequestAttach, buffers[tiles], int32(0), int32(0))117 requestProtocol(t, c, children[tiles], wayland.SurfaceRequestAttach, buffers[tiles], int32(0), int32(0))
118 requestProtocol(t, c, children[tiles], wayland.SurfaceRequestCommit)118 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 {
26 c := protocolClient(t, s, dir)26 c := protocolClient(t, s, dir)
27 // set_buffer_transform needs wl_compositor v2.27 // set_buffer_transform needs wl_compositor v2.
28 g, _ := c.Registry().FindGlobal("wl_compositor")28 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)
30 if err != nil {30 if err != nil {
31 t.Fatal(err)31 t.Fatal(err)
32 }32 }
@@ -44,7 +44,7 @@ func newTransformClient(t *testing.T) transformClient {
44 serials := make(chan uint32, 4)44 serials := make(chan uint32, 4)
45 cp := &configureProxy{serial: serials}45 cp := &configureProxy{serial: serials}
46 cp.SetID(xdg)46 cp.SetID(xdg)
47- c.Context().Register(cp)47+ registerWireProxy(c, cp)
48 requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)48 requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
49 top := c.AllocateID()49 top := c.AllocateID()
50 registerProtocol(t, c, top)50 registerProtocol(t, c, top)
@@ -66,7 +66,7 @@ func newTransformClient(t *testing.T) transformClient {
66 pool, bf := c.AllocateID(), c.AllocateID()66 pool, bf := c.AllocateID(), c.AllocateID()
67 registerProtocol(t, c, pool)67 registerProtocol(t, c, pool)
68 registerProtocol(t, c, bf)68 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 {
70 t.Fatal(err)70 t.Fatal(err)
71 }71 }
72 // argb8888: not opaque by format.72 // argb8888: not opaque by format.
M internal/adapters/wayland/virtualkeyboard.go +55 -4
@@ -25,8 +25,10 @@ type virtualKeyboard struct {
25 text string // the keymap, compared with the active one25 text string // the keymap, compared with the active one
26 mods ports.ModState // last modifiers request, zero before one26 mods ports.ModState // last modifiers request, zero before one
27 // pressed are keys held on focus; a focus change releases them by leave.27 // pressed are keys held on focus; a focus change releases them by leave.
28- pressed map[uint32]bool28+ pressed map[uint32]bool
29- focus ports.WindowID29+ 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
30 }32 }
31 33
32 // registerVirtualKeyboard exposes the global to every client, as wlroots34 // registerVirtualKeyboard exposes the global to every client, as wlroots
@@ -52,6 +54,17 @@ func (m virtualKeyboardManager) CreateVirtualKeyboard(r *virtualkeyboard.ZwpVirt
52 // Keymap copies the client keymap into a sealed memfd: other clients never54 // Keymap copies the client keymap into a sealed memfd: other clients never
53 // see the client's fd. An unreadable or malformed keymap is ignored.55 // see the client's fd. An unreadable or malformed keymap is ignored.
54 func (k *virtualKeyboard) Keymap(_ *virtualkeyboard.ZwpVirtualKeyboardV1, format uint32, fd int, size uint32) {56 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+ }
55 text, ok := readKeymap(fd, size)68 text, ok := readKeymap(fd, size)
56 unix.Close(fd)69 unix.Close(fd)
57 if format != uint32(wayland.KeyboardKeymapFormatXkbV1) || !ok {70 if format != uint32(wayland.KeyboardKeymapFormatXkbV1) || !ok {
@@ -102,6 +115,9 @@ func readKeymap(fd int, size uint32) (string, bool) {
102 // keyboards switches the focused client to k's keymap and returns its115 // keyboards switches the focused client to k's keymap and returns its
103 // keyboards, or nil after a no_keymap error or without focus.116 // keyboards, or nil after a no_keymap error or without focus.
104 func (k *virtualKeyboard) keyboards(r *virtualkeyboard.ZwpVirtualKeyboardV1) []*wayland.Keyboard {117 func (k *virtualKeyboard) keyboards(r *virtualkeyboard.ZwpVirtualKeyboardV1) []*wayland.Keyboard {
118+ if !k.admitted() {
119+ return nil
120+ }
105 if k.fd < 0 {121 if k.fd < 0 {
106 r.PostError(uint32(virtualkeyboard.ZwpVirtualKeyboardV1ErrorNoKeymap), "no keymap")122 r.PostError(uint32(virtualkeyboard.ZwpVirtualKeyboardV1ErrorNoKeymap), "no keymap")
107 return nil123 return nil
@@ -121,6 +137,9 @@ func (k *virtualKeyboard) keyboards(r *virtualkeyboard.ZwpVirtualKeyboardV1) []*
121 }137 }
122 138
123 func (k *virtualKeyboard) Key(r *virtualkeyboard.ZwpVirtualKeyboardV1, time, key, state uint32) {139 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+ }
124 keyboards := k.keyboards(r)143 keyboards := k.keyboards(r)
125 if keyboards == nil {144 if keyboards == nil {
126 return145 return
@@ -136,19 +155,46 @@ func (k *virtualKeyboard) Key(r *virtualkeyboard.ZwpVirtualKeyboardV1, time, key
136 }155 }
137 156
138 func (k *virtualKeyboard) Modifiers(r *virtualkeyboard.ZwpVirtualKeyboardV1, depressed, latched, locked, group uint32) {157 func (k *virtualKeyboard) Modifiers(r *virtualkeyboard.ZwpVirtualKeyboardV1, depressed, latched, locked, group uint32) {
158+ if !k.admitted() {
159+ return
160+ }
139 k.mods = ports.ModState{Depressed: depressed, Latched: latched, Locked: locked, Group: group}161 k.mods = ports.ModState{Depressed: depressed, Latched: latched, Locked: locked, Group: group}
140 for _, kb := range k.keyboards(r) {162 for _, kb := range k.keyboards(r) {
141 kb.SendModifiers(k.server.serial, depressed, latched, locked, group)163 kb.SendModifiers(k.server.serial, depressed, latched, locked, group)
142 }164 }
143 }165 }
144 166
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+
145 func (*virtualKeyboard) Destroy(*virtualkeyboard.ZwpVirtualKeyboardV1) {}189 func (*virtualKeyboard) Destroy(*virtualkeyboard.ZwpVirtualKeyboardV1) {}
146 190
147 // release runs when the resource dies, by request or client disconnect:191 // release runs when the resource dies, by request or client disconnect:
148 // keys still held on the focused client are released.192 // keys still held on the focused client are released.
149 func (k *virtualKeyboard) release() {193 func (k *virtualKeyboard) release() {
150 s := k.server194 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 {
152 _, keyboards := s.focusTarget(s.seat.focused)198 _, keyboards := s.focusTarget(s.seat.focused)
153 var ts unix.Timespec199 var ts unix.Timespec
154 _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)200 _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)
@@ -162,7 +208,12 @@ func (k *virtualKeyboard) release() {
162 }208 }
163 }209 }
164 if s.seat.keymapOwner == k {210 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+ }
166 }217 }
167 if k.fd >= 0 {218 if k.fd >= 0 {
168 unix.Close(k.fd)219 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
34 t.Fatal(err)34 t.Fatal(err)
35 }35 }
36 defer unix.Close(fd)36 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 {
38 t.Fatal(err)38 t.Fatal(err)
39 }39 }
40 }40 }
@@ -48,7 +48,7 @@ func TestVirtualKeyboardTypesIntoFocus(t *testing.T) {
48 registerProtocol(t, target, seat)48 registerProtocol(t, target, seat)
49 proxy := &eventsProxy{}49 proxy := &eventsProxy{}
50 proxy.SetID(target.AllocateID())50 proxy.SetID(target.AllocateID())
51- target.Context().Register(proxy)51+ registerWireProxy(target, proxy)
52 requestProtocol(t, target, seat, wayland.SeatRequestGetKeyboard, proxy.ID())52 requestProtocol(t, target, seat, wayland.SeatRequestGetKeyboard, proxy.ID())
53 w := toplevelMapper(t, target, events)()53 w := toplevelMapper(t, target, events)()
54 commands <- ports.FocusWindow{ID: w.ID}54 commands <- ports.FocusWindow{ID: w.ID}
@@ -104,7 +104,7 @@ func focusedTarget(t *testing.T, s *Server, events chan ports.ClientEvent, comma
104 registerProtocol(t, target, seat)104 registerProtocol(t, target, seat)
105 proxy := &eventsProxy{}105 proxy := &eventsProxy{}
106 proxy.SetID(target.AllocateID())106 proxy.SetID(target.AllocateID())
107- target.Context().Register(proxy)107+ registerWireProxy(target, proxy)
108 requestProtocol(t, target, seat, wayland.SeatRequestGetKeyboard, proxy.ID())108 requestProtocol(t, target, seat, wayland.SeatRequestGetKeyboard, proxy.ID())
109 w := toplevelMapper(t, target, events)()109 w := toplevelMapper(t, target, events)()
110 commands <- ports.FocusWindow{ID: w.ID}110 commands <- ports.FocusWindow{ID: w.ID}
@@ -167,7 +167,7 @@ func TestVirtualKeymapLateKeyboardAndRejected(t *testing.T) {
167 registerProtocol(t, target, seat)167 registerProtocol(t, target, seat)
168 late := &eventsProxy{}168 late := &eventsProxy{}
169 late.SetID(target.AllocateID())169 late.SetID(target.AllocateID())
170- target.Context().Register(late)170+ registerWireProxy(target, late)
171 requestProtocol(t, target, seat, wayland.SeatRequestGetKeyboard, late.ID())171 requestProtocol(t, target, seat, wayland.SeatRequestGetKeyboard, late.ID())
172 roundtrip(t, target)172 roundtrip(t, target)
173 if len(late.keymaps) != 1 || late.keymaps[0] != virtual {173 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 {
41 })41 })
42 }42 }
43 func (m *workspaceManager) Commit(*ext.ExtWorkspaceManagerV1) {43 func (m *workspaceManager) Commit(*ext.ExtWorkspaceManagerV1) {
44+ if m.s.protected() {
45+ m.pending = nil
46+ return
47+ }
44 ids := make([]uint64, 0, len(m.pending))48 ids := make([]uint64, 0, len(m.pending))
45 for _, id := range m.pending {49 for _, id := range m.pending {
46 if h := m.handles[id]; h != nil && h.res.Alive() {50 if h := m.handles[id]; h != nil && h.res.Alive() {
@@ -59,6 +63,9 @@ func (m *workspaceManager) Stop(*ext.ExtWorkspaceManagerV1) {
59 func (*workspaceManager) CreateWorkspace(*ext.ExtWorkspaceGroupHandleV1, string) {}63 func (*workspaceManager) CreateWorkspace(*ext.ExtWorkspaceGroupHandleV1, string) {}
60 func (*workspaceManager) Destroy(*ext.ExtWorkspaceGroupHandleV1) {}64 func (*workspaceManager) Destroy(*ext.ExtWorkspaceGroupHandleV1) {}
61 func (m *workspaceManager) Activate(r *ext.ExtWorkspaceHandleV1) {65 func (m *workspaceManager) Activate(r *ext.ExtWorkspaceHandleV1) {
66+ if m.s.protected() {
67+ return
68+ }
62 for id, h := range m.handles {69 for id, h := range m.handles {
63 if h.res.Resource == r.Resource {70 if h.res.Resource == r.Resource {
64 m.pending = append(m.pending, id)71 m.pending = append(m.pending, id)
@@ -79,6 +86,9 @@ func (*workspaceRequests) Remove(*ext.ExtWorkspaceHandleV1)
79 // outputBound announces every wl_output resource bound by this client for an86 // outputBound announces every wl_output resource bound by this client for an
80 // assigned output. A client may bind the same global more than once.87 // assigned output. A client may bind the same global more than once.
81 func (m *workspaceManager) outputBound(name string) bool {88 func (m *workspaceManager) outputBound(name string) bool {
89+ if m.s.protected() {
90+ return false
91+ }
82 g := m.groups[name]92 g := m.groups[name]
83 o := m.s.outputByNameExact(name)93 o := m.s.outputByNameExact(name)
84 if g == nil || !g.res.Alive() || o == nil {94 if g == nil || !g.res.Alive() || o == nil {
@@ -128,10 +138,17 @@ func (s *Server) forwardWorkspaces(ctx context.Context) {
128 }138 }
129 func (s *Server) updateWorkspaces(snapshot ports.Workspaces) {139 func (s *Server) updateWorkspaces(snapshot ports.Workspaces) {
130 s.workspaceSnapshot = snapshot140 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) {
131 for _, m := range s.workspaceManagers {149 for _, m := range s.workspaceManagers {
132 m.update(snapshot)150 m.update(snapshot)
133 }151 }
134- s.updateCaptureWorkspaces(snapshot)
135 }152 }
136 func workspaceState(w ports.WorkspaceInfo) uint32 {153 func workspaceState(w ports.WorkspaceInfo) uint32 {
137 var state uint32154 var state uint32
@@ -149,6 +166,10 @@ func (m *workspaceManager) sendCoordinates(r *ext.ExtWorkspaceHandleV1, index in
149 r.SendCoordinates(b[:])166 r.SendCoordinates(b[:])
150 }167 }
151 func (m *workspaceManager) update(snapshot ports.Workspaces) {168 func (m *workspaceManager) update(snapshot ports.Workspaces) {
169+ if m.s.protected() {
170+ m.pending = nil
171+ return
172+ }
152 if !m.res.Alive() {173 if !m.res.Alive() {
153 return174 return
154 }175 }
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) {
48 v[1] = e.Uint32()48 v[1] = e.Uint32()
49 child := &workspaceChildEvents{events: p.events, idEvents: p.idEvents, order: p.order, group: e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup)}49 child := &workspaceChildEvents{events: p.events, idEvents: p.idEvents, order: p.order, group: e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup)}
50 child.SetID(v[1])50 child.SetID(v[1])
51- p.client.Context().Register(child)51+ registerWireProxy(p.client, child)
52 }52 }
53 p.order.add(v[0])53 p.order.add(v[0])
54 p.events <- v54 p.events <- v
@@ -107,7 +107,7 @@ func TestWorkspaceProtocol(t *testing.T) {
107 managerID := bindProtocol(t, c, "ext_workspace_manager_v1")107 managerID := bindProtocol(t, c, "ext_workspace_manager_v1")
108 p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16)}108 p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16)}
109 p.SetID(managerID)109 p.SetID(managerID)
110- c.Context().Register(p)110+ registerWireProxy(c, p)
111 var handle, secondHandle uint32111 var handle, secondHandle uint32
112 for i := 0; i < 8 && secondHandle == 0; i++ {112 for i := 0; i < 8 && secondHandle == 0; i++ {
113 if err := c.Roundtrip(); err != nil {113 if err := c.Roundtrip(); err != nil {
@@ -246,7 +246,7 @@ func TestWorkspaceConfiguredIDAndBatchDone(t *testing.T) {
246 managerID := bindProtocol(t, c, "ext_workspace_manager_v1")246 managerID := bindProtocol(t, c, "ext_workspace_manager_v1")
247 p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16), order: &workspaceEventOrder{}}247 p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16), order: &workspaceEventOrder{}}
248 p.SetID(managerID)248 p.SetID(managerID)
249- c.Context().Register(p)249+ registerWireProxy(c, p)
250 if err := c.Roundtrip(); err != nil {250 if err := c.Roundtrip(); err != nil {
251 t.Fatal(err)251 t.Fatal(err)
252 }252 }
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 {
27 contextUnref func(uintptr)27 contextUnref func(uintptr)
28 keymapUnref func(uintptr)28 keymapUnref func(uintptr)
29 stateUnref func(uintptr)29 stateUnref func(uintptr)
30- keymapString func(uintptr, uint32) unsafe.Pointer30+ stateNew func(uintptr) uintptr
31- free func(uintptr)31+ // Secure tracking belongs to the same producer goroutine as native state.
32- getSym func(uintptr, uint32) uint3232+ epoch ports.SecurityState
33- symName func(uint32, uintptr, uintptr) int3233+ epochSet bool
34- updateKey func(uintptr, uint32, uint32) uint3234+ held, quarantined map[uint32]bool
35- serializeMods func(uintptr, uint32) uint3235+ keymapString func(uintptr, uint32) unsafe.Pointer
36- serializeLayout func(uintptr, uint32) uint3236+ free func(uintptr)
37- modActive func(uintptr, uintptr, uint32) int3237+ getSym func(uintptr, uint32) uint32
38- minCode func(uintptr) uint3238+ symName func(uint32, uintptr, uintptr) int32
39- maxCode func(uintptr) uint3239+ updateKey func(uintptr, uint32, uint32) uint32
40- symsLevel func(uintptr, uint32, uint32, uint32, *unsafe.Pointer) int3240+ serializeMods func(uintptr, uint32) uint32
41- fromName func(uintptr, uint32) uint3241+ 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
42 }47 }
43 48
44 type ruleNames struct{ rules, model, layout, variant, options uintptr }49 type ruleNames struct{ rules, model, layout, variant, options uintptr }
@@ -60,13 +65,12 @@ func New(names RMLVO) (_ *Keymap, err error) {
60 }65 }
61 var contextNew func(uint32) uintptr66 var contextNew func(uint32) uintptr
62 var keymapNew func(uintptr, *ruleNames, uint32) uintptr67 var keymapNew func(uintptr, *ruleNames, uint32) uintptr
63- var stateNew func(uintptr) uintptr
64 bind := func(fn any, name string) { purego.RegisterLibFunc(fn, k.lib, "xkb_"+name) }68 bind := func(fn any, name string) { purego.RegisterLibFunc(fn, k.lib, "xkb_"+name) }
65 bind(&contextNew, "context_new")69 bind(&contextNew, "context_new")
66 bind(&k.contextUnref, "context_unref")70 bind(&k.contextUnref, "context_unref")
67 bind(&keymapNew, "keymap_new_from_names")71 bind(&keymapNew, "keymap_new_from_names")
68 bind(&k.keymapUnref, "keymap_unref")72 bind(&k.keymapUnref, "keymap_unref")
69- bind(&stateNew, "state_new")73+ bind(&k.stateNew, "state_new")
70 bind(&k.stateUnref, "state_unref")74 bind(&k.stateUnref, "state_unref")
71 bind(&k.keymapString, "keymap_get_as_string")75 bind(&k.keymapString, "keymap_get_as_string")
72 bind(&k.getSym, "state_key_get_one_sym")76 bind(&k.getSym, "state_key_get_one_sym")
@@ -104,7 +108,7 @@ func New(names RMLVO) (_ *Keymap, err error) {
104 if k.mapPtr == 0 {108 if k.mapPtr == 0 {
105 return nil, fmt.Errorf("xkb_keymap_new_from_names failed")109 return nil, fmt.Errorf("xkb_keymap_new_from_names failed")
106 }110 }
107- k.state = stateNew(k.mapPtr)111+ k.state = k.stateNew(k.mapPtr)
108 if k.state == 0 {112 if k.state == 0 {
109 return nil, fmt.Errorf("xkb_state_new failed")113 return nil, fmt.Errorf("xkb_state_new failed")
110 }114 }
@@ -160,6 +164,63 @@ func (k *Keymap) Key(evdevCode uint32, pressed bool, timeMsec uint32) ports.KeyE
160 return event164 return event
161 }165 }
162 166
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+
163 // levelName is the first keysym name of a key at one shift level, or "".224 // levelName is the first keysym name of a key at one shift level, or "".
164 func (k *Keymap) levelName(code, layout, level uint32) string {225 func (k *Keymap) levelName(code, layout, level uint32) string {
165 var ptr unsafe.Pointer226 var ptr unsafe.Pointer
M internal/app/app.go +15 -5
@@ -19,6 +19,7 @@ import (
19 "github.com/bnema/neferwl/internal/adapters/launcher"19 "github.com/bnema/neferwl/internal/adapters/launcher"
20 "github.com/bnema/neferwl/internal/adapters/libinput"20 "github.com/bnema/neferwl/internal/adapters/libinput"
21 "github.com/bnema/neferwl/internal/adapters/sched"21 "github.com/bnema/neferwl/internal/adapters/sched"
22+ "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
22 "github.com/bnema/neferwl/internal/adapters/statefile"23 "github.com/bnema/neferwl/internal/adapters/statefile"
23 "github.com/bnema/neferwl/internal/adapters/vulkan"24 "github.com/bnema/neferwl/internal/adapters/vulkan"
24 "github.com/bnema/neferwl/internal/adapters/wayland"25 "github.com/bnema/neferwl/internal/adapters/wayland"
@@ -78,6 +79,9 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
78 }79 }
79 defer hw.close()80 defer hw.close()
80 }81 }
82+ security := &sessionsecurity.Gate{}
83+ securityChanges := make(chan ports.SecurityState, 8)
84+ securityEvents := make(chan ports.SecurityBackendEvent, 64)
81 client := make(chan ports.ClientEvent, 32)85 client := make(chan ports.ClientEvent, 32)
82 input := make(chan ports.InputEvent, 32)86 input := make(chan ports.InputEvent, 32)
83 output := make(chan ports.OutputEvent, 32)87 output := make(chan ports.OutputEvent, 32)
@@ -113,7 +117,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
113 // Only real sessions save scales: headless runs never touch the config file.117 // Only real sessions save scales: headless runs never touch the config file.
114 scales = make(chan ports.ScaleChanged, 8)118 scales = make(chan ports.ScaleChanged, 8)
115 }119 }
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}
117 c, err := core.New(opts.Config, ch)121 c, err := core.New(opts.Config, ch)
118 if err != nil {122 if err != nil {
119 return err123 return err
@@ -127,7 +131,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
127 // Every output renders on the same GPU: its formats are the clients'.131 // Every output renders on the same GPU: its formats are the clients'.
128 dmabuf := vulkan.Probe()132 dmabuf := vulkan.Probe()
129 log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")133 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"))
131 if err != nil {135 if err != nil {
132 km.Close()136 km.Close()
133 return err137 return err
@@ -151,6 +155,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
151 defer launcher.UnexportSession(childEnv, logging.For(ctx, "launcher"))155 defer launcher.UnexportSession(childEnv, logging.For(ctx, "launcher"))
152 }156 }
153 child := launcher.New(childEnv, logging.For(ctx, "launcher"))157 child := launcher.New(childEnv, logging.For(ctx, "launcher"))
158+ child.Security = security
154 if inject != nil {159 if inject != nil {
155 inject(input)160 inject(input)
156 }161 }
@@ -202,11 +207,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
202 log.Warn().Str("component", "sched").Err(err).Msg("input scheduling")207 log.Warn().Str("component", "sched").Err(err).Msg("input scheduling")
203 }208 }
204 }209 }
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)
206 })211 })
207 return212 return
208 }213 }
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+ }
210 }()220 }()
211 if opts.Script != nil {221 if opts.Script != nil {
212 go func() { <-ctx.Done(); _ = opts.Script.Close() }()222 go func() { <-ctx.Done(); _ = opts.Script.Close() }()
@@ -251,7 +261,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
251 261
252 go func() {262 go func() {
253 defer workers.Done()263 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}
255 apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))265 apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
256 renderLog := logging.For(ctx, "render")266 renderLog := logging.For(ctx, "render")
257 newRenderer := func(w, h int) (ports.Renderer, error) {267 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) {
87 done := make(chan error, 1)87 done := make(chan error, 1)
88 go func() {88 go func() {
89 done <- run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: config.Defaults(), NoTerminal: true}, func(input chan<- ports.InputEvent) {89 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}}
91 })91 })
92 }()92 }()
93 select {93 select {
@@ -133,7 +133,7 @@ func TestHeadlessSpawnClose(t *testing.T) {
133 t.Error("Run did not stop")133 t.Error("Run did not stop")
134 }134 }
135 }()135 }()
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}}
137 waitScene := func(count int, timeout time.Duration) {137 waitScene := func(count int, timeout time.Duration) {
138 t.Helper()138 t.Helper()
139 timer := time.NewTimer(timeout)139 timer := time.NewTimer(timeout)
@@ -157,7 +157,7 @@ func TestHeadlessSpawnClose(t *testing.T) {
157 }157 }
158 }158 }
159 waitScene(1, 10*time.Second)159 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}}
161 waitScene(0, 5*time.Second)161 waitScene(0, 5*time.Second)
162 }162 }
163 163
M internal/app/capture_test.go +1 -1
@@ -144,7 +144,7 @@ func TestOutputStopWithFullReplies(t *testing.T) {
144 replies := make(chan ports.CaptureDone, 1)144 replies := make(chan ports.CaptureDone, 1)
145 replies <- ports.CaptureDone{}145 replies <- ports.CaptureDone{}
146 set := newOutputSet(ctx, replies)146 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 {
148 <-ctx.Done()148 <-ctx.Done()
149 return nil149 return nil
150 })150 })
M internal/app/drm.go +199 -11
@@ -26,6 +26,87 @@ import (
26 26
27 var errTimeout = errors.New("timeout")27 var errTimeout = errors.New("timeout")
28 28
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+
29 // applyTimeout bounds how long outputs take to apply a configuration.110 // applyTimeout bounds how long outputs take to apply a configuration.
30 const applyTimeout = 5 * time.Second111 const applyTimeout = 5 * time.Second
31 112
@@ -165,6 +246,9 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
165 active := b.seat.Subscribe()246 active := b.seat.Subscribe()
166 defer b.seat.Unsubscribe(active)247 defer b.seat.Unsubscribe(active)
167 seatActive := true // openDRM waits for the first seat enable248 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 }
168 clientFDs := map[*drmCard]*os.File{}252 clientFDs := map[*drmCard]*os.File{}
169 lastLeaseConnectors := map[*drmCard][]ports.LeaseConnector{}253 lastLeaseConnectors := map[*drmCard][]ports.LeaseConnector{}
170 defer func() {254 defer func() {
@@ -184,12 +268,14 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
184 sendLease(ports.LeaseFinished{Card: c.Path(), LeaseID: id})268 sendLease(ports.LeaseFinished{Card: c.Path(), LeaseID: id})
185 }269 }
186 var next []ports.LeaseConnector270 var next []ports.LeaseConnector
187- if seatActive {271+ if seatActive && !protected() {
188 next = c.Leasable()272 next = c.Leasable()
189 }273 }
190 if len(next) == 0 {274 if len(next) == 0 {
191- if clientFDs[c] != nil {275+ if protected() || clientFDs[c] != nil {
192 sendLease(ports.LeaseConnectors{Card: c.Path()})276 sendLease(ports.LeaseConnectors{Card: c.Path()})
277+ }
278+ if clientFDs[c] != nil {
193 clientFDs[c].Close()279 clientFDs[c].Close()
194 delete(clientFDs, c)280 delete(clientFDs, c)
195 }281 }
@@ -222,8 +308,8 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
222 log.Warn().Err(err).Msg("hotplug watch disabled")308 log.Warn().Err(err).Msg("hotplug watch disabled")
223 }309 }
224 }()310 }()
225- set := newOutputSet(ctx, ch.captured)
226 cards := map[string]*drmCard{}311 cards := map[string]*drmCard{}
312+ outputs := map[string]*drm.Output{} // owner result, read only after done
227 send := func(ev ports.OutputEvent) {313 send := func(ev ports.OutputEvent) {
228 select {314 select {
229 case ch.events <- ev:315 case ch.events <- ev:
@@ -250,6 +336,38 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
250 timer.Stop()336 timer.Stop()
251 defer timer.Stop()337 defer timer.Stop()
252 var deadline <-chan time.Time338 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+ }
253 var op uint64371 var op uint64
254 complete := func(d applyDecision) {372 complete := func(d applyDecision) {
255 if !d.reply {373 if !d.reply {
@@ -303,15 +421,37 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
303 stop(name, true)421 stop(name, true)
304 }422 }
305 for _, o := range added {423 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
306 name := o.Info().Name427 name := o.Info().Name
307 if other := cards[name]; other != nil && other != c {428 if other := cards[name]; other != nil && other != c {
308 // Core and clients know outputs by connector name.429 // Core and clients know outputs by connector name.
309 log.Warn().Str("connector", name).Str("card", c.Path()).Msg("connector name already used by another card; ignored")430 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+ }
311 c.Release(name)450 c.Release(name)
312 continue451 continue
313 }452 }
314 cards[name] = c453 cards[name] = c
454+ outputs[name] = o
315 readySources[name] = o.Ready()455 readySources[name] = o.Ready()
316 if progress.active && !want(currentConfig).Disabled[name] {456 if progress.active && !want(currentConfig).Disabled[name] {
317 progress.required[name] = true457 progress.required[name] = true
@@ -337,8 +477,9 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
337 }477 }
338 active := b.seat.Subscribe()478 active := b.seat.Subscribe()
339 realtime := currentConfig.Performance.Realtime479 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 {
341 defer b.seat.Unsubscribe(active)481 defer b.seat.Unsubscribe(active)
482+ o.Security, o.SecurityChanges, o.Instance = ch.security, secure, instance
342 return safe("output "+name, func() error {483 return safe("output "+name, func() error {
343 defer o.Close()484 defer o.Close()
344 if realtime {485 if realtime {
@@ -351,12 +492,19 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
351 }492 }
352 return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu, ch.presented, cap, ch.captured)493 return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu, ch.presented, cap, ch.captured)
353 })494 })
354- })495+ }, &o.SecurityEvents)
496+ if !started {
497+ b.seat.Unsubscribe(active)
498+ o.Close()
499+ c.Release(name)
500+ continue
501+ }
355 send(ports.OutputAdded{Info: o.Info()})502 send(ports.OutputAdded{Info: o.Info()})
356 }503 }
357 }504 }
358 publishInventory()505 publishInventory()
359 complete(progress.scanError(errors.Join(scanErrors...)))506 complete(progress.scanError(errors.Join(scanErrors...)))
507+ barrier()
360 }508 }
361 scan()509 scan()
362 if len(set.outs) == 0 {510 if len(set.outs) == 0 {
@@ -404,12 +552,38 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
404 configs, configNext := apply.configOut(ch.configured), apply.config552 configs, configNext := apply.configOut(ch.configured), apply.config
405 replies, replyNext := apply.replyOut(ch.replies)553 replies, replyNext := apply.replyOut(ch.replies)
406 heads, headsNext := apply.headsOut(ch.heads)554 heads, headsNext := apply.headsOut(ch.heads)
555+ contents, contentOut, contentNext, contentOwner := set.contentOut(ch.contents)
407 select {556 select {
408 case <-ctx.Done():557 case <-ctx.Done():
409 return set.wait()558 return set.wait()
410 case err := <-readerErr:559 case err := <-readerErr:
411 // No more flips on that card: its outputs would freeze.560 // No more flips on that card: its outputs would freeze.
412 return errors.Join(err, set.wait())561 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()
413 case <-hotplug:587 case <-hotplug:
414 log.Info().Msg("hotplug")588 log.Info().Msg("hotplug")
415 scan()589 scan()
@@ -442,14 +616,14 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
442 switch req := event.(type) {616 switch req := event.(type) {
443 case ports.LeaseRequest:617 case ports.LeaseRequest:
444 reply := ports.LeaseReply{ID: req.ID, Err: fmt.Errorf("unknown card %s", req.Card)}618 reply := ports.LeaseReply{ID: req.ID, Err: fmt.Errorf("unknown card %s", req.Card)}
445- if !seatActive {619+ if !seatActive || protected() {
446- reply.Err = errors.New("DRM seat inactive")620+ reply.Err = errors.New("DRM seat inactive or session protected")
447 sendLease(reply)621 sendLease(reply)
448 break622 break
449 }623 }
450 for _, c := range b.cards {624 for _, c := range b.cards {
451 if c.Path() == req.Card {625 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)
453 publishLeases(c)627 publishLeases(c)
454 break628 break
455 }629 }
@@ -483,14 +657,28 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
483 complete(progress.timeout(op))657 complete(progress.timeout(op))
484 case s := <-ch.scenes:658 case s := <-ch.scenes:
485 set.scenes(s)659 set.scenes(s)
486- case c := <-ch.contents:660+ case c := <-contents:
487 set.content(c)661 set.content(c)
662+ case contentOut <- contentNext:
663+ contentOwner.contentSent()
488 case q := <-ch.captures:664 case q := <-ch.captures:
489 set.routeCapture(q)665 set.routeCapture(q)
490 case c := <-ch.cursorChanges:666 case c := <-ch.cursorChanges:
491 set.setCursor(c)667 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]
493 err := set.finish(name)674 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)
494 if ctx.Err() != nil {682 if ctx.Err() != nil {
495 return errors.Join(err, set.wait())683 return errors.Join(err, set.wait())
496 }684 }
M internal/app/headless.go +29 -6
@@ -16,6 +16,7 @@ import (
16 // are fixed, so apply answers every configuration without backend work.16 // are fixed, so apply answers every configuration without backend work.
17 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 {17 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 {
18 set := newOutputSet(ctx, ch.captured)18 set := newOutputSet(ctx, ch.captured)
19+ set.wireSecurity(ch)
19 inventory := ports.OutputHeads{}20 inventory := ports.OutputHeads{}
20 for i, size := range sizes {21 for i, size := range sizes {
21 name := fmt.Sprintf("HEADLESS-%d", i+1)22 name := fmt.Sprintf("HEADLESS-%d", i+1)
@@ -32,15 +33,23 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap
32 cur := &headless.Cursor{}33 cur := &headless.Cursor{}
33 curs.set(name, cur)34 curs.set(name, cur)
34 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 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
36 return headless.Run(octx, opts, sc, cc, cu, cap)38 return headless.Run(octx, opts, sc, cc, cu, cap)
37- })39+ }, &opts.SecurityEvents)
40+ if !started {
41+ return set.wait()
42+ }
38 select {43 select {
39 case ch.events <- ports.OutputAdded{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1]}}:44 case ch.events <- ports.OutputAdded{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1]}}:
40 case <-ctx.Done():45 case <-ctx.Done():
41 return set.wait()46 return set.wait()
42 }47 }
43 }48 }
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+ }
44 apply.heads(inventory)53 apply.heads(inventory)
45 // The pointer starts centred on the first output, like libinput's.54 // The pointer starts centred on the first output, like libinput's.
46 curs.move("HEADLESS-1", float64(sizes[0][0])/2, float64(sizes[0][1])/2)55 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
48 configs, configNext := apply.configOut(ch.configured), apply.config57 configs, configNext := apply.configOut(ch.configured), apply.config
49 replies, replyNext := apply.replyOut(ch.replies)58 replies, replyNext := apply.replyOut(ch.replies)
50 heads, headsNext := apply.headsOut(ch.heads)59 heads, headsNext := apply.headsOut(ch.heads)
60+ contents, contentOut, contentNext, contentOwner := set.contentOut(ch.contents)
51 select {61 select {
52 case <-ctx.Done():62 case <-ctx.Done():
53 return set.wait()63 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+ }
54 case ev := <-ch.reloads:72 case ev := <-ch.reloads:
55 apply.reload(ev.Config)73 apply.reload(ev.Config)
56 case req := <-ch.requests:74 case req := <-ch.requests:
@@ -63,15 +81,20 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap
63 apply.headsSent()81 apply.headsSent()
64 case s := <-ch.scenes:82 case s := <-ch.scenes:
65 set.scenes(s)83 set.scenes(s)
66- case c := <-ch.contents:84+ case c := <-contents:
67 set.content(c)85 set.content(c)
86+ case contentOut <- contentNext:
87+ contentOwner.contentSent()
68 case q := <-ch.captures:88 case q := <-ch.captures:
69 set.routeCapture(q)89 set.routeCapture(q)
70 case c := <-ch.cursorChanges:90 case c := <-ch.cursorChanges:
71 set.setCursor(c)91 set.setCursor(c)
72- case name := <-set.stopped:92+ case stopped := <-set.stopped:
73- // A headless output only stops on error: the run ends.93+ if !set.stoppedCurrent(stopped) {
74- err := set.finish(name)94+ continue
95+ }
96+ // An owner error is not protection or removal evidence.
97+ err := set.finish(stopped.name)
75 return joinErr(err, set.wait())98 return joinErr(err, set.wait())
76 }99 }
77 }100 }
M internal/app/outputapply_e2e_test.go +59 -77
@@ -11,50 +11,9 @@ import (
11 "github.com/bnema/neferwl/internal/ports"11 "github.com/bnema/neferwl/internal/ports"
12 wlr "github.com/bnema/purego-libwayland/protocol/wlroutputmanagement"12 wlr "github.com/bnema/purego-libwayland/protocol/wlroutputmanagement"
13 "github.com/bnema/wlturbo"13 "github.com/bnema/wlturbo"
14+ client "github.com/bnema/wlturbo/protocol/outputmanagement"
14 )15 )
15 16
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-
58 // A wlr-output-management client goes through Wayland, the headless owner17 // A wlr-output-management client goes through Wayland, the headless owner
59 // loop and core: an accepted scale reaches the scene, a disable is refused.18 // loop and core: an accepted scale reaches the scene, a disable is refused.
60 func TestHeadlessOutputManagementEndToEnd(t *testing.T) {19 func TestHeadlessOutputManagementEndToEnd(t *testing.T) {
@@ -119,17 +78,12 @@ func TestHeadlessOutputManagementEndToEnd(t *testing.T) {
119 if err := c.Roundtrip(); err != nil {78 if err := c.Roundtrip(); err != nil {
120 t.Fatal(err)79 t.Fatal(err)
121 }80 }
122- g, ok := c.Registry().FindGlobal("zwlr_output_manager_v1")81+ manager := client.NewOutputManager(c.Context())
123- if !ok {82+ var heads []*client.OutputHead
124- t.Fatal("no zwlr_output_manager_v1")83+ var serial uint32
125- }84+ manager.OnHead(func(head *client.OutputHead) { heads = append(heads, head) })
126- id, err := c.Registry().BindID(g.Name, g.Interface, 4)85+ manager.OnDone(func(value uint32) { serial = value })
127- if err != nil {86+ bindTestClient(t, c, client.OutputManagerInterface, 4, manager)
128- t.Fatal(err)
129- }
130- manager := &managerEvents{c: c}
131- manager.SetID(id)
132- c.Context().Register(manager)
133 roundtrip := func(until func() bool) {87 roundtrip := func(until func() bool) {
134 t.Helper()88 t.Helper()
135 // The owner loops answer asynchronously: roundtrip until they do,89 // The owner loops answer asynchronously: roundtrip until they do,
@@ -141,44 +95,72 @@ func TestHeadlessOutputManagementEndToEnd(t *testing.T) {
141 }95 }
142 }96 }
143 // The inventory reaches Wayland through the owner loop's outbox.97 // The inventory reaches Wayland through the owner loop's outbox.
144- roundtrip(func() bool { return manager.done != 0 && len(manager.heads) == 1 })98+ roundtrip(func() bool { return serial != 0 && len(heads) == 1 })
145- head, serial := manager.heads[0], manager.done99+ head, initialSerial := heads[0], serial
146- configure := func(set func(cfg uint32)) int {100+ configure := func(set func(*client.OutputConfiguration) *client.OutputConfigurationHead) int {
147 t.Helper()101 t.Helper()
148- cfg := &configurationEvents{}102+ // Inventory and core layout are independent startup publications. A
149- cfg.SetID(c.AllocateID())103+ // done serial can become stale before create_configuration reaches the
150- c.Context().Register(cfg)104+ // server. Only protocol cancellation is retryable, never a failed apply.
151- if err := c.SendRequest(id, uint16(wlr.ZwlrOutputManagerV1RequestCreateConfiguration), cfg.ID(), manager.done); err != nil {105+ for attempt := 0; attempt < 3; attempt++ {
152- t.Fatal(err)106+ requested := serial
153- }107+ cfg, err := manager.CreateConfiguration(requested)
154- set(cfg.ID())108+ if err != nil {
155- if err := c.SendRequest(cfg.ID(), uint16(wlr.ZwlrOutputConfigurationV1RequestApply)); err != nil {109+ t.Fatal(err)
156- 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+ }
157 }140 }
158- roundtrip(func() bool { return cfg.result != 0 })141+ t.Fatal("output inventory kept changing across three configurations")
159- return cfg.result - 1142+ return -1
160 }143 }
161- got := configure(func(cfg uint32) {144+ got := configure(func(cfg *client.OutputConfiguration) *client.OutputConfigurationHead {
162- ch := &ignoredEvents{c: c}145+ ch, err := cfg.EnableHead(head)
163- ch.SetID(c.AllocateID())146+ if err != nil {
164- c.Context().Register(ch)
165- if err := c.SendRequest(cfg, uint16(wlr.ZwlrOutputConfigurationV1RequestEnableHead), ch.ID(), head); err != nil {
166 t.Fatal(err)147 t.Fatal(err)
167 }148 }
168- if err := c.SendRequest(ch.ID(), uint16(wlr.ZwlrOutputConfigurationHeadV1RequestSetScale), int32(512)); err != nil {149+ if err := ch.SetScale(wlturbo.NewFixed(2)); err != nil {
169 t.Fatal(err)150 t.Fatal(err)
170 }151 }
152+ return ch
171 })153 })
172 if got != int(wlr.ZwlrOutputConfigurationV1EventSucceeded) {154 if got != int(wlr.ZwlrOutputConfigurationV1EventSucceeded) {
173 t.Fatalf("scale 2: event %d", got)155 t.Fatalf("scale 2: event %d", got)
174 }156 }
175 scene(func(s ports.Scene) bool { return s.Scale == 2 })157 scene(func(s ports.Scene) bool { return s.Scale == 2 })
176- // A headless output cannot be disabled. The new scale gives a new serial.158+ roundtrip(func() bool { return serial != initialSerial })
177- roundtrip(func() bool { return manager.done != serial })159+ got = configure(func(cfg *client.OutputConfiguration) *client.OutputConfigurationHead {
178- got = configure(func(cfg uint32) {160+ if err := cfg.DisableHead(head); err != nil {
179- if err := c.SendRequest(cfg, uint16(wlr.ZwlrOutputConfigurationV1RequestDisableHead), head); err != nil {
180 t.Fatal(err)161 t.Fatal(err)
181 }162 }
163+ return nil
182 })164 })
183 if got != int(wlr.ZwlrOutputConfigurationV1EventFailed) {165 if got != int(wlr.ZwlrOutputConfigurationV1EventFailed) {
184 t.Fatalf("disable: event %d", got)166 t.Fatalf("disable: event %d", got)
M internal/app/outputrouting.go +17 -14
@@ -3,21 +3,24 @@ package app
3 import "github.com/bnema/neferwl/internal/ports"3 import "github.com/bnema/neferwl/internal/ports"
4 4
5 // outputChannels is the shared app-to-backend wiring. The backend loop remains5 // outputChannels is the shared app-to-backend wiring. The backend loop remains
6-// the sole owner of outputSet and outputApply; routing does not introduce a6+// the sole owner of outputSet and outputApply. Security producers use bounded
7-// forwarding queue. heads, configured and replies are fed from the7+// lossless FIFO forwarders; content backpressure stays inside the backend select.
8-// outputApply outbox.8+// heads, configured and replies are fed from the outputApply outbox.
9 type outputChannels struct {9 type outputChannels struct {
10- leaseRequests <-chan ports.LeaseMessage10+ security ports.SessionSecurity
11- leaseEvents chan<- ports.LeaseMessage11+ securityChanges <-chan ports.SecurityState
12- events chan<- ports.OutputEvent12+ securityEvents chan<- ports.SecurityBackendEvent
13- scenes <-chan []ports.Scene13+ leaseRequests <-chan ports.LeaseMessage
14- contents <-chan ports.SurfaceContent14+ leaseEvents chan<- ports.LeaseMessage
15- cursorChanges <-chan ports.CursorChange15+ events chan<- ports.OutputEvent
16- presented chan<- ports.OutputPresented16+ scenes <-chan []ports.Scene
17- captures <-chan ports.CaptureRequest17+ contents <-chan ports.SurfaceContent
18- captured chan<- ports.CaptureDone18+ cursorChanges <-chan ports.CursorChange
19- formats chan<- ports.OutputFormats19+ presented chan<- ports.OutputPresented
20- heads chan<- ports.OutputHeads20+ captures <-chan ports.CaptureRequest
21+ captured chan<- ports.CaptureDone
22+ formats chan<- ports.OutputFormats
23+ heads chan<- ports.OutputHeads
21 // reloads carries file configuration; configured carries the effective24 // reloads carries file configuration; configured carries the effective
22 // configuration, overrides included, to core.25 // configuration, overrides included, to core.
23 reloads <-chan ports.ConfigChanged26 reloads <-chan ports.ConfigChanged
M internal/app/outputrouting_test.go +2 -2
@@ -14,7 +14,7 @@ func TestOutputRouting(t *testing.T) {
14 replies := make(chan ports.CaptureDone, 2)14 replies := make(chan ports.CaptureDone, 2)
15 set := newOutputSet(ctx, replies)15 set := newOutputSet(ctx, replies)
16 for _, name := range []string{"A", "B"} {16 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 {
18 <-ctx.Done()18 <-ctx.Done()
19 return nil19 return nil
20 })20 })
@@ -78,7 +78,7 @@ func TestOutputRoutingAllocations(t *testing.T) {
78 set.content(content)78 set.content(content)
79 ctx, cancel := context.WithCancel(context.Background())79 ctx, cancel := context.WithCancel(context.Background())
80 defer cancel()80 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 {
82 <-ctx.Done()82 <-ctx.Done()
83 return nil83 return nil
84 })84 })
M internal/app/outputs.go +196 -34
@@ -12,7 +12,7 @@ import (
12 12
13 // outputRun drives one output until ctx ends: it renders the scenes and13 // outputRun drives one output until ctx ends: it renders the scenes and
14 // surface contents it receives, with the cursor the client asks for.14 // 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) error15+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
16 16
17 // outputSet owns the running outputs and feeds them. Core sends one scene17 // outputSet owns the running outputs and feeds them. Core sends one scene
18 // per output; each goes to its output, latest first. Surface contents go to18 // 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
20 // each window is kept so a new output starts with every window drawn.20 // each window is kept so a new output starts with every window drawn.
21 // Only the goroutine running loop touches the set.21 // Only the goroutine running loop touches the set.
22 type outputSet struct {22 type outputSet struct {
23- outs map[string]*runningOutput23+ outs map[string]*runningOutput
24- latest map[ports.WindowID]ports.SurfaceContent24+ latest map[ports.WindowID]ports.SurfaceContent
25- cursor ports.CursorChange25+ cursor ports.CursorChange
26- stopped chan string26+ stopped chan outputStopped
27- captured chan<- ports.CaptureDone27+ captured chan<- ports.CaptureDone
28- ctx context.Context28+ ctx context.Context
29- quit chan struct{} // closed by wait: nobody reads stopped any more29+ 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
30 }43 }
31 44
32 type runningOutput struct {45 type runningOutput struct {
33- scenes chan ports.Scene46+ instance ports.OutputInstance
34- contents chan ports.SurfaceContent47+ security chan ports.SecurityState
35- cursor chan ports.CursorChange48+ securityEvents chan ports.SecurityBackendEvent
36- captures chan ports.CaptureRequest49+ pending []ports.SurfaceContent
37- ctx context.Context50+ scenes chan ports.Scene
38- stop context.CancelFunc51+ contents chan ports.SurfaceContent
39- err error52+ cursor chan ports.CursorChange
40- done chan struct{}53+ captures chan ports.CaptureRequest
54+ ctx context.Context
55+ stop context.CancelFunc
56+ err error
57+ done chan struct{}
41 }58 }
42 59
43 func newOutputSet(ctx context.Context, captured chan<- ports.CaptureDone) *outputSet {60 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{})}
45 }62 }
46 63
47 // start runs an output in its own goroutine and replays the window contents.64 // 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+ }
49 octx, stop := context.WithCancel(ctx)80 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+ }
51 s.outs[name] = r94 s.outs[name] = r
52 go func() {95 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+ }
54 stop()100 stop()
55 close(r.done)101 close(r.done)
56 select {102 select {
57- case s.stopped <- name:103+ case s.stopped <- outputStopped{name: name, instance: instance}:
58 case <-s.quit:104 case <-s.quit:
59 }105 }
60 }()106 }()
61 r.cursor <- s.cursor107 r.cursor <- s.cursor
62- for _, c := range s.latest {108+ return true
63- r.send(c)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())
64 }118 }
65 }119 }
66 120
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+
67 // setCursor gives every output the latest cursor, replacing an unread one.198 // setCursor gives every output the latest cursor, replacing an unread one.
68 func (s *outputSet) setCursor(c ports.CursorChange) {199 func (s *outputSet) setCursor(c ports.CursorChange) {
69 s.cursor = c200 s.cursor = c
@@ -76,15 +207,6 @@ func (s *outputSet) setCursor(c ports.CursorChange) {
76 }207 }
77 }208 }
78 209
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-
88 // scenes routes each scene to its output, replacing an unread one.210 // scenes routes each scene to its output, replacing an unread one.
89 func (s *outputSet) scenes(set []ports.Scene) {211 func (s *outputSet) scenes(set []ports.Scene) {
90 for _, sc := range set {212 for _, sc := range set {
@@ -105,7 +227,41 @@ func (s *outputSet) content(c ports.SurfaceContent) {
105 s.latest[c.ID] = c227 s.latest[c.ID] = c
106 }228 }
107 for _, r := range s.outs {229 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:]
109 }265 }
110 }266 }
111 267
@@ -124,8 +280,14 @@ func (s *outputSet) finish(name string) error {
124 280
125 // wait stops every output and returns the first error.281 // wait stops every output and returns the first error.
126 func (s *outputSet) wait() error {282 func (s *outputSet) wait() error {
127- var first error283+ 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+ }
128 close(s.quit)289 close(s.quit)
290+ s.forwarders.Wait()
129 for name, r := range s.outs {291 for name, r := range s.outs {
130 r.stop()292 r.stop()
131 <-r.done293 <-r.done
M internal/app/outputs_test.go +1 -1
@@ -17,7 +17,7 @@ func TestOutputSetCursor(t *testing.T) {
17 run := func(name string) outputRun {17 run := func(name string) outputRun {
18 got[name] = make(chan ports.CursorChange, 8)18 got[name] = make(chan ports.CursorChange, 8)
19 out := got[name]19 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 {
21 for {21 for {
22 select {22 select {
23 case <-ctx.Done():23 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 (
12 "time"12 "time"
13 13
14 "github.com/bnema/neferwl/internal/adapters/config"14 "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"
18 "github.com/bnema/wlturbo"15 "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"
19 "golang.org/x/sys/unix"19 "golang.org/x/sys/unix"
20 )20 )
21 21
@@ -37,53 +37,14 @@ func (b *lockedBuffer) String() string {
37 return b.buf.String()37 return b.buf.String()
38 }38 }
39 39
40-// textClient records what a text input receives.40+// textClient retains observations from an ordinary generated text-input proxy.
41 type textClient struct {41 type textClient struct {
42- wlturbo.BaseProxy42+ *clienttext.TextInputV3
43 entered bool43 entered bool
44 preedit []string44 preedit []string
45 commits []string45 commits []string
46 }46 }
47 47
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-
87 // NeferWL headless, the examples/testime input method and a text input48 // NeferWL headless, the examples/testime input method and a text input
88 // client: the input method's preedit and commit reach the client.49 // client: the input method's preedit and commit reach the client.
89 func TestHeadlessTextInputIME(t *testing.T) {50 func TestHeadlessTextInputIME(t *testing.T) {
@@ -177,16 +138,16 @@ func TestHeadlessTextInputIME(t *testing.T) {
177 }138 }
178 }139 }
179 poll("no text input enter", func() bool { return text.entered })140 poll("no text input enter", func() bool { return text.entered })
180- send := func(op uint32, args ...any) {141+ for i := 1; i <= 2; i++ {
181- t.Helper()142+ if err := text.Enable(); err != nil {
182- if err := c.SendRequest(text.ID(), uint16(op), args...); 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 {
183 t.Fatal(err)149 t.Fatal(err)
184 }150 }
185- }
186- for i := 1; i <= 2; i++ {
187- send(textinput.ZwpTextInputV3RequestEnable)
188- send(textinput.ZwpTextInputV3RequestSetContentType, uint32(0), uint32(0))
189- send(textinput.ZwpTextInputV3RequestCommit)
190 poll("missing commit", func() bool { return len(text.commits) == i })151 poll("missing commit", func() bool { return len(text.commits) == i })
191 }152 }
192 if len(text.preedit) != 2 || text.preedit[0] != "nihon" || text.commits[0] != "日本" || text.commits[1] != "日本" {153 if len(text.preedit) != 2 || text.preedit[0] != "nihon" || text.commits[0] != "日本" || text.commits[1] != "日本" {
@@ -206,80 +167,61 @@ func TestHeadlessTextInputIME(t *testing.T) {
206 // mapTextClient maps a 1x1 toplevel with a text input on c.167 // mapTextClient maps a 1x1 toplevel with a text input on c.
207 func mapTextClient(t *testing.T, c *wlturbo.Display) *textClient {168 func mapTextClient(t *testing.T, c *wlturbo.Display) *textClient {
208 t.Helper()169 t.Helper()
209- if err := c.Roundtrip(); err != nil {170+ check := func(err error) {
210- t.Fatal(err)
211- }
212- bind := func(iface string) uint32 {
213 t.Helper()171 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)
219 if err != nil {172 if err != nil {
220 t.Fatal(err)173 t.Fatal(err)
221 }174 }
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()
230 }175 }
231- req := func(id, op uint32, args ...any) {176+ check(c.Roundtrip())
232- t.Helper()177+ comp := clientcore.NewCompositor(c.Context())
233- if err := c.SendRequest(id, uint16(op), args...); err != nil {178+ shm := clientcore.NewShm(c.Context())
234- t.Fatal(err)179+ seat := clientcore.NewSeat(c.Context())
235- }180+ tim := clienttext.NewTextInputManagerV3(c.Context())
236- }181+ wm := clientxdg.NewXdgWmBase(c.Context())
237- comp, shm, seat, tim := bind("wl_compositor"), bind("wl_shm"), bind("wl_seat"), bind("zwp_text_input_manager_v3")182+ wm.OnPing(func(serial uint32) { check(wm.Pong(serial)) })
238- for _, id := range []uint32{comp, shm, seat, tim} {183+ bindTestClient(t, c, clientcore.CompositorInterface, 1, comp)
239- p := &quiet{}184+ bindTestClient(t, c, clientcore.ShmInterface, 1, shm)
240- p.SetID(id)185+ bindTestClient(t, c, clientcore.SeatInterface, 1, seat)
241- c.Context().Register(p)186+ bindTestClient(t, c, clienttext.TextInputManagerV3Interface, 1, tim)
242- }187+ bindTestClient(t, c, clientxdg.XdgWmBaseInterface, 1, wm)
243- wm := &wmPing{c: c}
244- wm.SetID(bind("xdg_wm_base"))
245- c.Context().Register(wm)
246 fd, err := unix.MemfdCreate("text-client", 0)188 fd, err := unix.MemfdCreate("text-client", 0)
247- if err != nil {189+ check(err)
248- t.Fatal(err)
249- }
250- defer unix.Close(fd)
251 if err := unix.Ftruncate(fd, 4); err != nil {190 if err := unix.Ftruncate(fd, 4); err != nil {
191+ _ = unix.Close(fd)
252 t.Fatal(err)192 t.Fatal(err)
253 }193 }
254- pool := newID()194+ // CreatePool owns the sent descriptor on success; only close it on error.
255- if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {195+ pool, err := shm.CreatePool(fd, 4)
196+ if err != nil {
197+ _ = unix.Close(fd)
256 t.Fatal(err)198 t.Fatal(err)
257 }199 }
258- buffer := newID()200+ buffer, err := pool.CreateBuffer(0, 1, 1, 4, clientcore.FORMAT_ARGB8888)
259- req(pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))201+ check(err)
260- surf := newID()202+ surf, err := comp.CreateSurface()
261- req(comp, wayland.CompositorRequestCreateSurface, surf)203+ check(err)
262- xdg := &xdgConfigure{}204+ xdg, err := wm.GetXdgSurface(surf)
263- xdg.SetID(c.AllocateID())205+ check(err)
264- c.Context().Register(xdg)206+ var serial uint32
265- req(wm.ID(), xdgshell.WmBaseRequestGetXdgSurface, xdg.ID(), surf)207+ xdg.OnConfigure(func(value uint32) { serial = value })
266- top := newID()208+ _, err = xdg.GetToplevel()
267- req(xdg.ID(), xdgshell.SurfaceRequestGetToplevel, top)209+ check(err)
268- text := &textClient{}210+ proxy, err := tim.GetTextInput(seat)
269- text.SetID(c.AllocateID())211+ check(err)
270- c.Context().Register(text)212+ text := &textClient{TextInputV3: proxy}
271- req(tim, textinput.ZwpTextInputManagerV3RequestGetTextInput, text.ID(), seat)213+ proxy.OnEnter(func(uint32) { text.entered = true })
272- req(surf, wayland.SurfaceRequestCommit)214+ proxy.OnPreeditString(func(value string, _, _ int32) { text.preedit = append(text.preedit, value) })
273- for deadline := time.Now().Add(5 * time.Second); xdg.serial == 0; time.Sleep(10 * time.Millisecond) {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) {
274 if time.Now().After(deadline) {218 if time.Now().After(deadline) {
275 t.Fatal("no configure")219 t.Fatal("no configure")
276 }220 }
277- if err := c.Roundtrip(); err != nil {221+ check(c.Roundtrip())
278- t.Fatal(err)
279- }
280 }222 }
281- req(xdg.ID(), xdgshell.SurfaceRequestAckConfigure, xdg.serial)223+ check(xdg.AckConfigure(serial))
282- req(surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))224+ check(surf.Attach(buffer, 0, 0))
283- req(surf, wayland.SurfaceRequestCommit)225+ check(surf.Commit())
284 return text226 return text
285 }227 }
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 {
154 f := cw.frame154 f := cw.frame
155 rebase := func(r Rect) Rect { r.X, r.Y = r.X-f.X, r.Y-f.Y; return r }155 rebase := func(r Rect) Rect { r.X, r.Y = r.X-f.X, r.Y-f.Y; return r }
156 s := &ports.Scene{156 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},
158 Scale: cw.sc.scale, Background: c.cfg.Background.Color,159 Scale: cw.sc.scale, Background: c.cfg.Background.Color,
159 Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive},160 Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive},
160 Windows: make([]ports.SceneWindow, 0, len(cw.placements)),161 Windows: make([]ports.SceneWindow, 0, len(cw.placements)),
M internal/core/core.go +130 -265
@@ -15,10 +15,14 @@ import (
15 15
16 var ErrQuit = errors.New("quit requested")16 var ErrQuit = errors.New("quit requested")
17 17
18+var errSecurityChanged = errors.New("security epoch changed during input")
19+
18 // Channels connects the owner to adapters. Scenes must have capacity 1: core is20 // Channels connects the owner to adapters. Scenes must have capacity 1: core is
19 // its only sender and may drain a stale set; consumers only receive. Each21 // its only sender and may drain a stale set; consumers only receive. Each
20 // send holds one scene per output.22 // send holds one scene per output.
21 type Channels struct {23 type Channels struct {
24+ // Security is the defensive session gate; nil preserves standalone operation.
25+ Security ports.SessionSecurity
22 Client <-chan ports.ClientEvent26 Client <-chan ports.ClientEvent
23 Input <-chan ports.InputEvent27 Input <-chan ports.InputEvent
24 Output <-chan ports.OutputEvent28 Output <-chan ports.OutputEvent
@@ -63,6 +67,12 @@ type binding struct {
63 key string67 key string
64 }68 }
65 type Core struct {69 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
66 nextWorkspaceID uint6476 nextWorkspaceID uint64
67 ch Channels77 ch Channels
68 cfg ports.Config78 cfg ports.Config
@@ -274,13 +284,20 @@ func (c *Core) publishPending(ctx context.Context) error {
274 // just switched to another workspace), it also refills the empty slots of284 // just switched to another workspace), it also refills the empty slots of
275 // the workspace now on screen.285 // the workspace now on screen.
276 func (c *Core) spawnSlots(ctx context.Context, shown bool) error {286 func (c *Core) spawnSlots(ctx context.Context, shown bool) error {
287+ if c.protectionRequested() {
288+ return nil
289+ }
277 keys := c.toSpawn290 keys := c.toSpawn
278 c.toSpawn = nil291 c.toSpawn = nil
279 if shown {292 if shown {
280 keys = append(keys, c.refill()...)293 keys = append(keys, c.refill()...)
281 }294 }
282 started := map[slotKey]bool{}295 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+ }
284 st, ok := c.slots[key]301 st, ok := c.slots[key]
285 // A key can be queued twice (new slot on the workspace on screen).302 // A key can be queued twice (new slot on the workspace on screen).
286 if !ok || started[key] || st.window != 0 {303 if !ok || started[key] || st.window != 0 {
@@ -289,13 +306,18 @@ func (c *Core) spawnSlots(ctx context.Context, shown bool) error {
289 started[key] = true306 started[key] = true
290 req := c.spawnSlot(key)307 req := c.spawnSlot(key)
291 // Wayland must read slot tokens before this window can map.308 // Wayland must read slot tokens before this window can map.
292- if err := c.publishPending(ctx); err != nil {309+ pendingErr := c.publishPending(ctx)
293- return err310+ 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
294 }315 }
295- select {316+ if !c.trySpawn(ctx, req) {
296- case <-ctx.Done():317+ c.placement.dropSlot(key)
318+ st.stale = false
319+ c.toSpawn = append(c.toSpawn, keys[i:]...)
297 return ctx.Err()320 return ctx.Err()
298- case c.ch.Spawn <- req:
299 }321 }
300 }322 }
301 return nil323 return nil
@@ -305,7 +327,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
305 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")327 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")
306 }328 }
307 // A placeholder screen holds windows until the first output arrives.329 // 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()}
309 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}331 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}
310 if err := c.apply(cfg); err != nil {332 if err := c.apply(cfg); err != nil {
311 return nil, err333 return nil, err
@@ -320,10 +342,19 @@ func (c *Core) now() time.Time {
320 }342 }
321 343
322 func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {344 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+ }
323 select {351 select {
324 case <-ctx.Done():352 case <-ctx.Done():
325 return ctx.Err()353 return ctx.Err()
326 case c.ch.Commands <- v:354 case c.ch.Commands <- v:
355+ if c.inputEpochChanged() {
356+ return errSecurityChanged
357+ }
327 return nil358 return nil
328 }359 }
329 }360 }
@@ -380,6 +411,9 @@ func (c *Core) windowFocus() map[*screen]WindowID {
380 411
381 // keyboardFocus is the surface holding the keyboard (see keyboard.focus).412 // keyboardFocus is the surface holding the keyboard (see keyboard.focus).
382 func (c *Core) keyboardFocus() WindowID {413 func (c *Core) keyboardFocus() WindowID {
414+ if c.security.Protected {
415+ return c.lockKeyboardFocus()
416+ }
383 var exclusive WindowID417 var exclusive WindowID
384 for _, sc := range c.screens {418 for _, sc := range c.screens {
385 for _, l := range sc.layers {419 for _, l := range sc.layers {
@@ -429,6 +463,13 @@ func (c *Core) setLayers(all []ports.LayerSurface) {
429 }463 }
430 464
431 func (c *Core) publish(ctx context.Context) error {465 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+ }
432 capture, err := c.captureEvaluate(ctx)473 capture, err := c.captureEvaluate(ctx)
433 if err != nil {474 if err != nil {
434 return err475 return err
@@ -470,7 +511,7 @@ func (c *Core) publish(ctx context.Context) error {
470 if frame != (Rect{W: o.W, H: o.H}) {511 if frame != (Rect{W: o.W, H: o.H}) {
471 clip = frame512 clip = frame
472 }513 }
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)}
474 layout := sc.mon.Layout()515 layout := sc.mon.Layout()
475 var real map[WindowID]Placement516 var real map[WindowID]Placement
476 if sc.mon.ov.open {517 if sc.mon.ov.open {
@@ -543,6 +584,12 @@ func (c *Core) publish(ctx context.Context) error {
543 return err584 return err
544 }585 }
545 c.resolveConstraint()586 c.resolveConstraint()
587+ if c.inputEpochChanged() {
588+ return errSecurityChanged
589+ }
590+ if c.syncSecurity() {
591+ return c.publish(ctx)
592+ }
546 latest(c.ch.Scenes, scenes)593 latest(c.ch.Scenes, scenes)
547 c.publishState()594 c.publishState()
548 c.publishWorkspaces()595 c.publishWorkspaces()
@@ -600,6 +647,9 @@ func (c *Core) rehit(ctx context.Context) error {
600 // updateInhibit makes the shortcuts inhibitor of the keyboard focus the647 // updateInhibit makes the shortcuts inhibitor of the keyboard focus the
601 // active one and tells wayland when it changes.648 // active one and tells wayland when it changes.
602 func (c *Core) updateInhibit(ctx context.Context) error {649 func (c *Core) updateInhibit(ctx context.Context) error {
650+ if c.security.Protected {
651+ return nil
652+ }
603 f := c.keyboardFocus()653 f := c.keyboardFocus()
604 release, activate, changed := c.keyboard.inhibit(f, c.windows.lookup(f).inhibitShortcuts)654 release, activate, changed := c.keyboard.inhibit(f, c.windows.lookup(f).inhibitShortcuts)
605 if !changed {655 if !changed {
@@ -765,6 +815,9 @@ func (c *Core) acceptsInput(id WindowID, x, y float64) bool {
765 // windows (last visible placement is topmost), then bottom and815 // windows (last visible placement is topmost), then bottom and
766 // background layers.816 // background layers.
767 func (c *Core) hit(x, y float64) (WindowID, float64, float64) {817 func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
818+ if c.security.Protected {
819+ return c.lockHit(x, y)
820+ }
768 o, ok := c.layout().At(x, y)821 o, ok := c.layout().At(x, y)
769 if !ok {822 if !ok {
770 return 0, 0, 0823 return 0, 0, 0
@@ -817,32 +870,50 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
817 }870 }
818 871
819 func (c *Core) Run(ctx context.Context) error {872 func (c *Core) Run(ctx context.Context) error {
873+ c.syncSecurity()
820 defer c.stopFrame()874 defer c.stopFrame()
821 defer c.stopCapture()875 defer c.stopCapture()
822- // Startup commands run once per session; a config reload does not876+ // Startup commands run once per session. Full launcher queues retain a
823- // run them again.877+ // bounded remainder, selectable alongside owner events without polling.
824- for _, argv := range c.cfg.Startup {878+ c.startup = make([][]string, len(c.cfg.Startup))
825- select {879+ for i, argv := range c.cfg.Startup {
826- case <-ctx.Done():880+ c.startup[i] = slices.Clone(argv)
827- return nil
828- case c.ch.Spawn <- ports.SpawnRequest{Argv: slices.Clone(argv)}:
829- }
830 }881 }
882+ c.spawnStartup(ctx)
831 if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil {883 if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil {
832 return nil884 return nil
833 }885 }
834 c.publishState()886 c.publishState()
835 c.publishWorkspaces()887 c.publishWorkspaces()
836 for {888 for {
889+ if c.securityCheckpoint(ctx) != nil {
890+ return nil
891+ }
892+ startupC, startupRequest := c.pendingStartup()
837 select {893 select {
838 case <-ctx.Done():894 case <-ctx.Done():
839 return nil895 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
840 case ev, ok := <-c.ch.Client:901 case ev, ok := <-c.ch.Client:
841 if !ok {902 if !ok {
842 c.ch.Client = nil903 c.ch.Client = nil
843 continue904 continue
844 }905 }
906+ if c.securityCheckpoint(ctx) != nil {
907+ return nil
908+ }
909+ if c.security.Protected && blockedProtectedEvent(ev) {
910+ continue
911+ }
845 switch v := ev.(type) {912 switch v := ev.(type) {
913+ case ports.SessionLockChanged:
914+ if !c.applyLockChanged(v) {
915+ continue
916+ }
846 case ports.CaptureSessionBegin:917 case ports.CaptureSessionBegin:
847 if c.captureBegin(ctx, v) != nil {918 if c.captureBegin(ctx, v) != nil {
848 return nil919 return nil
@@ -951,6 +1022,9 @@ func (c *Core) Run(ctx context.Context) error {
951 return nil1022 return nil
952 }1023 }
953 case f, ok := <-c.ch.Frames:1024 case f, ok := <-c.ch.Frames:
1025+ if c.securityCheckpoint(ctx) != nil {
1026+ return nil
1027+ }
954 if !ok {1028 if !ok {
955 c.ch.Frames = nil1029 c.ch.Frames = nil
956 continue1030 continue
@@ -960,12 +1034,18 @@ func (c *Core) Run(ctx context.Context) error {
960 }1034 }
961 continue1035 continue
962 case <-c.frameC:1036 case <-c.frameC:
1037+ if c.securityCheckpoint(ctx) != nil {
1038+ return nil
1039+ }
963 c.frameC, c.frameStop = nil, nil1040 c.frameC, c.frameStop = nil, nil
964 if c.step(ctx) != nil {1041 if c.step(ctx) != nil {
965 return nil1042 return nil
966 }1043 }
967 continue1044 continue
968 case <-c.captureC:1045 case <-c.captureC:
1046+ if c.securityCheckpoint(ctx) != nil {
1047+ return nil
1048+ }
969 c.captureExpired()1049 c.captureExpired()
970 if c.publish(ctx) != nil {1050 if c.publish(ctx) != nil {
971 return nil1051 return nil
@@ -976,6 +1056,9 @@ func (c *Core) Run(ctx context.Context) error {
976 c.ch.Output = nil1056 c.ch.Output = nil
977 continue1057 continue
978 }1058 }
1059+ if c.securityCheckpoint(ctx) != nil {
1060+ return nil
1061+ }
979 switch v := ev.(type) {1062 switch v := ev.(type) {
980 case ports.OutputAdded:1063 case ports.OutputAdded:
981 c.addScreen(v.Info)1064 c.addScreen(v.Info)
@@ -1000,6 +1083,9 @@ func (c *Core) Run(ctx context.Context) error {
1000 c.ch.Config = nil1083 c.ch.Config = nil
1001 continue1084 continue
1002 }1085 }
1086+ if c.securityCheckpoint(ctx) != nil {
1087+ return nil
1088+ }
1003 if err := c.apply(ev.Config); err != nil {1089 if err := c.apply(ev.Config); err != nil {
1004 select {1090 select {
1005 case c.ch.ConfigErrors <- err:1091 case c.ch.ConfigErrors <- err:
@@ -1018,254 +1104,27 @@ func (c *Core) Run(ctx context.Context) error {
1018 c.ch.Input = nil1104 c.ch.Input = nil
1019 continue1105 continue
1020 }1106 }
1021- if err := c.userActivity(ctx); err != nil {1107+ if c.securityCheckpoint(ctx) != nil {
1022 return nil1108 return nil
1023 }1109 }
1024- switch v := ev.(type) {1110+ // Even stale input is activity, but never becomes client input.
1025- case ports.PointerMotion:1111+ if err := c.userActivity(ctx); err != nil {
1026- c.motionMsec = v.TimeMsec1112+ return nil
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
1155 }1113 }
1156- // The overview takes its keys before any window; others still1114+ // Commands can backpressure; recheck after activity before admits/binds.
1157- // run binds, and are not forwarded.1115+ if c.securityCheckpoint(ctx) != nil {
1158- // A launcher or a menu holding the keyboard gets them first.1116+ return nil
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- }
1170 }1117 }
1171- // A focused window inhibiting shortcuts gets every key:1118+ var admitted bool
1172- // no bind runs (emergency quit and VT switch are handled by1119+ ev, admitted = c.admitInput(ev)
1173- // input before core).1120+ if !admitted {
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- }
1184 continue1121 continue
1185 }1122 }
1186- name := keyName(key.Keysym)1123+ if err := c.handleInput(ctx, ev); err != nil {
1187- // A bind on the keysym wins; then the unshifted keysym (Cmd+Shift+11124+ if errors.Is(err, errSecurityChanged) {
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- }
1261 continue1125 continue
1262 }1126 }
1263- c.pressed[held] = false1127+ return err
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- }
1269 }1128 }
1270 }1129 }
1271 }1130 }
@@ -1283,18 +1142,24 @@ func (c *Core) workspaceVisible(ctx context.Context, shown bool) error {
1283 // it after the output layout and before scenes, so a terminal never maps1142 // it after the output layout and before scenes, so a terminal never maps
1284 // before wayland knows its output.1143 // before wayland knows its output.
1285 func (c *Core) spawnEmpty(ctx context.Context) error {1144 func (c *Core) spawnEmpty(ctx context.Context) error {
1286- terms := c.fillEmpty()1145+ if c.protectionRequested() {
1287- if err := c.publishPending(ctx); err != nil {1146+ return nil
1288- return err
1289 }1147 }
1290- for _, req := range terms {1148+ first := c.firstTerminalResolved
1291- select {1149+ terms := c.fillEmpty()
1292- case <-ctx.Done():1150+ err := c.publishPending(ctx)
1293- return ctx.Err()1151+ for i, req := range terms {
1294- case c.ch.Spawn <- req: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
1295 }1160 }
1296 }1161 }
1297- return nil1162+ return err
1298 }1163 }
1299 1164
1300 // activate brings a window forward for a valid xdg-activation token. The1165 // 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) {
495 func TestFirstTerminalFollowsSetOutputs(t *testing.T) {495 func TestFirstTerminalFollowsSetOutputs(t *testing.T) {
496 cfg := config.Defaults()496 cfg := config.Defaults()
497 commands := make(chan ports.ClientCommand)497 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)
499 output := make(chan ports.OutputEvent, 1)501 output := make(chan ports.OutputEvent, 1)
500 scenes := make(chan []ports.Scene, 1)502 scenes := make(chan []ports.Scene, 1)
501 state := make(chan ports.State, 1)503 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 @@
1 package core1 package core
2 2
3 import (3 import (
4+ "strings"
4 "time"5 "time"
5 6
6 "github.com/bnema/neferwl/internal/ports"7 "github.com/bnema/neferwl/internal/ports"
@@ -48,6 +49,18 @@ func (c *Core) fillEmpty() []ports.SpawnRequest {
48 return reqs49 return reqs
49 }50 }
50 51
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+
51 func (c *Core) hasSlots(w *Workspace) bool {64 func (c *Core) hasSlots(w *Workspace) bool {
52 for key := range c.slots {65 for key := range c.slots {
53 if w.Name != "" && key.workspace == w.Name {66 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 {
775 775
776 // SpawnRequest carries core → launcher process arguments.776 // SpawnRequest carries core → launcher process arguments.
777 type SpawnRequest struct {777 type SpawnRequest struct {
778- Argv []string778+ // 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
779 // Env adds KEY=value entries to the child environment.782 // Env adds KEY=value entries to the child environment.
780 Env []string783 Env []string
781 }784 }
@@ -784,6 +787,10 @@ type SpawnRequest struct {
784 // one per output. Rects and the output size are logical and local to the787 // one per output. Rects and the output size are logical and local to the
785 // output; the renderer multiplies by Scale.788 // output; the renderer multiplies by Scale.
786 type Scene struct {789 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
787 Output string794 Output string
788 Seq uint64795 Seq uint64
789 OutputWidth, OutputHeight int796 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