Skip to main content

simulator_client/
session.rs

1use std::{
2    borrow::Cow,
3    collections::{BTreeMap, VecDeque},
4    future::Future,
5    time::Duration,
6};
7
8use futures::{SinkExt, StreamExt, stream};
9use simulator_api::{
10    AccountData, AccountModifications, AgentStatsReport, BacktestError, BacktestRequest,
11    BacktestResponse, BacktestStatus, ContinueParams, CreateBacktestSessionRequest,
12    CreateBacktestSessionRequestV1, SequencedResponse, SessionSummary,
13};
14use solana_address::Address;
15use solana_client::{
16    nonblocking::rpc_client::RpcClient,
17    rpc_response::{Response, RpcLogsResponse},
18};
19use solana_commitment_config::CommitmentConfig;
20use thiserror::Error;
21use tokio::net::TcpStream;
22use tokio_tungstenite::{
23    MaybeTlsStream, WebSocketStream,
24    tungstenite::{
25        Error as WsError, Message,
26        error::ProtocolError,
27        protocol::{CloseFrame, frame::coding::CloseCode},
28    },
29};
30
31use crate::{
32    BacktestClientError, BacktestClientResult, Continue,
33    injection::ProgramModError,
34    subscriptions::{
35        AccountDiffNotification, AccountDiffSubscriptionHandle, LogSubscriptionHandle,
36        SubscriptionError,
37    },
38};
39
40/// Outcome of waiting for readiness on a backtest session.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ReadyOutcome {
43    /// The session is ready to accept a `Continue` request.
44    Ready,
45    /// The session completed before becoming ready.
46    Completed,
47}
48
49/// Summary of responses collected while advancing a backtest.
50#[derive(Debug, Default)]
51pub struct ContinueResult {
52    /// Number of slot notifications observed.
53    pub slot_notifications: u64,
54    /// Last slot received via notification.
55    pub last_slot: Option<u64>,
56    /// Status messages received.
57    pub statuses: Vec<BacktestStatus>,
58    /// Whether the session became ready for the next `Continue`.
59    pub ready_for_continue: bool,
60    /// Whether the session completed while advancing.
61    pub completed: bool,
62}
63
64/// Mutable state for driving a session with `advance_step`.
65#[derive(Debug)]
66pub struct AdvanceState {
67    /// Expected number of slot notifications for this step.
68    pub expected_slots: u64,
69    /// Count of slot notifications received so far.
70    pub slot_notifications: u64,
71    /// Most recent slot notification.
72    pub last_slot: Option<u64>,
73    /// Status messages received so far.
74    pub statuses: Vec<BacktestStatus>,
75    /// Whether the session is ready for another `Continue`.
76    pub ready_for_continue: bool,
77    /// Whether the session completed while advancing.
78    pub completed: bool,
79    /// Session summary received on completion (if send_summary was enabled).
80    pub summary: Option<SessionSummary>,
81    /// Agent stats received on completion.
82    pub agent_stats: Option<Vec<AgentStatsReport>>,
83}
84
85impl AdvanceState {
86    /// Create new tracking state for a step that expects `expected_slots` notifications.
87    pub fn new(expected_slots: u64) -> Self {
88        Self {
89            expected_slots,
90            slot_notifications: 0,
91            last_slot: None,
92            statuses: Vec::new(),
93            ready_for_continue: false,
94            completed: false,
95            summary: None,
96            agent_stats: None,
97        }
98    }
99
100    /// Return true when the step is complete based on readiness and slot count.
101    pub fn is_done(&self, wait_for_slots: bool) -> bool {
102        if self.completed {
103            return true;
104        }
105
106        if !self.ready_for_continue {
107            return false;
108        }
109
110        !wait_for_slots || self.slot_notifications >= self.expected_slots
111    }
112}
113
114/// Coverage of a session's observed slot notifications and completion state.
115#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
116pub struct SessionCoverage {
117    completed: bool,
118    highest_slot_seen: Option<u64>,
119}
120
121impl SessionCoverage {
122    /// Record a slot notification.
123    pub fn observe_slot(&mut self, slot: u64) {
124        self.highest_slot_seen = Some(
125            self.highest_slot_seen
126                .map_or(slot, |current| current.max(slot)),
127        );
128    }
129
130    /// Mark the session as completed.
131    pub fn mark_completed(&mut self) {
132        self.completed = true;
133    }
134
135    /// Update coverage state from a backtest response.
136    pub fn observe_response(&mut self, response: &BacktestResponse) {
137        match response {
138            BacktestResponse::SlotNotification(slot) => self.observe_slot(*slot),
139            BacktestResponse::Completed { .. } => self.mark_completed(),
140            _ => {}
141        }
142    }
143
144    /// Return whether completion has been observed.
145    pub fn is_completed(&self) -> bool {
146        self.completed
147    }
148
149    /// Return the highest slot observed via slot notifications.
150    pub fn highest_slot_seen(&self) -> Option<u64> {
151        self.highest_slot_seen
152    }
153
154    /// Validate that the session completed and reached `expected_end_slot`.
155    pub fn validate_end_slot(&self, expected_end_slot: u64) -> Result<(), CoverageError> {
156        if !self.completed {
157            return Err(CoverageError::NotCompleted);
158        }
159
160        let Some(actual_end_slot) = self.highest_slot_seen else {
161            return Err(CoverageError::NoSlotsObserved);
162        };
163
164        if actual_end_slot < expected_end_slot {
165            return Err(CoverageError::RangeNotReached {
166                actual_end_slot,
167                expected_end_slot,
168            });
169        }
170
171        Ok(())
172    }
173}
174
175/// Coverage validation failures for a backtest session.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
177pub enum CoverageError {
178    #[error("ended before completion")]
179    NotCompleted,
180    #[error("completed without slot notifications")]
181    NoSlotsObserved,
182    #[error("completed at slot {actual_end_slot} but expected at least {expected_end_slot}")]
183    RangeNotReached {
184        actual_end_slot: u64,
185        expected_end_slot: u64,
186    },
187}
188
189/// Active backtest session over a WebSocket connection.
190///
191/// Dropping the session sends a best-effort close frame if a Tokio runtime is
192/// available. Call [`BacktestSession::close`] to request server-side cleanup.
193pub struct BacktestSession {
194    ws: Option<WebSocketStream<MaybeTlsStream<TcpStream>>>,
195    session_id: Option<String>,
196    rpc_endpoint: Option<String>,
197    task_id: Option<String>,
198    rpc: Option<RpcClient>,
199    last_sequence: Option<u64>,
200    pub(crate) ready_for_continue: bool,
201    request_timeout: Option<Duration>,
202    log_raw: bool,
203    backlog: VecDeque<(Option<u64>, BacktestResponse)>,
204}
205
206impl BacktestSession {
207    pub(crate) fn new(
208        ws: WebSocketStream<MaybeTlsStream<TcpStream>>,
209        request_timeout: Option<Duration>,
210        log_raw: bool,
211    ) -> Self {
212        Self {
213            ws: Some(ws),
214            session_id: None,
215            rpc_endpoint: None,
216            task_id: None,
217            rpc: None,
218            last_sequence: None,
219            ready_for_continue: false,
220            request_timeout,
221            log_raw,
222            backlog: VecDeque::new(),
223        }
224    }
225
226    /// Return the server-assigned session id, if known.
227    pub fn session_id(&self) -> Option<&str> {
228        self.session_id.as_deref()
229    }
230
231    /// Return the session-scoped RPC endpoint if provided.
232    pub fn rpc_endpoint(&self) -> Option<&str> {
233        self.rpc_endpoint.as_deref()
234    }
235
236    /// Return the opaque `task_id` reported by the server for this session,
237    /// if any.
238    pub fn task_id(&self) -> Option<&str> {
239        self.task_id.as_deref()
240    }
241
242    /// Return the highest sequenced control-websocket response observed so far.
243    pub fn last_sequence(&self) -> Option<u64> {
244        self.last_sequence
245    }
246
247    /// Return the RPC client for this session's endpoint.
248    ///
249    /// Always available after [`BacktestClient::create_session`](crate::BacktestClient::create_session) completes.
250    pub fn rpc(&self) -> &RpcClient {
251        self.rpc
252            .as_ref()
253            .expect("rpc is set during session creation")
254    }
255
256    /// Return whether the session is currently ready to accept `Continue`.
257    pub fn is_ready_for_continue(&self) -> bool {
258        self.ready_for_continue
259    }
260
261    /// Update internal readiness state based on a response.
262    pub fn apply_response(&mut self, response: &BacktestResponse) {
263        match response {
264            BacktestResponse::ReadyForContinue | BacktestResponse::Paused(_) => {
265                self.ready_for_continue = true;
266            }
267            BacktestResponse::Completed { .. } => {
268                self.ready_for_continue = false;
269            }
270            _ => {}
271        }
272    }
273
274    fn ws_mut(&mut self) -> BacktestClientResult<&mut WebSocketStream<MaybeTlsStream<TcpStream>>> {
275        self.ws.as_mut().ok_or_else(|| BacktestClientError::Closed {
276            reason: "websocket closed".to_string(),
277        })
278    }
279
280    pub(crate) async fn create_with_request(
281        &mut self,
282        request: CreateBacktestSessionRequest,
283        rpc_base_url: String,
284        mut on_parallel_session_created: Option<&mut (dyn FnMut(String) + Send)>,
285    ) -> BacktestClientResult<CreateRequestResult> {
286        let expect_parallel = matches!(
287            &request,
288            CreateBacktestSessionRequest::V1(CreateBacktestSessionRequestV1 { parallel: true, .. })
289        );
290        self.send(&BacktestRequest::CreateBacktestSession(request), None)
291            .await?;
292        let mut streamed_parallel_session_ids = Vec::new();
293        let mut streamed_parallel_task_ids: Vec<Option<String>> = Vec::new();
294        // Collect intermediate messages (e.g. Status, SlotNotification) in a local buffer.
295        // Pushing them back on self.backlog can cause re-reading of the same message,
296        // triggering an infinite loop.
297        let mut pending: Vec<(Option<u64>, BacktestResponse)> = Vec::new();
298
299        loop {
300            let response =
301                self.next_response(None)
302                    .await?
303                    .ok_or_else(|| BacktestClientError::Closed {
304                        reason: "websocket ended before SessionCreated".to_string(),
305                    })?;
306
307            match response {
308                BacktestResponse::SessionCreated {
309                    session_id,
310                    rpc_endpoint,
311                    task_id,
312                } => {
313                    if expect_parallel {
314                        if let Some(callback) = on_parallel_session_created.as_mut() {
315                            (**callback)(session_id.clone());
316                        }
317                        streamed_parallel_session_ids.push(session_id);
318                        streamed_parallel_task_ids.push(task_id);
319                        continue;
320                    }
321                    let created_session_id = session_id.clone();
322                    let created_task_id = task_id.clone();
323                    self.session_id = Some(session_id);
324                    self.task_id = task_id;
325                    let resolved = resolve_rpc_url(&rpc_base_url, &rpc_endpoint);
326                    self.rpc = Some(RpcClient::new_with_commitment(
327                        resolved.clone(),
328                        CommitmentConfig::confirmed(),
329                    ));
330                    self.rpc_endpoint = Some(resolved);
331                    self.backlog.extend(pending);
332                    return Ok(CreateRequestResult::Single {
333                        session_id: created_session_id,
334                        task_id: created_task_id,
335                    });
336                }
337                BacktestResponse::SessionsCreatedV2 {
338                    session_ids,
339                    task_ids,
340                    ..
341                } => {
342                    if expect_parallel && session_ids.is_empty() {
343                        self.backlog.extend(pending);
344                        return Ok(CreateRequestResult::Parallel {
345                            session_ids: streamed_parallel_session_ids,
346                            task_ids: streamed_parallel_task_ids,
347                        });
348                    }
349                    let task_ids = align_task_ids(task_ids, session_ids.len());
350                    self.backlog.extend(pending);
351                    return Ok(CreateRequestResult::Parallel {
352                        session_ids,
353                        task_ids,
354                    });
355                }
356                BacktestResponse::ReadyForContinue => {
357                    self.ready_for_continue = true;
358                }
359                BacktestResponse::Error(err) => return Err(BacktestClientError::Remote(err)),
360                other => {
361                    pending.push((self.last_sequence, other));
362                }
363            }
364        }
365    }
366
367    pub(crate) async fn attach(
368        &mut self,
369        session_id: String,
370        last_sequence: Option<u64>,
371        rpc_base_url: String,
372    ) -> BacktestClientResult<()> {
373        self.send(
374            &BacktestRequest::AttachBacktestSession {
375                session_id,
376                last_sequence,
377            },
378            None,
379        )
380        .await?;
381
382        self.wait_for_response(
383            || BacktestClientError::Closed {
384                reason: "websocket ended before SessionAttached".to_string(),
385            },
386            move |session, response| match response {
387                BacktestResponse::SessionAttached {
388                    session_id,
389                    rpc_endpoint,
390                    task_id,
391                } => {
392                    session.session_id = Some(session_id);
393                    session.task_id = task_id;
394                    let resolved = resolve_rpc_url(&rpc_base_url, &rpc_endpoint);
395                    session.rpc = Some(RpcClient::new_with_commitment(
396                        resolved.clone(),
397                        CommitmentConfig::confirmed(),
398                    ));
399                    session.rpc_endpoint = Some(resolved);
400                    Ok(Some(()))
401                }
402                BacktestResponse::ReadyForContinue => {
403                    session.ready_for_continue = true;
404                    Ok(None)
405                }
406                BacktestResponse::Error(err) => Err(BacktestClientError::Remote(err)),
407                other => {
408                    session.backlog.push_back((session.last_sequence, other));
409                    Ok(None)
410                }
411            },
412        )
413        .await
414    }
415
416    /// Sent after reattaching and rebuilding any dependent subscriptions so the
417    /// manager can resume a session that was paused for handoff.
418    pub async fn resume_attached_session(&mut self) -> BacktestClientResult<()> {
419        self.send(&BacktestRequest::ResumeAttachedSession, None)
420            .await?;
421
422        self.wait_for_response(
423            || BacktestClientError::Closed {
424                reason: "websocket ended before ResumeAttachedSession acknowledgement".to_string(),
425            },
426            |session, response| match response {
427                BacktestResponse::Success => Ok(Some(())),
428                BacktestResponse::Error(err) => Err(BacktestClientError::Remote(err)),
429                other => {
430                    session.backlog.push_back((session.last_sequence, other));
431                    Ok(None)
432                }
433            },
434        )
435        .await
436    }
437
438    async fn wait_for_response<T, E, F>(
439        &mut self,
440        closed_error: E,
441        mut handle_response: F,
442    ) -> BacktestClientResult<T>
443    where
444        E: FnOnce() -> BacktestClientError,
445        F: FnMut(&mut Self, BacktestResponse) -> BacktestClientResult<Option<T>>,
446    {
447        let mut closed_error = Some(closed_error);
448
449        loop {
450            let response = self
451                .next_response(None)
452                .await?
453                .ok_or_else(|| closed_error.take().expect("closed error set")())?;
454
455            if let Some(result) = handle_response(self, response)? {
456                return Ok(result);
457            }
458        }
459    }
460
461    /// Send a raw backtest request over the WebSocket.
462    pub async fn send(
463        &mut self,
464        request: &BacktestRequest,
465        timeout: Option<Duration>,
466    ) -> BacktestClientResult<()> {
467        let text = serde_json::to_string(request)
468            .map_err(|source| BacktestClientError::SerializeRequest { source })?;
469
470        let request_timeout = self.request_timeout;
471        let timeout = timeout.or(request_timeout);
472
473        let send_fut = self.ws_mut()?.send(Message::Text(text));
474        let send_result = match timeout {
475            Some(duration) => tokio::time::timeout(duration, send_fut)
476                .await
477                .map_err(|_| BacktestClientError::Timeout {
478                    action: "sending",
479                    duration,
480                })?,
481            None => send_fut.await,
482        };
483
484        send_result.map_err(|source| BacktestClientError::WebSocket {
485            action: "sending",
486            source: Box::new(source),
487        })?;
488
489        Ok(())
490    }
491
492    /// Receive the next response, using the backlog first.
493    pub async fn next_response(
494        &mut self,
495        timeout: Option<Duration>,
496    ) -> BacktestClientResult<Option<BacktestResponse>> {
497        if let Some((sequence, response)) = self.backlog.pop_front() {
498            self.last_sequence = sequence.or(self.last_sequence);
499            return Ok(Some(response));
500        }
501
502        let text = match self.next_text(timeout).await? {
503            Some(text) => text,
504            None => return Ok(None),
505        };
506
507        let (sequence, response) = match serde_json::from_str::<SequencedResponse>(&text) {
508            Ok(sequenced) => (Some(sequenced.seq_id), sequenced.response),
509            Err(_) => {
510                let response =
511                    serde_json::from_str::<BacktestResponse>(&text).map_err(|source| {
512                        BacktestClientError::DeserializeResponse {
513                            raw: text.clone(),
514                            source,
515                        }
516                    })?;
517                (None, response)
518            }
519        };
520        self.last_sequence = sequence.or(self.last_sequence);
521
522        Ok(Some(response))
523    }
524
525    /// Receive the next response and update readiness state.
526    pub async fn next_event(
527        &mut self,
528        timeout: Option<Duration>,
529    ) -> BacktestClientResult<Option<BacktestResponse>> {
530        let response = self.next_response(timeout).await?;
531        if let Some(ref response) = response {
532            self.apply_response(response);
533        }
534        Ok(response)
535    }
536
537    /// Stream responses, updating readiness state as items arrive.
538    ///
539    /// This consumes the session and yields responses until the connection ends.
540    pub fn responses(
541        self,
542        timeout: Option<Duration>,
543    ) -> impl futures::Stream<Item = BacktestClientResult<BacktestResponse>> {
544        stream::unfold(Some(self), move |state| async move {
545            let mut session = match state {
546                Some(session) => session,
547                None => return None,
548            };
549
550            match session.next_response(timeout).await {
551                Ok(Some(response)) => {
552                    session.apply_response(&response);
553                    Some((Ok(response), Some(session)))
554                }
555                Ok(None) => None,
556                Err(err) => Some((Err(err), None)),
557            }
558        })
559    }
560
561    /// Wait for the session to become ready or completed.
562    pub async fn ensure_ready(
563        &mut self,
564        timeout: Option<Duration>,
565    ) -> BacktestClientResult<ReadyOutcome> {
566        if self.ready_for_continue {
567            return Ok(ReadyOutcome::Ready);
568        }
569
570        loop {
571            let response =
572                self.next_response(timeout)
573                    .await?
574                    .ok_or_else(|| BacktestClientError::Closed {
575                        reason: "websocket ended while waiting for ReadyForContinue".to_string(),
576                    })?;
577
578            match response {
579                BacktestResponse::ReadyForContinue => {
580                    self.ready_for_continue = true;
581                    return Ok(ReadyOutcome::Ready);
582                }
583                BacktestResponse::Completed { .. } => return Ok(ReadyOutcome::Completed),
584                BacktestResponse::Error(err) => return Err(BacktestClientError::Remote(err)),
585                _ => {}
586            }
587        }
588    }
589
590    /// Wait for a specific status to be emitted.
591    pub async fn wait_for_status(
592        &mut self,
593        desired: BacktestStatus,
594        timeout: Option<Duration>,
595    ) -> BacktestClientResult<()> {
596        let desired = std::mem::discriminant(&desired);
597
598        loop {
599            let response =
600                self.next_response(timeout)
601                    .await?
602                    .ok_or_else(|| BacktestClientError::Closed {
603                        reason: "websocket ended while waiting for status".to_string(),
604                    })?;
605
606            match response {
607                BacktestResponse::Status { status }
608                    if std::mem::discriminant(&status) == desired =>
609                {
610                    return Ok(());
611                }
612                BacktestResponse::Error(err) => return Err(BacktestClientError::Remote(err)),
613                BacktestResponse::Completed {
614                    summary,
615                    agent_stats,
616                } => {
617                    return Err(BacktestClientError::UnexpectedResponse {
618                        context: "waiting for status",
619                        response: Box::new(BacktestResponse::Completed {
620                            summary,
621                            agent_stats,
622                        }),
623                    });
624                }
625                _ => {}
626            }
627        }
628    }
629
630    /// Push a response onto the end of the receive backlog.
631    pub(crate) fn push_backlog(&mut self, response: BacktestResponse) {
632        self.backlog.push_back((None, response));
633    }
634
635    /// Send a `Continue` request and reset readiness.
636    pub async fn send_continue(
637        &mut self,
638        params: ContinueParams,
639        timeout: Option<Duration>,
640    ) -> BacktestClientResult<()> {
641        self.ready_for_continue = false;
642        self.send(&BacktestRequest::Continue(params), timeout).await
643    }
644
645    /// Read and apply a single response while advancing.
646    pub async fn advance_step<F>(
647        &mut self,
648        state: &mut AdvanceState,
649        wait_for_slots: bool,
650        timeout: Option<Duration>,
651        on_event: &mut F,
652    ) -> BacktestClientResult<()>
653    where
654        F: FnMut(&BacktestResponse),
655    {
656        let Some(response) = self.next_response(timeout).await? else {
657            return Err(BacktestClientError::Closed {
658                reason: "websocket ended while awaiting continue responses".to_string(),
659            });
660        };
661
662        if self.log_raw {
663            tracing::debug!("<- {response:?}");
664        }
665
666        on_event(&response);
667
668        match response {
669            BacktestResponse::ReadyForContinue => {
670                self.ready_for_continue = true;
671                state.ready_for_continue = true;
672            }
673            BacktestResponse::SlotNotification(slot) => {
674                state.slot_notifications += 1;
675                state.last_slot = Some(slot);
676            }
677            BacktestResponse::Status { status } => {
678                state.statuses.push(status);
679            }
680            BacktestResponse::Success => {}
681            BacktestResponse::Completed {
682                summary,
683                agent_stats,
684            } => {
685                state.completed = true;
686                state.summary = summary;
687                state.agent_stats = agent_stats;
688            }
689            BacktestResponse::Error(err @ BacktestError::SimulationError { .. }) => {
690                tracing::warn!(error = %crate::error::err_chain(&err), "simulation error");
691            }
692            BacktestResponse::Error(err) => return Err(BacktestClientError::Remote(err)),
693            BacktestResponse::SessionCreated { .. }
694            | BacktestResponse::SessionAttached { .. }
695            | BacktestResponse::SessionsCreatedV2 { .. }
696            | BacktestResponse::ParallelSessionAttachedV2 { .. }
697            | BacktestResponse::SessionEventV2 { .. }
698            | BacktestResponse::Paused(_)
699            | BacktestResponse::DiscoveryBatch(_) => {
700                return Err(BacktestClientError::UnexpectedResponse {
701                    context: "continuing",
702                    response: Box::new(response),
703                });
704            }
705        }
706
707        if wait_for_slots && state.slot_notifications > state.expected_slots {
708            tracing::warn!(
709                "received {} slot notifications (expected {})",
710                state.slot_notifications,
711                state.expected_slots
712            );
713        }
714
715        Ok(())
716    }
717
718    /// Advance until the session becomes ready for another `Continue`.
719    pub async fn continue_until_ready<F>(
720        &mut self,
721        cont: Continue,
722        timeout: Option<Duration>,
723        mut on_event: F,
724    ) -> BacktestClientResult<ContinueResult>
725    where
726        F: FnMut(&BacktestResponse),
727    {
728        let expected_slots = cont.advance_count;
729        self.advance_internal(
730            cont.into_params(),
731            expected_slots,
732            false,
733            timeout,
734            &mut on_event,
735        )
736        .await
737    }
738
739    /// Advance and wait for both readiness and slot notifications.
740    pub async fn advance<F>(
741        &mut self,
742        cont: Continue,
743        timeout: Option<Duration>,
744        mut on_event: F,
745    ) -> BacktestClientResult<ContinueResult>
746    where
747        F: FnMut(&BacktestResponse),
748    {
749        let expected_slots = cont.advance_count;
750        self.advance_internal(
751            cont.into_params(),
752            expected_slots,
753            true,
754            timeout,
755            &mut on_event,
756        )
757        .await
758    }
759
760    async fn advance_internal<F>(
761        &mut self,
762        params: ContinueParams,
763        expected_slots: u64,
764        wait_for_slots: bool,
765        timeout: Option<Duration>,
766        on_event: &mut F,
767    ) -> BacktestClientResult<ContinueResult>
768    where
769        F: FnMut(&BacktestResponse),
770    {
771        self.send_continue(params, timeout).await?;
772
773        let mut state = AdvanceState::new(expected_slots);
774        while !state.is_done(wait_for_slots) {
775            self.advance_step(&mut state, wait_for_slots, timeout, on_event)
776                .await?;
777        }
778
779        Ok(ContinueResult {
780            slot_notifications: state.slot_notifications,
781            last_slot: state.last_slot,
782            statuses: state.statuses,
783            ready_for_continue: state.ready_for_continue,
784            completed: state.completed,
785        })
786    }
787
788    /// Build a program-data account modification from raw ELF bytes.
789    ///
790    /// Derives the `ProgramData` address from `program_id`, queries the session's RPC endpoint
791    /// for the current slot and rent-exempt minimum, then returns a modification map ready to
792    /// pass to [`Continue::builder().modify_accounts(...)`](crate::Continue).
793    ///
794    /// The deploy slot is set to `current_slot - 1` so the program appears deployed before the
795    /// next executed slot.
796    pub async fn modify_program(
797        &self,
798        program_id: &str,
799        elf: &[u8],
800    ) -> Result<BTreeMap<Address, AccountData>, ProgramModError> {
801        let rpc = self.rpc.as_ref().ok_or(ProgramModError::NoRpcEndpoint)?;
802        crate::injection::modify_program_via_rpc(rpc, program_id, elf).await
803    }
804
805    /// Modify accounts on the session's RPC endpoint via the custom `modifyAccounts` method.
806    ///
807    /// Returns the number of accounts modified on success.
808    pub async fn modify_accounts(
809        &self,
810        modifications: &AccountModifications,
811    ) -> BacktestClientResult<usize> {
812        let rpc_endpoint =
813            self.rpc_endpoint
814                .as_deref()
815                .ok_or_else(|| BacktestClientError::Closed {
816                    reason: "no RPC endpoint available".to_string(),
817                })?;
818
819        crate::rpc::modify_accounts(rpc_endpoint, modifications).await
820    }
821
822    /// Subscribe to program log notifications using the session's RPC endpoint.
823    ///
824    /// Equivalent to calling [`subscribe_program_logs`](crate::subscribe_program_logs) with
825    /// the endpoint from [`rpc_endpoint`](Self::rpc_endpoint), which is set after
826    /// [`BacktestClient::create_session`](crate::BacktestClient::create_session) completes.
827    pub async fn subscribe_program_logs<F, Fut>(
828        &self,
829        program_id: &str,
830        commitment: CommitmentConfig,
831        on_notification: F,
832    ) -> Result<LogSubscriptionHandle, SubscriptionError>
833    where
834        F: Fn(Response<RpcLogsResponse>) -> Fut + Send + Sync + 'static,
835        Fut: Future<Output = ()> + Send + 'static,
836    {
837        let rpc_endpoint = self
838            .rpc_endpoint
839            .as_deref()
840            .ok_or(SubscriptionError::NoRpcEndpoint)?;
841        crate::subscriptions::subscribe_program_logs(
842            rpc_endpoint,
843            program_id,
844            commitment,
845            on_notification,
846        )
847        .await
848    }
849
850    /// Subscribe to account diff notifications using the session's RPC endpoint.
851    ///
852    /// Equivalent to calling [`subscribe_account_diffs`](crate::subscribe_account_diffs) with
853    /// the endpoint from [`rpc_endpoint`](Self::rpc_endpoint), which is set after
854    /// [`BacktestClient::create_session`](crate::BacktestClient::create_session) completes.
855    pub async fn subscribe_account_diffs<F, Fut>(
856        &self,
857        account: &str,
858        on_notification: F,
859    ) -> Result<AccountDiffSubscriptionHandle, SubscriptionError>
860    where
861        F: Fn(AccountDiffNotification) -> Fut + Send + Sync + 'static,
862        Fut: Future<Output = ()> + Send + 'static,
863    {
864        let rpc_endpoint = self
865            .rpc_endpoint
866            .as_deref()
867            .ok_or(SubscriptionError::NoRpcEndpoint)?;
868        crate::subscriptions::subscribe_account_diffs(rpc_endpoint, account, on_notification).await
869    }
870
871    /// Request server cleanup and close the underlying WebSocket.
872    ///
873    /// This is idempotent and will return `Ok(())` if the connection is already closed.
874    pub async fn close(&mut self, timeout: Option<Duration>) -> BacktestClientResult<()> {
875        self.close_with_frame(timeout, None).await
876    }
877
878    /// Close the session with a specific WebSocket close frame.
879    pub async fn close_with_frame(
880        &mut self,
881        timeout: Option<Duration>,
882        frame: Option<CloseFrame<'static>>,
883    ) -> BacktestClientResult<()> {
884        if self.ws.is_none() {
885            return Ok(());
886        }
887
888        let mut sent = false;
889        match self
890            .send(&BacktestRequest::CloseBacktestSession, timeout)
891            .await
892        {
893            Ok(()) => sent = true,
894            Err(err) if is_close_ok(&err) => {}
895            Err(err) => return Err(err),
896        }
897
898        if sent {
899            let response = match self.next_response(timeout).await {
900                Ok(Some(r)) => r,
901                Ok(None) => {
902                    self.ws.take();
903                    return Ok(());
904                }
905                Err(BacktestClientError::Closed { .. }) => {
906                    self.ws.take();
907                    return Ok(());
908                }
909                Err(BacktestClientError::WebSocket {
910                    action: "receiving",
911                    source,
912                }) if is_reset_without_close(&source) => {
913                    self.ws.take();
914                    return Ok(());
915                }
916                Err(err) => return Err(err),
917            };
918
919            match response {
920                BacktestResponse::Success | BacktestResponse::Completed { .. } => {}
921                BacktestResponse::Error(err) => return Err(BacktestClientError::Remote(err)),
922                other => {
923                    return Err(BacktestClientError::UnexpectedResponse {
924                        context: "closing session",
925                        response: Box::new(other),
926                    });
927                }
928            }
929        }
930
931        // Flush our close frame and drop — don't read the peer's close back: the
932        // management loop exits on CloseBacktestSession and never sends one.
933        if let Some(mut ws) = self.ws.take()
934            && let Err(source) = ws.close(frame).await
935            && !is_ws_closed_error(&source)
936        {
937            return Err(BacktestClientError::WebSocket {
938                action: "closing",
939                source: Box::new(source),
940            });
941        }
942        Ok(())
943    }
944
945    /// Close the session with a close code and reason.
946    pub async fn close_with_reason(
947        &mut self,
948        timeout: Option<Duration>,
949        code: CloseCode,
950        reason: impl Into<String>,
951    ) -> BacktestClientResult<()> {
952        let frame = CloseFrame {
953            code,
954            reason: Cow::Owned(reason.into()),
955        };
956        self.close_with_frame(timeout, Some(frame)).await
957    }
958
959    /// Drop the connection without sending `CloseBacktestSession`, so the server
960    /// reaps the session via its disconnect timeout (the dropped-connection path)
961    /// rather than the prompt teardown [`close`](Self::close) requests.
962    ///
963    /// Consumes the session so [`Drop`] can't run its best-effort close: the taken
964    /// stream is dropped here, closing the socket with a bare FIN.
965    pub fn abort(mut self) {
966        let _ws = self.ws.take();
967    }
968
969    async fn next_text(
970        &mut self,
971        timeout: Option<Duration>,
972    ) -> BacktestClientResult<Option<String>> {
973        loop {
974            let request_timeout = self.request_timeout;
975            let timeout = timeout.or(request_timeout);
976
977            let next_fut = self.ws_mut()?.next();
978            let msg = match timeout {
979                Some(duration) => tokio::time::timeout(duration, next_fut)
980                    .await
981                    .map_err(|_| BacktestClientError::Timeout {
982                        action: "receiving",
983                        duration,
984                    })?,
985                None => next_fut.await,
986            };
987
988            let Some(msg) = msg else {
989                return Ok(None);
990            };
991
992            let msg = match msg {
993                Ok(msg) => msg,
994                Err(source) => {
995                    return Err(BacktestClientError::WebSocket {
996                        action: "receiving",
997                        source: Box::new(source),
998                    });
999                }
1000            };
1001
1002            match msg {
1003                Message::Text(text) => {
1004                    if self.log_raw {
1005                        tracing::debug!("<- raw: {text}");
1006                    }
1007                    return Ok(Some(text));
1008                }
1009                Message::Binary(bin) => match String::from_utf8(bin) {
1010                    Ok(text) => {
1011                        if self.log_raw {
1012                            tracing::debug!("<- raw(bin): {text}");
1013                        }
1014                        return Ok(Some(text));
1015                    }
1016                    Err(err) => {
1017                        tracing::warn!("discarding non-utf8 binary message: {err}");
1018                        continue;
1019                    }
1020                },
1021                Message::Close(frame) => {
1022                    let reason = close_reason(frame);
1023                    return Err(BacktestClientError::Closed { reason });
1024                }
1025                Message::Ping(_) | Message::Pong(_) => continue,
1026                Message::Frame(_) => continue,
1027            }
1028        }
1029    }
1030}
1031
1032impl std::fmt::Debug for BacktestSession {
1033    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1034        f.debug_struct("BacktestSession")
1035            .field("session_id", &self.session_id)
1036            .field("rpc_endpoint", &self.rpc_endpoint)
1037            .field(
1038                "rpc",
1039                &self
1040                    .rpc
1041                    .as_ref()
1042                    .map(|_| "<RpcClient>")
1043                    .unwrap_or("<not set>"),
1044            )
1045            .field("ready_for_continue", &self.ready_for_continue)
1046            .field("request_timeout", &self.request_timeout)
1047            .finish_non_exhaustive()
1048    }
1049}
1050
1051#[derive(Debug)]
1052pub(crate) enum CreateRequestResult {
1053    Single {
1054        session_id: String,
1055        task_id: Option<String>,
1056    },
1057    Parallel {
1058        session_ids: Vec<String>,
1059        task_ids: Vec<Option<String>>,
1060    },
1061}
1062
1063/// Pad or truncate a server-supplied `task_ids` slice to match `session_ids` length.
1064/// Older servers omit `task_ids` entirely; treat that as an all-`None` slice.
1065fn align_task_ids(mut task_ids: Vec<Option<String>>, expected_len: usize) -> Vec<Option<String>> {
1066    if task_ids.len() < expected_len {
1067        task_ids.resize(expected_len, None);
1068    } else if task_ids.len() > expected_len {
1069        task_ids.truncate(expected_len);
1070    }
1071    task_ids
1072}
1073
1074impl Drop for BacktestSession {
1075    fn drop(&mut self) {
1076        let Some(ws) = self.ws.take() else {
1077            return;
1078        };
1079
1080        if let Ok(handle) = tokio::runtime::Handle::try_current() {
1081            handle.spawn(async move {
1082                let mut ws = ws;
1083                let _ = ws.close(None).await;
1084            });
1085        }
1086    }
1087}
1088
1089fn resolve_rpc_url(base: &str, endpoint: &str) -> String {
1090    if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
1091        endpoint.to_string()
1092    } else {
1093        format!("{}/{}", base, endpoint.trim_start_matches('/'))
1094    }
1095}
1096
1097fn close_reason(frame: Option<CloseFrame<'static>>) -> String {
1098    match frame {
1099        Some(frame) => format!("{:?}: {}", frame.code, frame.reason),
1100        None => "no close frame".to_string(),
1101    }
1102}
1103
1104fn is_reset_without_close(err: &WsError) -> bool {
1105    matches!(
1106        err,
1107        WsError::Protocol(ProtocolError::ResetWithoutClosingHandshake)
1108    )
1109}
1110
1111fn is_ws_closed_error(err: &WsError) -> bool {
1112    matches!(
1113        err,
1114        WsError::ConnectionClosed
1115            | WsError::AlreadyClosed
1116            | WsError::Protocol(ProtocolError::ResetWithoutClosingHandshake)
1117    )
1118}
1119
1120fn is_close_ok(err: &BacktestClientError) -> bool {
1121    match err {
1122        BacktestClientError::Closed { .. } => true,
1123        BacktestClientError::WebSocket { source, .. } => is_ws_closed_error(source),
1124        _ => false,
1125    }
1126}
1127
1128#[cfg(test)]
1129mod tests {
1130    use super::*;
1131
1132    #[test]
1133    fn coverage_tracks_slot_and_completion_from_responses() {
1134        let mut coverage = SessionCoverage::default();
1135        coverage.observe_response(&BacktestResponse::SlotNotification(10));
1136        coverage.observe_response(&BacktestResponse::SlotNotification(12));
1137        coverage.observe_response(&BacktestResponse::Completed {
1138            summary: None,
1139            agent_stats: None,
1140        });
1141
1142        assert!(coverage.is_completed());
1143        assert_eq!(coverage.highest_slot_seen(), Some(12));
1144    }
1145
1146    #[test]
1147    fn coverage_validate_end_slot_checks_completion_and_range() {
1148        let mut coverage = SessionCoverage::default();
1149        assert_eq!(
1150            coverage.validate_end_slot(5),
1151            Err(CoverageError::NotCompleted)
1152        );
1153
1154        coverage.mark_completed();
1155        assert_eq!(
1156            coverage.validate_end_slot(5),
1157            Err(CoverageError::NoSlotsObserved)
1158        );
1159
1160        coverage.observe_slot(4);
1161        assert_eq!(
1162            coverage.validate_end_slot(5),
1163            Err(CoverageError::RangeNotReached {
1164                actual_end_slot: 4,
1165                expected_end_slot: 5,
1166            })
1167        );
1168
1169        coverage.observe_slot(6);
1170        assert_eq!(coverage.validate_end_slot(5), Ok(()));
1171    }
1172}