Skip to main content

ic_testkit/pic/
time.rs

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