v0.2.7-pre.007

This commit is contained in:
2026-08-22 20:42:06 +02:00
parent 435126f67a
commit b67fa89f44
11 changed files with 1352 additions and 112 deletions

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml
# version: 200
# version: 201
[workspace]
resolver = "3"
members = ["crates/ksp-app-config-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"]
[workspace.package]
version = "0.2.7-pre.6.fix.1"
version = "0.2.7-pre.7"
edition = "2024"
license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-core-lib/tests/workspace_dependencies.rs
// version: 2
// version: 3
//! Workspace-level dependency policy canaries owned by the foundational KSP test surface.
@@ -63,7 +63,7 @@ fn transport_manifest_preserves_ksp_dependency_firewall() {
assert!(manifest.contains("ksp-logging-lib"));
assert!(manifest.contains("futures-util = { workspace = true, features = [\"sink\", \"std\"] }"));
assert!(manifest.contains("reqwest = { workspace = true, features = [\"rustls\"] }"));
assert!(manifest.contains("tokio = { workspace = true, features = [\"macros\", \"rt\", \"sync\", \"time\"] }"));
assert!(manifest.contains("tokio = { workspace = true, features = [\"macros\", \"net\", \"rt\", \"sync\", \"time\"] }"));
assert!(manifest.contains("tokio-tungstenite = { workspace = true, features = [\"connect\", \"rustls-tls-webpki-roots\"] }"));
assert!(manifest.contains("[dev-dependencies]"));
assert!(manifest.contains("tokio = { workspace = true, features = [\"net\", \"rt\"] }"));

View File

@@ -1,5 +1,5 @@
# file: crates/ksp-onchain-transport-lib/Cargo.toml
# version: 4
# version: 5
[package]
name = "ksp-onchain-transport-lib"
@@ -14,7 +14,7 @@ futures-util = { workspace = true, features = ["sink", "std"] }
reqwest = { workspace = true, features = ["rustls"] }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] }
tokio = { workspace = true, features = ["macros", "net", "rt", "sync", "time"] }
tokio-tungstenite = { workspace = true, features = ["connect", "rustls-tls-webpki-roots"] }
[dev-dependencies]

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-onchain-transport-lib/README.md -->
<!-- version: 12 -->
<!-- version: 13 -->
# `ksp-onchain-transport-lib`
@@ -101,7 +101,7 @@ La première session physique WebSocket est matérialisée sans introduire de po
- ne projette jamais l'URL dans `Debug`, snapshot, erreurs KSP ou logs ;
- répond aux `Ping` reçus et tolère les `Pong`; le lifecycle complet `Close`/shutdown a été durci ensuite en `pre.005`.
Le chemin JSON-RPC générique reste `pub(crate)`. Il sert de primitive au moteur typed de subscriptions et **ne constitue pas une API publique raw provider-extension**. Le registry subscriptions et le mapping remote/local sont matérialisés en `pre.006`; reconnect/resubscribe puis le durcissement backpressure restent dans les tranches suivantes.
Le chemin JSON-RPC générique reste `pub(crate)`. Il sert de primitive au moteur typed de subscriptions et **ne constitue pas une API publique raw provider-extension**. Le registry subscriptions et le mapping remote/local sont matérialisés en `pre.006`; reconnect/resubscribe est actif depuis `pre.007`, tandis que le durcissement backpressure per-sub reste dans la tranche suivante.
Les tests déterministes utilisent un serveur WebSocket local et prouvent le handshake, le round-trip JSON-RPC, le dispatch de réponses hors ordre, l'isolation des erreurs RPC applicatives, deux sessions physiques distinctes sur la même URL et la redaction des erreurs de connexion.
@@ -111,7 +111,7 @@ La session physique dispose désormais de `WsSession::close().await`. Le signal
Les limites `max_message_size`, `max_frame_size`, `max_write_buffer_size` et `max_pending_requests` sont couvertes par des fixtures adversariales locales. Les requests outbound qui dépassent les bornes message/frame sont rejetées avant écriture ; les frames/messages inbound surdimensionnés sont rejetés par Tungstenite avant parse JSON. Les timeouts pending libèrent leur capacité sans faire tomber une session encore saine.
Ping/Pong/Close sont traités comme control frames : le Pong automatique Tungstenite est flushé, un Close distant propre mène à `Closed`, tandis qu'une erreur I/O ou une violation de protocole mène à `Failed`. Aucun heartbeat applicatif périodique n'est ajouté.
Ping/Pong/Close sont traités comme control frames : le Pong automatique Tungstenite est flushé et aucun heartbeat applicatif périodique n'est ajouté. Depuis `pre.007`, un Close distant inattendu, EOF, erreur I/O/TLS/WebSocket ou violation protocolaire structurelle entre dans le reconnect borné ; `Closed` reste réservé au shutdown local explicite ou à la disparition des handles.
### Registry de subscriptions `0.2.7-pre.006`
@@ -126,7 +126,17 @@ La création générique typed reste `pub(crate)` jusqu'aux wrappers standard de
L'ACK de subscribe est traité atomiquement dans l'actor : le remote ID est lié au local ID avant que la notification suivante puisse être dispatchée. Les notifications inconnues/stale sont ignorées avec un diagnostic sûr. Un mismatch de méthode de notification ou un échec de décodage typed termine uniquement la subscription concernée ; la session physique reste `Active`.
Le reconnect/resubscribe et les races associées restent hors scope jusqu'à `pre.007`. Le durcissement adversarial final de backpressure/leaks reste `pre.008`.
### Reconnect et resubscribe `0.2.7-pre.007`
Une perte de connexion physique invalide immédiatement les remote subscription IDs et incrémente `continuity_gap_count`. Le runtime utilise `WsReconnectSettings` pour appliquer un nombre fini de tentatives avec backoff exponentiel borné et sans jitter. Le shutdown surveille les phases de backoff et de handshake et interrompt la reprise sans reconnecter uniquement pour nettoyer des subscriptions.
Avec `WsResubscribePolicy::ActiveSubscriptions`, les subscriptions encore désirées passent en `Resubscribing` et sont restaurées dans l'ordre croissant de leur `WsSubscriptionId`. Les paramètres de subscribe conservés par l'actor sont rejoués, puis chaque nouvel ACK remappe un remote ID sans changer l'identité locale. Le retour à `Active` ne se produit qu'après la fin de cette restauration, ce qui réinitialise alors le budget de reconnect.
Avec `WsResubscribePolicy::Never`, la session physique peut se reconnecter mais les subscriptions précédentes deviennent terminales. Une cancellation locale reçue pendant reconnect gagne toujours : elle retire la subscription de la restauration ; si un ACK distant arrive après cette cancellation, l'actor envoie un unsubscribe best-effort du nouvel ID sans réactiver le handle local.
Le compteur de continuity gaps est un signal d'observabilité, pas une garantie de livraison. Transport n'ajoute aucun backfill HTTP et ne promet aucune continuité lossless pendant l'intervalle de déconnexion.
Le durcissement adversarial final de backpressure/leaks reste `pre.008`.
## Résilience

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-onchain-transport-lib/USAGE.md -->
<!-- version: 12 -->
<!-- version: 13 -->
# Utilisation de `ksp-onchain-transport-lib`
@@ -98,6 +98,14 @@ Le socket brut et la primitive JSON-RPC générique ne sont pas publics. À part
Le snapshot de session expose les subscriptions actuellement enregistrées via `WsSubscriptionSnapshot`, avec `remote_bound: bool` seulement. Le remote ID réel n'est jamais projeté.
### Reconnect automatique borné
Depuis `0.2.7-pre.007`, les settings de session contrôlent réellement le reconnect physique. Une perte de socket publie `Reconnecting { attempt }`, invalide les remote IDs et incrémente `continuity_gap_count`. Avec la policy par défaut `ActiveSubscriptions`, les handles logiques gardent leur `WsSubscriptionId` et passent temporairement en `Resubscribing`; l'actor recrée leurs subscriptions dans l'ordre local avant de republier `Active`.
`WsResubscribePolicy::Never` reconnecte uniquement la session physique : les subscriptions existantes deviennent terminales et doivent être recréées explicitement par le consumer. Dans les deux modes, les requests applicatives qui étaient en vol lors de la coupure échouent et ne sont pas rejouées implicitement.
`unsubscribe().await` peut être appelé pendant `Reconnecting` ou `Resubscribing`. La cancellation locale gagne et le handle ne redevient jamais `Active`. Un ACK distant tardif est nettoyé best-effort par l'actor. Aucun backfill HTTP n'est déclenché automatiquement ; le consumer doit traiter `continuity_gap_count` comme un signal de réconciliation éventuelle.
### Fermeture explicite
À partir de `0.2.7-pre.005`, fermer explicitement la session est la voie normale de shutdown :

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
// file: crates/ksp-onchain-transport-lib/src/ws_subscription.rs
// version: 1
// version: 2
/// Typed handle for one logical Solana WebSocket subscription.
///
/// The handle owns the bounded typed notification receiver while the physical session actor owns the remote subscription identity and socket. The remote
/// numeric subscription identifier is intentionally never exposed because it is transient and will be remapped by reconnect support in later tranches.
/// numeric subscription identifier is intentionally never exposed because it is transient and is remapped by the session actor after reconnect.
pub struct WsSubscription<T> {
session_id: crate::WsSessionId,
id: crate::WsSubscriptionId,
@@ -63,8 +63,9 @@ impl<T> WsSubscription<T> {
/// Cancels this logical subscription and sends the matching Solana unsubscribe request when a remote binding still exists.
///
/// The returned boolean preserves the standard Solana unsubscribe result. Local cancellation is terminal for this handle even when the remote endpoint
/// returns an application error; reconnect race hardening is completed in `0.2.7-pre.007`.
/// The returned boolean preserves the standard Solana unsubscribe result when the current remote binding is reachable. Local cancellation is terminal
/// for this handle; during reconnect it wins before resubscribe selection, and a late remote acknowledgement is cleaned up best-effort without
/// reactivation.
pub async fn unsubscribe(&mut self) -> ksp_core_lib::Result<bool> {
match self.state() {
crate::WsSubscriptionState::Closed => return std::result::Result::Ok(false),
@@ -171,6 +172,8 @@ pub(crate) struct WsSubscriptionRuntime {
pub(crate) kind: crate::WsSubscriptionKind,
/// Current logical lifecycle state.
pub(crate) state: crate::WsSubscriptionState,
/// Original standard subscribe parameters retained internally for deterministic resubscribe.
pub(crate) params: std::vec::Vec<serde_json::Value>,
/// Current transient remote subscription identity when bound.
pub(crate) remote_id: std::option::Option<u64>,
/// Lifecycle publisher observed by the public typed handle.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
// version: 4
// version: 5
use futures_util::SinkExt; // rust-rules: trait-import
use futures_util::StreamExt; // rust-rules: trait-import
@@ -44,6 +44,23 @@ fn session_settings(
);
}
fn reconnect_session_settings(max_retries: u32, backoff: std::time::Duration, resubscribe: crate::WsResubscribePolicy) -> crate::WsSessionSettings {
let defaults = crate::WsSessionSettings::default();
return crate::WsSessionSettings::new(
std::time::Duration::from_millis(250),
std::time::Duration::from_millis(200),
crate::WsReconnectSettings::new(max_retries, backoff, backoff),
resubscribe,
defaults.command_queue_capacity(),
defaults.notification_queue_capacity(),
defaults.max_active_subscriptions(),
defaults.max_pending_requests(),
defaults.max_message_size_bytes(),
defaults.max_frame_size_bytes(),
defaults.max_write_buffer_size_bytes(),
);
}
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
let address = listener.local_addr().expect("local listener must expose address");
@@ -84,6 +101,17 @@ async fn wait_for_pending_count(session: &crate::WsSession, expected: usize) {
}
}
async fn wait_for_gap_count(session: &crate::WsSession, expected: u64) {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
if session.snapshot().continuity_gap_count() == expected && session.state() == crate::WsSessionState::Active {
return;
}
assert!(tokio::time::Instant::now() < deadline, "session did not recover with continuity gap count {expected}");
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
}
#[tokio::test(flavor = "current_thread")]
async fn websocket_session_connects_and_round_trips_internal_json_rpc() {
let (listener, url) = bind_local_listener().await;
@@ -314,17 +342,17 @@ async fn websocket_ping_flushes_automatic_pong_and_keeps_session_active() {
}
#[tokio::test(flavor = "current_thread")]
async fn websocket_remote_close_transitions_to_closed_instead_of_failed() {
async fn websocket_remote_close_consumes_bounded_reconnect_budget_before_terminal_failure() {
let (listener, url) = bind_local_listener().await;
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("local server must accept client");
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
websocket.send(tokio_tungstenite::tungstenite::Message::Close(std::option::Option::None)).await.expect("fixture Close must send");
let _ = tokio::time::timeout(std::time::Duration::from_millis(200), websocket.next()).await;
});
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
wait_for_state(&session, crate::WsSessionState::Closed).await;
assert!(session.close().await.is_ok());
let settings = reconnect_session_settings(1, std::time::Duration::from_millis(20), crate::WsResubscribePolicy::ActiveSubscriptions);
let session = crate::WsSession::connect(local_endpoint_with_session(url.as_str(), settings)).await.expect("client handshake must succeed");
wait_for_state(&session, crate::WsSessionState::Failed).await;
assert_eq!(session.snapshot().continuity_gap_count(), 1);
server.await.expect("local server task must complete");
}
@@ -381,6 +409,319 @@ async fn websocket_repeated_connect_close_cycles_are_bounded() {
.expect("repeated close server task must join");
}
#[tokio::test(flavor = "current_thread")]
async fn websocket_reconnect_resubscribes_in_local_id_order_and_remaps_remote_ids() {
let (listener, url) = bind_local_listener().await;
let server = tokio::spawn(async move {
let (first_stream, _) = listener.accept().await.expect("initial client must connect");
let mut first = tokio_tungstenite::accept_async(first_stream).await.expect("initial handshake must succeed");
let account = read_request(&mut first).await;
assert_eq!(account.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("accountSubscribe"));
send_result(&mut first, &account, serde_json::json!(101)).await;
let logs = read_request(&mut first).await;
assert_eq!(logs.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("logsSubscribe"));
send_result(&mut first, &logs, serde_json::json!(202)).await;
drop(first);
let (second_stream, _) = listener.accept().await.expect("replacement client must connect");
let mut second = tokio_tungstenite::accept_async(second_stream).await.expect("replacement handshake must succeed");
let restored_account = read_request(&mut second).await;
assert_eq!(restored_account.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("accountSubscribe"));
assert_eq!(restored_account.get("params"), account.get("params"));
send_result(&mut second, &restored_account, serde_json::json!(301)).await;
let restored_logs = read_request(&mut second).await;
assert_eq!(restored_logs.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("logsSubscribe"));
assert_eq!(restored_logs.get("params"), logs.get("params"));
send_result(&mut second, &restored_logs, serde_json::json!(302)).await;
send_notification(&mut second, "logsNotification", 302, serde_json::json!({"generation": 2, "family": "logs"})).await;
send_notification(&mut second, "accountNotification", 301, serde_json::json!({"generation": 2, "family": "account"})).await;
let request = read_request(&mut second).await;
send_result(&mut second, &request, serde_json::json!(true)).await;
});
let settings = reconnect_session_settings(2, std::time::Duration::from_millis(20), crate::WsResubscribePolicy::ActiveSubscriptions);
let session = crate::WsSession::connect(local_endpoint_with_session(url.as_str(), settings)).await.expect("client handshake must succeed");
let mut account = session
.subscribe_typed(crate::WsSubscriptionKind::Account, std::vec![serde_json::json!("account-canary")], |value| {
return std::result::Result::Ok(value);
})
.await
.expect("account subscribe must succeed");
let mut logs = session
.subscribe_typed(crate::WsSubscriptionKind::Logs, std::vec![serde_json::json!({"mentions": ["log-canary"]})], |value| {
return std::result::Result::Ok(value);
})
.await
.expect("logs subscribe must succeed");
let account_id = account.id();
let logs_id = logs.id();
let logs_value = tokio::time::timeout(std::time::Duration::from_secs(2), logs.recv())
.await
.expect("logs resubscribe notification must remain bounded")
.expect("logs channel must remain open")
.expect("logs notification must decode");
let account_value = tokio::time::timeout(std::time::Duration::from_secs(2), account.recv())
.await
.expect("account resubscribe notification must remain bounded")
.expect("account channel must remain open")
.expect("account notification must decode");
assert_eq!(logs_value, serde_json::json!({"generation": 2, "family": "logs"}));
assert_eq!(account_value, serde_json::json!({"generation": 2, "family": "account"}));
assert_eq!(account.id(), account_id);
assert_eq!(logs.id(), logs_id);
assert_eq!(account.state(), crate::WsSubscriptionState::Active);
assert_eq!(logs.state(), crate::WsSubscriptionState::Active);
assert_eq!(session.snapshot().continuity_gap_count(), 1);
assert!(session.snapshot().subscriptions().iter().all(|snapshot| return snapshot.remote_bound()));
let result = session.execute_json_rpc("afterResubscribe", std::vec::Vec::new()).await.expect("reconnected session must remain usable");
assert_eq!(result, serde_json::json!(true));
server.await.expect("local server task must complete");
}
#[tokio::test(flavor = "current_thread")]
async fn websocket_unsubscribe_during_reconnect_prevents_resubscribe() {
let (listener, url) = bind_local_listener().await;
let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel();
let server = tokio::spawn(async move {
let (first_stream, _) = listener.accept().await.expect("initial client must connect");
let mut first = tokio_tungstenite::accept_async(first_stream).await.expect("initial handshake must succeed");
let subscribe = read_request(&mut first).await;
send_result(&mut first, &subscribe, serde_json::json!(41)).await;
drop(first);
let _ = dropped_tx.send(());
let (second_stream, _) = listener.accept().await.expect("replacement client must connect");
let mut second = tokio_tungstenite::accept_async(second_stream).await.expect("replacement handshake must succeed");
let first_request = read_request(&mut second).await;
assert_eq!(first_request.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("afterReconnectCancel"));
send_result(&mut second, &first_request, serde_json::json!(true)).await;
});
let settings = reconnect_session_settings(2, std::time::Duration::from_millis(120), crate::WsResubscribePolicy::ActiveSubscriptions);
let session = crate::WsSession::connect(local_endpoint_with_session(url.as_str(), settings)).await.expect("client handshake must succeed");
let mut subscription = session
.subscribe_typed(crate::WsSubscriptionKind::Root, std::vec::Vec::new(), |value| {
return std::result::Result::Ok(value);
})
.await
.expect("root subscribe must succeed");
dropped_rx.await.expect("fixture must drop initial socket");
wait_for_state(&session, crate::WsSessionState::Reconnecting { attempt: 1 }).await;
assert!(!subscription.unsubscribe().await.expect("local cancellation during reconnect must succeed"));
assert_eq!(subscription.state(), crate::WsSubscriptionState::Closed);
wait_for_gap_count(&session, 1).await;
assert_eq!(session.snapshot().subscription_count(), 0);
let result = session
.execute_json_rpc("afterReconnectCancel", std::vec::Vec::new())
.await
.expect("session must remain usable after reconnect cancellation");
assert_eq!(result, serde_json::json!(true));
server.await.expect("local server task must complete");
}
#[tokio::test(flavor = "current_thread")]
async fn websocket_late_resubscribe_ack_after_unsubscribe_is_cleaned_up_without_reactivation() {
let (listener, url) = bind_local_listener().await;
let (resubscribe_seen_tx, resubscribe_seen_rx) = tokio::sync::oneshot::channel();
let (release_ack_tx, release_ack_rx) = tokio::sync::oneshot::channel();
let server = tokio::spawn(async move {
let (first_stream, _) = listener.accept().await.expect("initial client must connect");
let mut first = tokio_tungstenite::accept_async(first_stream).await.expect("initial handshake must succeed");
let subscribe = read_request(&mut first).await;
send_result(&mut first, &subscribe, serde_json::json!(41)).await;
drop(first);
let (second_stream, _) = listener.accept().await.expect("replacement client must connect");
let mut second = tokio_tungstenite::accept_async(second_stream).await.expect("replacement handshake must succeed");
let resubscribe = read_request(&mut second).await;
assert_eq!(resubscribe.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("rootSubscribe"));
let _ = resubscribe_seen_tx.send(());
release_ack_rx.await.expect("test must release stale acknowledgement");
send_result(&mut second, &resubscribe, serde_json::json!(99)).await;
let cleanup = read_request(&mut second).await;
assert_eq!(cleanup.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("rootUnsubscribe"));
assert_eq!(cleanup.get("params"), std::option::Option::Some(&serde_json::json!([99])));
send_result(&mut second, &cleanup, serde_json::json!(true)).await;
let request = read_request(&mut second).await;
assert_eq!(request.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("afterStaleAck"));
send_result(&mut second, &request, serde_json::json!(true)).await;
});
let settings = reconnect_session_settings(2, std::time::Duration::from_millis(20), crate::WsResubscribePolicy::ActiveSubscriptions);
let session = crate::WsSession::connect(local_endpoint_with_session(url.as_str(), settings)).await.expect("client handshake must succeed");
let mut subscription = session
.subscribe_typed(crate::WsSubscriptionKind::Root, std::vec::Vec::new(), |value| {
return std::result::Result::Ok(value);
})
.await
.expect("root subscribe must succeed");
resubscribe_seen_rx.await.expect("fixture must observe replacement subscribe");
assert!(!subscription.unsubscribe().await.expect("local cancellation must win while resubscribe acknowledgement is pending"));
assert_eq!(subscription.state(), crate::WsSubscriptionState::Closed);
release_ack_tx.send(()).expect("fixture stale acknowledgement must be released");
wait_for_gap_count(&session, 1).await;
assert_eq!(subscription.state(), crate::WsSubscriptionState::Closed);
assert_eq!(session.snapshot().subscription_count(), 0);
let result = session.execute_json_rpc("afterStaleAck", std::vec::Vec::new()).await.expect("session must remain usable after stale ack cleanup");
assert_eq!(result, serde_json::json!(true));
server.await.expect("local server task must complete");
}
#[tokio::test(flavor = "current_thread")]
async fn websocket_reconnect_budget_resets_after_full_active_recovery() {
let (listener, url) = bind_local_listener().await;
let server = tokio::spawn(async move {
let (first_stream, _) = listener.accept().await.expect("initial client must connect");
let mut first = tokio_tungstenite::accept_async(first_stream).await.expect("initial handshake must succeed");
let first_request = read_request(&mut first).await;
send_result(&mut first, &first_request, serde_json::json!(0)).await;
drop(first);
let (second_stream, _) = listener.accept().await.expect("first replacement must connect");
let mut second = tokio_tungstenite::accept_async(second_stream).await.expect("first replacement handshake must succeed");
let second_request = read_request(&mut second).await;
send_result(&mut second, &second_request, serde_json::json!(1)).await;
drop(second);
let (third_stream, _) = listener.accept().await.expect("second replacement must connect");
let mut third = tokio_tungstenite::accept_async(third_stream).await.expect("second replacement handshake must succeed");
let third_request = read_request(&mut third).await;
send_result(&mut third, &third_request, serde_json::json!(2)).await;
});
let settings = reconnect_session_settings(1, std::time::Duration::from_millis(20), crate::WsResubscribePolicy::ActiveSubscriptions);
let session = crate::WsSession::connect(local_endpoint_with_session(url.as_str(), settings)).await.expect("client handshake must succeed");
let first = session.execute_json_rpc("cycleZero", std::vec::Vec::new()).await.expect("initial request must succeed");
assert_eq!(first, serde_json::json!(0));
wait_for_gap_count(&session, 1).await;
let second = session.execute_json_rpc("cycleOne", std::vec::Vec::new()).await.expect("first recovered request must succeed");
assert_eq!(second, serde_json::json!(1));
wait_for_gap_count(&session, 2).await;
let third = session.execute_json_rpc("cycleTwo", std::vec::Vec::new()).await.expect("second recovered request must succeed");
assert_eq!(third, serde_json::json!(2));
server.await.expect("local server task must complete");
}
#[tokio::test(flavor = "current_thread")]
async fn websocket_resubscribe_never_keeps_session_but_fails_logical_subscription() {
let (listener, url) = bind_local_listener().await;
let server = tokio::spawn(async move {
let (first_stream, _) = listener.accept().await.expect("initial client must connect");
let mut first = tokio_tungstenite::accept_async(first_stream).await.expect("initial handshake must succeed");
let subscribe = read_request(&mut first).await;
send_result(&mut first, &subscribe, serde_json::json!(71)).await;
drop(first);
let (second_stream, _) = listener.accept().await.expect("replacement client must connect");
let mut second = tokio_tungstenite::accept_async(second_stream).await.expect("replacement handshake must succeed");
let request = read_request(&mut second).await;
assert_eq!(request.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("afterNeverPolicy"));
send_result(&mut second, &request, serde_json::json!(true)).await;
});
let settings = reconnect_session_settings(2, std::time::Duration::from_millis(20), crate::WsResubscribePolicy::Never);
let session = crate::WsSession::connect(local_endpoint_with_session(url.as_str(), settings)).await.expect("client handshake must succeed");
let subscription = session
.subscribe_typed(crate::WsSubscriptionKind::Root, std::vec::Vec::new(), |value| {
return std::result::Result::Ok(value);
})
.await
.expect("root subscribe must succeed");
wait_for_gap_count(&session, 1).await;
assert_eq!(subscription.state(), crate::WsSubscriptionState::Failed);
assert_eq!(session.snapshot().subscription_count(), 0);
let result = session.execute_json_rpc("afterNeverPolicy", std::vec::Vec::new()).await.expect("physical session must reconnect without resubscribe");
assert_eq!(result, serde_json::json!(true));
server.await.expect("local server task must complete");
}
#[test]
fn websocket_reconnect_backoff_is_exponential_and_bounded() {
let settings = crate::WsReconnectSettings::new(5, std::time::Duration::from_millis(10), std::time::Duration::from_millis(40));
assert_eq!(super::reconnect_backoff(&settings, 1), std::time::Duration::from_millis(10));
assert_eq!(super::reconnect_backoff(&settings, 2), std::time::Duration::from_millis(20));
assert_eq!(super::reconnect_backoff(&settings, 3), std::time::Duration::from_millis(40));
assert_eq!(super::reconnect_backoff(&settings, 4), std::time::Duration::from_millis(40));
}
#[tokio::test(flavor = "current_thread")]
async fn websocket_shutdown_interrupts_reconnect_backoff_without_new_connection() {
let (listener, url) = bind_local_listener().await;
let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("initial client must connect");
let websocket = tokio_tungstenite::accept_async(stream).await.expect("initial handshake must succeed");
drop(websocket);
let _ = dropped_tx.send(());
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
});
let defaults = crate::WsSessionSettings::default();
let settings = crate::WsSessionSettings::new(
std::time::Duration::from_millis(250),
std::time::Duration::from_millis(150),
crate::WsReconnectSettings::new(5, std::time::Duration::from_secs(2), std::time::Duration::from_secs(2)),
crate::WsResubscribePolicy::ActiveSubscriptions,
defaults.command_queue_capacity(),
defaults.notification_queue_capacity(),
defaults.max_active_subscriptions(),
defaults.max_pending_requests(),
defaults.max_message_size_bytes(),
defaults.max_frame_size_bytes(),
defaults.max_write_buffer_size_bytes(),
);
let session = crate::WsSession::connect(local_endpoint_with_session(url.as_str(), settings)).await.expect("client handshake must succeed");
dropped_rx.await.expect("fixture must drop physical connection");
wait_for_state(&session, crate::WsSessionState::Reconnecting { attempt: 1 }).await;
tokio::time::timeout(std::time::Duration::from_millis(300), session.close())
.await
.expect("shutdown must interrupt reconnect backoff")
.expect("shutdown during reconnect must finish cleanly");
assert_eq!(session.state(), crate::WsSessionState::Closed);
server.await.expect("local server task must complete");
}
#[tokio::test(flavor = "current_thread")]
async fn websocket_resubscribe_application_error_fails_only_one_subscription() {
let (listener, url) = bind_local_listener().await;
let server = tokio::spawn(async move {
let (first_stream, _) = listener.accept().await.expect("initial client must connect");
let mut first = tokio_tungstenite::accept_async(first_stream).await.expect("initial handshake must succeed");
let account = read_request(&mut first).await;
send_result(&mut first, &account, serde_json::json!(11)).await;
let logs = read_request(&mut first).await;
send_result(&mut first, &logs, serde_json::json!(12)).await;
drop(first);
let (second_stream, _) = listener.accept().await.expect("replacement client must connect");
let mut second = tokio_tungstenite::accept_async(second_stream).await.expect("replacement handshake must succeed");
let failed = read_request(&mut second).await;
let failed_id = failed.get("id").and_then(serde_json::Value::as_u64).expect("resubscribe id must be numeric");
let error = serde_json::json!({"jsonrpc":"2.0","id":failed_id,"error":{"code":-32602,"message":"fixture resubscribe rejection"}});
second.send(tokio_tungstenite::tungstenite::Message::Text(error.to_string().into())).await.expect("resubscribe application error must send");
let restored = read_request(&mut second).await;
assert_eq!(restored.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("logsSubscribe"));
send_result(&mut second, &restored, serde_json::json!(88)).await;
send_notification(&mut second, "logsNotification", 88, serde_json::json!({"restored": true})).await;
let request = read_request(&mut second).await;
send_result(&mut second, &request, serde_json::json!(true)).await;
});
let settings = reconnect_session_settings(2, std::time::Duration::from_millis(20), crate::WsResubscribePolicy::ActiveSubscriptions);
let session = crate::WsSession::connect(local_endpoint_with_session(url.as_str(), settings)).await.expect("client handshake must succeed");
let account = session
.subscribe_typed(crate::WsSubscriptionKind::Account, std::vec![serde_json::json!("account")], |value| {
return std::result::Result::Ok(value);
})
.await
.expect("account subscribe must succeed");
let mut logs = session
.subscribe_typed(crate::WsSubscriptionKind::Logs, std::vec![serde_json::json!("all")], |value| {
return std::result::Result::Ok(value);
})
.await
.expect("logs subscribe must succeed");
let notification = tokio::time::timeout(std::time::Duration::from_secs(2), logs.recv())
.await
.expect("restored logs notification must remain bounded")
.expect("logs channel must remain open")
.expect("logs notification must decode");
assert_eq!(notification, serde_json::json!({"restored": true}));
assert_eq!(account.state(), crate::WsSubscriptionState::Failed);
assert_eq!(logs.state(), crate::WsSubscriptionState::Active);
assert_eq!(session.state(), crate::WsSessionState::Active);
assert_eq!(session.snapshot().continuity_gap_count(), 1);
let result = session.execute_json_rpc("afterPartialResubscribe", std::vec::Vec::new()).await.expect("session must remain usable");
assert_eq!(result, serde_json::json!(true));
server.await.expect("local server task must complete");
}
async fn send_notification(
websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
method: &str,

152
deltas/0.2.7/pre.007.md Normal file
View File

@@ -0,0 +1,152 @@
<!-- file: deltas/0.2.7/pre.007.md -->
<!-- version: 1 -->
# Delta `0.2.7-pre.007` — reconnect borné et resubscribe déterministe
## Base
Base directe validée par l'opérateur :
```text
0.2.7-pre.006-fix.001
Cargo 0.2.7-pre.6.fix.1
```
Le checkpoint précédent est vert sur `cargo fmt --all`, audit Python KSP, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, `cargo test -p ksp-onchain-transport-lib` avec 275 tests réussis, puis `cargo test --workspace` avec uniquement les smokes/diagnostics déjà attendus en ignored.
## Objectif
Matérialiser la résilience de session WebSocket prévue par le plan sans avancer sur les wrappers typed publics ni sur le backpressure per-subscription :
- reconnect automatique uniquement après perte physique ou protocolaire structurelle ;
- budget fini `max_retries` ;
- backoff exponentiel borné sans jitter ;
- shutdown prioritaire pendant backoff et handshake ;
- invalidation immédiate des remote subscription IDs après rupture ;
- incrément du `continuity_gap_count` une fois par perte de continuité ;
- restauration déterministe des subscriptions encore désirées en ordre croissant de `WsSubscriptionId` ;
- conservation et replay exact des paramètres de subscribe ;
- remapping des nouveaux remote IDs sans changer les IDs locaux ;
- policy `Never` sans restauration automatique ;
- cancellation locale gagnante pendant reconnect/resubscribe ;
- nettoyage best-effort d'un ACK de resubscribe devenu stale ;
- reset du budget seulement après retour complet à `Active` ;
- aucun replay implicite des requests applicatives en vol ;
- aucun backfill HTTP et aucune promesse lossless.
## Signal de version
```text
livraison 0.2.7-pre.007
workspace.package.version 0.2.7-pre.7
commit attendu v0.2.7-pre.007
Git tag aucun
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-core-lib/tests/workspace_dependencies.rs
crates/ksp-onchain-transport-lib/Cargo.toml
crates/ksp-onchain-transport-lib/README.md
crates/ksp-onchain-transport-lib/USAGE.md
crates/ksp-onchain-transport-lib/src/ws_session.rs
crates/ksp-onchain-transport-lib/src/ws_subscription.rs
crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
deltas/0.2.7/pre.007.md
```
## Implémentation
### Session physique
Une perte détectée après activation de la session entre dans `Reconnecting { attempt }` au lieu de terminer immédiatement l'actor. Les triggers retenus restent les erreurs physiques/socket/TLS/WebSocket, EOF ou Close distant inattendu et les violations protocolaires structurelles. Les erreurs RPC applicatives continuent de ne pas déclencher de reconnect.
Chaque tentative attend un backoff exponentiel borné par les settings existants. Le signal de shutdown est surveillé séparément de la command queue pendant le backoff et le handshake de remplacement afin qu'un `WsSession::close()` puisse interrompre la reprise sans ouvrir une nouvelle connexion uniquement pour effectuer du nettoyage.
Le budget est consommé pour la perte courante et n'est considéré réinitialisé qu'après établissement d'un nouveau socket, restauration des subscriptions retenues et publication complète de `Active`.
### Continuity gaps et subscriptions
Lors d'une rupture, tous les remote IDs sont invalidés et le mapping remote vers local est vidé. `continuity_gap_count` est incrémenté une fois pour la perte logique. Ce compteur ne constitue pas une garantie de livraison et Transport n'effectue aucun backfill.
Avec `WsResubscribePolicy::ActiveSubscriptions`, les subscriptions `Active` passent en `Resubscribing`. L'actor conserve leurs paramètres de création et les rejoue séquentiellement dans l'ordre des IDs locaux. Chaque ACK réussi remappe un nouveau remote ID au même `WsSubscriptionId`.
Avec `WsResubscribePolicy::Never`, la reconnexion physique reste possible mais les anciennes subscriptions deviennent terminales et doivent être recréées par le consumer.
### Races de cancellation
Un unsubscribe reçu pendant le backoff retire immédiatement la subscription de la restauration et retourne sans reconnecter pour nettoyer l'ancien remote ID devenu invalide.
Si la cancellation arrive après l'émission d'un resubscribe mais avant son ACK, le handle local devient terminal et ne peut plus repasser à `Active`. L'actor conserve toutefois cette request interne jusqu'à son ACK ou son timeout borné avant de poursuivre la restauration séquentielle. Lorsque l'ACK tardif fournit un nouvel ID distant, l'actor envoie un `*Unsubscribe` best-effort pour cet ID sans le publier dans le registry local. Cette attente bornée évite qu'une request stale consomme `max_pending_requests` pendant la restauration de la subscription suivante.
### Dépendances
Le runtime Transport active désormais la feature Tokio `net` parce que la session de remplacement utilise le type concret `tokio::net::TcpStream` retourné par `tokio-tungstenite`. La dépendance reste déclarée au workspace root et la canary de frontière est mise à jour. Aucun nouveau package tiers n'est ajouté.
## Tests déterministes ajoutés ou recalibrés
Les fixtures locales couvrent notamment :
- remote Close avec budget insuffisant puis terminaison `Failed` bornée ;
- deux subscriptions restaurées en ordre local stable avec remote IDs remappés ;
- replay exact des paramètres ;
- unsubscribe pendant backoff empêchant tout resubscribe ;
- unsubscribe après émission du resubscribe et ACK tardif nettoyé sans réactivation ;
- policy `Never` ;
- deux pertes distinctes démontrant le reset du budget après retour complet à `Active` ;
- progression de `continuity_gap_count` de 1 à 2 ;
- backoff exponentiel borné ;
- shutdown interrompant un backoff long ;
- erreur RPC applicative sur un resubscribe ne faisant échouer que la subscription concernée.
Les tests HTTP et les canaries de registry existants ne sont pas modifiés fonctionnellement.
## Logging et sécurité
Les nouveaux diagnostics runtime passent exclusivement par `ksp-logging-lib` avec `TRACING_TARGET = "ksp-onchain-transport-lib"`.
Les champs observables restent limités aux métadonnées sûres : session ID local, subscription ID local, nom logique d'endpoint, tentative de reconnect, compteur de gap, kind et code d'erreur sûr. Aucune URL, credential, payload arbitraire ni remote subscription ID n'est projeté dans les logs publics/snapshots.
## Validation exécutée dans le sandbox
```text
python3 scripts/audit_rust_workspace_rules.py
General Rust rule audit: clean
Rust export completeness audit: 0 candidate(s)
KSP workspace Rust rule audit: clean
direct tracing scan runtime/tests clean
question-mark scan runtime modifié clean
Rust lines > 160 0
workspace.package.version 0.2.7-pre.7
```
Le sandbox ne fournit pas `cargo`, `rustc` ni `rustfmt`. Aucune validation Cargo de cette tranche n'est donc revendiquée ici.
## Validation opérateur requise
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
cargo test --workspace
```
## Décisions
- Le reconnect initial de `WsSession::connect` n'est pas transformé en boucle automatique dans cette tranche : la policy de reprise s'applique après acquisition réussie d'une session physique.
- Les requests applicatives en vol lors d'une rupture échouent et ne sont pas rejouées automatiquement.
- Une erreur RPC applicative pendant un resubscribe fait échouer la subscription concernée sans casser la nouvelle session physique.
- La restoration est séquentielle afin de préserver un ordre déterministe et de ne pas dépasser artificiellement `max_pending_requests`.
- Le backpressure/overflow par subscription et les canaries de leak associées restent réservés à `pre.008`.
- Les wrappers standards typed publics restent réservés aux lots `pre.009+`.
## Questions ouvertes
Aucune question bloquante pour ce checkpoint. Les décisions de backpressure per-subscription restent à matérialiser dans `pre.008` conformément au plan.

View File

@@ -1,9 +1,9 @@
<!-- file: docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md -->
<!-- version: 8 -->
<!-- version: 9 -->
# Plan `0.2.7` — WebSocket Solana standard
> **Statut : actif, `0.2.7-pre.005`.** `pre.004-fix.001` est validé opérateur. `pre.005` durcit la session physique avec limites adversariales, control frames, purge des pending requests et shutdown explicite borné. Les subscriptions typed restent différées.
> **Statut : actif, `0.2.7-pre.007`.** `pre.006-fix.001` est validé opérateur. `pre.007` active le reconnect borné, le resubscribe déterministe, le compteur de continuity gaps et les races unsubscribe/reconnect. Les wrappers typed publics restent différés.
## 1. Objet et base vérifiée
@@ -490,6 +490,8 @@ Le handle public `WsSession` communique avec cet actor par canal bounded. Aucun
`pre.006` matérialise le registry dans ce même actor. L'ACK `*Subscribe` lie atomiquement le remote ID au `WsSubscriptionId` local avant le dispatch des notifications suivantes. La création générique typed reste crate-private ; `WsSubscription<T>` devient le handle public commun pour `id/state/recv/unsubscribe`. Les channels de notification sont bornés et typed via un dispatcher actor type-erased. Les remote IDs restent absents de l'API publique et des snapshots.
`pre.007` active la résilience physique déjà paramétrée par `WsSessionSettings`. Une perte I/O, EOF, Close distant inattendu ou violation protocolaire structurelle invalide les remote IDs et incrémente `continuity_gap_count`. Le reconnect utilise un nombre fini d'essais, un backoff exponentiel borné sans jitter et surveille le shutdown pendant backoff et handshake. Avec `ActiveSubscriptions`, les subscriptions encore désirées sont remises en `Resubscribing` puis restaurées séquentiellement par `WsSubscriptionId` croissant avant le retour complet à `Active`; avec `Never`, elles deviennent terminales sans empêcher la reconnexion physique. La cancellation locale gagne pendant reconnect et un ACK tardif déclenche un unsubscribe distant best-effort sans réactivation locale.
### 9.4 Identités
```text
@@ -879,7 +881,7 @@ pre.003 DONE — std.transport V2 HTTP+WS + backward V1 + discriminateur WS + s
pre.004 DONE — deps tokio-tungstenite/futures-util + actor physique + handshake/read/write + pending JSON-RPC + serveur local
pre.005 DONE — limites frame/message/request + control frames + cancellation/close/shutdown + adversarial socket tests
pre.006 DONE — registry subscriptions + IDs locaux + generic subscribe/unsubscribe engine + channels typed bounded
pre.007 reconnect borné + resubscribe déterministe + continuity gap + races unsubscribe/reconnect
pre.007 DONE — reconnect borné + resubscribe déterministe + continuity gap + races unsubscribe/reconnect
pre.008 backpressure per-sub + overflow/limits + leak/lifecycle adversarial tests
pre.009 wrappers stable lot A : account + program + logs, DTOs/options/KSP-TRANSPORT-007
pre.010 wrappers stable lot B : signature + slot + root, terminaison signature/KSP-TRANSPORT-007

View File

@@ -1,9 +1,9 @@
<!-- file: docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md -->
<!-- version: 8 -->
<!-- version: 9 -->
# Validation `0.2.7` — WebSocket Solana standard
> **Statut : matrice active, `0.2.7-pre.005`.** Settings/lifecycle `pre.002`, Config V2 `pre.003`, session physique `pre.004` et durcissement limits/control/shutdown `pre.005` sont matérialisés. Registry subscriptions, reconnect et wrappers typed restent ouverts.
> **Statut : matrice active, `0.2.7-pre.007`.** Settings/lifecycle `pre.002`, Config V2 `pre.003`, session physique `pre.004`, durcissement `pre.005`, registry `pre.006` et reconnect/resubscribe `pre.007` sont matérialisés. Backpressure per-sub et wrappers typed restent ouverts.
## 1. Baseline normative
@@ -216,8 +216,8 @@ Les votes observés sont gossip/pre-consensus ; aucune garantie d'entrée dans l
| JSON borné indirectement par message | oversized + malformed fixture | Planned |
| queues notifications bornées | capacité settings, channel futur | Partial `pre.002` |
| pending RPC borné + timeout | map actor bornée + timeout request | **Done `pre.004`** |
| reconnect loop bornée | repeated disconnect fixture | Planned |
| unsubscribe pendant reconnect ne resubscribe pas | race fixture | Planned |
| reconnect loop bornée | repeated disconnect fixture | Done `pre.007` |
| unsubscribe pendant reconnect ne resubscribe pas | race fixture | Done `pre.007` |
| signature terminale ne resubscribe pas | terminal fixture | Planned |
| shutdown ne bloque pas | peer hostile/no close ack fixture | Planned |
| no Store/Program/Wallet/Config dep dans Transport | cargo tree + source canary | Planned |
@@ -368,7 +368,7 @@ outbound JSON-RPC > borne message/frame -> rejet avant socket write, session Act
inbound frame/message oversized -> rejet Tungstenite avant parse JSON, session Failed
pending request silencieuse -> timeout + purge de capacité, session Active
Ping distant -> Pong automatique flushé, session Active
Close distant propre -> Closed et non Failed
Close distant propre -> checkpoint `pre.005` Closed ; à partir de `pre.007`, perte physique reprise par reconnect borné
WsSession::close() -> Closing -> Closed
close() annule les pending requests avec ws_session_closed
peer hostile qui ne répond pas au Close -> shutdown borné
@@ -390,7 +390,7 @@ remote -> local mapping actor-owned
subscribe generic crate-private, wrappers publics futurs
unsubscribe handle public, bool Solana préservé
notification queue typed + bounded
reconnect/resubscribe non, pre.007
reconnect/resubscribe non à ce checkpoint ; matérialisé en pre.007
backpressure adversarial complet non, pre.008
```
@@ -410,6 +410,43 @@ snapshot -> local IDs + remote_bound uniquement
Le registry est détenu par le même actor que le socket et la pending map JSON-RPC. Aucun caller ne manipule le socket ni le remote ID. Les wrappers publics `accountSubscribe`, `programSubscribe`, etc. restent volontairement différés aux lots `pre.009+` afin de ne pas exposer une API raw provider-extension intermédiaire.
## 9.5 Checkpoint reconnect/resubscribe `pre.007`
Surface matérialisée :
```text
reconnect triggers EOF, Close distant inattendu, I/O/TLS/WebSocket/protocole structurel
reconnect budget fini, configurable
backoff exponentiel borné, sans jitter
shutdown pendant reconnect prioritaire, annule backoff/handshake
continuity_gap_count incrémenté une fois par perte de continuité logique
remote IDs après perte invalidés immédiatement
ActiveSubscriptions resubscribe local-ID croissant
Never aucune restauration automatique
budget reset seulement après retour complet à Active
unsubscribe pendant reconnect cancellation locale gagnante
ACK resubscribe tardif cleanup distant best-effort, aucune réactivation locale
backfill HTTP aucun
lossless guarantee aucune
```
Gates déterministes ajoutés :
```text
remote Close + budget insuffisant -> Reconnecting puis Failed, jamais boucle infinie
2 subscriptions -> resubscribe dans l'ordre WsSubscriptionId 1 puis 2
remote IDs 101/202 -> remappés 301/302 sans changer les IDs locaux
params de subscribe -> conservés et rejoués à l'identique
unsubscribe pendant backoff -> sub Closed et absente de la sélection resubscribe
unsubscribe après émission resubscribe -> ACK tardif nettoyé par *Unsubscribe best-effort
policy Never -> session reconnectée Active, subscription terminale Failed
2 pertes séparées avec max_retries=1 -> deux recoveries possibles, budget réinitialisé après Active
continuity_gap_count -> 1 puis 2 sur deux pertes distinctes
```
Les requests applicatives en vol au moment d'une perte physique échouent ; Transport ne les rejoue pas implicitement. La restauration ne promet aucune continuité lossless et n'effectue aucun backfill HTTP. Les remote subscription IDs restent strictement internes et sont remplacés à chaque ACK de resubscribe.
## 10. Validation du gate `pre.001`
Exécuté dans le sandbox :