Repository navigation
Conversation
…own IC The 06cb:009a sensor replies to the '3e' (get_flash_info) command with only the 2-byte status word (0000) and no 14-byte header/partition table while its flash is uninitialized. get_flash_info() unconditionally unpacked the header and crashed with struct.error, leaving the service in a restart loop. Return an empty FlashInfo (no IC, no partitions) for short replies, and make init_flash() re-query once after reset_blob. If the flash IC is still unknown, refuse to format instead of guessing flash parameters (size/sector size/erase command), which could corrupt the sensor flash. Builds on the analysis and patch in uunicorn#272 and the short-reply handling in uunicorn#273/uunicorn#245. Tests: tests/test_flash_info.py (no hardware required).
After a wake-up or reset the 06cb:009a sensor answers the first command(s) with a transient busy status, which assert_status() treated as a fatal error. Add cmd_retry_busy(), a bounded retry helper that also retries USB timeout errors, and use it for RomInfo.get() and get_fw_info() in send_init(). Stop asserting on the init_hardcoded response: a non-zero status there means 'firmware not loaded yet', which the following 4302 check already handles, so asserting made the clean-slate fallback unreachable (as analysed in uunicorn#272 and fixed in uunicorn#245/uunicorn#273). Tests: tests/test_usb_retry.py.
On 06cb:009a the protected-flash commands issued by init_flash() time out when the hardcoded init/firmware blobs from send_init() have not been loaded first. Reordering the sequence fixes initialization on that hardware (as tested in uunicorn#272).
When the sensor stops answering, init() raised USBTimeoutError after the 15s USB timeout. atexit.register(close) is only reached after a successful open_common(), so the only code that resets the USB device (usb.close()) never ran; systemd restarted the service straight back into the same stuck state. On this machine that produced 12,701 restarts and a multi-GB journal. Add usb.reset()/usb.reopen() (re-discovery, so a re-enumerated device is found again) and wrap open()/open_devpath() in _open_with_recovery(), which retries a bounded number of times, resetting and re-opening the device in between. RebootException (normal post-format reboot) is still propagated. atexit registration is now idempotent. Tests: tests/test_init_recovery.py.
Resume() called init.open_common(), which skips usb.open() and reuses the pre-suspend PyUSB handle. After a resume the kernel has reset/re-enumerated the device, so operations failed with 'USBError: [Errno 19] No such device' and the service stayed up but unusable. Add init.resume(), which resets TLS state, re-acquires the device (usb.reopen() with a fallback to usb.open()) and re-runs the bounded init recovery. This is the targeted change discussed in uunicorn#249/uunicorn#285 without the extra systemd unit. Tests: tests/test_resume.py.
The packaged unit started dbus-service with --debug, which enables DEBUG-level hex dumps of every USB/TLS command. On 2026-10-01 the unit had only 2 starts yet wrote 113,639 journal lines (~106 MiB), and it also dumped raw key material. Debug tracing stays available via an explicit override. Set Restart=on-failure with RestartSec=5 and bound the restart storm with StartLimitIntervalSec=300 / StartLimitBurst=5. Previously the unit relied on Restart=no (which Arch patches to on-failure without a delay); with a 15s per-attempt init and the default StartLimitBurst=5/10s, the limit never tripped no matter how many times the service failed. Tests: tests/test_service_unit.py.
|
Test run on the PR branch (no hardware): Commit range: |
Local validation (part 1) — applied on the reporting machine
Before (from the journal): 12,701 scheduled restarts, max counter 5,075, After install:
Still to do before this comes out of draft: 2 full reboots and >=10 real
|
close() only caught RebootException, so a USB error in reboot() (the device is often already reset/gone during shutdown or suspend) produced an "Exception ignored in atexit callback" traceback on every stop. Log it at debug level instead; the USB close still runs in finally. Tests: tests/test_init_recovery.py.
A cancelled verify (or one that times out waiting for a finger) raised CancelledException, fell into the generic handler and logged a full chained traceback containing the 30s USBTimeoutError. This is normal operation and was a large part of the journal volume. Handle CancelledException and USBError explicitly with a single log line and keep the existing status callbacks. Tests: the verify/enroll paths are DBus-bound; behaviour is covered by the hardware suspend/resume matrix rather than unit tests.
|
Two more commits added, plus a fuller local suspend/resume run. Added commits
Real suspend/resume matrix (no longer simulated)On the same
Remaining before un-drafting: 2 full reboots. A reboot ends the agent session, |
Final hardware validation — pulling out of draftTwo full reboots and 14 real suspend/resume cycles on the affected Reboods (2/2)
Suspend/resume (14/14)
Journal volume
With the two extra commits (atexit hardening + expected cancel/timeout logging), |
|
A ready-to-test overlay with this exact patch, the Arch packaging to apply it, https://github.com/alhasapi/python-validity-fix
Useful if you want to reproduce the hardware validation without assembling the |
|
Tested on hardware: ThinkPad T480, Synaptics 06cb:009a. This fixes the System
Before (AUR python-validity 0.15, unpatched)
After (0.15 + this PR, applied with
The service file changes didn't apply to the AUR package's unit, so I applied them with Thanks for this, and for the root cause in #272. It would be great to see it merged. |
What this fixes
On a Lenovo
06cb:009a(Synaptics Metallica MIS) running Python 3.14, the daemoncan come up with an unresponsive sensor and then crash-loop forever while
flooding the journal:
atexit.register(close)is only reached after a successfulopen_common(),and
close()is the only place that resets the USB device. So a failed initnever reset the sensor, and every systemd restart met the same stuck state.
Machine-generated evidence from the affected host (10 days):
Scheduled restart jobUSBTimeoutError: [Errno 110]lines that day--debugonly, no crash loop (2026-10-01)Changes (one commit per concern)
flash: tolerate short/empty flash-info reply and never format an unknown ICA
0000-only3ereply crashedunpack(). Return an emptyFlashInfo,re-query once after
reset_blob, and refuse to format if the flash IC isstill unknown instead of guessing flash parameters.
usb: retry transient 0x0104/0x0401 busy status during send_initBounded
cmd_retry_busy()for the busy statuses and USB timeouts; stopasserting on the
init_hardcodedreply so the clean-slate fallback works.init: run send_init() before init_flash()The ordering that makes protected-flash commands time out on
06cb:009a.init: reset and re-open the sensor instead of crash-loopingusb.reset()/usb.reopen()(re-discovery, so a re-enumerated device isfound again) plus
_open_with_recovery(): a bounded number of attempts witha hard reset/re-open in between.
RebootExceptionstill propagates.dbus: re-acquire the USB device on Resumeinit.resume()re-opens the device and rebuilds state instead of reusing astale post-suspend PyUSB handle.
service: drop --debug default and throttle restartsRemove
--debug, setRestart=on-failure,RestartSec=5, and bound thestorm with
StartLimitIntervalSec=300/StartLimitBurst=5.Relationship to existing work (nothing silently duplicated)
This is a consolidated PR that adopts and credits the open proposals and
adds the pieces none of them covered:
send_init()/init_flash()ordering. Commits 1–3 are based on it. Two gapsare fixed here:
partition_flash()dereferencedinfo.icunconditionally,and nothing reset a stuck sensor.
--debug— commit 6.init.resume()form.limited to initialization, recovery, resume and service logging.
PrepareForSleep): separate PR against that repo.Happy to split or rebase any commit if you prefer a different grouping; they are
deliberately independent.
Testing
tests/(new, pytest, no hardware required): 15 tests —full/short flash replies, refusing to format an unknown IC, busy/timeout
retry bounds, non-timeout errors not retried, init ordering, bounded
reset/re-open recovery,
RebootExceptionpropagation, resume re-acquire,and the unit file (no
--debug, throttled restarts).python -m py_compileclean.06cb:009a(reboot + suspend/resumematrix) is in progress and will be posted here as a follow-up comment.
Marked draft until that hardware run is attached.
Notes
python -m pytestand a localpytest.ini.recovery only engages after a failure.