Skip to main content

ic_testkit/pic/
time.rs

1use ic_platform_pocket_ic::ClientError;
2use ic_platform_pocket_ic::PocketIc;
3use std::{task::Poll, time::Duration};
4
5/// A caller-owned readiness check failed or exhausted its simulated round budget.
6#[derive(Debug, thiserror::Error)]
7pub enum TickUntilError<E> {
8    /// A simulated progression operation failed; no remaining ticks are dispatched.
9    #[error("PocketIC progression failed: {0}")]
10    Progress(#[source] ClientError),
11    /// The predicate was still pending after this many advance/tick rounds.
12    #[error("readiness remained pending after {rounds} rounds")]
13    ProgressLimit {
14        /// Completed simulated advance/tick rounds.
15        rounds: u32,
16    },
17    /// The predicate returned its own failure.
18    #[error("readiness check failed: {0}")]
19    Failed(#[source] E),
20}
21
22/// Advance simulated time once, then deliver exactly the caller-selected ticks.
23///
24/// Zero duration performs no time read or write; zero ticks permits advancement
25/// without delivering a round. This function does not poll, retry, start a server
26/// or decide application readiness. Callers own completion and cadence budgets.
27///
28/// # Errors
29/// Returns the first client failure without dispatching remaining ticks.
30pub fn advance_and_tick(pic: &PocketIc, advance: Duration, ticks: u32) -> Result<(), ClientError> {
31    if !advance.is_zero() {
32        pic.advance_time(advance)?;
33    }
34    for _ in 0..ticks {
35        pic.tick()?;
36    }
37    Ok(())
38}
39
40/// Check a caller-owned predicate, advancing simulated time and ticking while pending.
41///
42/// The predicate runs once before any mutation. Each pending result permits one
43/// round of `advance_time(advance)`, then `tick()`, then another predicate check,
44/// up to `max_rounds`. A zero budget still performs the initial check. A zero
45/// duration permits ticks without reading or writing simulated time. Completion
46/// on the last allowed round succeeds; predicate failures return immediately without another round.
47///
48/// This bounds simulated progression, not wall-clock time spent inside `PocketIC`
49/// calls or the predicate. Client failures return immediately as `Progress`.
50/// Applications own readiness meaning and predicate error types.
51///
52/// # Errors
53/// Returns the predicate error or `ProgressLimit` when all permitted rounds remain pending.
54pub fn tick_until<T, E>(
55    pic: &PocketIc,
56    max_rounds: u32,
57    advance: Duration,
58    mut poll: impl FnMut(&PocketIc) -> Poll<Result<T, E>>,
59) -> Result<T, TickUntilError<E>> {
60    poll_with_rounds(
61        max_rounds,
62        || advance_and_tick(pic, advance, 1),
63        || poll(pic),
64    )
65}
66
67fn poll_with_rounds<T, E>(
68    max_rounds: u32,
69    mut round: impl FnMut() -> Result<(), ClientError>,
70    mut poll: impl FnMut() -> Poll<Result<T, E>>,
71) -> Result<T, TickUntilError<E>> {
72    let mut rounds = 0;
73    loop {
74        match poll() {
75            Poll::Ready(result) => return result.map_err(TickUntilError::Failed),
76            Poll::Pending if rounds == max_rounds => {
77                return Err(TickUntilError::ProgressLimit { rounds });
78            }
79            Poll::Pending => {
80                round().map_err(TickUntilError::Progress)?;
81                rounds += 1;
82            }
83        }
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use std::cell::Cell;
91
92    #[test]
93    fn initial_completion_and_failure_do_not_advance() {
94        for budget in [0, 3] {
95            for result in [Ok(42), Err("predicate failure")] {
96                assert_eq!(
97                    poll_with_rounds(
98                        budget,
99                        || panic!("unexpected round"),
100                        || Poll::Ready(result)
101                    )
102                    .map_err(|e| e.to_string()),
103                    result
104                        .map_err(TickUntilError::Failed)
105                        .map_err(|e| e.to_string())
106                );
107            }
108        }
109    }
110
111    #[test]
112    fn progression_failure_stops_before_polling_again() {
113        let polls = Cell::new(0);
114        let result = poll_with_rounds(
115            3,
116            || Err(ClientError::Protocol("tick failed".into())),
117            || {
118                polls.set(polls.get() + 1);
119                Poll::<Result<(), ()>>::Pending
120            },
121        );
122        assert_eq!(polls.get(), 1);
123        assert!(
124            matches!(result, Err(TickUntilError::Progress(ClientError::Protocol(message))) if message == "tick failed")
125        );
126    }
127
128    #[test]
129    fn completion_on_last_round_preserves_poll_order() {
130        let rounds = Cell::new(0);
131        let polls = Cell::new(0);
132        let result = poll_with_rounds(
133            3,
134            || {
135                assert_eq!(polls.get(), rounds.get() + 1);
136                rounds.set(rounds.get() + 1);
137                Ok(())
138            },
139            || {
140                assert_eq!(polls.get(), rounds.get());
141                polls.set(polls.get() + 1);
142                if rounds.get() == 3 {
143                    Poll::Ready(Ok::<_, ()>(42))
144                } else {
145                    Poll::Pending
146                }
147            },
148        );
149        assert_eq!(result.unwrap(), 42);
150        assert_eq!((rounds.get(), polls.get()), (3, 4));
151    }
152
153    #[test]
154    fn exhaustion_performs_exactly_the_allowed_rounds() {
155        for budget in [0, 1, 3] {
156            let rounds = Cell::new(0);
157            let polls = Cell::new(0);
158            let result = poll_with_rounds(
159                budget,
160                || {
161                    rounds.set(rounds.get() + 1);
162                    Ok(())
163                },
164                || {
165                    polls.set(polls.get() + 1);
166                    Poll::<Result<(), ()>>::Pending
167                },
168            );
169            assert!(
170                matches!(result, Err(TickUntilError::ProgressLimit { rounds }) if rounds == budget)
171            );
172            assert_eq!((rounds.get(), polls.get()), (budget, budget + 1));
173        }
174    }
175
176    #[test]
177    fn failure_after_progress_preserves_the_original_cause() {
178        let rounds = Cell::new(0);
179        let result = poll_with_rounds(
180            3,
181            || {
182                rounds.set(rounds.get() + 1);
183                Ok(())
184            },
185            || {
186                if rounds.get() == 1 {
187                    Poll::Ready(Err::<(), _>(std::io::Error::from(
188                        std::io::ErrorKind::PermissionDenied,
189                    )))
190                } else {
191                    Poll::Pending
192                }
193            },
194        );
195        let error = result.unwrap_err();
196        let source = std::error::Error::source(&error).unwrap();
197        assert_eq!(
198            source.downcast_ref::<std::io::Error>().unwrap().kind(),
199            std::io::ErrorKind::PermissionDenied
200        );
201        assert_eq!(rounds.get(), 1);
202    }
203    #[test]
204    fn polling_transport_skips_zero_time_and_preserves_nonzero_advancement() {
205        for advance in [Duration::ZERO, Duration::from_nanos(7)] {
206            assert_time_requests(advance, 1, |pic| {
207                let mut polls = 0;
208                let result = tick_until(pic, 1, advance, |_| {
209                    polls += 1;
210                    if polls == 2 {
211                        Poll::Ready(Ok::<_, ()>(42))
212                    } else {
213                        Poll::Pending
214                    }
215                });
216                assert_eq!(result.unwrap(), 42);
217                assert_eq!(polls, 2);
218            });
219        }
220    }
221
222    #[test]
223    fn tick_batches_preserve_exact_transport_order_and_selected_counts() {
224        for advance in [Duration::ZERO, Duration::from_nanos(7)] {
225            for ticks in [0, 2, 4, 24] {
226                assert_time_requests(advance, ticks, |pic| {
227                    advance_and_tick(pic, advance, ticks).unwrap();
228                });
229            }
230        }
231    }
232
233    #[test]
234    fn install_cooldown_uses_exactly_two_ticks() {
235        use crate::pic::CanisterInstallExt as _;
236        for advance in [Duration::ZERO, Duration::from_nanos(7)] {
237            assert_time_requests(advance, 2, |pic| {
238                pic.wait_out_install_code_rate_limit(advance).unwrap();
239            });
240        }
241    }
242
243    fn assert_time_requests(advance: Duration, ticks: u32, run: impl FnOnce(&PocketIc)) {
244        use std::{io::Write as _, net::TcpListener, thread, time::Instant};
245        let mut expected = Vec::new();
246        if !advance.is_zero() {
247            expected.extend(["/instances/0/read/get_time", "/instances/0/update/set_time"]);
248        }
249        expected.extend(std::iter::repeat_n(
250            "/instances/0/update/tick",
251            ticks as usize,
252        ));
253        let request_count = expected.len();
254        let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap();
255        listener.set_nonblocking(true).unwrap();
256        let url = format!("http://{}/", listener.local_addr().unwrap())
257            .parse()
258            .unwrap();
259        let worker = thread::spawn(move || {
260            let deadline = Instant::now() + Duration::from_secs(5);
261            let mut requests = Vec::new();
262            while requests.len() < request_count {
263                assert!(Instant::now() < deadline, "simulated-time peer timed out");
264                let (mut stream, _) = match listener.accept() {
265                    Ok(connection) => connection,
266                    Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
267                        thread::sleep(Duration::from_millis(1));
268                        continue;
269                    }
270                    Err(error) => panic!("accept simulated-time request: {error}"),
271                };
272                let request = ic_host_process::testing::http::capture_http_request(
273                    &mut stream,
274                    Duration::from_secs(1),
275                    16_384,
276                    16_384,
277                    ic_host_process::testing::http::HttpContentLength::Optional,
278                )
279                .into_result()
280                .unwrap();
281                let text = std::str::from_utf8(&request).unwrap();
282                let path = text.split_whitespace().nth(1).unwrap().to_owned();
283                let reply = if path.ends_with("/read/get_time") {
284                    "{\"nanos_since_epoch\":100}"
285                } else {
286                    "null"
287                };
288                if path.ends_with("/update/set_time") {
289                    let body = text.split_once("\r\n\r\n").unwrap().1;
290                    assert_eq!(
291                        serde_json::from_str::<serde_json::Value>(body).unwrap()["nanos_since_epoch"],
292                        100 + u64::from(advance.subsec_nanos())
293                    );
294                }
295                stream
296                    .set_write_timeout(Some(Duration::from_secs(1)))
297                    .unwrap();
298                write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{reply}", reply.len()).unwrap();
299                requests.push(path);
300            }
301            requests
302        });
303        let pic = PocketIc::new_from_existing_instance(url, 0, Some(1_000));
304        run(&pic);
305        assert_eq!(worker.join().unwrap(), expected);
306    }
307}