diff --git a/internal/gamepad/export_linux_test.go b/internal/gamepad/export_linux_test.go index 24090a2..f58e2f3 100644 --- a/internal/gamepad/export_linux_test.go +++ b/internal/gamepad/export_linux_test.go @@ -23,3 +23,11 @@ const ( FFEffectUnionOffset = unsafe.Offsetof(ff_effect{}.u) FFEffectRumbleOffset = unsafe.Offsetof(ff_effect{}.u) + unsafe.Offsetof(ff_effect_union{}.rumble) ) + +const DirName = dirName + +// InitNativeGamepadsForTest initializes the device-polling backend, as the first [Update] does. +func InitNativeGamepadsForTest() error { + var gs gamepads + return newNativeGamepadsImpl().init(&gs) +} diff --git a/internal/gamepad/gamepad_linux.go b/internal/gamepad/gamepad_linux.go index 375dd73..88be7fc 100644 --- a/internal/gamepad/gamepad_linux.go +++ b/internal/gamepad/gamepad_linux.go @@ -65,26 +65,27 @@ func (g *nativeGamepadsImpl) init(gamepads *gamepads) (err error) { return nil } - inotify, err := unix.InotifyInit1(unix.IN_NONBLOCK | unix.IN_CLOEXEC) - if err != nil { - return fmt.Errorf("gamepad: InotifyInit1 failed: %w", err) + // Another program of the same user can exhaust the inotify limits (#3304). That only costs + // hotplug detection, so it is not fatal here, and GLFW ignores the same errors. + if inotify, err := unix.InotifyInit1(unix.IN_NONBLOCK | unix.IN_CLOEXEC); err == nil { + g.inotifyPlus1 = inotify + 1 + + // Register for IN_ATTRIB to get notified when udev is done. + // This works well in practice but the true way is libudev. + if watch, err := unix.InotifyAddWatch(g.inotifyPlus1-1, dirName, unix.IN_CREATE|unix.IN_ATTRIB|unix.IN_DELETE); err == nil { + g.watch = watch + } else { + _ = unix.Close(g.inotifyPlus1 - 1) + g.inotifyPlus1 = 0 + } } - g.inotifyPlus1 = inotify + 1 defer func() { - if err != nil { + if err != nil && g.inotifyPlus1 != 0 { _ = unix.Close(g.inotifyPlus1 - 1) g.inotifyPlus1 = 0 } }() - // Register for IN_ATTRIB to get notified when udev is done. - // This works well in practice but the true way is libudev. - watch, err := unix.InotifyAddWatch(g.inotifyPlus1-1, dirName, unix.IN_CREATE|unix.IN_ATTRIB|unix.IN_DELETE) - if err != nil { - return fmt.Errorf("gamepad: InotifyAddWatch failed: %w", err) - } - g.watch = watch - ents, err := os.ReadDir(dirName) if err != nil { return fmt.Errorf("gamepad: ReadDir(%s) failed: %w", dirName, err) diff --git a/internal/gamepad/gamepad_linux_test.go b/internal/gamepad/gamepad_linux_test.go new file mode 100644 index 0000000..7144d08 --- /dev/null +++ b/internal/gamepad/gamepad_linux_test.go @@ -0,0 +1,74 @@ +// Copyright 2026 The Ebitengine Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !android && !nintendosdk && !playstation5 + +package gamepad_test + +import ( + "os" + "strconv" + "strings" + "testing" + + "golang.org/x/sys/unix" + + "github.com/hajimehoshi/ebiten/v2/internal/gamepad" +) + +// TestInitWithoutInotify verifies that initialization succeeds when no inotify instance can be +// created, because another program of the same user took them all (#3304). +func TestInitWithoutInotify(t *testing.T) { + if _, err := os.Stat(gamepad.DirName); err != nil { + t.Skipf("%s is not available, initialization never reaches inotify", gamepad.DirName) + } + + // Reading the limit up front bounds the loop below and keeps file descriptors to spare. + var rlimit unix.Rlimit + if err := unix.Getrlimit(unix.RLIMIT_NOFILE, &rlimit); err != nil { + t.Skipf("unix.Getrlimit: %v", err) + } + content, err := os.ReadFile("/proc/sys/fs/inotify/max_user_instances") + if err != nil { + t.Skipf("os.ReadFile: %v", err) + } + limit, err := strconv.Atoi(strings.TrimSpace(string(content))) + if err != nil { + t.Skipf("strconv.Atoi: %v", err) + } + if uint64(limit)+256 > uint64(rlimit.Cur) { + t.Skipf("fs.inotify.max_user_instances is %d, too close to the file descriptor limit %d to exhaust", limit, rlimit.Cur) + } + + var held []int + defer func() { + for _, fd := range held { + _ = unix.Close(fd) + } + }() + for len(held) <= limit { + fd, err := unix.InotifyInit1(unix.IN_NONBLOCK | unix.IN_CLOEXEC) + if err != nil { + break + } + held = append(held, fd) + } + if len(held) > limit { + t.Skip("could not exhaust the inotify instances of this user") + } + + if err := gamepad.InitNativeGamepadsForTest(); err != nil { + t.Errorf("gamepad.InitNativeGamepadsForTest(): got: %v, want: nil", err) + } +}