Skip to content

Emit a terminal sponsor outcome when a consumed pairing invitation disconnects #15

Description

@mkdir700

Background

A real two-GUI Tauri E2E test exposed a terminal-state gap in sponsor-side pairing.

When a joiner submits a valid invitation code with the wrong Space passphrase:

  1. The sponsor matches the pending invitation.
  2. The sponsor removes it from the local holder and consumes it from rendezvous.
  3. The sponsor sends KeyslotOffer.
  4. The joiner cannot unwrap the MasterKey and closes the pairing session without sending ChallengeResponse.
  5. The sponsor releases the parked handshake context on PairingSessionEvent::Closed, but emits no PairingOutcome::Failure.

The invitation is already unusable, but the sponsor never receives a terminal setup event and continues displaying the consumed code.

Observed with Engine v0.20.0-rc.11 (8f9d09789cbe14d3d6bd328edca17fa6a0b14ef9) on macOS. The behavior is protocol-level and not macOS-specific.

Evidence

The observed sponsor timeline was:

accepted joiner request for pending invitation
cloud channel invitation consumed
KeyslotOffer sent; awaiting ChallengeResponse
session closed with parked handshake ctx; released

The joiner reported:

unwrap_master_key rejected: KEK does not match wrapped master key
handshake aborted: wrong passphrase
POST /v2/setup/redeem -> 400

A second redeem attempt with the correct passphrase then failed because both discovery channels returned InvitationNotFound, confirming that the invitation was terminal after the first matched request.

The relevant sponsor path currently handles PairingSessionEvent::Closed by calling handle_session_closed only:

PairingSessionEvent::Closed { session, reason } => {
self.handshake
.handle_session_closed(&session, reason.as_deref())
.await;
}
}

The matched invitation is removed and consumed before the handshake completes:

"inbound pairing Request received; matching invitation"
);
let Some(invitation_code) = self.match_invitation(&session, &request).await else {
return;
};
self.notify_consume(&invitation_code).await;
// Slice 8b' · stamp the per-session start time so the verified

emit_failure performs both outcome delivery and handshake_started_at cleanup, but the Closed branch never calls it:

fn take_started_at(&self, session: &PairingSessionId) -> Option<Instant> {
self.handshake_started_at.lock().unwrap().remove(session)
}
/// Fire `pairing_failed` with structured reason and broadcast the
/// `PairingOutcome::Failure` to subscribers in lock-step. Order matters:
/// telemetry first so a slow / dead subscriber doesn't drop the analytics
/// signal, then the broadcast for UX-facing consumers.
fn emit_failure(&self, session: &PairingSessionId, reason: PairingFailureReason) {
// Drop any started_at entry parked at on_incoming so the map stays
// bounded even on the failure paths.
let _ = self.take_started_at(session);
self.analytics.capture(Event::PairingFailed {
method: PairingMethod::Code,
failure_reason: reason,
});
let _ = self.outcome_tx.send(PairingOutcome::Failure { reason });
}

This also leaves the per-session handshake_started_at entry behind after this disconnect path.

Expected Behavior

Once a sponsor has matched and consumed an invitation, every terminal handshake path must produce exactly one sponsor-side outcome and clean all per-session state.

The sponsor does not need to infer that the passphrase was wrong. A generic terminal reason such as ConnectionLost is sufficient when the peer disconnects without an explicit protocol rejection.

Acceptance Criteria

  • A session that closes after a valid invitation was matched and consumed emits exactly one PairingOutcome::Failure.
  • The failure reaches PairingCompletion::Failure and the daemon's setup.pairingCompleted event with success: false.
  • handshake_started_at and the parked handshake context are both removed on this path.
  • Unmatched or rejected-before-match connections do not emit an outcome for another pending invitation.
  • Existing explicit failure paths do not emit duplicate outcomes when their session later closes.
  • Add a regression test for Incoming(Request) followed by Closed before ChallengeResponse: the holder is empty, rendezvous was consumed, one failure is observed, and session state is cleaned.

Desktop E2E Follow-up

The desktop E2E currently recovers by having the sponsor cancel the stale displayed code, refresh setup state, issue a new invitation, and complete pairing. Once this Engine issue is fixed and released, the test should assert that the sponsor automatically returns to the ready-to-invite state after the failed attempt.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions