Skip to content

fix(linux): close the DRM render-node fd owned by wl::dmabuf_t - #5764

Open
raphaelpereira wants to merge 2 commits into
LizardByte:masterfrom
raphaelpereira:fix/wlr-drm-fd-leak
Open

raphaelpereira wants to merge 2 commits into
LizardByte:masterfrom
raphaelpereira:fix/wlr-drm-fd-leak

Conversation

@raphaelpereira

@raphaelpereira raphaelpereira commented Sep 21, 2026 •

Copy link
Copy Markdown

Description

wl::dmabuf_t::init_gbm() opens the render node and hands the descriptor to gbm_create_device(); the destructor destroys the GBM device but never closes the descriptor — the comment says "We should close the DRM FD, but it's owned by GBM", which is not the case: libgbm leaves the fd to the caller (gbm_device_destroy() does not close it).

Every capture (re)initialisation constructs a new dmabuf_t — one per streaming session start, one per output mode change, and one per encoder validation pass that reaches a real capture — so the process accumulates one open /dev/dri/renderD* descriptor per (re)init for its whole lifetime.

Change

  • New wl::gbm_device_t: owns the render-node descriptor together with the GBM device. init() opens the node (O_CLOEXEC, so prep/do commands do not inherit it) and creates the device, closing the descriptor if creation fails; reset() destroys the device then closes the descriptor; the destructor calls reset().
  • The open/create/destroy calls are injectable through gbm_device_accessors_t — the same shape as the existing gbm_bo_accessors_t from fix(linux): export all Wayland DMA-BUF planes #5699 — so the lifecycle is unit-testable without a GPU.
  • dmabuf_t holds a gbm_device_t instead of a raw gbm_device *; init_gbm() and the destructor delegate to it.

Tests

tests/unit/platform/linux/test_wayland.cpp, WaylandGbmDeviceTest (fake accessors open /dev/null and hand out a dummy device pointer):

  • ClosesRenderNodeDescriptorOnReset — after reset() the descriptor fails fcntl(F_GETFD) and the device was destroyed exactly once
  • ClosesRenderNodeDescriptorWhenDeviceCreationFails — init() returns false, nothing is held, the opened descriptor is closed
  • DestructorClosesRenderNodeDescriptor

Against the previous code the first and third fail (descriptor still open after teardown).

Observed

Long-running host: Hyprland, capture = wlr, encoder = nvenc, RTX 5060 Ti, nvidia-open-dkms 610.57.04, Sunshine v2026.516.143833 (the code is unchanged on master).

ls -l /proc/$(pgrep -x sunshine)/fd | grep -c /dev/dri around three Moonlight connect/disconnect cycles (each connect re-inits capture ~3× on this host because a prep command changes the output mode):

before after 3 sessions
/dev/dri fds held by Sunshine 4 8
compositor (Hyprland) /dev/dri fds 14 14

Only a restart of Sunshine returns them. At this host's reconnect pace that is a few hundred descriptors a day.

Related but different: #5023 / #5030 (descriptors leaked by failed imports on the wrong render node) and #5699 (exported plane fds closed on error). Neither closes the render-node fd the capture object itself opens.

Not in this PR

The same host also retains GPU memory across capture re-inits (~20 MiB per session, not attributed to the process by nvidia-smi, returned by a Sunshine restart). An isolated test shows a leaked DRM fd alone does not pin memory on this driver, so that is a separate defect (encode-session or GL-object teardown); it will follow with its own measurement.

Verification

Built on Arch (cmake -DBUILD_TESTS=ON -DSUNSHINE_ENABLE_WAYLAND=ON -DSUNSHINE_ENABLE_CUDA=ON); test_sunshine --gtest_filter='Wayland*' → 12 tests, all passed. clang-format --dry-run --Werror clean on the three files.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added tests to cover my changes
  • All new and existing unit tests pass locally with my changes

`dmabuf_t::init_gbm()` opens the render node and hands the descriptor to
`gbm_create_device()`; the destructor destroys the GBM device but never
closes the descriptor, on the assumption that GBM owns it. It does not:
libgbm leaves the fd to the caller (`gbm_device_destroy()` does not close
it).

Every capture (re)initialisation constructs a new `dmabuf_t` -- one per
streaming session start, plus one per output mode change and per encoder
validation pass that reaches a real capture -- so the process keeps one
open `/dev/dri/renderD*` descriptor per (re)init for its lifetime.
Measured on a long-running host (Hyprland, `capture = wlr`, `encoder =
nvenc`, NVIDIA 610.57.04, v2026.516.143833; unchanged on master):
`/dev/dri` descriptors went 4 -> 8 across three Moonlight connect /
disconnect cycles and only a restart of Sunshine returned them.

Own the descriptor together with the device in a small RAII type,
`wl::gbm_device_t`, whose `reset()` destroys the device and then closes
the descriptor (and which closes it when device creation fails); open it
with `O_CLOEXEC` so prep/do commands do not inherit it. The open/create/
destroy calls are injectable (`gbm_device_accessors_t`, same shape as
`gbm_bo_accessors_t`) so the lifecycle is covered by unit tests that need
no GPU: the descriptor is closed on reset, on the failure path and on
destruction.

Signed-off-by: Raphael Derosso Pereira <raphael@insignis.com.br>

@ReenigneArcher ReenigneArcher left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GBM descriptor ownership and teardown order look sound. I left two focused test comments below.

};
fake_destroyed_devices() = 0;

// Learn which descriptor the next open() will return, so its fate can be checked afterwards.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please record the descriptor actually opened by the fake accessor. This test closes probe and assumes the next open returns the same number; another thread can claim that number between the calls. The test can then fail intermittently or check a different fd while the render-node fd remains open. Capturing the fd in open_fake_render_node or fail_to_create_device would make the assertion reliable.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a36b606: open_fake_render_node() now records the descriptor it returns, and the test asserts on that descriptor; the probe open()/close() is gone.

this->accessors = accessors;

drm_fd = accessors.open_render_node(render_path.c_str());
if (drm_fd < 0) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a test with open_render_node returning -1 and verify that create_device is never called and the owner remains empty. This new early-return path is not covered by the three added tests.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added DoesNotCreateDeviceWhenRenderNodeFailsToOpen in a36b606: open_render_node returns -1, the fake create_device accessors count their calls, and the test asserts zero calls, no device, fd() == -1 and nothing destroyed. It fails if the early return is removed.

ClosesRenderNodeDescriptorWhenDeviceCreationFails no longer predicts the
descriptor with a probe open()/close(): open_fake_render_node() records
the descriptor it returned, and the test checks that one, so another
thread claiming the number between the calls cannot make it flaky or
check the wrong fd.

DoesNotCreateDeviceWhenRenderNodeFailsToOpen covers the early return in
gbm_device_t::init(): with open_render_node returning -1, create_device
is never called (counted by the fake accessors), and the owner stays
empty (no device, fd() == -1, nothing destroyed).
@sonarqubecloud

Copy link
Copy Markdown

@jeffscottward

Copy link
Copy Markdown

I combined this PR with #5748 and am running the result on the host from #5810 (Hyprland 0.56.2, NVIDIA RTX 3080 Ti, driver 610.57.04, capture = wlr, NVENC). Both PRs merge cleanly on current master together. My build is on 2026.830 (f53f0f9), where they needed small test-only conflict fixes.

Results:

  • The Wayland unit tests from both PRs pass (9/9).
  • I ran wlr_ram_t::capture() without a client against the live output: 60 s on a static screen with DPMS on, then 5 display re-creations.
    • With perf(linux): capture wlr screencopy frames with damage #5748, 58 of the 1000 ms snapshots timed out with a damage-pending request left in flight. Unattributed VRAM (nvidia-smi used minus the process table) stayed flat at about 430 MiB and fell back to its baseline after exit.
    • Render-node fds open in the process stayed at 2 across the re-creations. With the unpatched f53f0f9 sources, the same harness left one more open after each re-creation (3 → 8).
  • The patched build now runs as the user service. Startup encoder probing finds h264_nvenc and hevc_nvenc, as before.

Not verified yet:

  • A real idle Moonlight session over several hours.
  • A DPMS-off output with the patched build. I did not power the monitor off for the test. The guarded path above is the same one a powered-off output would hit, but I have not measured that case directly.

Is there a rough timeline for reviewing/merging #5748 and #5764? They fix the VRAM growth in #5810, and for now I'm running a local build to avoid it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants