mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(acp): ACK-gated sentinel lifecycle, D7 admission, NIP-AO docs
Implements the frozen ACK-waiter contract for the ask permission policy: - Publishing→Pending→Writing/Terminal lifecycle: entry inserted as Publishing before publish; transitions to Pending only on relay OK accepted=true. Non-accepted outcomes (Rejected, timeout at min(10s, expiresAt), socket failure) deny immediately (fail closed). - Registration-before-send via register_publish_ack: background relay task inserts the ACK waiter before sending the EVENT frame. - Early-decision buffering: an authorized decision arriving while still in Publishing is stored in early_decision and applied on admission with no additional round trip. - Exactly-once terminal consumption: ack_result_rx arm, timeout check, and cancel path each consume the record exactly once via finish_permission / finish_permission_sync. - Remove the D7 !relay_active escape hatch: admission now requires publisher AND byte-equal initiator==owner; missing relay context denies synchronously with the D7 diagnostic, zero card events. - Store wire expiresAt once in PermissionEntry at build time; kind-40003 resolved edit reuses it with no recompute drift. - Add test_pair_rejecting, test_pair_silent, test_pair_dead to RelayEventPublisher for the ACK lifecycle test matrix. - Fix test compile errors: IdleTimeout(Duration), remove test_from_cmd_tx dependency, add allow(collapsible_match). - NIP-AO: replace published-immediately with ACK-gated admission semantics including fail-closed paths and early-decision note. Tests: 746 pass / 0 fail (cargo test -p buzz-acp) Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
+1125
-118
File diff suppressed because it is too large
Load Diff
@@ -641,6 +641,34 @@ impl RelayEventPublisher {
|
||||
Ok(ack_rx.await.unwrap_or(AckOutcome::Uncertain))
|
||||
}
|
||||
|
||||
/// Register an ACK waiter for a signed event and return the receiver
|
||||
/// **without** awaiting the outcome.
|
||||
///
|
||||
/// The background task sends the EVENT frame and resolves the waiter
|
||||
/// exactly once (accepted, rejected, or uncertain). The caller owns the
|
||||
/// returned [`oneshot::Receiver`] and must poll or await it — typically
|
||||
/// in a `tokio::select!` arm alongside other loop futures.
|
||||
///
|
||||
/// Registration-before-send is guaranteed: the background task inserts the
|
||||
/// waiter into `ack_waiters` before writing the EVENT frame.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns `RelayError::ConnectionClosed` if the command channel is closed.
|
||||
pub async fn register_publish_ack(
|
||||
&self,
|
||||
event: Event,
|
||||
) -> Result<oneshot::Receiver<AckOutcome>, RelayError> {
|
||||
let (ack_tx, ack_rx) = oneshot::channel();
|
||||
self.cmd_tx
|
||||
.send(RelayCommand::PublishEventAcked {
|
||||
event: Box::new(event),
|
||||
ack_tx,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| RelayError::ConnectionClosed)?;
|
||||
Ok(ack_rx)
|
||||
}
|
||||
|
||||
/// Test-only publisher pair: published events are forwarded to the
|
||||
/// returned receiver instead of a live relay socket.
|
||||
#[cfg(test)]
|
||||
@@ -666,7 +694,77 @@ impl RelayEventPublisher {
|
||||
});
|
||||
(Self { cmd_tx }, event_rx)
|
||||
}
|
||||
}
|
||||
|
||||
/// Test publisher that rejects every `PublishEventAcked` command with
|
||||
/// `AckOutcome::Rejected`. Used to test the rejected-ACK deny path.
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::collapsible_match)]
|
||||
pub(crate) fn test_pair_rejecting() -> (Self, mpsc::Receiver<Event>) {
|
||||
let (cmd_tx, mut cmd_rx) = mpsc::channel::<RelayCommand>(64);
|
||||
let (event_tx, event_rx) = mpsc::channel(64);
|
||||
tokio::spawn(async move {
|
||||
while let Some(cmd) = cmd_rx.recv().await {
|
||||
match cmd {
|
||||
RelayCommand::PublishEvent { event } => {
|
||||
if event_tx.send(*event).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
RelayCommand::PublishEventAcked { event, ack_tx } => {
|
||||
let _ = event_tx.send(*event).await;
|
||||
let _ = ack_tx.send(AckOutcome::Rejected {
|
||||
message: "rate-limited".to_string(),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
(Self { cmd_tx }, event_rx)
|
||||
}
|
||||
|
||||
/// Test publisher that never sends an ACK for `PublishEventAcked` commands
|
||||
/// (simulates a relay that accepts the command but never responds with OK).
|
||||
/// Used to test the timeout path.
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::collapsible_match)]
|
||||
pub(crate) fn test_pair_silent() -> (Self, mpsc::Receiver<Event>) {
|
||||
let (cmd_tx, mut cmd_rx) = mpsc::channel::<RelayCommand>(64);
|
||||
let (event_tx, event_rx) = mpsc::channel(64);
|
||||
tokio::spawn(async move {
|
||||
while let Some(cmd) = cmd_rx.recv().await {
|
||||
match cmd {
|
||||
RelayCommand::PublishEvent { event } => {
|
||||
if event_tx.send(*event).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
RelayCommand::PublishEventAcked { event, ack_tx: _ } => {
|
||||
// Intentionally drop ack_tx without sending — simulates
|
||||
// a relay that never confirms the event.
|
||||
let _ = event_tx.send(*event).await;
|
||||
// ack_tx is dropped here → ack_rx.await returns Err(RecvError) → Uncertain
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
(Self { cmd_tx }, event_rx)
|
||||
}
|
||||
|
||||
/// Test publisher whose command channel is dead on arrival (receiver dropped
|
||||
/// before the first send). Any [`RelayCommand`] sent through this publisher
|
||||
/// returns `Err(SendError)`, which the production code maps to
|
||||
/// [`RelayError::ConnectionClosed`] — the same error path as a real socket failure.
|
||||
///
|
||||
/// Used by `sentinel_ack_socket_failure_denies_synchronously_map_empty`.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_pair_dead() -> Self {
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel::<RelayCommand>(1);
|
||||
drop(cmd_rx); // close the channel immediately
|
||||
Self { cmd_tx }
|
||||
}
|
||||
} // end impl RelayEventPublisher
|
||||
|
||||
impl HarnessRelay {
|
||||
/// Connect to relay and authenticate via NIP-42.
|
||||
|
||||
+12
-2
@@ -334,8 +334,18 @@ observer feed. The sentinel lifecycle is:
|
||||
|
||||
### Sentinel event structure
|
||||
|
||||
**PENDING card (kind 9)** — published immediately when the harness registers the
|
||||
request in the pending map.
|
||||
**PENDING card (kind 9)** — published after the relay acknowledges the event with
|
||||
`OK accepted=true`. The harness registers the request in the `Publishing` state and
|
||||
sends the event to the relay; only on relay `OK accepted=true` does the entry
|
||||
transition to `Pending` and the card become visible to the owner.
|
||||
|
||||
If the relay rejects the publish (`OK accepted=false`), the relay does not respond
|
||||
within `min(10 s, expiresAt)`, or the relay connection fails, the request is denied
|
||||
immediately with no card shown (fail closed).
|
||||
|
||||
An authorized owner decision that arrives while the entry is still in `Publishing`
|
||||
state is buffered and applied as soon as the relay `OK` is received, with no
|
||||
additional round trip.
|
||||
|
||||
The event content is a compact JSON object that matches the D6 frozen schema
|
||||
(`requestNonce`, `optionIds`, `labels`, `expiresAt`, `hasDurableRule`, …). Desktop
|
||||
|
||||
Reference in New Issue
Block a user