v1.11.3 — unload no longer dies on a RecursionError
A maintenance release. Nothing changes while you play — both fixes are about what the plugin does on the way out, and about controller focus in the About tab.
Fixed
RecursionError on every plugin unload (#2)
Every time the plugin was unloaded — Decky restarting, Steam shutting down, the plugin being disabled — the backend log ended on a RecursionError raised several hundred frames deep inside Task.cancel().
The unload routine gathered the tasks to cancel with asyncio.all_tasks(), which includes the currently running task — that is, the unload coroutine itself. So it cancelled itself, Task.cancel() recursed through the waiter chain until Python gave up, and the gather() that followed was waiting on itself. Everything past that point never ran, including clearing the action cache, and the shutdown line was never reached. The sweep was also too broad: it cancelled every task the loop happened to be running, not just the plugin's own.
SkullKey now tracks its own background tasks and cancels only those. Nothing was visible while playing, but a teardown that never finishes is the kind of thing that turns into a hang at shutdown later.
Background tasks could be collected while still running. The four tasks started at boot kept no reference, and asyncio holds only a weak one — so Python was free to garbage-collect a task mid-execution. They are now held for their whole lifetime.
Controller navigation in the About tab. flow-children="horizontal" is not a value the Steam client's focus engine accepts; it fell through to a default branch that logged Unhandled flow-children on every render and produced no focus flow at all. Replaced with row. This one was fixed in the repository back on 2026-07-23 and simply never made it into a release — it ships here.
Also worth recording
While fixing the above, one Decky behaviour turned out to be worth writing down for anyone else building a plugin: the plugin's event loop stops being run as soon as _unload suspends, so no await past that point ever resumes. With the recursion fixed but a bounded asyncio.wait() still in place after the cancellation, the log stopped dead at Cancelling N pending tasks... and never reached the end. Cancellation itself is synchronous, so it lands regardless — but nothing that matters may sit behind an await during unload.
Full changelog: CHANGELOG.md