Skip to content

Commit c34e3cd

Browse files
authored
fix(linux): stop reporting a window with no pid as a failed X11 query (#1513)
1 parent 2f2f435 commit c34e3cd

16 files changed

Lines changed: 692 additions & 68 deletions

docs/CROSS_PLATFORM.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,10 @@ as the bundle identifier for per-app config — but not its PID, because a Wayla
398398
client cannot read another client's process credentials.
399399
`SystemPort.FocusedApplicationPID` best-effort matches the app_id against
400400
`/proc`; with no match it returns `CodeNotSupported` carrying the app_id rather
401-
than a fabricated number.
401+
than a fabricated number. A session where *nothing* is focused is a different
402+
answer from that one and says so: the foreign-toplevel manager answered, and
403+
`neru doctor` explains it the way the X11 arm's unfocused desktop is explained
404+
below rather than as an unavailable capability.
402405

403406
**An unfocused desktop is not a failure on X11 either.** The X11 arm of the same
404407
method reads `_NET_ACTIVE_WINDOW`, which has four ways of not giving you a
@@ -413,6 +416,23 @@ it was. `neru doctor` downgrades the `process` capability to
413416
now; the `Focused app:` line beside it is what separates "focus a window" from
414417
"install or fix something".
415418

419+
**And neither is a window that publishes no pid.** One property further down,
420+
`_NET_WM_PID` splits the same way: a window that is alive and simply does not
421+
advertise a pid — EWMH makes the property a convention, older toolkits omit it,
422+
and a client on another machine has none this one could use — is
423+
`CodeNotSupported` with its own explanation, while a window that closed under
424+
the query, a failed read and a malformed property are `CodeActionFailed`. The
425+
two "not supported" answers are separate sentences on purpose: focusing another
426+
window fixes one and cannot fix the other.
427+
428+
**`FocusedWindowBounds` on X11 tells the same two apart.** The X11 arm reads the
429+
geometry of whatever `_NET_ACTIVE_WINDOW` names. Nothing focused is `found=false`
430+
with no error — the caller widens to the active screen and is obeying an answer.
431+
A display with no live window manager, a failed or malformed property, or a
432+
window the X server would not describe is an error, so a caller that widens the
433+
same way can tell it is guessing. That is what the Wayland arms report under
434+
[Accessibility And Hints](#accessibility-and-hints), on the X11 path.
435+
416436
**App watcher.** macOS gets focus changes pushed from an NSWorkspace observer.
417437
Linux has no equivalent single API, so `appwatcher/platform_linux.go` subscribes
418438
to a backend focus-change fd (`linux.SubscribeFocusedApp`: X11 event fd, or the

internal/adapter/accessibility/native/linux/element_x11_cgo.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,17 @@ func linuxFocusedApplicationIdentity() (string, int) {
6969
C.free(unsafe.Pointer(className))
7070
}
7171

72-
var ok C.int
73-
pid := int(C.neru_x11_get_window_pid(display, window, &ok))
72+
// The pid answers collapse here the same way the active-window ones do: this
73+
// signature has no error to carry, and a window that advertises no pid and
74+
// one that closed under the query both leave this caller with no pid to
75+
// report. system_x11_window_pid.go is where the difference is told, for the
76+
// caller that can act on it.
77+
var pid C.ulong
78+
if C.neru_x11_get_window_pid(display, window, &pid) != C.int(C.NERU_X11_WINDOW_PID_OK) {
79+
pid = 0
80+
}
7481

75-
return bundleID, pid
82+
return bundleID, int(pid)
7683
}
7784

7885
func linuxApplicationBundleIdentifier(pid int) string {

internal/adapter/platform/linux/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Read `../AGENTS.md` first (slots, factory, stubs, generated `wlr_protocol/`). This file covers what it omits. These rules are scattered across file-head comments; violating them fails at link or run time, not compile time.
44

55
- **Backend selection is runtime, not build-tag**: KDE and wlroots are identical at compile time (`linux && wayland`); dispatch on `platform.DetectLinuxBackend`, never add a compositor build tag.
6-
- **Every cgo symbol has a `_nocgo.go` twin** returning `derrors.CodeNotSupported` ("requires CGO-enabled Linux builds"); pinned by `system_stub_contract_test.go` — a supported capability must never answer `CodeNotSupported` or return nil. The one exception, on both backends, is a query that worked and found nothing: `FocusedApplicationPID` reports `CodeNotSupported` when no window is focused, so callers degrade instead of surfacing a live desktop as a failure. It is not a gap, and the capability is live-probed rather than declared, so the guardrail sees the two move together.
6+
- **Every cgo symbol has a `_nocgo.go` twin** returning `derrors.CodeNotSupported` ("requires CGO-enabled Linux builds"); pinned by `system_stub_contract_test.go` — a supported capability must never answer `CodeNotSupported` or return nil. The exceptions, on both backends, are the queries that worked and found nothing: `FocusedApplicationPID` reports `CodeNotSupported` when no window is focused, and again when the window that is focused publishes no pid (`_NET_WM_PID` is a convention EWMH does not require), so callers degrade instead of surfacing a live desktop as a failure. Each wears its own sentinel — `errNoFocusedWindow`, `errNoWindowPID` — because `neru doctor` explains them in different words, and a failure must wear neither. They are not gaps, and the capability is live-probed rather than declared, so the guardrail sees the two move together.
77
- **The `.c` files compile in exactly one unit** (`cgo.go`). Packages that call bridge symbols must blank-import `platform/linux` — and `wlr_protocol` separately — or the linker fails with undefined symbols.
88
- **Xlib protocol errors go through the one shared trap** (`x11_error_trap.h`), never a second `XSetErrorHandler`. The default handler calls `exit()`, so any request that can legitimately fail — reading pixels off a drawable that shrank, reading a property off a window whose client just died — has to be trapped; and the handler is process-global, so two traps under two mutexes would interleave and leave one caller's requests running under the handler that exits. Keep trapped sections short: everything else that traps waits behind them.
99
- **`doc.go` is tagged `linux`, not `linux && cgo` — deliberately.** The `.c` files already gate cgo builds; the broader tag keeps analysis working on other hosts. Don't "fix" it.

internal/adapter/platform/linux/system_common.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,16 @@ func (s *SystemAdapter) unavailableDetail(feature string, cause error) string {
680680
": the query works and answers as soon as a window takes focus"
681681
}
682682

683+
// The same argument one property down, and the reason it is a second
684+
// sentinel rather than the first: a window is focused, and it advertises no
685+
// _NET_WM_PID. Nothing is broken and nothing can be installed to change it —
686+
// EWMH does not require the property — so the sentence above would send the
687+
// user to focus a window they already focused.
688+
if errors.Is(cause, errNoWindowPID) {
689+
return feature + " found no _NET_WM_PID on the focused window on linux backend " +
690+
s.backendLabel() + ": the query works and answers for a window that publishes one"
691+
}
692+
683693
if !nativeBackendsCompiledIn {
684694
return feature + " is unavailable: this binary was built without CGO, so the X11 " +
685695
"and wlroots client stacks are absent; use a CGO-enabled build"

internal/adapter/platform/linux/system_focused_pid.go

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,15 @@ import (
2323
func waylandFocusedApplicationPID() (int, error) {
2424
appID, ok := WaylandFocusedAppID()
2525
if !ok || appID == "" {
26-
return 0, derrors.New(
27-
derrors.CodeNotSupported,
28-
"FocusedApplicationPID: no focused app_id available on this Wayland compositor",
29-
)
26+
return 0, waylandNoFocusedAppError()
3027
}
3128

3229
pid, found := resolvePIDByAppID(appID, "/proc")
3330
if !found {
31+
// Not the unfocused-desktop sentinel: a window *is* focused, and the
32+
// heuristic that maps its app_id onto a process missed. Wearing the
33+
// sentinel here would tell the user to focus a window they already have
34+
// focused.
3435
return 0, derrors.Newf(
3536
derrors.CodeNotSupported,
3637
"FocusedApplicationPID: Wayland exposes no PID for the focused app (app_id=%q); no /proc match found",
@@ -41,6 +42,35 @@ func waylandFocusedApplicationPID() (int, error) {
4142
return pid, nil
4243
}
4344

45+
// waylandNoFocusedAppError explains an empty answer from the foreign-toplevel
46+
// manager, which is the wlroots family's version of an unfocused desktop.
47+
//
48+
// It wraps errNoFocusedWindow so the capability surface explains it the way the
49+
// X11 arm's is explained (#1495): the query works, and it answers as soon as a
50+
// window takes focus. The CGO-off build takes the other branch, because there
51+
// the manager is absent rather than idle, and the reassuring sentence would be a
52+
// lie on a binary that can never answer.
53+
//
54+
// What it cannot yet separate is a compositor whose foreign-toplevel manager
55+
// never bound: WaylandFocusedAppID reports that with the same bare false as an
56+
// idle session, so on a CGO build both arrive here. Telling them apart needs the
57+
// wlroots client to say which happened, in both its cgo and nocgo halves.
58+
func waylandNoFocusedAppError() error {
59+
if !nativeBackendsCompiledIn {
60+
return derrors.New(
61+
derrors.CodeNotSupported,
62+
"FocusedApplicationPID: this binary was built without CGO, so the "+
63+
"foreign-toplevel client that reports the focused app_id is absent",
64+
)
65+
}
66+
67+
return derrors.Wrap(
68+
errNoFocusedWindow,
69+
derrors.CodeNotSupported,
70+
"FocusedApplicationPID: no focused app_id available on this Wayland compositor",
71+
)
72+
}
73+
4474
// resolvePIDByAppID scans procRoot (normally "/proc") for a process whose
4575
// identity matches appID and returns its PID. It is a heuristic: Wayland
4676
// app_ids and process names do not always agree, so a miss is expected and

internal/adapter/platform/linux/system_focused_pid_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@
33
package linux
44

55
import (
6+
"errors"
67
"os"
78
"path/filepath"
89
"strconv"
10+
"strings"
911
"testing"
12+
13+
"github.com/y3owk1n/neru/internal/derrors"
1014
)
1115

1216
const (
@@ -198,3 +202,47 @@ func TestResolvePIDByAppID(t *testing.T) {
198202
}
199203
})
200204
}
205+
206+
// TestWaylandNoFocusedAppError is the wlroots half of what #1495 fixed for X11.
207+
// The blessed Wayland stack answers an unfocused session the same way an
208+
// unfocused X11 display does, so `neru doctor` has to explain it the same way:
209+
// "focused-app inspection is unavailable" on a session where nothing has taken
210+
// focus yet sends the user looking for something to install.
211+
func TestWaylandNoFocusedAppError(t *testing.T) {
212+
t.Parallel()
213+
214+
err := waylandNoFocusedAppError()
215+
216+
if !derrors.IsNotSupported(err) {
217+
t.Fatalf("waylandNoFocusedAppError() = %v, want %q so callers degrade through it",
218+
err, derrors.CodeNotSupported)
219+
}
220+
221+
// The sentinel's premise is that a native backend answered, which a build
222+
// without the foreign-toplevel client cannot have done.
223+
wrapsSentinel := errors.Is(err, errNoFocusedWindow)
224+
if wrapsSentinel != nativeBackendsCompiledIn {
225+
t.Fatalf("waylandNoFocusedAppError() wraps errNoFocusedWindow = %v, want %v",
226+
wrapsSentinel, nativeBackendsCompiledIn)
227+
}
228+
229+
if !nativeBackendsCompiledIn {
230+
return
231+
}
232+
233+
feature := focusedAppFeature
234+
detail := NewSystemAdapter(backendWaylandWlroots).unavailableDetail(feature, err)
235+
236+
if strings.Contains(detail, "unavailable") {
237+
t.Errorf("an unfocused wlroots session is described as %q; it must not claim "+
238+
"the capability is unavailable", detail)
239+
}
240+
241+
// "takes focus" rather than "focus": the sentence has to promise the answer
242+
// arrives when a window is focused, not merely mention the word.
243+
for _, word := range []string{feature, backendWaylandWlroots, "takes focus"} {
244+
if !strings.Contains(detail, word) {
245+
t.Errorf("the unfocused-session detail %q does not mention %q", detail, word)
246+
}
247+
}
248+
}

internal/adapter/platform/linux/system_x11_active_window.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,11 @@ const (
4949
// explains it differently from a failure, because focusing any window fixes it
5050
// and installing something does not.
5151
//
52-
// Only the X11 arm wraps it today. The Wayland arm reports its own
53-
// CodeNotSupported for the same state without the sentinel, so `neru doctor`
54-
// still explains that one as an unavailable capability.
52+
// Both arms wrap it: the X11 one for a display whose _NET_ACTIVE_WINDOW reads
53+
// None, and the wlroots one for a foreign-toplevel manager with nothing
54+
// activated (waylandNoFocusedAppError). What it does not cover is a window that
55+
// has focus and no pid to report — that is errNoWindowPID, a sibling sentinel,
56+
// because the sentence this one buys would be false there.
5557
var errNoFocusedWindow = errors.New("no window has focus")
5658

5759
// x11ActiveWindowQueryError returns the error a caller owes for result, or nil

internal/adapter/platform/linux/system_x11_active_window_test.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ const activeWindowProperty = "_NET_ACTIVE_WINDOW"
1919
// manager from one that exited leaving its advertisements on the root window.
2020
const supportingWMCheckProperty = "_NET_SUPPORTING_WM_CHECK"
2121

22+
// focusedAppFeature is the capability label `neru doctor` prints these details
23+
// under, shared by every test that reads one back.
24+
const focusedAppFeature = "focused-app inspection"
25+
2226
// TestX11ActiveWindowQueryError is the whole point of splitting the
2327
// _NET_ACTIVE_WINDOW answers apart: a desktop with nothing focused is a state
2428
// callers degrade through, and the three ways the query can fail are failures
@@ -160,7 +164,7 @@ func TestX11ActiveWindowQueryError_OnlyTheUnfocusedDesktopCarriesTheSentinel(t *
160164
// what has to stop claiming the subsystem is gone.
161165
func TestSystemAdapter_UnavailableDetail_SeparatesAnUnfocusedDesktopFromAFailure(t *testing.T) {
162166
adapter := NewSystemAdapter(backendX11)
163-
feature := "focused-app inspection"
167+
feature := focusedAppFeature
164168

165169
unfocused := adapter.unavailableDetail(feature, x11ActiveWindowQueryError(x11ActiveWindowNone))
166170

internal/adapter/platform/linux/system_x11_cgo.go

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -122,22 +122,39 @@ func x11FocusedApplicationPID() (int, error) {
122122
return 0, queryErr
123123
}
124124

125-
var ok C.int
126-
pid := C.neru_x11_get_window_pid(display, window, &ok)
127-
if ok == 0 {
128-
// Either the window exposes no pid, or _NET_ACTIVE_WINDOW named one
129-
// that has since closed — a window manager that exited leaves the
130-
// property behind pointing at a window nobody updates any more.
131-
return 0, derrors.New(
132-
derrors.CodeActionFailed,
133-
"failed to query _NET_WM_PID for the active X11 window; it exposes no pid, "+
134-
"or the window it names has closed",
135-
)
125+
var pid C.ulong
126+
127+
pidErr := x11WindowPIDError(x11WindowPIDQuery(display, window, &pid))
128+
if pidErr != nil {
129+
return 0, pidErr
136130
}
137131

138132
return int(pid), nil
139133
}
140134

135+
// x11WindowPIDQuery reads _NET_WM_PID off window and translates the C result
136+
// code into this package's vocabulary. Like x11ActiveWindowQuery, the mapping is
137+
// by name rather than by value, so the two numberings are free to differ.
138+
func x11WindowPIDQuery(display *C.Display, window C.Window, pid *C.ulong) x11WindowPIDResult {
139+
switch C.neru_x11_get_window_pid(display, window, pid) {
140+
case C.int(C.NERU_X11_WINDOW_PID_OK):
141+
return x11WindowPIDFound
142+
case C.int(C.NERU_X11_WINDOW_PID_ABSENT):
143+
return x11WindowPIDAbsent
144+
case C.int(C.NERU_X11_WINDOW_PID_WINDOW_GONE):
145+
return x11WindowPIDWindowGone
146+
case C.int(C.NERU_X11_WINDOW_PID_QUERY_FAILED):
147+
return x11WindowPIDQueryFailed
148+
case C.int(C.NERU_X11_WINDOW_PID_MALFORMED):
149+
return x11WindowPIDMalformed
150+
default:
151+
// A result the header grew and this switch has not. Reporting it as a
152+
// failed query is the safe direction: the caller surfaces it instead of
153+
// degrading past a state nobody has classified.
154+
return x11WindowPIDQueryFailed
155+
}
156+
}
157+
141158
// x11FocusedAppID returns the WM_CLASS "class" of the active X11 window, used
142159
// as the per-app bundle identifier (matching accessibility's focused-app
143160
// identity on X11). The bool is false when DISPLAY is unset, no window is
@@ -236,24 +253,36 @@ func x11ScreenEventFD() (int, bool) {
236253
}
237254

238255
// x11FocusedWindowBounds returns the global bounds of the currently focused
239-
// window via _NET_ACTIVE_WINDOW. found is false (with a nil error) when there is
240-
// no active window or its geometry could not be queried, so callers fall back to
241-
// the active-screen bounds.
256+
// window via _NET_ACTIVE_WINDOW.
257+
//
258+
// found is false with a nil error only when the display answered that nothing
259+
// has focus; callers widen to the active-screen bounds and are obeying an
260+
// answer. A query that never got one — no live window manager, a failed or
261+
// malformed property, or a window the X server would not describe — is an error,
262+
// so the same widening can be told apart from guessing.
242263
func x11FocusedWindowBounds() (image.Rectangle, bool, error) {
243264
display, err := x11OpenDisplay()
244265
if err != nil {
245266
return image.Rectangle{}, false, err
246267
}
247268
defer C.neru_x11_close_display(display)
248269

249-
var posX, posY, width, height C.int
250-
found := C.neru_x11_get_focused_window_bounds(
251-
display, &posX, &posY, &width, &height,
252-
)
253-
if found == 0 {
270+
window, result := x11ActiveWindowQuery(display)
271+
272+
readGeometry, queryErr := x11ShouldReadWindowGeometry(result)
273+
if queryErr != nil {
274+
return image.Rectangle{}, false, queryErr
275+
}
276+
277+
if !readGeometry {
254278
return image.Rectangle{}, false, nil
255279
}
256280

281+
var posX, posY, width, height C.int
282+
if C.neru_x11_get_window_bounds(display, window, &posX, &posY, &width, &height) == 0 {
283+
return image.Rectangle{}, false, x11WindowGeometryError()
284+
}
285+
257286
return image.Rect(
258287
int(posX),
259288
int(posY),

internal/adapter/platform/linux/system_x11_integration_linux_test.go

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,11 @@ func TestX11FocusedApplicationPID_AWindowManagerIsNeverMistakenForNoWindowManage
4747
case err == nil:
4848
// A window is focused and exposes its PID — the ordinary path.
4949
case derrors.IsNotSupported(err):
50-
// Nothing is focused. That is the answer this ticket exists to allow,
51-
// and callers degrade through it.
52-
case strings.Contains(err.Error(), windowPIDProperty):
53-
// The focused window exposes no _NET_WM_PID. A different property, and
54-
// not what this test is about.
50+
// Nothing is focused, or the focused window advertises no pid. Both are
51+
// answers this ticket exists to allow, and callers degrade through them.
52+
case strings.Contains(err.Error(), wmPIDProperty):
53+
// The pid property failed to read. A different property, and not what
54+
// this test is about.
5555
default:
5656
t.Fatalf(
5757
"a display whose _NET_SUPPORTING_WM_CHECK handshake completes answered %v "+
@@ -61,10 +61,6 @@ func TestX11FocusedApplicationPID_AWindowManagerIsNeverMistakenForNoWindowManage
6161
}
6262
}
6363

64-
// windowPIDProperty names the second property the focused-app path reads, so
65-
// its failure can be told apart from the active-window query's.
66-
const windowPIDProperty = "_NET_WM_PID"
67-
6864
// supportingWMCheck is the property whose two-step self-reference proves a
6965
// window manager is running now rather than having run once.
7066
const supportingWMCheck = "_NET_SUPPORTING_WM_CHECK"

0 commit comments

Comments
 (0)