v0.2.9-pre.010

This commit is contained in:
2026-08-24 18:39:19 +02:00
parent d32d8de3a5
commit 4568d94a62
9 changed files with 1226 additions and 227 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/lib.rs
// version: 42
// version: 43
#![warn(missing_docs)]
#![deny(unreachable_pub)]
@@ -140,6 +140,8 @@ pub use self::grpc_settings::YellowstoneGrpcSessionSettings;
pub use self::grpc_settings::YellowstoneGrpcTransportSettings;
/// Standard Yellowstone bidirectional Subscribe session.
pub use self::grpc_stream::SolanaYellowstoneGrpcSubscribeSession;
/// Safe Yellowstone reconnect/replay continuity snapshot.
pub use self::grpc_stream::YellowstoneGrpcSubscribeSnapshot;
/// Safe Yellowstone bidirectional Subscribe lifecycle state.
pub use self::grpc_stream::YellowstoneGrpcSubscribeState;
/// One validated standard Yellowstone account predicate.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs
// version: 47
// version: 48
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
@@ -993,3 +993,22 @@ fn public_v0_2_9_pre_009_yellowstone_bidi_session_contract_is_available_from_cra
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_GRPC_SESSION_CLOSED.domain(), "onchain_transport");
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_GRPC_SESSION_CLOSED.code(), "grpc_session_closed");
}
#[test]
fn public_v0_2_9_pre_010_yellowstone_reconnect_snapshot_is_available_from_crate_root() {
fn assert_copy<T: Copy>() {
let _ = std::marker::PhantomData::<T>;
return;
}
assert_copy::<ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot>();
let _snapshot = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot>();
let _state = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeState::Reconnecting;
let _session_snapshot = ksp_onchain_transport_lib::SolanaYellowstoneGrpcSubscribeSession::snapshot;
let _reconnect_count = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::reconnect_count;
let _gap_count = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::continuity_gap_count;
let _duplicate_count = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::duplicate_update_count;
let _replay_count = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::replay_attempt_count;
let _requested = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::last_requested_from_slot;
let _observed = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::last_observed_slot;
let _terminal = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::terminal_error_code;
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs
// version: 40
// version: 41
//! Release-level completeness canaries for staged HTTP and WebSocket Transport coverage.
@@ -1264,7 +1264,7 @@ fn release_v0_2_9_pre_008_standard_blocks_contract_remains_complete() {
}
#[test]
fn release_v0_2_9_pre_009_opens_one_bounded_standard_bidi_session_without_reconnect_or_provider_coupling() {
fn release_v0_2_9_pre_009_bounded_standard_bidi_session_contract_remains_complete() {
let stream_source = include_str!("../src/grpc_stream.rs");
let subscribe_source = include_str!("../src/grpc_subscribe.rs");
let channel_source = include_str!("../src/grpc_channel.rs");
@@ -1301,10 +1301,46 @@ fn release_v0_2_9_pre_009_opens_one_bounded_standard_bidi_session_without_reconn
assert!(!stream_source.contains("PublicNode"));
assert!(!stream_source.contains("OrbitFlare"));
assert!(!stream_source.contains("Helius"));
assert!(!stream_source.contains("reconnect"));
assert!(!crate_root.contains("pub use tonic"));
assert!(!crate_root.contains("pub use yellowstone_grpc_proto"));
let _session = std::any::type_name::<ksp_onchain_transport_lib::SolanaYellowstoneGrpcSubscribeSession>();
let _state = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeState::Active;
let _update = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneSubscribeUpdate>();
}
#[test]
fn release_v0_2_9_pre_010_adds_bounded_reconnect_replay_and_conservative_continuity_observability() {
let stream_source = include_str!("../src/grpc_stream.rs");
let crate_root = include_str!("../src/lib.rs");
for required in [
"YellowstoneGrpcSubscribeState::Reconnecting",
"YellowstoneGrpcSubscribeSnapshot",
"reconnect_subscribe_stream",
"replay_first_available",
"SubscribeReplayInfo",
"last_requested_from_slot",
"last_observed_slot",
"reconnect_count",
"replay_attempt_count",
"continuity_gap_count",
"duplicate_update_count",
"MAX_RECENT_UPDATE_IDENTITIES",
"first_available > requested",
"max_optional_slot",
"reconnect budget is exhausted",
"request mutation",
] {
assert!(stream_source.contains(required), "missing pre.010 reconnect/replay contract token: {required}");
}
assert!(stream_source.contains("still delivered to the caller"));
assert!(stream_source.contains("does not claim exactly-once delivery"));
assert!(!stream_source.contains("unbounded_channel"));
assert!(!stream_source.contains("SubscribeDeshred"));
assert!(!stream_source.contains("PublicNode"));
assert!(!stream_source.contains("OrbitFlare"));
assert!(!stream_source.contains("Helius"));
assert!(!crate_root.contains("pub use tonic"));
assert!(!crate_root.contains("pub use yellowstone_grpc_proto"));
let _snapshot = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot>();
let _state = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeState::Reconnecting;
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/grpc_stream.rs
// version: 1
// version: 2
#[derive(Clone, Copy)]
enum FixtureMode {
@@ -11,12 +11,16 @@ enum FixtureMode {
Malformed,
Oversized,
Idle,
ReconnectReplay,
ReplayGap,
ReconnectExhausted,
}
#[derive(Clone)]
struct FixtureGeyser {
mode: FixtureMode,
half_close_seen: std::sync::Arc<std::sync::atomic::AtomicBool>,
subscribe_calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
#[allow(clippy::implicit_return)] // tonic::async_trait generates async wrapper tails outside the authored fixture bodies.
@@ -32,6 +36,10 @@ impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser {
if let std::result::Result::Err(error) = verify_fixture_metadata(request.metadata()) {
return std::result::Result::Err(error);
}
let subscribe_call = self.subscribe_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst).saturating_add(1);
if matches!(self.mode, FixtureMode::ReconnectExhausted) && subscribe_call > 1 {
return std::result::Result::Err(tonic::Status::unavailable("GRPC-RECONNECT-SECRET-CANARY"));
}
let mut inbound = request.into_inner();
let (outbound_tx, outbound_rx) = tokio::sync::mpsc::channel(64);
let mode = self.mode;
@@ -111,6 +119,45 @@ impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser {
FixtureMode::Idle => {
let _ = inbound.message().await;
},
FixtureMode::ReconnectReplay => {
if subscribe_call == 1 {
assert_eq!(initial.from_slot, std::option::Option::None);
let _ = outbound_tx.send(std::result::Result::Ok(slot_update(500))).await;
} else {
assert_eq!(subscribe_call, 2);
assert_eq!(initial.from_slot, std::option::Option::Some(500));
if outbound_tx.send(std::result::Result::Ok(slot_update(500))).await.is_err() {
return;
}
if outbound_tx.send(std::result::Result::Ok(slot_update(501))).await.is_err() {
return;
}
let half_close = inbound.message().await;
if matches!(half_close, std::result::Result::Ok(std::option::Option::None)) {
half_close_seen.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
},
FixtureMode::ReplayGap => {
if subscribe_call == 1 {
assert_eq!(initial.from_slot, std::option::Option::None);
let _ = outbound_tx.send(std::result::Result::Ok(slot_update(500))).await;
} else {
assert_eq!(subscribe_call, 2);
assert_eq!(initial.from_slot, std::option::Option::Some(505));
if outbound_tx.send(std::result::Result::Ok(slot_update(505))).await.is_err() {
return;
}
let half_close = inbound.message().await;
if matches!(half_close, std::result::Result::Ok(std::option::Option::None)) {
half_close_seen.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
},
FixtureMode::ReconnectExhausted => {
assert_eq!(subscribe_call, 1);
let _ = outbound_tx.send(std::result::Result::Ok(slot_update(700))).await;
},
}
});
return std::result::Result::Ok(tonic::Response::new(super::MpscStream::new(outbound_rx)));
@@ -125,9 +172,17 @@ impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser {
async fn subscribe_replay_info(
&self,
_request: tonic::Request<yellowstone_grpc_proto::geyser::SubscribeReplayInfoRequest>,
request: tonic::Request<yellowstone_grpc_proto::geyser::SubscribeReplayInfoRequest>,
) -> std::result::Result<tonic::Response<yellowstone_grpc_proto::geyser::SubscribeReplayInfoResponse>, tonic::Status> {
return std::result::Result::Err(tonic::Status::unimplemented("unary is outside this fixture"));
if let std::result::Result::Err(error) = verify_fixture_metadata(request.metadata()) {
return std::result::Result::Err(error);
}
let first_available = match self.mode {
FixtureMode::ReconnectReplay | FixtureMode::ReconnectExhausted => std::option::Option::Some(400),
FixtureMode::ReplayGap => std::option::Option::Some(505),
_ => return std::result::Result::Err(tonic::Status::unimplemented("replay info is outside this fixture mode")),
};
return std::result::Result::Ok(tonic::Response::new(yellowstone_grpc_proto::geyser::SubscribeReplayInfoResponse { first_available }));
}
async fn ping(
@@ -176,6 +231,7 @@ impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser {
struct FixtureServer {
endpoint_url: std::string::String,
half_close_seen: std::sync::Arc<std::sync::atomic::AtomicBool>,
subscribe_calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
shutdown: std::option::Option<tokio::sync::oneshot::Sender<()>>,
task: tokio::task::JoinHandle<()>,
}
@@ -187,9 +243,15 @@ impl FixtureServer {
let local_address = incoming.local_addr().expect("fixture gRPC listener must expose local address");
let (shutdown, shutdown_receiver) = tokio::sync::oneshot::channel();
let half_close_seen = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let subscribe_calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let service_half_close_seen = half_close_seen.clone();
let service_subscribe_calls = subscribe_calls.clone();
let task = tokio::spawn(async move {
let service = yellowstone_grpc_proto::geyser::geyser_server::GeyserServer::new(FixtureGeyser { mode, half_close_seen: service_half_close_seen });
let service = yellowstone_grpc_proto::geyser::geyser_server::GeyserServer::new(FixtureGeyser {
mode,
half_close_seen: service_half_close_seen,
subscribe_calls: service_subscribe_calls,
});
let result = tonic::transport::Server::builder()
.serve_with_incoming_shutdown(service, incoming, async move {
let _ = shutdown_receiver.await;
@@ -200,6 +262,7 @@ impl FixtureServer {
return Self {
endpoint_url: format!("http://{local_address}"),
half_close_seen,
subscribe_calls,
shutdown: std::option::Option::Some(shutdown),
task,
};
@@ -229,13 +292,32 @@ fn fixture_settings(
update_capacity: usize,
max_inbound_message_size_bytes: usize,
max_outbound_message_size_bytes: usize,
) -> crate::YellowstoneGrpcEndpointSettings {
let defaults = crate::YellowstoneGrpcSessionSettings::default();
return fixture_settings_with_reconnect(
url,
request_capacity,
update_capacity,
max_inbound_message_size_bytes,
max_outbound_message_size_bytes,
crate::YellowstoneGrpcReconnectSettings::new(0, defaults.reconnect().initial_backoff(), defaults.reconnect().max_backoff()),
);
}
fn fixture_settings_with_reconnect(
url: &str,
request_capacity: usize,
update_capacity: usize,
max_inbound_message_size_bytes: usize,
max_outbound_message_size_bytes: usize,
reconnect: crate::YellowstoneGrpcReconnectSettings,
) -> crate::YellowstoneGrpcEndpointSettings {
let defaults = crate::YellowstoneGrpcSessionSettings::default();
let session = crate::YellowstoneGrpcSessionSettings::new(
defaults.connect_timeout(),
defaults.unary_timeout(),
std::time::Duration::from_millis(250),
defaults.reconnect().clone(),
reconnect,
request_capacity,
update_capacity,
max_inbound_message_size_bytes,
@@ -466,3 +548,138 @@ async fn yellowstone_outbound_mutation_size_is_rejected_before_queue_dispatch()
session.close().await.expect("idle fixture must observe client half-close");
server.stop().await;
}
#[tokio::test(flavor = "current_thread")]
async fn yellowstone_reconnect_replays_from_last_observed_slot_and_counts_duplicate_identity() {
let server = FixtureServer::start(FixtureMode::ReconnectReplay).await;
let defaults = crate::YellowstoneGrpcSessionSettings::default();
let settings = fixture_settings_with_reconnect(
server.endpoint_url.as_str(),
8,
8,
defaults.max_inbound_message_size_bytes(),
defaults.max_outbound_message_size_bytes(),
crate::YellowstoneGrpcReconnectSettings::new(3, std::time::Duration::from_millis(5), std::time::Duration::from_millis(20)),
);
let channel = crate::YellowstoneGrpcChannel::connect(&settings).await.expect("fixture channel must connect");
let mut session = channel.open_standard_subscribe(initial_request()).await.expect("fixture Subscribe stream must open");
let first = session.next_update().await.expect("first slot must decode").expect("first slot must be present");
match first {
crate::YellowstoneSubscribeUpdate::Slot(value) => assert_eq!(value.slot(), 500),
_ => panic!("fixture must return first Slot"),
}
let duplicate = session.next_update().await.expect("replayed duplicate must decode").expect("replayed duplicate must be present");
match duplicate {
crate::YellowstoneSubscribeUpdate::Slot(value) => assert_eq!(value.slot(), 500),
_ => panic!("fixture must return replayed Slot"),
}
let resumed = session.next_update().await.expect("resumed slot must decode").expect("resumed slot must be present");
match resumed {
crate::YellowstoneSubscribeUpdate::Slot(value) => assert_eq!(value.slot(), 501),
_ => panic!("fixture must return resumed Slot"),
}
let snapshot = session.snapshot();
assert_eq!(snapshot.state(), crate::YellowstoneGrpcSubscribeState::Active);
assert_eq!(snapshot.reconnect_count(), 1);
assert_eq!(snapshot.replay_attempt_count(), 1);
assert_eq!(snapshot.continuity_gap_count(), 0);
assert_eq!(snapshot.duplicate_update_count(), 1);
assert_eq!(snapshot.last_requested_from_slot(), std::option::Option::Some(500));
assert_eq!(snapshot.last_observed_slot(), std::option::Option::Some(501));
session.close().await.expect("reconnected stream must close cleanly");
assert_eq!(server.subscribe_calls.load(std::sync::atomic::Ordering::SeqCst), 2);
server.stop().await;
}
#[tokio::test(flavor = "current_thread")]
async fn yellowstone_replay_info_proves_and_clamps_retention_gap_without_lossless_claim() {
let server = FixtureServer::start(FixtureMode::ReplayGap).await;
let defaults = crate::YellowstoneGrpcSessionSettings::default();
let settings = fixture_settings_with_reconnect(
server.endpoint_url.as_str(),
8,
8,
defaults.max_inbound_message_size_bytes(),
defaults.max_outbound_message_size_bytes(),
crate::YellowstoneGrpcReconnectSettings::new(3, std::time::Duration::from_millis(5), std::time::Duration::from_millis(20)),
);
let channel = crate::YellowstoneGrpcChannel::connect(&settings).await.expect("fixture channel must connect");
let mut session = channel.open_standard_subscribe(initial_request()).await.expect("fixture Subscribe stream must open");
let _ = session.next_update().await.expect("first slot must decode").expect("first slot must be present");
let resumed = session.next_update().await.expect("clamped replay must decode").expect("clamped replay must be present");
match resumed {
crate::YellowstoneSubscribeUpdate::Slot(value) => assert_eq!(value.slot(), 505),
_ => panic!("fixture must return clamped Slot"),
}
let snapshot = session.snapshot();
assert_eq!(snapshot.reconnect_count(), 1);
assert_eq!(snapshot.replay_attempt_count(), 1);
assert_eq!(snapshot.continuity_gap_count(), 1);
assert_eq!(snapshot.duplicate_update_count(), 0);
assert_eq!(snapshot.last_requested_from_slot(), std::option::Option::Some(505));
assert_eq!(snapshot.last_observed_slot(), std::option::Option::Some(505));
session.close().await.expect("gap fixture must close cleanly");
server.stop().await;
}
#[tokio::test(flavor = "current_thread")]
async fn yellowstone_reconnect_budget_exhaustion_is_terminal_and_safe() {
let server = FixtureServer::start(FixtureMode::ReconnectExhausted).await;
let defaults = crate::YellowstoneGrpcSessionSettings::default();
let settings = fixture_settings_with_reconnect(
server.endpoint_url.as_str(),
8,
8,
defaults.max_inbound_message_size_bytes(),
defaults.max_outbound_message_size_bytes(),
crate::YellowstoneGrpcReconnectSettings::new(2, std::time::Duration::from_millis(5), std::time::Duration::from_millis(10)),
);
let channel = crate::YellowstoneGrpcChannel::connect(&settings).await.expect("fixture channel must connect");
let mut session = channel.open_standard_subscribe(initial_request()).await.expect("fixture Subscribe stream must open");
let _ = session.next_update().await.expect("first slot must decode").expect("first slot must be present");
let error = session.next_update().await.expect_err("failed reconnect attempts must exhaust the bounded budget");
assert_eq!(error.code(), crate::ERROR_CODE_GRPC_CHANNEL_FAILED);
let snapshot = session.snapshot();
assert_eq!(snapshot.state(), crate::YellowstoneGrpcSubscribeState::Failed);
assert_eq!(snapshot.reconnect_count(), 0);
assert_eq!(snapshot.replay_attempt_count(), 2);
assert_eq!(snapshot.terminal_error_code(), std::option::Option::Some(crate::ERROR_CODE_GRPC_CHANNEL_FAILED));
let rendered = format!("{error:?} {session:?}");
assert!(!rendered.contains("GRPC-RECONNECT-SECRET-CANARY"));
assert!(!rendered.contains(server.endpoint_url.as_str()));
assert_eq!(server.subscribe_calls.load(std::sync::atomic::Ordering::SeqCst), 3);
let _ = session.close().await;
server.stop().await;
}
#[tokio::test(flavor = "current_thread")]
async fn yellowstone_shutdown_interrupts_reconnect_backoff_and_mutation_is_rejected_during_reconnect() {
let server = FixtureServer::start(FixtureMode::ReconnectReplay).await;
let defaults = crate::YellowstoneGrpcSessionSettings::default();
let settings = fixture_settings_with_reconnect(
server.endpoint_url.as_str(),
8,
8,
defaults.max_inbound_message_size_bytes(),
defaults.max_outbound_message_size_bytes(),
crate::YellowstoneGrpcReconnectSettings::new(3, std::time::Duration::from_millis(500), std::time::Duration::from_millis(500)),
);
let channel = crate::YellowstoneGrpcChannel::connect(&settings).await.expect("fixture channel must connect");
let mut session = channel.open_standard_subscribe(initial_request()).await.expect("fixture Subscribe stream must open");
let _ = session.next_update().await.expect("first slot must decode").expect("first slot must be present");
tokio::time::timeout(std::time::Duration::from_secs(1), async {
loop {
if session.state() == crate::YellowstoneGrpcSubscribeState::Reconnecting {
return;
}
tokio::task::yield_now().await;
}
})
.await
.expect("fixture must enter reconnecting state");
let mutation_error = session.try_update(&mutated_request()).expect_err("request mutation must be rejected during reconnect");
assert_eq!(mutation_error.code(), crate::ERROR_CODE_GRPC_SESSION_CLOSED);
session.close().await.expect("shutdown must interrupt reconnect backoff without opening another stream");
assert_eq!(server.subscribe_calls.load(std::sync::atomic::Ordering::SeqCst), 1);
server.stop().await;
}