Skip to content

upload_effect() doesn't write kernel-assigned ID back to Effect object — silent breakage on re-upload #250

@cratercamper

Description

@cratercamper

Bug

InputDevice.upload_effect(effect) returns the kernel-assigned effect ID but does not
write it back into the effect object's .id field. Since the initial Effect() is
constructed with id=-1 (meaning "create new"), every subsequent call to
upload_effect(effect) creates a new effect instead of updating the existing one.

This silently fills all available FF effect slots (typically 16 on consumer wheels)
within fractions of a second. After that, upload_effect raises OSError: [Errno 28] No
space left on device, and force feedback dies.

Root cause

def upload_effect(self, effect):
data = memoryview(effect).tobytes() # <-- creates a COPY
ff_id = _input.upload_effect(self.fd, data) # kernel writes ID into data, not
effect
return ff_id

memoryview(effect).tobytes() creates a byte copy. The kernel's EVIOCSFF ioctl writes
the assigned ID into that copy's buffer. The original effect ctypes struct is never
updated, so effect.id stays -1.

Reproduction

import evdev
from evdev import ecodes, ff

dev = evdev.InputDevice('/dev/input/event17') # any FF-capable device

envelope = ff.Envelope(0, 0, 0, 0)
constant = ff.Constant(16384, envelope)
effect = ff.Effect(
ecodes.FF_CONSTANT, -1, 16384,
ff.Trigger(0, 0),
ff.Replay(0, 0),
ff.EffectType(ff_constant_effect=constant)
)

. # First upload — works, returns e.g. 0
eid = dev.upload_effect(effect)
print(f"Returned ID: {eid}, effect.id: {effect.id}")
. # Output: Returned ID: 0, effect.id: -1 <-- BUG: should be 0

. # Second upload — creates ANOTHER effect instead of updating #0
eid2 = dev.upload_effect(effect)
print(f"Returned ID: {eid2}")
. # Output: Returned ID: 1 <-- new effect, not an update

. # After 16 uploads: OSError: [Errno 28] No space left on device

Expected behavior

After upload_effect(), effect.id should be set to the kernel-assigned ID, so that
subsequent calls update the existing effect in-place (the standard Linux FF pattern).

Workaround

Manually set the ID after the first upload:

eid = dev.upload_effect(effect)
effect.id = eid # <-- required workaround

. # Now re-uploads update in-place
constant.level = 32767
effect.u.ff_constant_effect = constant
dev.upload_effect(effect) # updates effect #0, doesn't create new

Impact

This affects anyone using upload_effect in a loop for continuous force feedback — the
standard pattern for driving simulators, haptic feedback, and any application using
FF_CONSTANT updated per-frame. Single-shot effects are unaffected.

The failure mode is particularly insidious: FF works for the first 16 updates (~0.3
seconds at 60 Hz), then silently dies or throws an opaque ENOSPC error that doesn't
suggest the real cause.

Suggested fix

Either:

A) Write the ID back into the effect object (preferred — matches user expectations):
def upload_effect(self, effect):
data = memoryview(effect).tobytes()
ff_id = _input.upload_effect(self.fd, data)
effect.id = ff_id # <-- write back
return ff_id

B) Document the behavior prominently in the docstring and tutorial, with the
workaround.

Environment

  • python-evdev 1.9.3
  • Linux 6.18.8 (also confirmed conceptually for any kernel version)
  • Device: Logitech G29 (hid-logitech / new-lg4ff), but the bug is device-independent
  • found by: Claude Code Opus 4.5 (topoMars project)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions