1use ic_platform_pocket_ic::ClientError;
2use ic_platform_pocket_ic::PocketIc;
3use std::{task::Poll, time::Duration};
4
5#[derive(Debug, thiserror::Error)]
7pub enum TickUntilError<E> {
8 #[error("PocketIC progression failed: {0}")]
10 Progress(#[source] ClientError),
11 #[error("readiness remained pending after {rounds} rounds")]
13 ProgressLimit {
14 rounds: u32,
16 },
17 #[error("readiness check failed: {0}")]
19 Failed(#[source] E),
20}
21
22pub 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
40pub 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}