docs: fix stale Wayland-era comments, rewrite ADR 0003 for the eventfd model, trim ADR 0007 and performance provenance
- commit
- 86bd4909cad28f3c46cff4efb0b780d725ba0a32
- author
- brice · · 2026-10-01 17:55 UTC
- parent
- e3eeaa9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +32 -41
M docs/adr/0003-vulkan-dmabuf-presentation.md +15 -11
| @@ -4,25 +4,29 @@ Status: accepted | |||
| 4 | 4 | ||
| 5 | 5 | ## Decision | |
| 6 | 6 | ||
| 7 | - | NeferGUI renders with Vulkan into exportable images and presents them as `zwp_linux_dmabuf_v1` buffers. It does not use `VK_KHR_swapchain`. | |
| 7 | + | NeferGUI renders with Vulkan into exportable images and hands them to the application as DMA-BUF buffers (`Renderer`, see ADR 0007). The application presents them through `zwp_linux_dmabuf_v1`. NeferGUI does not use `VK_KHR_swapchain`. | |
| 8 | 8 | ||
| 9 | - | - Synchronization uses `wp_linux_drm_syncobj_v1` with Vulkan timeline semaphores. There is no CPU-fence fallback. | |
| 10 | - | - The Vulkan physical device must match the `main_device` of linux-dmabuf feedback. There is no cross-GPU fallback. | |
| 11 | - | - Formats: one-plane `XRGB8888` for opaque windows (default) and `ARGB8888` for transparent windows, both mapped to `VK_FORMAT_B8G8R8A8_UNORM`, with a modifier supported by both the compositor and the GPU. | |
| 12 | - | - Three exportable images per window. | |
| 9 | + | - Synchronization uses DRM syncobj timelines (`wp_linux_drm_syncobj_v1` on the Wayland side) with Vulkan timeline semaphores. There is no CPU-fence fallback. | |
| 10 | + | - The Vulkan physical device must match the `main_device` of linux-dmabuf feedback, passed in `RendererConfig.MainDevice`. There is no cross-GPU fallback. | |
| 11 | + | - Formats: one-plane `XRGB8888` by default and `ARGB8888` when `RendererConfig.Transparent` is set, both mapped to `VK_FORMAT_B8G8R8A8_UNORM`, with a modifier supported by both the compositor (`RendererConfig.Formats`) and the GPU. | |
| 12 | + | - Three exportable images per Renderer, re-created on resize. | |
| 13 | 13 | - Shaders are GLSL compiled by `glslc` through `go generate`. SPIR-V is committed and embedded; consumers do not need `glslc`. | |
| 14 | 14 | ||
| 15 | 15 | ## Buffer lifecycle | |
| 16 | 16 | ||
| 17 | 17 | ```text | |
| 18 | - | Available → Rendering → acquire point exported → committed with acquire/release points | |
| 19 | - | → compositor reads → release point of its latest commit reached → Available | |
| 18 | + | Available → Rendering → acquire point exported → Output returned by Render | |
| 19 | + | → compositor reads → release point of its latest commit signaled → Released → Available | |
| 20 | 20 | ``` | |
| 21 | 21 | ||
| 22 | - | While a `wp_linux_drm_syncobj_surface_v1` exists, `wl_buffer.release` delivery is undefined (linux-drm-syncobj-v1). A buffer is reused only after the release point set for its most recent commit is signaled; each buffer has its own release timeline because points may signal out of order. `wl_buffer.release` is traced when it arrives but never gates reuse. Frame callbacks schedule drawing; they never authorize reuse. | |
| 22 | + | Each buffer has its own release timeline, because points may signal out of order. After each `Render` the Renderer arms an eventfd on the release point of that commit (`DRM_IOCTL_SYNCOBJ_EVENTFD`, one-shot). `Output.ReleaseFD` is that eventfd. When it becomes readable the application calls `Released(buffer)`, which reads the eventfd and returns the buffer to the pool. A buffer is reused only after the release point set for its most recent commit has signaled; `wl_buffer.release` never gates reuse, because its delivery is undefined while a syncobj surface exists. Frame callbacks schedule drawing; they never authorize reuse. | |
| 23 | 23 | ||
| 24 | - | The UI loop never blocks in Wayland dispatch while GPU or compositor completion is pending. Blocking sources (Wayland socket, release timeline waits) run on their own goroutines and only post events to the loop, which owns all presentation state. Resize creates a new generation; old images are retired after their latest release point signals. | |
| 24 | + | The Renderer starts no goroutine and never blocks on the GPU or the compositor. `Render` returns false when no buffer is free; `Pending()` reports a built frame that waits for the GPU rather than the compositor, and the application retries after a short timer. Resize creates a new generation: buffers of the old one are reported in `Output.Retired` once their release point has signaled, and their descriptors stay open until the next `Render` or `Close`. | |
| 25 | 25 | ||
| 26 | - | At shutdown, release points protect reuse of contents, **not** the lifetime of exported DMA-BUF memory: kernel FD references and compositor imports are independent of the client's Vulkan allocation. Detach and commit null, destroy the syncobj surface, wait at most 200 ms for outstanding release points for tracing, wait for client GPU completion, destroy `wl_buffer`s and **all** client images/memory/semaphores/syncobj handles, then destroy the device. Never abandon Vulkan allocations solely because a compositor release point has not arrived. | |
| 26 | + | All descriptors in `Output` (DMA-BUF planes, timelines, eventfds) stay owned by the Renderer. The application duplicates them before a transport consumes them. | |
| 27 | 27 | ||
| 28 | - | `Opaque()` sets a full-surface opaque region. `Transparent()` uses premultiplied alpha and no opaque region. Background blur is a compositor setting, not a NeferGUI feature. | |
| 28 | + | ## Shutdown | |
| 29 | + | ||
| 30 | + | Release points protect reuse of contents, **not** the lifetime of exported DMA-BUF memory: kernel descriptor references and compositor imports are independent of the client's Vulkan allocation. `Close` waits for client GPU completion, then frees **all** images, memory, semaphores, syncobj handles and eventfds, and destroys the device. It never abandons Vulkan allocations because a compositor release point has not arrived. The application detaches and destroys its own Wayland buffers and syncobj surface before or after `Close`; the Renderer does not know about them. `Close` also returns any Vulkan validation errors recorded while `NEFERGUI_VULKAN_VALIDATION=1` was set. | |
| 31 | + | ||
| 32 | + | Opaque or transparent regions, background blur and cursor mechanics belong to the application: `Output.InputRects` and `Output.Cursor` carry the requests, and `RendererConfig.Transparent` selects premultiplied alpha output. | |
| @@ -4,25 +4,29 @@ Status: accepted | |||||
| 4 | 4 | ||||
| 5 | ## Decision | 5 | ## Decision | ||
| 6 | 6 | ||||
| 7 | - | NeferGUI renders with Vulkan into exportable images and presents them as `zwp_linux_dmabuf_v1` buffers. It does not use `VK_KHR_swapchain`. | 7 | + | NeferGUI renders with Vulkan into exportable images and hands them to the application as DMA-BUF buffers (`Renderer`, see ADR 0007). The application presents them through `zwp_linux_dmabuf_v1`. NeferGUI does not use `VK_KHR_swapchain`. |
| 8 | 8 | ||||
| 9 | - | - Synchronization uses `wp_linux_drm_syncobj_v1` with Vulkan timeline semaphores. There is no CPU-fence fallback. | 9 | + | - Synchronization uses DRM syncobj timelines (`wp_linux_drm_syncobj_v1` on the Wayland side) with Vulkan timeline semaphores. There is no CPU-fence fallback. |
| 10 | - | - The Vulkan physical device must match the `main_device` of linux-dmabuf feedback. There is no cross-GPU fallback. | 10 | + | - The Vulkan physical device must match the `main_device` of linux-dmabuf feedback, passed in `RendererConfig.MainDevice`. There is no cross-GPU fallback. |
| 11 | - | - Formats: one-plane `XRGB8888` for opaque windows (default) and `ARGB8888` for transparent windows, both mapped to `VK_FORMAT_B8G8R8A8_UNORM`, with a modifier supported by both the compositor and the GPU. | 11 | + | - Formats: one-plane `XRGB8888` by default and `ARGB8888` when `RendererConfig.Transparent` is set, both mapped to `VK_FORMAT_B8G8R8A8_UNORM`, with a modifier supported by both the compositor (`RendererConfig.Formats`) and the GPU. |
| 12 | - | - Three exportable images per window. | 12 | + | - Three exportable images per Renderer, re-created on resize. |
| 13 | - Shaders are GLSL compiled by `glslc` through `go generate`. SPIR-V is committed and embedded; consumers do not need `glslc`. | 13 | - Shaders are GLSL compiled by `glslc` through `go generate`. SPIR-V is committed and embedded; consumers do not need `glslc`. | ||
| 14 | 14 | ||||
| 15 | ## Buffer lifecycle | 15 | ## Buffer lifecycle | ||
| 16 | 16 | ||||
| 17 | ```text | 17 | ```text | ||
| 18 | - | Available → Rendering → acquire point exported → committed with acquire/release points | 18 | + | Available → Rendering → acquire point exported → Output returned by Render |
| 19 | - | → compositor reads → release point of its latest commit reached → Available | 19 | + | → compositor reads → release point of its latest commit signaled → Released → Available |
| 20 | ``` | 20 | ``` | ||
| 21 | 21 | ||||
| 22 | - | While a `wp_linux_drm_syncobj_surface_v1` exists, `wl_buffer.release` delivery is undefined (linux-drm-syncobj-v1). A buffer is reused only after the release point set for its most recent commit is signaled; each buffer has its own release timeline because points may signal out of order. `wl_buffer.release` is traced when it arrives but never gates reuse. Frame callbacks schedule drawing; they never authorize reuse. | 22 | + | Each buffer has its own release timeline, because points may signal out of order. After each `Render` the Renderer arms an eventfd on the release point of that commit (`DRM_IOCTL_SYNCOBJ_EVENTFD`, one-shot). `Output.ReleaseFD` is that eventfd. When it becomes readable the application calls `Released(buffer)`, which reads the eventfd and returns the buffer to the pool. A buffer is reused only after the release point set for its most recent commit has signaled; `wl_buffer.release` never gates reuse, because its delivery is undefined while a syncobj surface exists. Frame callbacks schedule drawing; they never authorize reuse. |
| 23 | 23 | ||||
| 24 | - | The UI loop never blocks in Wayland dispatch while GPU or compositor completion is pending. Blocking sources (Wayland socket, release timeline waits) run on their own goroutines and only post events to the loop, which owns all presentation state. Resize creates a new generation; old images are retired after their latest release point signals. | 24 | + | The Renderer starts no goroutine and never blocks on the GPU or the compositor. `Render` returns false when no buffer is free; `Pending()` reports a built frame that waits for the GPU rather than the compositor, and the application retries after a short timer. Resize creates a new generation: buffers of the old one are reported in `Output.Retired` once their release point has signaled, and their descriptors stay open until the next `Render` or `Close`. |
| 25 | 25 | ||||
| 26 | - | At shutdown, release points protect reuse of contents, **not** the lifetime of exported DMA-BUF memory: kernel FD references and compositor imports are independent of the client's Vulkan allocation. Detach and commit null, destroy the syncobj surface, wait at most 200 ms for outstanding release points for tracing, wait for client GPU completion, destroy `wl_buffer`s and **all** client images/memory/semaphores/syncobj handles, then destroy the device. Never abandon Vulkan allocations solely because a compositor release point has not arrived. | 26 | + | All descriptors in `Output` (DMA-BUF planes, timelines, eventfds) stay owned by the Renderer. The application duplicates them before a transport consumes them. |
| 27 | 27 | ||||
| 28 | - | `Opaque()` sets a full-surface opaque region. `Transparent()` uses premultiplied alpha and no opaque region. Background blur is a compositor setting, not a NeferGUI feature. | 28 | + | ## Shutdown |
| 29 | + | ||||
| 30 | + | Release points protect reuse of contents, **not** the lifetime of exported DMA-BUF memory: kernel descriptor references and compositor imports are independent of the client's Vulkan allocation. `Close` waits for client GPU completion, then frees **all** images, memory, semaphores, syncobj handles and eventfds, and destroys the device. It never abandons Vulkan allocations because a compositor release point has not arrived. The application detaches and destroys its own Wayland buffers and syncobj surface before or after `Close`; the Renderer does not know about them. `Close` also returns any Vulkan validation errors recorded while `NEFERGUI_VULKAN_VALIDATION=1` was set. | |||
| 31 | + | ||||
| 32 | + | Opaque or transparent regions, background blur and cursor mechanics belong to the application: `Output.InputRects` and `Output.Cursor` carry the requests, and `RendererConfig.Transparent` selects premultiplied alpha output. | |||
M docs/adr/0007-wayland-free-renderer.md +6 -7
| @@ -1,10 +1,10 @@ | |||
| 1 | 1 | # ADR 0007 — Wayland-free Renderer | |
| 2 | 2 | ||
| 3 | - | Status: accepted. Supersedes ADR 0002 (Wayland bindings and FD ownership) and ADR 0004 (keyboard). | |
| 3 | + | Status: accepted | |
| 4 | 4 | ||
| 5 | 5 | ## Decision | |
| 6 | 6 | ||
| 7 | - | NeferGUI is a rendering library. It builds views, lays them out, styles them with CSS and draws them with Vulkan into DMA-BUF buffers. It does not connect to Wayland, import a Wayland library, link xkbcommon, or own a window, an event loop or a goroutine. | |
| 7 | + | NeferGUI is a rendering library. It builds views, lays them out, styles them with CSS and draws them with Vulkan into DMA-BUF buffers. The library does not own a Wayland connection. | |
| 8 | 8 | ||
| 9 | 9 | The public entry point is `Renderer` (`NewRenderer`, `Resize`, `Input`, `Invalidate`, `Render`, `Released`, `Wake`, `Pending`, `Close`). The application owns the Wayland client and runs one owner goroutine: | |
| 10 | 10 | ||
| @@ -17,8 +17,7 @@ Layering is application → Wayland client library → bindings. NeferGUI sits b | |||
| 17 | 17 | ||
| 18 | 18 | ## Consequences | |
| 19 | 19 | ||
| 20 | - | - `Run`, `RunFrames`, `RunLock`, the window and layer-shell options, `OnInput`, `OnResize`, `OnSurface`, `Wake` as an option, `SecretBuffer` and `WaylandSurface` are removed, together with the Wayland platform, keyboard, harness and example code. This is a breaking change. | |
| 21 | - | - The module depends only on purego, purego-vulkan, go-text, x/image and x/sys. `go list -deps` shows no Wayland or xkbcommon module. | |
| 22 | - | - Clipboard and input-method ports (`Clipboard`, `IME`) remain interfaces the application implements. | |
| 23 | - | - `Password(true)` still masks the displayed value of an in-memory editor value. | |
| 24 | - | - ADR 0003 still describes the Vulkan, DMA-BUF and explicit-sync design; the Wayland object handling it mentions now belongs to the application. | |
| 20 | + | - The module depends only on purego, purego-vulkan, go-text, x/image and x/sys. | |
| 21 | + | - Clipboard and input-method ports (`Clipboard`, `IME`) are interfaces the application implements. | |
| 22 | + | - `Password(true)` masks the displayed value of an in-memory editor value. | |
| 23 | + | - ADR 0003 describes the Vulkan, DMA-BUF and explicit-sync design. | |
| @@ -1,10 +1,10 @@ | |||||
| 1 | # ADR 0007 — Wayland-free Renderer | 1 | # ADR 0007 — Wayland-free Renderer | ||
| 2 | 2 | ||||
| 3 | - | Status: accepted. Supersedes ADR 0002 (Wayland bindings and FD ownership) and ADR 0004 (keyboard). | 3 | + | Status: accepted |
| 4 | 4 | ||||
| 5 | ## Decision | 5 | ## Decision | ||
| 6 | 6 | ||||
| 7 | - | NeferGUI is a rendering library. It builds views, lays them out, styles them with CSS and draws them with Vulkan into DMA-BUF buffers. It does not connect to Wayland, import a Wayland library, link xkbcommon, or own a window, an event loop or a goroutine. | 7 | + | NeferGUI is a rendering library. It builds views, lays them out, styles them with CSS and draws them with Vulkan into DMA-BUF buffers. The library does not own a Wayland connection. |
| 8 | 8 | ||||
| 9 | The public entry point is `Renderer` (`NewRenderer`, `Resize`, `Input`, `Invalidate`, `Render`, `Released`, `Wake`, `Pending`, `Close`). The application owns the Wayland client and runs one owner goroutine: | 9 | The public entry point is `Renderer` (`NewRenderer`, `Resize`, `Input`, `Invalidate`, `Render`, `Released`, `Wake`, `Pending`, `Close`). The application owns the Wayland client and runs one owner goroutine: | ||
| 10 | 10 | ||||
| @@ -17,8 +17,7 @@ Layering is application → Wayland client library → bindings. NeferGUI sits b | |||||
| 17 | 17 | ||||
| 18 | ## Consequences | 18 | ## Consequences | ||
| 19 | 19 | ||||
| 20 | - | - `Run`, `RunFrames`, `RunLock`, the window and layer-shell options, `OnInput`, `OnResize`, `OnSurface`, `Wake` as an option, `SecretBuffer` and `WaylandSurface` are removed, together with the Wayland platform, keyboard, harness and example code. This is a breaking change. | 20 | + | - The module depends only on purego, purego-vulkan, go-text, x/image and x/sys. |
| 21 | - | - The module depends only on purego, purego-vulkan, go-text, x/image and x/sys. `go list -deps` shows no Wayland or xkbcommon module. | 21 | + | - Clipboard and input-method ports (`Clipboard`, `IME`) are interfaces the application implements. |
| 22 | - | - Clipboard and input-method ports (`Clipboard`, `IME`) remain interfaces the application implements. | 22 | + | - `Password(true)` masks the displayed value of an in-memory editor value. |
| 23 | - | - `Password(true)` still masks the displayed value of an in-memory editor value. | 23 | + | - ADR 0003 describes the Vulkan, DMA-BUF and explicit-sync design. |
| 24 | - | - ADR 0003 still describes the Vulkan, DMA-BUF and explicit-sync design; the Wayland object handling it mentions now belongs to the application. | |||
M docs/controls.md +1 -1
| @@ -16,7 +16,7 @@ The pointer cursor follows the CSS `cursor` of the hovered element (`pointer`, ` | |||
| 16 | 16 | ||
| 17 | 17 | `internal/edit` supplies the headless clipboard (`ReadText(limit)`, `WriteText`) and IME (`Enable`, `Disable`, `Surrounding`, `CursorRect`, `ContentType`) ports. A read is limited to 1 MiB and rejected unless UTF-8. IME batches represent preedit, commit and delete-surrounding relative to the UTF-8 cursor, processed at done. The preedit is shaped inline at the model cursor with an underline and its own cursor commands, without changing the model or its normal text run until commit. Password preedit is masked per grapheme before shaping or entering the display list. Ordinary keyboard text is inserted while an IME port is active, except while a preedit is shown; an input method that grabs the keyboard receives its keys instead of the editor. The ports do not negotiate MIME types or own FDs; the application implements them and passes them in `RendererConfig`. | |
| 18 | 18 | ||
| 19 | - | Controls are implemented in `internal/ui`; the root package re-exports their types. Display-list JSON snapshots under `internal/ui/testdata/control-display` cover every control's normal and hover states, interactive focus-visible and disabled states, checkbox/radio checked states, and Input LTR/Arabic selections and preedit. They are deterministic command snapshots, not raster screenshots. Platform `Run` uses the same control tree and display list. | |
| 19 | + | Controls are implemented in `internal/ui`; the root package re-exports their types. Display-list JSON snapshots under `internal/ui/testdata/control-display` cover every control's normal and hover states, interactive focus-visible and disabled states, checkbox/radio checked states, and Input LTR/Arabic selections and preedit. They are deterministic command snapshots, not raster screenshots. `Renderer` uses the same control tree and display list. | |
| 20 | 20 | ||
| 21 | 21 | The additive UA rules for `checkbox`, `radio`, `slider`, `separator`, `spacer`, and `icon` live in `internal/css/ua.css`. Their dimensions and colors are regular computed CSS and can be overridden by author styles. The control helpers hard-code no paint values; only the indicator painter fixes the shape details listed above. | |
| 22 | 22 | ||
| @@ -16,7 +16,7 @@ The pointer cursor follows the CSS `cursor` of the hovered element (`pointer`, ` | |||||
| 16 | 16 | ||||
| 17 | `internal/edit` supplies the headless clipboard (`ReadText(limit)`, `WriteText`) and IME (`Enable`, `Disable`, `Surrounding`, `CursorRect`, `ContentType`) ports. A read is limited to 1 MiB and rejected unless UTF-8. IME batches represent preedit, commit and delete-surrounding relative to the UTF-8 cursor, processed at done. The preedit is shaped inline at the model cursor with an underline and its own cursor commands, without changing the model or its normal text run until commit. Password preedit is masked per grapheme before shaping or entering the display list. Ordinary keyboard text is inserted while an IME port is active, except while a preedit is shown; an input method that grabs the keyboard receives its keys instead of the editor. The ports do not negotiate MIME types or own FDs; the application implements them and passes them in `RendererConfig`. | 17 | `internal/edit` supplies the headless clipboard (`ReadText(limit)`, `WriteText`) and IME (`Enable`, `Disable`, `Surrounding`, `CursorRect`, `ContentType`) ports. A read is limited to 1 MiB and rejected unless UTF-8. IME batches represent preedit, commit and delete-surrounding relative to the UTF-8 cursor, processed at done. The preedit is shaped inline at the model cursor with an underline and its own cursor commands, without changing the model or its normal text run until commit. Password preedit is masked per grapheme before shaping or entering the display list. Ordinary keyboard text is inserted while an IME port is active, except while a preedit is shown; an input method that grabs the keyboard receives its keys instead of the editor. The ports do not negotiate MIME types or own FDs; the application implements them and passes them in `RendererConfig`. | ||
| 18 | 18 | ||||
| 19 | - | Controls are implemented in `internal/ui`; the root package re-exports their types. Display-list JSON snapshots under `internal/ui/testdata/control-display` cover every control's normal and hover states, interactive focus-visible and disabled states, checkbox/radio checked states, and Input LTR/Arabic selections and preedit. They are deterministic command snapshots, not raster screenshots. Platform `Run` uses the same control tree and display list. | 19 | + | Controls are implemented in `internal/ui`; the root package re-exports their types. Display-list JSON snapshots under `internal/ui/testdata/control-display` cover every control's normal and hover states, interactive focus-visible and disabled states, checkbox/radio checked states, and Input LTR/Arabic selections and preedit. They are deterministic command snapshots, not raster screenshots. `Renderer` uses the same control tree and display list. |
| 20 | 20 | ||||
| 21 | The additive UA rules for `checkbox`, `radio`, `slider`, `separator`, `spacer`, and `icon` live in `internal/css/ua.css`. Their dimensions and colors are regular computed CSS and can be overridden by author styles. The control helpers hard-code no paint values; only the indicator painter fixes the shape details listed above. | 21 | The additive UA rules for `checkbox`, `radio`, `slider`, `separator`, `spacer`, and `icon` live in `internal/css/ua.css`. Their dimensions and colors are regular computed CSS and can be overridden by author styles. The control helpers hard-code no paint values; only the indicator painter fixes the shape details listed above. | ||
| 22 | 22 | ||||
M docs/performance.md +3 -14
| @@ -29,7 +29,7 @@ The demo-frame benchmark builds the light, comfortable document workspace with a | |||
| 29 | 29 | ||
| 30 | 30 | Natural container measurement prevents nested flex text from collapsing, at a CPU cost that grows with nesting depth. Layout1000 measures about 36% slower than the earlier 656 µs run; allocation count stays at 1,027. A per-layout node/width cache increased time and retained allocation in a trial and is not used. LayoutPaintTree stays near its previous cost. These are correctness/performance tradeoffs, not a claim of universal speed improvement. | |
| 31 | 31 | ||
| 32 | - | `TestAllocDemoFrame` checks the 1,100 allocs/op limit with `testing.AllocsPerRun`. `TestAllocRendererSteadyFrame` measures `Renderer.Render` for a view whose text changes through `TextInt`; it measured 244 allocations per steady Render+Released cycle on 2026-10-01 (the guard fails above that baseline), needs a GPU, and is skipped unless `NEFERGUI_RENDER_NODE` is set. Focused tests also check constant quad allocation, bounded paint allocation, allocation-free warm list-buffer reuse, and allocation-free idle wait results. The real-ioctl allocation test is opt-in via `NEFERGUI_RENDER_NODE`. The other table budgets are reference targets, not automated assertions. | |
| 32 | + | `TestAllocDemoFrame` checks the 1,100 allocs/op limit with `testing.AllocsPerRun`. `TestAllocRendererSteadyFrame` measures `Renderer.Render` for a view whose text changes through `TextInt`; it measured 244 allocations per steady Render+Released cycle on 2026-10-01 (the guard fails above that baseline), needs a GPU, and is skipped unless `NEFERGUI_RENDER_NODE` is set. Focused tests also check constant quad allocation, bounded paint allocation, allocation-free warm list-buffer reuse and allocation-free idle wait results (the real-ioctl variant is opt-in via `NEFERGUI_RENDER_NODE`). The other table budgets are reference targets, not automated assertions. | |
| 33 | 33 | ||
| 34 | 34 | Frame construction limits allocation churn through: | |
| 35 | 35 | ||
| @@ -39,14 +39,7 @@ Frame construction limits allocation churn through: | |||
| 39 | 39 | - font family normalization at font load and identity path keys computed once per element; | |
| 40 | 40 | - `text.Engine.Measure` caching by text, request and width. Entries unused for two frames are evicted; the cache holds at most 4,096 entries. Results are immutable; callers use `Layout.Clone` before adjusting positions. | |
| 41 | 41 | ||
| 42 | - | `Renderer.Render` builds a frame only on input or a redraw request. Idle frame construction stops. Expected release-wait timeouts return without constructing errors; real ioctl failures retain their context and wrapped errno. | |
| 43 | - | ||
| 44 | - | On commit `9064a73`, measured **2026-09-30, 06:34 CEST**, `BenchmarkWaitPointIdle` on an unsignaled real DRM timeline took a median **21,370,548 ns/op, 0 B/op, 0 allocs/op**. This includes the requested 20 ms timeout, not active CPU time: | |
| 45 | - | ||
| 46 | - | ```sh | |
| 47 | - | NEFERGUI_RENDER_NODE=/dev/dri/renderD128 CGO_ENABLED=0 GOWORK=off \ | |
| 48 | - | go test -run '^$' -bench BenchmarkWaitPointIdle -benchtime=10x -benchmem -count=5 ./internal/presentation/syncobj | |
| 49 | - | ``` | |
| 42 | + | `Renderer.Render` builds a frame only on input or a redraw request. Idle frame construction stops. | |
| 50 | 43 | ||
| 51 | 44 | Profile frame construction with: | |
| 52 | 45 | ||
| @@ -77,14 +70,10 @@ An external Go module with minimal-counter and demo views, a 960×640 window at | |||
| 77 | 70 | ||
| 78 | 71 | Heap/RSS/PSS use the default Go profiling rate. Allocation figures use separate `GODEBUG=memprofilerate=1` runs: subtract cumulative profiles at frames 1 and 1,000, exclude stacks containing the measurement sampler or `runtime/pprof`, sum the remaining flat allocated bytes, then divide by 999 frames. These runs recorded about **41–43% less allocation churn** than the baseline. They do not demonstrate an equivalent reduction in RSS or frame latency. Single-run process-memory values vary with runtime and driver behavior. | |
| 79 | 72 | ||
| 80 | - | The minimal view starts at about 0.75 MiB live Go heap, reaches 5.1 MiB after the first frame, and returns to about 0.9 MiB after closing. No sustained live-heap growth was observed over 3,000 frames. Retained heap stayed close to baseline; the session's reusable conversion buffers retain a small amount of storage. Driver mappings and Go heap capacity can remain resident after closing; RSS is not a leak measurement on its own. | |
| 73 | + | The minimal view starts at about 0.75 MiB live Go heap, reaches 5.1 MiB after the first frame, and returns to about 0.9 MiB after closing. No sustained live-heap growth was observed over 3,000 frames. Retained heap stayed close to baseline; the reusable conversion buffers retain a small amount of storage. Driver mappings and Go heap capacity can remain resident after closing; RSS is not a leak measurement on its own. | |
| 81 | 74 | ||
| 82 | 75 | DRM counters reported about 41 MiB VRAM and 4 MiB GTT once all three presentation buffers were active, unchanged from baseline and released at shutdown. Window dimensions, scale, fonts and driver affect these figures. These are measurements on one stack, not portable limits. | |
| 83 | 76 | ||
| 84 | - | A separate approximately 20-second idle profile recorded about **91 KiB** allocated under `syncobj.WaitPoint` in the baseline and **no allocations attributed to that path** on the measured implementation. Other runtime and consumer instrumentation allocations remain; this is not a claim that the whole process allocates nothing at idle. | |
| 85 | - | ||
| 86 | - | The measured commit includes `purego-vulkan` v0.6.0, `wlturbo` v0.3.0 and `typesetting` v0.3.5. The subsequent `2e4532d` commit adds an empty-glyph regression test and raises the minimum compositor version checked at startup; it does not change the measured frame paths on this version-6 compositor. The comparison therefore includes both library optimizations and dependency updates, not isolated effects for each. | |
| 87 | - | ||
| 88 | 77 | Prepared quads and layout painting still allocate detached outputs. Editor and indicator command insertion can also reallocate the display list; those UI-owned costs are included in the demo measurements and are not optimized here. Glyph rasterization and glyph-key hashing also appear in cumulative allocation profiles. | |
| 89 | 78 | ||
| 90 | 79 | Use `pprof`'s `alloc_space` to find cumulative allocation churn and `inuse_space` after GC to inspect retained Go memory. Use `/proc/<pid>/smaps_rollup` for RSS/PSS and DRM fdinfo for GPU counters; heap profiles do not include driver or GPU memory. | |
| @@ -29,7 +29,7 @@ The demo-frame benchmark builds the light, comfortable document workspace with a | |||||
| 29 | 29 | ||||
| 30 | Natural container measurement prevents nested flex text from collapsing, at a CPU cost that grows with nesting depth. Layout1000 measures about 36% slower than the earlier 656 µs run; allocation count stays at 1,027. A per-layout node/width cache increased time and retained allocation in a trial and is not used. LayoutPaintTree stays near its previous cost. These are correctness/performance tradeoffs, not a claim of universal speed improvement. | 30 | Natural container measurement prevents nested flex text from collapsing, at a CPU cost that grows with nesting depth. Layout1000 measures about 36% slower than the earlier 656 µs run; allocation count stays at 1,027. A per-layout node/width cache increased time and retained allocation in a trial and is not used. LayoutPaintTree stays near its previous cost. These are correctness/performance tradeoffs, not a claim of universal speed improvement. | ||
| 31 | 31 | ||||
| 32 | - | `TestAllocDemoFrame` checks the 1,100 allocs/op limit with `testing.AllocsPerRun`. `TestAllocRendererSteadyFrame` measures `Renderer.Render` for a view whose text changes through `TextInt`; it measured 244 allocations per steady Render+Released cycle on 2026-10-01 (the guard fails above that baseline), needs a GPU, and is skipped unless `NEFERGUI_RENDER_NODE` is set. Focused tests also check constant quad allocation, bounded paint allocation, allocation-free warm list-buffer reuse, and allocation-free idle wait results. The real-ioctl allocation test is opt-in via `NEFERGUI_RENDER_NODE`. The other table budgets are reference targets, not automated assertions. | 32 | + | `TestAllocDemoFrame` checks the 1,100 allocs/op limit with `testing.AllocsPerRun`. `TestAllocRendererSteadyFrame` measures `Renderer.Render` for a view whose text changes through `TextInt`; it measured 244 allocations per steady Render+Released cycle on 2026-10-01 (the guard fails above that baseline), needs a GPU, and is skipped unless `NEFERGUI_RENDER_NODE` is set. Focused tests also check constant quad allocation, bounded paint allocation, allocation-free warm list-buffer reuse and allocation-free idle wait results (the real-ioctl variant is opt-in via `NEFERGUI_RENDER_NODE`). The other table budgets are reference targets, not automated assertions. |
| 33 | 33 | ||||
| 34 | Frame construction limits allocation churn through: | 34 | Frame construction limits allocation churn through: | ||
| 35 | 35 | ||||
| @@ -39,14 +39,7 @@ Frame construction limits allocation churn through: | |||||
| 39 | - font family normalization at font load and identity path keys computed once per element; | 39 | - font family normalization at font load and identity path keys computed once per element; | ||
| 40 | - `text.Engine.Measure` caching by text, request and width. Entries unused for two frames are evicted; the cache holds at most 4,096 entries. Results are immutable; callers use `Layout.Clone` before adjusting positions. | 40 | - `text.Engine.Measure` caching by text, request and width. Entries unused for two frames are evicted; the cache holds at most 4,096 entries. Results are immutable; callers use `Layout.Clone` before adjusting positions. | ||
| 41 | 41 | ||||
| 42 | - | `Renderer.Render` builds a frame only on input or a redraw request. Idle frame construction stops. Expected release-wait timeouts return without constructing errors; real ioctl failures retain their context and wrapped errno. | 42 | + | `Renderer.Render` builds a frame only on input or a redraw request. Idle frame construction stops. |
| 43 | - | ||||
| 44 | - | On commit `9064a73`, measured **2026-09-30, 06:34 CEST**, `BenchmarkWaitPointIdle` on an unsignaled real DRM timeline took a median **21,370,548 ns/op, 0 B/op, 0 allocs/op**. This includes the requested 20 ms timeout, not active CPU time: | |||
| 45 | - | ||||
| 46 | - | ```sh | |||
| 47 | - | NEFERGUI_RENDER_NODE=/dev/dri/renderD128 CGO_ENABLED=0 GOWORK=off \ | |||
| 48 | - | go test -run '^$' -bench BenchmarkWaitPointIdle -benchtime=10x -benchmem -count=5 ./internal/presentation/syncobj | |||
| 49 | - | ``` | |||
| 50 | 43 | ||||
| 51 | Profile frame construction with: | 44 | Profile frame construction with: | ||
| 52 | 45 | ||||
| @@ -77,14 +70,10 @@ An external Go module with minimal-counter and demo views, a 960×640 window at | |||||
| 77 | 70 | ||||
| 78 | Heap/RSS/PSS use the default Go profiling rate. Allocation figures use separate `GODEBUG=memprofilerate=1` runs: subtract cumulative profiles at frames 1 and 1,000, exclude stacks containing the measurement sampler or `runtime/pprof`, sum the remaining flat allocated bytes, then divide by 999 frames. These runs recorded about **41–43% less allocation churn** than the baseline. They do not demonstrate an equivalent reduction in RSS or frame latency. Single-run process-memory values vary with runtime and driver behavior. | 71 | Heap/RSS/PSS use the default Go profiling rate. Allocation figures use separate `GODEBUG=memprofilerate=1` runs: subtract cumulative profiles at frames 1 and 1,000, exclude stacks containing the measurement sampler or `runtime/pprof`, sum the remaining flat allocated bytes, then divide by 999 frames. These runs recorded about **41–43% less allocation churn** than the baseline. They do not demonstrate an equivalent reduction in RSS or frame latency. Single-run process-memory values vary with runtime and driver behavior. | ||
| 79 | 72 | ||||
| 80 | - | The minimal view starts at about 0.75 MiB live Go heap, reaches 5.1 MiB after the first frame, and returns to about 0.9 MiB after closing. No sustained live-heap growth was observed over 3,000 frames. Retained heap stayed close to baseline; the session's reusable conversion buffers retain a small amount of storage. Driver mappings and Go heap capacity can remain resident after closing; RSS is not a leak measurement on its own. | 73 | + | The minimal view starts at about 0.75 MiB live Go heap, reaches 5.1 MiB after the first frame, and returns to about 0.9 MiB after closing. No sustained live-heap growth was observed over 3,000 frames. Retained heap stayed close to baseline; the reusable conversion buffers retain a small amount of storage. Driver mappings and Go heap capacity can remain resident after closing; RSS is not a leak measurement on its own. |
| 81 | 74 | ||||
| 82 | DRM counters reported about 41 MiB VRAM and 4 MiB GTT once all three presentation buffers were active, unchanged from baseline and released at shutdown. Window dimensions, scale, fonts and driver affect these figures. These are measurements on one stack, not portable limits. | 75 | DRM counters reported about 41 MiB VRAM and 4 MiB GTT once all three presentation buffers were active, unchanged from baseline and released at shutdown. Window dimensions, scale, fonts and driver affect these figures. These are measurements on one stack, not portable limits. | ||
| 83 | 76 | ||||
| 84 | - | A separate approximately 20-second idle profile recorded about **91 KiB** allocated under `syncobj.WaitPoint` in the baseline and **no allocations attributed to that path** on the measured implementation. Other runtime and consumer instrumentation allocations remain; this is not a claim that the whole process allocates nothing at idle. | |||
| 85 | - | ||||
| 86 | - | The measured commit includes `purego-vulkan` v0.6.0, `wlturbo` v0.3.0 and `typesetting` v0.3.5. The subsequent `2e4532d` commit adds an empty-glyph regression test and raises the minimum compositor version checked at startup; it does not change the measured frame paths on this version-6 compositor. The comparison therefore includes both library optimizations and dependency updates, not isolated effects for each. | |||
| 87 | - | ||||
| 88 | Prepared quads and layout painting still allocate detached outputs. Editor and indicator command insertion can also reallocate the display list; those UI-owned costs are included in the demo measurements and are not optimized here. Glyph rasterization and glyph-key hashing also appear in cumulative allocation profiles. | 77 | Prepared quads and layout painting still allocate detached outputs. Editor and indicator command insertion can also reallocate the display list; those UI-owned costs are included in the demo measurements and are not optimized here. Glyph rasterization and glyph-key hashing also appear in cumulative allocation profiles. | ||
| 89 | 78 | ||||
| 90 | Use `pprof`'s `alloc_space` to find cumulative allocation churn and `inuse_space` after GC to inspect retained Go memory. Use `/proc/<pid>/smaps_rollup` for RSS/PSS and DRM fdinfo for GPU counters; heap profiles do not include driver or GPU memory. | 79 | Use `pprof`'s `alloc_space` to find cumulative allocation churn and `inuse_space` after GC to inspect retained Go memory. Use `/proc/<pid>/smaps_rollup` for RSS/PSS and DRM fdinfo for GPU counters; heap profiles do not include driver or GPU memory. | ||
M internal/presentation/buffers/buffers.go +2 -2
| @@ -1,5 +1,5 @@ | |||
| 1 | - | // Package buffers models Wayland/Vulkan buffer ownership without GPU dependencies. | |
| 2 | - | // The caller serializes events from the renderer, the Wayland dispatch loop, and DRM. | |
| 1 | + | // Package buffers models buffer ownership between the renderer and the | |
| 2 | + | // compositor without GPU dependencies. The caller serializes all events. | |
| 3 | 3 | package buffers | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| @@ -1,5 +1,5 @@ | |||||
| 1 | - | // Package buffers models Wayland/Vulkan buffer ownership without GPU dependencies. | 1 | + | // Package buffers models buffer ownership between the renderer and the |
| 2 | - | // The caller serializes events from the renderer, the Wayland dispatch loop, and DRM. | 2 | + | // compositor without GPU dependencies. The caller serializes all events. |
| 3 | package buffers | 3 | package buffers | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
M internal/presentation/syncobj/syncobj.go +3 -4
| @@ -118,9 +118,8 @@ func (n *Node) Destroy(handle uint32) error { | |||
| 118 | 118 | return n.ioctl(ioctlDestroy, unsafe.Pointer(&destroyArg{Handle: handle})) | |
| 119 | 119 | } | |
| 120 | 120 | ||
| 121 | - | // ExportTimeline returns a new owned syncobj FD (not a sync_file). The caller | |
| 122 | - | // hands it to WLTurbo's ImportTimeline, which closes only after a successful | |
| 123 | - | // request send; on send failure the caller must close the FD. | |
| 121 | + | // ExportTimeline returns a new owned syncobj FD (not a sync_file). The | |
| 122 | + | // caller owns it and closes it, or passes a duplicate to its Wayland client. | |
| 124 | 123 | func (n *Node) ExportTimeline(handle uint32) (int, error) { | |
| 125 | 124 | a := handleArg{Handle: handle, FD: -1} | |
| 126 | 125 | if err := n.ioctl(ioctlHandleToFD, unsafe.Pointer(&a)); err != nil { | |
| @@ -210,7 +209,7 @@ func (n *Node) ReleaseFence(timeline uint32, point uint64) (int, error) { | |||
| 210 | 209 | } | |
| 211 | 210 | ||
| 212 | 211 | // WaitPoint waits for a submitted release point for at most timeout. Call it | |
| 213 | - | // only from a waiter goroutine, never from the presentation loop. WAIT_FOR_SUBMIT | |
| 212 | + | // only off the render path: it can block for up to timeout. WAIT_FOR_SUBMIT | |
| 214 | 213 | // handles the interval before the compositor attaches its fence to the point. | |
| 215 | 214 | func (n *Node) WaitPoint(handle uint32, point uint64, timeout time.Duration) (bool, error) { | |
| 216 | 215 | if timeout < 0 || timeout > time.Second { | |
| @@ -118,9 +118,8 @@ func (n *Node) Destroy(handle uint32) error { | |||||
| 118 | return n.ioctl(ioctlDestroy, unsafe.Pointer(&destroyArg{Handle: handle})) | 118 | return n.ioctl(ioctlDestroy, unsafe.Pointer(&destroyArg{Handle: handle})) | ||
| 119 | } | 119 | } | ||
| 120 | 120 | ||||
| 121 | - | // ExportTimeline returns a new owned syncobj FD (not a sync_file). The caller | 121 | + | // ExportTimeline returns a new owned syncobj FD (not a sync_file). The |
| 122 | - | // hands it to WLTurbo's ImportTimeline, which closes only after a successful | 122 | + | // caller owns it and closes it, or passes a duplicate to its Wayland client. |
| 123 | - | // request send; on send failure the caller must close the FD. | |||
| 124 | func (n *Node) ExportTimeline(handle uint32) (int, error) { | 123 | func (n *Node) ExportTimeline(handle uint32) (int, error) { | ||
| 125 | a := handleArg{Handle: handle, FD: -1} | 124 | a := handleArg{Handle: handle, FD: -1} | ||
| 126 | if err := n.ioctl(ioctlHandleToFD, unsafe.Pointer(&a)); err != nil { | 125 | if err := n.ioctl(ioctlHandleToFD, unsafe.Pointer(&a)); err != nil { | ||
| @@ -210,7 +209,7 @@ func (n *Node) ReleaseFence(timeline uint32, point uint64) (int, error) { | |||||
| 210 | } | 209 | } | ||
| 211 | 210 | ||||
| 212 | // WaitPoint waits for a submitted release point for at most timeout. Call it | 211 | // WaitPoint waits for a submitted release point for at most timeout. Call it | ||
| 213 | - | // only from a waiter goroutine, never from the presentation loop. WAIT_FOR_SUBMIT | 212 | + | // only off the render path: it can block for up to timeout. WAIT_FOR_SUBMIT |
| 214 | // handles the interval before the compositor attaches its fence to the point. | 213 | // handles the interval before the compositor attaches its fence to the point. | ||
| 215 | func (n *Node) WaitPoint(handle uint32, point uint64, timeout time.Duration) (bool, error) { | 214 | func (n *Node) WaitPoint(handle uint32, point uint64, timeout time.Duration) (bool, error) { | ||
| 216 | if timeout < 0 || timeout > time.Second { | 215 | if timeout < 0 || timeout > time.Second { | ||
M internal/render/instances.go +1 -1
| @@ -30,7 +30,7 @@ func ListBatches(frame Frame) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) | |||
| 30 | 30 | // The slices it returns alias its storage: they are valid only until the next | |
| 31 | 31 | // List or Release call, and must not be retained, shared across goroutines, or | |
| 32 | 32 | // used while another List may run. This is safe for consumers that copy the | |
| 33 | - | // data synchronously (Session.tick uploads instances into GPU memory and | |
| 33 | + | // data synchronously (Target.submit uploads instances into GPU memory and | |
| 34 | 34 | // resolve copies the batches before recording returns); anything that keeps the | |
| 35 | 35 | // slices past that point must use ListBatches instead. Retained frames hold | |
| 36 | 36 | // only render.Frame, never these slices, so a blocked frame is re-listed on retry. | |
| @@ -30,7 +30,7 @@ func ListBatches(frame Frame) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) | |||||
| 30 | // The slices it returns alias its storage: they are valid only until the next | 30 | // The slices it returns alias its storage: they are valid only until the next | ||
| 31 | // List or Release call, and must not be retained, shared across goroutines, or | 31 | // List or Release call, and must not be retained, shared across goroutines, or | ||
| 32 | // used while another List may run. This is safe for consumers that copy the | 32 | // used while another List may run. This is safe for consumers that copy the | ||
| 33 | - | // data synchronously (Session.tick uploads instances into GPU memory and | 33 | + | // data synchronously (Target.submit uploads instances into GPU memory and |
| 34 | // resolve copies the batches before recording returns); anything that keeps the | 34 | // resolve copies the batches before recording returns); anything that keeps the | ||
| 35 | // slices past that point must use ListBatches instead. Retained frames hold | 35 | // slices past that point must use ListBatches instead. Retained frames hold | ||
| 36 | // only render.Frame, never these slices, so a blocked frame is re-listed on retry. | 36 | // only render.Frame, never these slices, so a blocked frame is re-listed on retry. | ||
M internal/ui/tree.go +1 -1
| @@ -1,5 +1,5 @@ | |||
| 1 | 1 | // Package ui owns the immediate-mode frame tree, controls, editors, input | |
| 2 | - | // routing and the Wayland run loop behind the public nefergui facade. | |
| 2 | + | // routing and the Renderer behind the public nefergui facade. | |
| 3 | 3 | package ui | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| @@ -1,5 +1,5 @@ | |||||
| 1 | // Package ui owns the immediate-mode frame tree, controls, editors, input | 1 | // Package ui owns the immediate-mode frame tree, controls, editors, input | ||
| 2 | - | // routing and the Wayland run loop behind the public nefergui facade. | 2 | + | // routing and the Renderer behind the public nefergui facade. |
| 3 | package ui | 3 | package ui | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||