Skip to content

Make HID settings writes nonblocking#89

Merged
kissetfall merged 4 commits into
ergohaven:mainfrom
IgorArkhipov:igor/nonblocking-hid-settings-writes-fix
Jul 19, 2026
Merged

Make HID settings writes nonblocking#89
kissetfall merged 4 commits into
ergohaven:mainfrom
IgorArkhipov:igor/nonblocking-hid-settings-writes-fix

Conversation

@IgorArkhipov

@IgorArkhipov IgorArkhipov commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

EN

Problem

Module and touchpad controls performed QMK HID writes synchronously inside UI callbacks. A slow write, retry, or readback could block Entropy for hundreds of milliseconds or longer and leave no clear indication whether firmware saved the value.

Fix

  • Move module and touchpad QMK setting writes to one serialized background queue per connected device.
  • Keep one HID command in flight, preserve FIFO ordering, and return the HID handle before starting the next command.
  • Verify each write with delayed firmware readback and expose per-setting Saving, Saved, or Failed state.
  • Protect repeated writes to the same QSID so an older completion cannot overwrite newer pending UI state.
  • Pause device scanning, switching, key assignment, whole-layer operations, and undo while the queue owns HID transport.
  • Preserve detailed group, field, QSID, old value, requested value, and readback diagnostics.

Debouncing and coalescing rapid slider writes remain separate follow-up work.

Verification

  • cargo test --locked: 194 passed.
  • cargo test settings_write --locked: 3 passed.
  • cargo clippy --locked --all-targets: passed with existing repository warnings.
  • python3 scripts/check_i18n.py: passed.
  • Scoped rustfmt and git diff --check: passed.
  • TARGET=aarch64-apple-darwin scripts/build_macos_app.sh: arm64 app, DMG, and ZIP built; plist and code-signature validation passed.
  • Built executable verified as Mach-O 64-bit arm64.

RU

Проблема

Настройки модулей и тачпада выполняли QMK HID-запись синхронно внутри UI-callback. Медленная запись, повтор или контрольное чтение могли блокировать Entropy на сотни миллисекунд или дольше, при этом интерфейс не показывал, сохранила ли прошивка значение.

Исправление

  • Запись QMK-настроек модулей и тачпада перенесена в одну последовательную фоновую очередь для подключенного устройства.
  • Одновременно выполняется только одна HID-команда; порядок FIFO сохраняется, а HID-handle возвращается перед запуском следующей команды.
  • Каждая запись проверяется отложенным чтением из прошивки; для настройки показываются состояния Сохранение, Сохранено или Ошибка.
  • Для повторных записей одного QSID старый результат больше не может перезаписать новое ожидающее состояние интерфейса.
  • Пока очередь владеет HID-транспортом, приостанавливаются сканирование и переключение устройства, назначение клавиш, операции целого слоя и undo.
  • В диагностике сохраняются группа, поле, QSID, старое и запрошенное значения, а также результат контрольного чтения.

Debounce и объединение быстрых изменений слайдера остаются отдельной следующей задачей.

Проверка

  • cargo test --locked: прошли 194 теста.
  • cargo test settings_write --locked: прошли 3 теста.
  • cargo clippy --locked --all-targets: прошел с существующими предупреждениями репозитория.
  • python3 scripts/check_i18n.py: прошел.
  • Scoped rustfmt и git diff --check: прошли.
  • TARGET=aarch64-apple-darwin scripts/build_macos_app.sh: собраны arm64-приложение, DMG и ZIP; plist и подпись прошли проверку.
  • Исполняемый файл проверен как Mach-O 64-bit arm64.

@IgorArkhipov
IgorArkhipov force-pushed the igor/nonblocking-hid-settings-writes-fix branch from abdd668 to c777b48 Compare July 17, 2026 21:05

@kissetfall kissetfall left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for moving module/touchpad settings writes off the UI thread. The FIFO direction is sound, but the shared HID lifecycle still has several correctness issues that need to be fixed before merge:

  1. settings_write_task takes self.hid_device, while the existing macro/combo/combo-term/tap-dance lifecycle continues to run later in the same update. With the handle temporarily absent, macro saving clears macros_dirty with an error, and combo/combo-term saving can leave its success flag true, clear the dirty state, and report “Saved” without writing anything. Please coordinate every existing HID writer with the owning transport lifecycle so temporary queue ownership defers work without clearing dirty state or emitting false success. Add lifecycle tests for dirty macro/combo data while a settings write is in flight.

  2. qmk_setting_transport_available() treats an active layer_write_task as available, so module/touchpad requests can be queued behind it. If the layer write finishes with a disconnect or worker failure, no HID handle is restored and the settings request remains pending forever. qmk_settings_write_busy() then blocks scanning, device switching, and reconnect. Please provide an explicit successful handoff or terminal failure/cancellation path for every handle-loss case, and prove that the queue always leaves the busy state after a layer-write disconnect.

  3. ModuleSettingWritebackError::ReadbackMismatch contains the actual firmware value, but finish_settings_write() discards it. Module state remains at the old value and touchpad state remains at the optimistic requested value, so the UI can disagree with the device even though the real readback is known. Please reconcile firmware-backed state with the actual readback and add regression coverage for both module and touchpad targets.

  4. draw_settings_write_status() always installs its hover tooltip. Please pass through the settings list's suppress_tooltips state so status tooltips are hidden while scrolling or dragging, consistently with the rest of the page.

The current three queue tests cover FIFO/status bookkeeping only. Please add app-lifecycle coverage for the cross-owner cases above. Afterward, validate on both USB and Bluetooth: settings writes remain nonblocking, dirty macro/combo work is preserved, disconnect cannot wedge the app, and UI values match firmware readback.

@IgorArkhipov

Copy link
Copy Markdown
Contributor Author

Addressed in 5056858:

  1. Deferred macro, combo, combo-term, tap-hold, tap-dance, and key-override writes while settings/layer tasks own HID; preserved dirty state and added lifecycle coverage.
  2. Added terminal cancellation for queued settings if layer write loses transport, clearing busy state.
  3. Reconciled module and touchpad UI state from readback, including mismatch results; added stale same-QSID regression coverage.
  4. Propagated suppress_tooltips to settings-write status controls.

@kissetfall kissetfall left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — head 5056858 addresses the four previously reported issues on its old base: dirty synchronous writers are gated, queued settings terminate after transport loss, readback mismatch reconciles module/touchpad state, and status tooltips respect suppression.

The PR is not mergeable after #82: GitHub reports CONFLICTING, and the real merge conflicts in eight lifecycle files (app_init, app_state, app_lifecycle, device_connect_task, device_connection, layer_operations, layout_device_dropdown, and layout_top_tabs). These are not mechanical conflicts: #82 made Combo writes a background HID owner and added close/exit chaining, so the two parallel lifecycle implementations must be unified before this can merge.

Blocking requirements for the rebase:

  1. Keep one production HID ownership contract for Layer, Combo, and Settings tasks. A module/touchpad request made while Combo owns the handle must remain pending, not fail as “device not connected”; Combo and synchronous writers must likewise defer while Settings owns it. Device scan/switch, Refresh Device Data, imports, undo, and every real close path must use the same owner/busy state.
  2. Preserve the #82 exit guarantee when Settings has an active task or a queued request behind another owner: chain all pending HID work, return the real handle, flush exit writes once, then close. Do not let a queue-only state fall through as idle.
  3. Replace start_settings_write_for_test() coverage with the real test HID backend and production worker result. Its sender is dropped immediately and it does not prove handle return or post-handoff writes. Add a lifecycle regression that starts a real Settings task, marks Macro/Combo/Combo Timeout/Tap Dance/Key Override/Tap Hold pending, proves no competing request while Settings owns HID, then proves each deferred write executes exactly once after return. Also exercise a real Layer-task disconnect and show the queued Settings state leaves busy terminally.

Please rebase on current main (including #91 and #82), resolve the ownership path in the owning abstraction rather than keeping duplicate hid_write_task_active() implementations, and rerun the full merge lifecycle suite.

@IgorArkhipov
IgorArkhipov force-pushed the igor/nonblocking-hid-settings-writes-fix branch from 5056858 to 6008a00 Compare July 19, 2026 17:08
@IgorArkhipov

Copy link
Copy Markdown
Contributor Author

Rebased on current main and unified HID ownership at 6008a00.

  • Layer, Combo, and Settings now share one owner/busy contract. Settings stays queued while another owner holds HID; Combo and synchronous writers defer while Settings owns it. Scan/switch, refresh, import, undo, and deferred exit use that same state.
  • Deferred exit now treats queued Settings work as busy, drains pending HID writes after handle return, then closes.
  • Replaced artificial Settings-task test setup with real test-HID production-worker coverage. Added lifecycle regression covering Settings ownership followed by Macro, Combo, Combo Timeout, Tap Dance, Key Override, and Tap Hold draining once before close; transport-loss coverage verifies queued Settings writes terminate and busy state clears.

@kissetfall kissetfall left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Head 6008a00 fixes the earlier rebase conflicts and most of the old-base issues, and the local suite passes 247/247. Two lifecycle blockers remain before this is safe to merge:

  1. The shared owner still has no explicit Combo -> Settings handoff. poll_settings_write() runs before poll_combo_write() in the background update; when Combo returns the HID handle, poll_combo_write() only finishes Combo and does not start the queued Settings request. The request therefore waits for an unrelated later frame (up to the normal 250 ms cadence while visible and 5 s while hidden to tray). The Layer completion path has the same missing start in this PR. Please start the next owner at the completion boundary and add real-HID coverage for Settings queued behind both Combo and Layer, rather than only the opposite direction.

  2. Deferred exit is not terminal on transport/worker failure. If Settings loses the HID handle while Macro/Combo/Combo Timeout/Tap Dance/Key Override/Tap Hold work is dirty, finish_deferred_exit_after_hid_write() keeps seeing those flags and repainting, while the synchronous writers cannot clear them without a handle. The window can remain stuck in a cancelled-close loop. Please define the failure policy (preserve/report the unwritten state, then allow close) and add a production-worker close regression for disconnect and channel failure, with exactly-once assertions for writes completed before failure.

The four platform build checks are green, i18n and diff checks pass, and cargo clippy completes. This review is specifically blocking on lifecycle handoff and terminal exit behavior.

@IgorArkhipov

Copy link
Copy Markdown
Contributor Author

Addressed:

  • Added explicit completion-boundary handoff through continue_pending_settings_writes(). Both Combo and Layer now either start queued Settings work immediately after returning HID or terminally fail it when transport is lost. Added real test-HID coverage for Settings queued behind both Combo and Layer.
  • Defined deferred-exit failure behavior: if no HID transport remains after a writer failure, Entropy preserves dirty unwritten state and the per-setting Failed status, clears the deferred-exit flag, and closes instead of repainting indefinitely. Added real production-worker regressions for both disconnect and channel failure; they verify the already-completed Combo write runs exactly once, Settings queue becomes idle, and QSID 121 reports Failed.

@kissetfall kissetfall left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Head 84790d4 resolves the two remaining lifecycle blockers. Combo and Layer now hand queued Settings work off at their completion boundary in the same frame, including transport-loss paths. Settings disconnect and worker-channel failure terminate the queue, preserve the other dirty HID state, clear deferred-exit ownership, and emit one final Close instead of looping indefinitely. The new failure-injectable test HID covers both terminal paths and verifies exactly-once completed Combo/Settings requests.

Local verification passed 251/251 tests, Clippy, i18n, diff-check, and a release build. All four hosted platform builds are green, the head stayed stable, and the PR is merge-clean. The changed Settings controls continue to use the existing Entropy design-language helpers; no new UI-system violation found.

@kissetfall
kissetfall merged commit 025abb2 into ergohaven:main Jul 19, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants