Skip to content

daemon: Fail discovery when all transports exit silently - #226

Draft
Preovaleo wants to merge 10 commits into
linux-credentials:mainfrom
Preovaleo:perso/generic_transport-split/3
Draft

daemon: Fail discovery when all transports exit silently#226
Preovaleo wants to merge 10 commits into
linux-credentials:mainfrom
Preovaleo:perso/generic_transport-split/3

Conversation

@Preovaleo

@Preovaleo Preovaleo commented Sep 5, 2026

Copy link
Copy Markdown

Draft — depends on #225, which depends on #224. Review the isolated changes in the fork PR.

Split and stacked review

Original PR: linux-credentials/credentialsd#220.

The replacement is split into four parts. Parts 1 → 2 → 3 form native GitHub stack #4 on the fork; part 4 is independent.

Part Upstream integration PR (targets main) Focused stacked review on the fork
1 — Shared transport lifecycle #224 Preovaleo/credentialsd#2/1main
2 — Cancellation and request ownership #225 Preovaleo/credentialsd#1/2/1
3 — Silent transport exhaustion #226 Preovaleo/credentialsd#3/3/2
4 — Discovery concurrency bound #227 Independent; review directly upstream

Review and integrate parts 1 → 2 → 3 in order. Part 4 can be reviewed and merged independently.

GitHub does not support native stacks across forks. Use Files changed on the fork PRs for incremental review of each layer. The upstream PRs target main; #225 and #226 include earlier layers until their dependencies land and the branches are rebased. Integration into the main project happens through the upstream PRs; merging the fork stack only updates the fork.

Hello 👋,

Summary

This PR extracts silent transport exhaustion handling from #220. It builds on the request lifecycle and ownership guarantees introduced in parts 1 and 2.

Previously, every selected transport could end without producing a terminal result. The merged discovery stream would then end while the credential request remained active, leaving its caller waiting indefinitely.

This PR:

  • wraps the merged transport streams with aggregate discovery lifecycle handling;
  • completes the originating request with an error if every selected transport ends without a terminal result;
  • introduces a transport-independent failure update and maps it to the UI background error event;
  • preserves discovery while another selected transport remains active;
  • ignores exhaustion from cancelled or stale discovery streams;
  • adds regression coverage and updates the architecture documentation and changelog.

Result

A transport ending without a result does not immediately fail discovery. Other selected transports can continue and complete the request.

If all selected transports end without a terminal event, the daemon completes the request with an internal error and emits the corresponding failure update.

Explicit cancellation retains its cancellation outcome. Exhaustion from an older discovery stream cannot consume or fail a newer request. A valid terminal result continues to complete the request and cancel competing transports.

Scope and dependencies

This is part 3 of the split. It depends on part 2, which depends on part 1.

The focused review base is perso/generic_transport-split/2. Review and integrate the parts in the order 1 → 2 → 3. Part 4 is independent.

Authenticator enumeration, dynamic source-list updates, and retry policy remain separate concerns tracked by #32 and #206.

Diff composition

Scope Test code Daemon code Moved/reused text (included)
This layer, relative to part 2 ≈82% ≈18% ≈0%

Commit sequence and rationale

The commit order follows the dependencies between these changes:

  1. daemon: Fail discovery when all transports exit silently

    A transport reaching EOF is not enough to conclude that the request cannot succeed: another selected transport may still produce a credential. The failure decision therefore belongs around the merged discovery stream. This is why the change adds aggregate lifecycle handling after parts 1 and 2, using their request snapshot to scope completion and distinguish exhaustion from cancellation.

    No individual transport supplies the error in this case, so DeviceStateUpdate::Failed represents the aggregate outcome and maps it to the UI error event. Resolving the caller's response and exposing that failure to the UI are included together. The accompanying tests establish the boundary of the decision: partial exhaustion leaves discovery running, global exhaustion ends it, and cancelled or stale discovery cannot fail a replacement request. The concurrent-terminal test also exercises how the new wrapper composes with the single accepted completion from part 2.

  2. docs: Describe silent transport exhaustion

    The architecture and changelog record the new termination rule once the aggregate behavior is in place. The rule applies to exhaustion of the selected streams; decisions about selecting new authenticators or starting another discovery attempt remain with the discovery policy.

Testing

Validation performed at the branch tip during the split:

  • cargo test -p credentialsd --bin credentialsd — 38 tests passed
  • cargo clippy -p credentialsd --bin credentialsd -- -Dwarnings — passed
  • cargo fmt --all --check — passed
  • git diff --check — passed

The tests check response delivery, public failure events, cancellation, and protection of a newer request from stale discovery exhaustion.

Théo Bougé added 10 commits September 5, 2026 15:52
The push-based helper was embedded in the credential service tests, and its cancellation-focused names hid that it could script any transport state. Move it to test_support, rename it around that broader role, and share typed completion and failure helpers across USB, hybrid, and NFC tests.
USB, hybrid, and NFC duplicated state conversion and request completion in separate stream wrappers, making their terminal behavior easy to desynchronize. Route typed transport events through one lifecycle stream and cover success and failure for every backend.
A lifecycle stream only noticed cancellation after another transport event, so idle discovery could remain pending. Await transport polling through CancellationToken::run_until_cancelled so cancellation wakes the stream immediately.
A late terminal event could consume whichever request context was active and complete a newer request. Attach the request ID and a unique marker to each lifecycle stream, then ignore terminal results whose ownership no longer matches.
Discovery let each transport read the active request independently, so a replacement between reads could mix ownership and cancellation data. Snapshot the request ID, marker, and token once and pass that typed lifecycle to every selected transport.
A terminal event can become ready in the same poll that cancels its request. Without the post-poll cancellation check, that event could still consume the active context.

Cancel the token from inside the scripted poll and verify that the terminal result is discarded without sending a response.
When the final selected transport ended without a terminal result, discovery returned EOF while the request stayed active and its caller could wait indefinitely. Complete that request with a transport-independent failure while ignoring exhaustion from stale or explicitly cancelled discovery.
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.

1 participant