fix(linux): warn when permissions block udmabuf imports #2111

Merged
simonwjackson merged 1 commit from udmabuf-warning into main 2026-11-11 03:34:47 +00:00
simonwjackson commented 2026-11-11 03:23:56 +00:00 (Migrated from github.com)

Linux users can lose much of their frame rate when device permissions prevent Hookshot from using /dev/udmabuf, without being told how to fix it. On my RX 7900 XT, a matched Kokiri Forest test measured 16.1–16.2 game fps with host-pointer imports versus 36.1 with DMA buffers.

What changed

  • Warn once for EACCES or EPERM, with the error, slower-import consequence and group/udev access advice. Other open failures keep the existing logging.
  • Preserve the original stderr when startup redirects its runtime log to hookshot_log.txt; writing another line to the redirected stderr alone would not reach the original caller. With unredirected stderr, the warning is logged only once.
  • Document the existing owning-group option and device-scoped udev alternatives: active-local-session uaccess, or a dedicated group for service/SSH users. No world-writable device, root game process or automatic permission change.
  • Add a game-free regression check. Linux-only descriptors remain outside Windows code.

Related: #1000; follows the DMA-import support merged in #448. This does not fix every Linux performance issue or close the tracking issue.

Evidence and tests

Base: upstream main 215ab3dd6536cb21fc67c2e89c31c10a18df555c.

Historical hardware evidence: RX 7900 XT / RADV NAVI31, Mesa 26.2.3, Linux 7.2.7, Ryzen 5 7600X. The permission-denied device was root:kvm 0660. With mesh draws disabled in both cases, the matched DMA run reached 36.1 game fps; reverting to HOS_GUEST_MEMFD=0 with the same access grant returned to 16.2. Queue-submit time was 29.10–30.15 ms without DMA and 0.49 ms with it. These are earlier diagnostic runs, not a benchmark of this warning patch or generated-display fps.

  • Regression demonstrated before the change: no warning on stderr.

  • python -I -B tests/run_all.py udmabuf_warning low_memory_notice: 2 passed. The new test checks EACCES/EPERM, ENOENT/EIO, success, repeated probes, and redirected/unredirected stderr.

  • Compiled and ran the actual warning block plus the production logger against a real mode-000 file: one warning on original stderr and one in the redirected log. No game assets needed.

  • C fixture: GCC, -std=c11 -Wall -Wextra -Werror; Ruff and git diff --check: passed.

  • Full game-free suite: python -I -B tests/run_all.py --jobs 6 --timeout 1200 — 354 passed, 2 failed, 12 skipped, 520 s on Linux. tests/test_finish_output.py and tests/test_linux_update.py fail with NameError: name 'a' is not defined in the extracted nx2/make_game.py finish block; both failures reproduce on untouched upstream at the base SHA above. Skips cover game/GPU/Windows-only checks and unavailable dotnet, PIL and numpy. Earlier runs exposed an unrelated tests/test_make_game_ngx.py failure, reproduced on untouched upstream; upstream fixed it in #2068, and this branch was rebased onto that fix before the earlier passing full run.

  • Whole-file host/hos_main.c Clang syntax/type check: passed, using the project's game-free runtime-header generators.

Not tested: the patched full game, Windows runtime/build, other GPUs, or applying either persistent udev policy. No new hardware benchmark was run, so no new tested-configurations row is claimed.

Screenshots

Before/after terminal captures of the game-free EACCES fixture, using the same warning code and real logger (not game screenshots):

Before: no output on stderr. After (once, also in hookshot_log.txt):

[hos host] WARNING: /dev/udmabuf permission denied; GPU imports may be slower, especially on AMD/RADV. Ask your administrator for access via its device group or a device-specific udev rule (see README, On Linux). Do not run the game as root. (Permission denied)

Both probes run twice; the new stderr warning appears once.

Note: tests/test_finish_output.py and tests/test_linux_update.py also fail on untouched current main (NameError: name 'a' is not defined); they are unrelated to this change.

Linux users can lose much of their frame rate when device permissions prevent Hookshot from using `/dev/udmabuf`, without being told how to fix it. On my RX 7900 XT, a matched Kokiri Forest test measured 16.1–16.2 game fps with host-pointer imports versus 36.1 with DMA buffers. ## What changed - Warn once for `EACCES` or `EPERM`, with the error, slower-import consequence and group/udev access advice. Other open failures keep the existing logging. - Preserve the original stderr when startup redirects its runtime log to `hookshot_log.txt`; writing another line to the redirected stderr alone would not reach the original caller. With unredirected stderr, the warning is logged only once. - Document the existing owning-group option and device-scoped udev alternatives: active-local-session `uaccess`, or a dedicated group for service/SSH users. No world-writable device, root game process or automatic permission change. - Add a game-free regression check. Linux-only descriptors remain outside Windows code. Related: #1000; follows the DMA-import support merged in #448. This does not fix every Linux performance issue or close the tracking issue. ## Evidence and tests Base: upstream main `215ab3dd6536cb21fc67c2e89c31c10a18df555c`. Historical hardware evidence: RX 7900 XT / RADV NAVI31, Mesa 26.2.3, Linux 7.2.7, Ryzen 5 7600X. The permission-denied device was `root:kvm 0660`. With mesh draws disabled in both cases, the matched DMA run reached 36.1 game fps; reverting to `HOS_GUEST_MEMFD=0` with the same access grant returned to 16.2. Queue-submit time was 29.10–30.15 ms without DMA and 0.49 ms with it. These are earlier diagnostic runs, not a benchmark of this warning patch or generated-display fps. - Regression demonstrated before the change: no warning on stderr. - `python -I -B tests/run_all.py udmabuf_warning low_memory_notice`: 2 passed. The new test checks EACCES/EPERM, ENOENT/EIO, success, repeated probes, and redirected/unredirected stderr. - Compiled and ran the actual warning block plus the production logger against a real mode-000 file: one warning on original stderr and one in the redirected log. No game assets needed. - C fixture: GCC, `-std=c11 -Wall -Wextra -Werror`; Ruff and `git diff --check`: passed. - Full game-free suite: `python -I -B tests/run_all.py --jobs 6 --timeout 1200` — **354 passed, 2 failed, 12 skipped**, 520 s on Linux. `tests/test_finish_output.py` and `tests/test_linux_update.py` fail with `NameError: name 'a' is not defined` in the extracted `nx2/make_game.py` finish block; both failures reproduce on untouched upstream at the base SHA above. Skips cover game/GPU/Windows-only checks and unavailable dotnet, PIL and numpy. Earlier runs exposed an unrelated `tests/test_make_game_ngx.py` failure, reproduced on untouched upstream; upstream fixed it in #2068, and this branch was rebased onto that fix before the earlier passing full run. - Whole-file `host/hos_main.c` Clang syntax/type check: passed, using the project's game-free runtime-header generators. Not tested: the patched full game, Windows runtime/build, other GPUs, or applying either persistent udev policy. No new hardware benchmark was run, so no new tested-configurations row is claimed. ## Screenshots Before/after terminal captures of the game-free EACCES fixture, using the same warning code and real logger (not game screenshots): Before: no output on stderr. After (once, also in `hookshot_log.txt`): ```text [hos host] WARNING: /dev/udmabuf permission denied; GPU imports may be slower, especially on AMD/RADV. Ask your administrator for access via its device group or a device-specific udev rule (see README, On Linux). Do not run the game as root. (Permission denied) ``` Both probes run twice; the new stderr warning appears once. Note: `tests/test_finish_output.py` and `tests/test_linux_update.py` also fail on untouched current `main` (`NameError: name 'a' is not defined`); they are unrelated to this change.
hckrman101 (Migrated from github.com) approved these changes 2026-11-11 03:32:02 +00:00
hckrman101 (Migrated from github.com) left a comment

Thanks, this is a useful one: a silent 2x on AMD is exactly the kind of thing people never find on their own.

I checked it against how the renderer actually gets the device: import_dma_buf in gpu/vk/vk_memory.cpp opens /dev/udmabuf with the same O_RDWR | O_CLOEXEC, and it only gets that far when hos_guest_memfd is set, which is gated by this exact probe in reserve_guest. So the probe is the right place, and the warning fires for the right reason.

No false warnings that I could find:

  • Module not loaded / device missing is ENOENT and stays on the existing log line only. Same for a Flatpak-style sandbox without the device node (ENOENT), and macOS/other POSIX builds where the node does not exist.
  • AppImage runs with the user's own permissions, so EACCES there is a real permission problem.
  • Windows and the web build (A64_MEM_ZERO) compile all of it out: the declaration, the probe and the fcntl dup are all under !defined(_WIN32) && !defined(A64_MEM_ZERO), and fcntl.h/unistd.h are already included on that path.

The stderr dup also checks out: the redirect only happens when fd 2 is /dev/null, a socket (the journal when started from the desktop entry) or closed (GetFileType in host/posix/win32_posix.c), so the extra copy lands in the journal for a desktop launch, is harmless for /dev/null, and fcntl just returns -1 if fd 2 is closed. With a terminal there is no dup and the line appears once. The dup is closed on the first probe, so no fd leaks into the game.

Readme advice is safe: group with the kvm caveat, a device-scoped uaccess rule (70- sorts before 73-seat-late, as it must), a dedicated group for SSH/service users, and an explicit no to root and world-writable. The rule syntax and the udevadm trigger --action=add /sys/class/misc/udmabuf line are right.

Test is declared the way run_all.py wants (RUN_ALL_ARGS = ["--cc", "gcc"], so a PC without gcc skips it; RUN_ALL_SKIP for Windows) and covers the cases that matter.

Nits, none blocking:

  • HOS_GUEST_MEMFD=0 (inline).
  • The close(startup_stderr) sits inside the udmabuf probe block. Fine since this is its only user, but a short comment saying the dup exists only for this probe would save the next person a search.
  • The readme paragraph is dense for the install steps; it may read better under If something goes wrong > Low FPS or stutter with a one-line pointer from On Linux. If you move it, update the "(see README, On Linux)" in the warning to match.
  • udmabuf loaded with modprobe does not survive a reboot. Mentioning /etc/modules-load.d/udmabuf.conf would save a repeat visit.

CI: only the title check has run so far. The source-regression, AppImage and Windows workflows are waiting for a maintainer to approve them (first-time contributor).

Thanks, this is a useful one: a silent 2x on AMD is exactly the kind of thing people never find on their own. I checked it against how the renderer actually gets the device: `import_dma_buf` in `gpu/vk/vk_memory.cpp` opens `/dev/udmabuf` with the same `O_RDWR | O_CLOEXEC`, and it only gets that far when `hos_guest_memfd` is set, which is gated by this exact probe in `reserve_guest`. So the probe is the right place, and the warning fires for the right reason. No false warnings that I could find: - Module not loaded / device missing is `ENOENT` and stays on the existing log line only. Same for a Flatpak-style sandbox without the device node (ENOENT), and macOS/other POSIX builds where the node does not exist. - AppImage runs with the user's own permissions, so EACCES there is a real permission problem. - Windows and the web build (`A64_MEM_ZERO`) compile all of it out: the declaration, the probe and the `fcntl` dup are all under `!defined(_WIN32) && !defined(A64_MEM_ZERO)`, and `fcntl.h`/`unistd.h` are already included on that path. The stderr dup also checks out: the redirect only happens when fd 2 is /dev/null, a socket (the journal when started from the desktop entry) or closed (`GetFileType` in `host/posix/win32_posix.c`), so the extra copy lands in the journal for a desktop launch, is harmless for /dev/null, and `fcntl` just returns -1 if fd 2 is closed. With a terminal there is no dup and the line appears once. The dup is closed on the first probe, so no fd leaks into the game. Readme advice is safe: group with the `kvm` caveat, a device-scoped `uaccess` rule (70- sorts before 73-seat-late, as it must), a dedicated group for SSH/service users, and an explicit no to root and world-writable. The rule syntax and the `udevadm trigger --action=add /sys/class/misc/udmabuf` line are right. Test is declared the way `run_all.py` wants (`RUN_ALL_ARGS = ["--cc", "gcc"]`, so a PC without gcc skips it; `RUN_ALL_SKIP` for Windows) and covers the cases that matter. Nits, none blocking: - `HOS_GUEST_MEMFD=0` (inline). - The `close(startup_stderr)` sits inside the udmabuf probe block. Fine since this is its only user, but a short comment saying the dup exists only for this probe would save the next person a search. - The readme paragraph is dense for the install steps; it may read better under *If something goes wrong > Low FPS or stutter* with a one-line pointer from *On Linux*. If you move it, update the "(see README, On Linux)" in the warning to match. - `udmabuf` loaded with `modprobe` does not survive a reboot. Mentioning `/etc/modules-load.d/udmabuf.conf` would save a repeat visit. CI: only the title check has run so far. The source-regression, AppImage and Windows workflows are waiting for a maintainer to approve them (first-time contributor).
@ -163,0 +169,4 @@
const char *warning = "WARNING: /dev/udmabuf permission denied; GPU imports may be slower, especially on AMD/RADV. "
"Ask your administrator for access via its device group or a device-specific udev rule "
"(see README, On Linux). Do not run the game as root.";
if (startup_stderr >= 0) dprintf(startup_stderr, "%s (%s)\n", warning, strerror(err));
hckrman101 (Migrated from github.com) commented 2026-11-11 03:32:02 +00:00

Nit: with HOS_GUEST_MEMFD=0 the user has already chosen anonymous memory, so a warning about slower imports is just noise there (the existing log line below is already skipped when e is set). Something like && !(e && !atoi(e)) would keep it for the default case and for =1, where it is still true.

Nit: with `HOS_GUEST_MEMFD=0` the user has already chosen anonymous memory, so a warning about slower imports is just noise there (the existing log line below is already skipped when `e` is set). Something like `&& !(e && !atoi(e))` would keep it for the default case and for `=1`, where it is still true.
Commenting is not possible because the repository is archived.
No description provided.