Skip to content

Distinguish poll-again from stop-polling in the initiator contract - #15

Closed
chrysh wants to merge 5 commits into
t1-hardeningfrom
poll-contract
Closed

Distinguish poll-again from stop-polling in the initiator contract#15
chrysh wants to merge 5 commits into
t1-hardeningfrom
poll-contract

Conversation

@chrysh

@chrysh chrysh commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Concrete proposal for the poll-contract discussion from the #12 review — code to argue about instead of an issue thread. Stacked on #14 (retarget/rebase as things merge); only the last commit is new here.

The problem: the polling caller can't implement a correct loop. A request in flight with T2 not yet elapsed — the most common polling state — surfaces as Err(FdInitiatorModeError), the same error as calling in a non-initiator state. Waiting, done, and misuse are indistinguishable. caliptra-mcu-sw grew ResponderAction::Continue/Complete plus a cancellation flag for the responder side of the same problem; their T1 path sets neither, so outcomes keep leaking into side-channels.

The proposal: put the outcome in the return type. generate_initiator_request returns InitiatorAction:

  • Request(n) — transmit msg_buf[..n] (1 MCTP type byte + encoded PLDM request)
  • Waiting — request in flight or operation still running; poll again
  • Cancelled — the UA stayed silent for T1 and the update was cancelled (Modified generate_initiator_request  #12); reported once, next poll returns Idle. Report it and keep serving responder commands so the UA can still GetStatus the reason.
  • Idle — the FD left initiator mode; stop polling. Carries no reason: GetStatusReasonCode is the channel for "why"

Err is reserved for real faults (codec, transport, FdOps). The T1 cancel started out as Err(T1Timeout) here, but that pushed callers the wrong way — ? in a polling loop would exit the loop and take the responder path down with it, leaving the UA's follow-up GetStatus unanswered — so it moved into the action type (review discussion below).

Side effect worth its own look: turning the waiting case into a non-error inside fd_progress makes the T1 check reachable while waiting — previously the error short-circuited past it, so a silent UA could only be timed out on the T2 resend path. There's a test pinning that (test_fd_progress_t1_fires_while_waiting).

Resolved during review:

  • FdReqState::Failed now arms T1 rather than mapping to Waiting forever: a silent UA that owes a CancelUpdate after a failed transfer/verify/apply gets timed out like any other silence.
  • Complete was renamed to Idle — it also covers "update was cancelled and FD is idle again", so "complete" overpromised.

Relates to #13.

chrysh added 2 commits August 21, 2026 12:40
…ator

The polling caller could not implement a correct loop: a request in
flight with T2 not yet elapsed surfaced as FdInitiatorModeError, the
same error as calling in a non-initiator state, so waiting, done and
misuse were indistinguishable. caliptra-mcu-sw grew ResponderAction
and a cancellation flag for the responder side of the same problem;
this puts the outcome in the return type instead.

generate_initiator_request now returns InitiatorAction:
- Request(n): transmit msg_buf[..n] (includes the MCTP type byte)
- Waiting: request in flight or operation still running, poll again
- Complete: the FD left initiator mode, stop polling

The waiting case inside fd_progress becomes Ok(0) instead of an
error, which also makes the T1 check reachable while waiting - before
this, a silent UA could only be timed out on the T2 resend path.
Errors are now real faults only, plus T1Timeout from the cancel path.

Signed-off-by: Christina Quast <christina.quast@9elements.com>
The message-type byte is stamped before it is known whether a request
is due, so Waiting still mutates the buffer and a too-short buffer
errors on every poll. A caller reusing one TX/RX buffer and treating
Waiting as buffer-untouched would read stale bytes as a frame.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christina Quast <christina.quast@9elements.com>
@leongross

Copy link
Copy Markdown
Member

I can't find usage of FdReqState::Failed in this PRs changes, could you elaborate a bit more on this?

@CourtneyDrant

Copy link
Copy Markdown
Collaborator

Referencing your question about the return of Complete, perhaps not. We may want the UA to do a GetStatus and retrieve a GetStatusReasonCode to examine the Timeout.

chrysh added 2 commits August 24, 2026 20:28
Complete reads as success, but the variant also covers the T1-cancel
path where the update failed. Idle states the FD fact and stays
truthful in both cases; the reason stays on the GetStatus path.

Signed-off-by: Christina Quast <christina.quast@9elements.com>
After a failed transfer/verify/apply the FD sets FdReqState::Failed
and waits for the UA's CancelUpdate. The T1 cancel check only matched
FdReqState::Sent, so a UA that went silent at that point left the
caller polling Waiting forever. Extend the check to Failed, and skip
the verify/apply pre-check T1 refresh in Failed for the same reason.

The T1 baseline in Failed is the failure response itself:
handle_response refreshes T1 before dispatching, so the UA gets a
full T1 to issue the cancel.

Signed-off-by: Christina Quast <christina.quast@9elements.com>
@chrysh

chrysh commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

I can't find usage of FdReqState::Failed in this PRs changes, could you elaborate a bit more on this?

I would need to dig into PLDM in detail, but

The PR never touches FdReqState::Failed directly — but the PR does change its observable behavior, indirectly. That's the elaboration he's asking for. The chain:

  1. When a TransferComplete/VerifyComplete/ApplyComplete response reports failure, the FD sets FdReqState::Failed and waits for the UA to cancel (fd_context.rs:816,844,869).
  2. should_send_fd_request returns false for Failed (fd_context.rs:1053), so fd_progress produces no request and returns 0.
  3. Before this PR, that 0/no-request path surfaced as Err(FdInitiatorModeError). After this PR, it surfaces as InitiatorAction::Waiting. So Failed → "poll again", without any line mentioning Failed changing.
    That's why the PR body raises it as an open question: is "poll again" the honest answer for a failed update, or does it deserve a terminal signal?

We agreed with the AI on this (see latest commit)

The T1 cancel check in fd_progress now matches Sent | Failed instead of only Sent, and the verify/apply pre-check T1 refresh skips Failed (otherwise it would reset the clock every poll and the timeout could never elapse in Verify/Apply).

@chrysh

chrysh commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Referencing your question about the return of Complete, perhaps not.

Renamed to idle

We may want the UA to do a GetStatus and retrieve a GetStatusReasonCode to examine the Timeout.

Is that not done yet?

@CourtneyDrant

Copy link
Copy Markdown
Collaborator

Referencing your question about the return of Complete, perhaps not.

Renamed to idle

We may want the UA to do a GetStatus and retrieve a GetStatusReasonCode to examine the Timeout.

Is that not done yet?

The UA, not OpenProt, will do a GetStatus Request. The FD, OpenProt, will give the response which includes GetStatusReasonCode.

GetStatusResponse is implemented in OpenProt FD.

@chrysh

chrysh commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Referencing your question about the return of Complete, perhaps not.

Renamed to idle

We may want the UA to do a GetStatus and retrieve a GetStatusReasonCode to examine the Timeout.

Is that not done yet?

The UA, not OpenProt, will do a GetStatus Request. The FD, OpenProt, will give the response which includes GetStatusReasonCode.

GetStatusResponse is implemented in OpenProt FD.

Thanks for the clarification. Can you explain in more detail what change you suggested with your original comment?

@CourtneyDrant

Copy link
Copy Markdown
Collaborator

Referencing your question about the return of Complete, perhaps not.

Renamed to idle

We may want the UA to do a GetStatus and retrieve a GetStatusReasonCode to examine the Timeout.

Is that not done yet?

The UA, not OpenProt, will do a GetStatus Request. The FD, OpenProt, will give the response which includes GetStatusReasonCode.
GetStatusResponse is implemented in OpenProt FD.

Thanks for the clarification. Can you explain in more detail what change you suggested with your original comment?

Could a user of run_terminus, like the Orchestrator, see an unexpected Idle and report that so that a UA can do a GetStatus to see why?

The T1 cancel is a protocol outcome, not a fault: the update is over and
the FD is idle again, but it must keep serving responder commands so the
UA can GetStatus the reason. Returning it as Err pushed callers the
wrong way - ? on the polling loop would exit the loop and take the
responder path down with it, leaving that GetStatus unanswered.

fd_progress now returns FdProgress (Request/Waiting/Cancelled) instead
of a bare length with the cancel smuggled through MsgHandlerError, and
generate_initiator_request maps that to a new InitiatorAction::Cancelled.
Errors are faults only; T1Timeout leaves MsgHandlerError.

Signed-off-by: Christina Quast <christina.quast@9elements.com>
@chrysh

chrysh commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Could a user of run_terminus, like the Orchestrator, see an unexpected Idle and report that so that a UA can do a GetStatus to see why?

@CourtneyDrant A bit AI-ish, but hopefully helpful:

Yes — and chasing this exposed a bug in the proposal, fixed in 7a339fd. The T1 cancel surfaced as Err(T1Timeout), so the obvious run_terminus port (? on every error) would have exited the loop — taking the responder path down and leaving exactly that GetStatus unanswered. It's now InitiatorAction::Cancelled: reported once, next poll returns Idle, Err is faults only.

With that, Idle can't happen unannounced: the FD only reaches it through a UA command (the UA knows why) or through T1, which announces itself as Cancelled. The reason code (e.g. DownloadTimeout) is stored before Cancelled is reported, so GetStatus works immediately after.

Phase 1 of run_terminus_inner on the openprot side then becomes (tested against this branch, all 5 host tests pass):

let action = self
    .cmd_interface
    .generate_initiator_request(&mut fw_buf)
    .map_err(PldmServiceError::MsgHandler)?;
let initiator_active = !matches!(action, InitiatorAction::Idle);
match action {
    InitiatorAction::Request(pldm_len) => {
        // unchanged: send via requester_transport, feed the response
        // back through process_initiator_response
    }
    // Nothing due; fall through to the responder poll.
    InitiatorAction::Waiting => {}
    // T1 fired, update cancelled, FD is Idle again. Keep serving so
    // the UA can GetStatus the reason. The platform already saw the
    // cancel synchronously via FdOps::cancel_update_component.
    InitiatorAction::Cancelled => {}
    // Not in initiator mode; the responder poll below blocks with
    // the caller's idle timeout.
    InitiatorAction::Idle => {}
}

Two things fall out:

  1. The should_start_initiator_mode() pre-check disappears — Idle is that answer, and initiator_active (short vs. long responder-poll timeout) is derived from the action.
  2. Dropping the Cancelled or Idle arm is a compile error, so the kill-the-loop mistake can't come back in a future caller.

If the Orchestrator wants more than the FdOps callback — a log line or counter in the Cancelled arm — that's additive and local to that arm.

@CourtneyDrant If you think #14 and/or #15 are useful, can you merge them? If not, also feel free to close them!

@CourtneyDrant

Copy link
Copy Markdown
Collaborator

Could a user of run_terminus, like the Orchestrator, see an unexpected Idle and report that so that a UA can do a GetStatus to see why?

@CourtneyDrant A bit AI-ish, but hopefully helpful:

Yes — and chasing this exposed a bug in the proposal, fixed in 7a339fd. The T1 cancel surfaced as Err(T1Timeout), so the obvious run_terminus port (? on every error) would have exited the loop — taking the responder path down and leaving exactly that GetStatus unanswered. It's now InitiatorAction::Cancelled: reported once, next poll returns Idle, Err is faults only.

With that, Idle can't happen unannounced: the FD only reaches it through a UA command (the UA knows why) or through T1, which announces itself as Cancelled. The reason code (e.g. DownloadTimeout) is stored before Cancelled is reported, so GetStatus works immediately after.

Phase 1 of run_terminus_inner on the openprot side then becomes (tested against this branch, all 5 host tests pass):

let action = self
    .cmd_interface
    .generate_initiator_request(&mut fw_buf)
    .map_err(PldmServiceError::MsgHandler)?;
let initiator_active = !matches!(action, InitiatorAction::Idle);
match action {
    InitiatorAction::Request(pldm_len) => {
        // unchanged: send via requester_transport, feed the response
        // back through process_initiator_response
    }
    // Nothing due; fall through to the responder poll.
    InitiatorAction::Waiting => {}
    // T1 fired, update cancelled, FD is Idle again. Keep serving so
    // the UA can GetStatus the reason. The platform already saw the
    // cancel synchronously via FdOps::cancel_update_component.
    InitiatorAction::Cancelled => {}
    // Not in initiator mode; the responder poll below blocks with
    // the caller's idle timeout.
    InitiatorAction::Idle => {}
}

Two things fall out:

  1. The should_start_initiator_mode() pre-check disappears — Idle is that answer, and initiator_active (short vs. long responder-poll timeout) is derived from the action.
  2. Dropping the Cancelled or Idle arm is a compile error, so the kill-the-loop mistake can't come back in a future caller.

If the Orchestrator wants more than the FdOps callback — a log line or counter in the Cancelled arm — that's additive and local to that arm.

@CourtneyDrant If you think #14 and/or #15 are useful, can you merge them? If not, also feel free to close them!

We shouldn't get rid of "should_start_initiator_mode. We are only in initiator mode in three states, Download, Verify, and Apply.

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.

3 participants