1use ic_platform::pocket_ic::PocketIc;
2use std::{task::Poll, time::Duration};
3
4#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
6pub enum TickUntilError<E> {
7 #[error("readiness remained pending after {rounds} rounds")]
9 ProgressLimit {
10 rounds: u32,
12 },
13 #[error("readiness check failed: {0}")]
15 Failed(#[source] E),
16}
17
18pub 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
35pub 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}