cfsolve 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/README.md +10 -9
  4. data/UPSTREAM.md +6 -6
  5. data/lib/cfsolve/profiles/safari-26-ios/answers.json +2562 -1887
  6. data/lib/cfsolve/profiles/safari-26-ios/challenge-page-answers.json +1873 -1198
  7. data/lib/cfsolve/profiles/safari-26-ios.json +0 -8
  8. data/lib/cfsolve/version.rb +2 -2
  9. data/vendor/cfsolve/README.md +0 -35
  10. data/vendor/cfsolve/examples/build_profile.rs +17 -15
  11. data/vendor/cfsolve/examples/capture_session.rs +37 -0
  12. data/vendor/cfsolve/examples/check_answers.rs +66 -0
  13. data/vendor/cfsolve/examples/compare_session.rs +190 -0
  14. data/vendor/cfsolve/examples/stage2_snapshot.rs +4 -1
  15. data/vendor/cfsolve/profiles/safari-26-ios/answers.json +2562 -1887
  16. data/vendor/cfsolve/profiles/safari-26-ios/challenge-page-answers.json +1873 -1198
  17. data/vendor/cfsolve/profiles/safari-26-ios.json +0 -8
  18. data/vendor/cfsolve/scripts/capture_cssom.mjs +42 -0
  19. data/vendor/cfsolve/scripts/serve_cssom.py +111 -0
  20. data/vendor/cfsolve/src/capture/profile.rs +7 -18
  21. data/vendor/cfsolve/src/capture/recorded.rs +42 -7
  22. data/vendor/cfsolve/src/challenge/installed/audit.rs +131 -0
  23. data/vendor/cfsolve/src/challenge/installed.rs +122 -271
  24. data/vendor/cfsolve/src/challenge/payload.rs +107 -5
  25. data/vendor/cfsolve/src/challenge/state.rs +98 -16
  26. data/vendor/cfsolve/src/deobfuscate/encoded_strings.rs +3 -2
  27. data/vendor/cfsolve/src/deobfuscate/helper_objects.rs +17 -32
  28. data/vendor/cfsolve/src/deobfuscate/register_arrays.rs +18 -5
  29. data/vendor/cfsolve/src/embedding_page/extra_params.rs +0 -14
  30. data/vendor/cfsolve/src/embedding_page/page.rs +28 -2
  31. data/vendor/cfsolve/src/http/client.rs +11 -0
  32. data/vendor/cfsolve/src/js/ast.rs +12 -3
  33. data/vendor/cfsolve/src/js/objects.rs +9 -2
  34. data/vendor/cfsolve/src/listing/fold.rs +0 -83
  35. data/vendor/cfsolve/src/listing/query.rs +25 -244
  36. data/vendor/cfsolve/src/listing.rs +0 -1
  37. data/vendor/cfsolve/src/session/initial.rs +14 -5
  38. data/vendor/cfsolve/src/session/probes.rs +160 -125
  39. data/vendor/cfsolve/src/session/timing.rs +58 -53
  40. data/vendor/cfsolve/src/session/widget.rs +5 -6
  41. data/vendor/cfsolve/src/stage2/analysis/analyze.rs +50 -33
  42. data/vendor/cfsolve/src/stage2/analysis/discovery.rs +61 -66
  43. data/vendor/cfsolve/src/stage2/analysis/envelope.rs +31 -21
  44. data/vendor/cfsolve/src/stage2/analysis/recognize.rs +3 -5
  45. data/vendor/cfsolve/src/stage2/analysis.rs +0 -1
  46. data/vendor/cfsolve/src/stage2/audit_globals.rs +310 -0
  47. data/vendor/cfsolve/src/stage2/benchmark.rs +5 -8
  48. data/vendor/cfsolve/src/stage2/call_stack.rs +72 -16
  49. data/vendor/cfsolve/src/stage2/interaction/native.rs +2 -1
  50. data/vendor/cfsolve/src/stage2/interaction/plan.rs +0 -3
  51. data/vendor/cfsolve/src/stage2/payload/recover.rs +54 -23
  52. data/vendor/cfsolve/src/stage2/probes/ice.rs +11 -10
  53. data/vendor/cfsolve/src/stage2/probes/media.rs +47 -33
  54. data/vendor/cfsolve/src/stage2/probes/performance.rs +1 -0
  55. data/vendor/cfsolve/src/stage2/probes/rects.rs +12 -17
  56. data/vendor/cfsolve/src/stage2/probes/stack.rs +50 -103
  57. data/vendor/cfsolve/src/stage2/probes/websocket_echo.rs +17 -86
  58. data/vendor/cfsolve/src/stage2/probes.rs +0 -1
  59. data/vendor/cfsolve/src/stage2/records.rs +4 -13
  60. data/vendor/cfsolve/src/stage2/reference.json +1 -1
  61. data/vendor/cfsolve/src/stage2/reference.rs +98 -100
  62. data/vendor/cfsolve/src/stage2/registry.rs +41 -34
  63. data/vendor/cfsolve/src/stage2/replay.rs +182 -254
  64. data/vendor/cfsolve/src/stage2/stylesheets.rs +176 -0
  65. data/vendor/cfsolve/src/stage2/trace.rs +18 -0
  66. data/vendor/cfsolve/src/stage2/vocabulary.rs +111 -401
  67. data/vendor/cfsolve/src/stage2.rs +2 -1
  68. data/vendor/cfsolve/src/vm/disassemble.rs +39 -10
  69. data/vendor/cfsolve/src/vm/interpret/access.rs +5 -21
  70. data/vendor/cfsolve/src/vm/interpret/builtin.rs +6 -53
  71. data/vendor/cfsolve/src/vm/interpret/call.rs +86 -9
  72. data/vendor/cfsolve/src/vm/interpret/compile.rs +626 -0
  73. data/vendor/cfsolve/src/vm/interpret/copies.rs +232 -120
  74. data/vendor/cfsolve/src/vm/interpret/evaluate/tests.rs +96 -8
  75. data/vendor/cfsolve/src/vm/interpret/evaluate.rs +219 -92
  76. data/vendor/cfsolve/src/vm/interpret/explore.rs +155 -39
  77. data/vendor/cfsolve/src/vm/interpret/frozen.rs +171 -0
  78. data/vendor/cfsolve/src/vm/interpret/intrinsic.rs +350 -0
  79. data/vendor/cfsolve/src/vm/interpret/plan.rs +71 -44
  80. data/vendor/cfsolve/src/vm/interpret/run.rs +202 -13
  81. data/vendor/cfsolve/src/vm/interpret.rs +7 -2
  82. data/vendor/cfsolve/src/vm/machine.rs +6 -2
  83. data/vendor/cfsolve/src/vm/packed_names.rs +307 -0
  84. data/vendor/cfsolve/src/vm/session.rs +36 -14
  85. data/vendor/cfsolve/src/vm/value.rs +13 -0
  86. data/vendor/cfsolve/src/vm.rs +1 -0
  87. data/vendor/cfsolve/vendor/btls-sys/src/lib.rs +3 -0
  88. metadata +13 -6
  89. data/vendor/cfsolve/src/listing/normalize.rs +0 -264
  90. data/vendor/cfsolve/src/stage2/analysis/outputs.rs +0 -327
  91. data/vendor/cfsolve/src/stage2/probes/stylesheets.rs +0 -249
  92. data/vendor/cfsolve/src/stage2/reference-expanded.json +0 -42
  93. data/vendor/cfsolve/src/stage2/session_names.rs +0 -470
@@ -11,7 +11,7 @@ use url::Url;
11
11
  use wreq::ws::message::Message;
12
12
 
13
13
  use super::channel::Submission;
14
- use super::timing::{self, Schedule, Timer};
14
+ use super::timing::{self, Schedule, Timeline, Timer};
15
15
  use super::widget::{Embedding, WidgetSession};
16
16
  use crate::browser::{Profile, Solve};
17
17
  use crate::challenge::payload;
@@ -59,8 +59,14 @@ pub(crate) async fn prepare(session: &WidgetSession<'_>, solve: &Solve) -> Resul
59
59
  let engine = session.client.engine();
60
60
  let plan = session.analysis.plan();
61
61
  let mut options = session.request.options.clone();
62
- let (mut observed, fetches) = observe(session, plan, &options, profile).await?;
62
+ let mut observed = observe(session, plan, profile).await?;
63
63
  observed.page_signals = Some(merged.page_signals.clone());
64
+ let replay = answers.map(|answers| Replay::new(answers, plan));
65
+ let planned = durations(plan, &profile.durations, &observed, replay.as_ref());
66
+ // The stack is needed only for the records, so rebuild it while the probes run.
67
+ let probes = run_probes(session, plan, &options, profile, &mut observed, &planned);
68
+ let ((schedule, fetches), stack) = tokio::try_join!(probes, probe_stack(session, plan))?;
69
+ observed.stack = stack;
64
70
  // Resource timings now include the first POST and probe requests.
65
71
  if let Some(timing) = &merged.state.timing {
66
72
  let mut resources = merged.resources.clone();
@@ -82,17 +88,6 @@ pub(crate) async fn prepare(session: &WidgetSession<'_>, solve: &Solve) -> Resul
82
88
  }
83
89
  window.extend(profile.window.clone());
84
90
 
85
- let replay = answers
86
- .map(|answers| Replay::new(answers, plan, &session.fetched.source, &session.installed))
87
- .transpose()?;
88
- let pending_ms = i64::from(profile.pending_ms);
89
- let schedule = timing::schedule(
90
- &durations(plan, &profile.durations, &observed, replay.as_ref()),
91
- profile.startup_ms.into(),
92
- pending_ms,
93
- session.arrived_ms,
94
- performance_clock::epoch_ms(),
95
- );
96
91
  if let Some((probe, Operation::PerformanceEntries { fields })) =
97
92
  plan.operation(Kind::PerformanceEntries)
98
93
  {
@@ -107,15 +102,19 @@ pub(crate) async fn prepare(session: &WidgetSession<'_>, solve: &Solve) -> Resul
107
102
  engine,
108
103
  ));
109
104
  }
105
+ let mut globals = merged.audit_globals.0.clone();
106
+ globals.extend(replay::Globals::recover(&session.fetched.source).0);
107
+ let globals = replay::Globals(globals);
110
108
  let stored = replay::Session {
111
- iframe_url: session.fetched.iframe.url.as_str(),
112
109
  iframe_html: &session.fetched.iframe.html,
110
+ iframe_url: session.fetched.iframe.url.as_str(),
113
111
  modified: Local::now().format("%m/%d/%Y %H:%M:%S").to_string(),
114
112
  referrer: session.fetched.referrer.as_deref().unwrap_or_default(),
115
113
  navigation: &session.fetched.iframe.navigation,
116
114
  options: &options,
117
115
  trace: Trace::recover(&session.fetched.source),
118
116
  controller: plan.controller_trace.as_deref(),
117
+ globals: &globals,
119
118
  };
120
119
  let records = records(
121
120
  plan,
@@ -133,14 +132,14 @@ pub(crate) async fn prepare(session: &WidgetSession<'_>, solve: &Solve) -> Resul
133
132
  },
134
133
  records,
135
134
  &profile.document,
136
- pending_ms,
135
+ profile.pending_ms.into(),
137
136
  )?;
138
137
  ensure!(
139
138
  plaintext.len() > plan.probes().len(),
140
139
  "the assembled payload holds nothing besides records"
141
140
  );
142
141
  payload::append_send_time_at(&mut plaintext, &merged.state, &options, schedule.send_at);
143
- let geometry = window_geometry(replay.as_ref(), &plaintext);
142
+ let geometry = window_geometry(plan, replay.as_ref(), &plaintext);
144
143
  Ok(ProbeSubmission {
145
144
  payload: Submission {
146
145
  plaintext,
@@ -152,33 +151,28 @@ pub(crate) async fn prepare(session: &WidgetSession<'_>, solve: &Solve) -> Resul
152
151
  })
153
152
  }
154
153
 
155
- /// Collects native measurements, and the requests the iframe's resource timing lists. Runs
156
- /// requests and stack recovery concurrently.
154
+ /// Collects the measurements that make no requests, except the stack. Probes with requests make
155
+ /// them as they run; see [`run_probes`].
157
156
  async fn observe(
158
157
  session: &WidgetSession<'_>,
159
158
  plan: &ProbePlan,
160
- options: &Options,
161
159
  profile: &Profile,
162
- ) -> Result<(Observed, Vec<LoadTiming>)> {
160
+ ) -> Result<Observed> {
163
161
  let mut observed = Observed::default();
164
- let requests = requests(session, plan, options, profile, &mut observed);
165
- let stack = probe_stack(session, plan);
166
- let (fetches, stack) = tokio::try_join!(requests, stack)?;
167
- observed.stack = stack;
168
162
  let facts = &profile.facts;
169
163
  let resolution = session.clock_resolution();
170
- if let Some((probe, Operation::Benchmark { fields })) = plan.operation(Kind::Benchmark) {
171
- let measured = fields
172
- .measure(&facts.benchmark_worker, resolution)
173
- .with_context(|| format!("running probe {}'s benchmark", probe.index))?;
174
- observed.benchmark = Some(measured);
175
- }
176
164
  if let (Some((probe, _)), Some(previous)) = (plan.operation(Kind::Media), &facts.media) {
177
165
  let media = ice::refresh(previous.clone())
178
166
  .with_context(|| format!("refreshing probe {}'s media", probe.index))?;
179
167
  session.sink.json("media-measurement.json", &media)?;
180
168
  observed.media = Some(media);
181
169
  }
170
+ if let Some((probe, Operation::Benchmark { fields })) = plan.operation(Kind::Benchmark) {
171
+ let measured = fields
172
+ .measure(&facts.benchmark_worker, resolution)
173
+ .with_context(|| format!("running probe {}'s benchmark", probe.index))?;
174
+ observed.benchmark = Some(measured);
175
+ }
182
176
  // The worker compares 5000 consecutive clock-reading pairs. Chrome's call overhead usually
183
177
  // crosses a 100 µs boundary; faster native reads may miss all boundaries. Retry until a
184
178
  // pair crosses one.
@@ -190,7 +184,7 @@ async fn observe(
190
184
  .unwrap_or_else(|| worker.back_to_back(clock::PAIRS));
191
185
  observed.clock = Some(readings);
192
186
  }
193
- Ok((observed, fetches))
187
+ Ok(observed)
194
188
  }
195
189
 
196
190
  /// Runs the WebSocket echo exchange. After opening, sends `String(performance.now())` and sends a
@@ -278,7 +272,8 @@ async fn probe_stack(session: &WidgetSession<'_>, plan: &ProbePlan) -> Result<Op
278
272
  let origin = fetched.script_position()?;
279
273
  let engine = session.client.engine();
280
274
  let stack = task::spawn_blocking(move || {
281
- let geometry = call_stack::recover_probe(&javascript, &cases, calls)?;
275
+ let resumed = call_stack::Resumption::WorkerMessage;
276
+ let geometry = call_stack::recover_probe(&javascript, &cases, calls, resumed)?;
282
277
  anyhow::Ok(geometry.format(&origin, engine))
283
278
  })
284
279
  .await
@@ -310,109 +305,145 @@ fn durations(
310
305
  plan.probes().iter().map(duration).collect()
311
306
  }
312
307
 
313
- /// Makes probe requests as the iframe would and returns the loads included in resource timing.
314
- async fn requests(
308
+ /// Runs the probes in order from the end of startup, or now if later. A probe that makes
309
+ /// requests waits for its start and lasts at least until they end. Returns the probes' marks
310
+ /// and the loads resource timing lists.
311
+ async fn run_probes(
315
312
  session: &WidgetSession<'_>,
316
313
  plan: &ProbePlan,
317
314
  options: &Options,
318
315
  profile: &Profile,
319
316
  observed: &mut Observed,
320
- ) -> Result<Vec<LoadTiming>> {
321
- let (client, iframe) = (session.client, &session.fetched.iframe);
317
+ planned: &[i64],
318
+ ) -> Result<(Schedule, Vec<LoadTiming>)> {
319
+ let startup_end = session.arrived_ms + i64::from(profile.startup_ms);
320
+ let mut timeline = Timeline::starting(performance_clock::epoch_ms().max(startup_end));
322
321
  let mut fetches = Vec::new();
323
- for probe in plan.probes() {
324
- match &probe.operation {
325
- Some(Operation::PrivateToken { fields }) => {
326
- let url = fields.url(options, &probe.input, &iframe.url)?;
327
- let started_ms = performance_clock::epoch_ms();
328
- // The worker calls `r.text()` before posting results. Treat body-read failures
329
- // like fetch failures because both reject the promise chain.
330
- let fetched = http::fetch_from_worker(client, iframe, &url, FetchOptions::NoCache);
331
- let outcome = async {
332
- let response = fetched.await?;
333
- let status = response.status();
334
- anyhow::Ok(private_token::WorkerOutcome::Response {
335
- ok: status.is_success(),
336
- status: status.as_u16(),
337
- text: response.text().await?,
338
- })
339
- }
340
- .await
341
- .unwrap_or_else(|error| private_token::WorkerOutcome::Rejected {
342
- worker_error: error.to_string(),
343
- });
344
- observed.private_token = Some(private_token::Measurement {
345
- outcome,
346
- started_ms,
347
- finished_ms: performance_clock::epoch_ms(),
348
- });
349
- }
350
- Some(Operation::WorkerFetch { .. }) if profile.facts.worker_fetch_rejected => {
351
- observed.worker_fetch = Some(worker_fetch::Measurement::Rejected);
352
- }
353
- Some(Operation::HostReachability { fields }) => {
354
- // CORS rejects a response that does not allow the iframe's origin.
355
- let origin = iframe.url.origin().ascii_serialization();
356
- let mut outcomes = Vec::new();
357
- for url in &fields.urls {
358
- let url = Url::parse(url).context("parsing a host reachability URL")?;
359
- let timer = Timer::start();
360
- let options = FetchOptions::NoStoreWithoutCredentials;
361
- let fetched = http::fetch_from_worker(client, iframe, &url, options);
362
- outcomes.push(match time::timeout(REACHABILITY_TIMEOUT, fetched).await {
363
- Ok(Ok(response))
364
- if response
365
- .headers()
366
- .get("access-control-allow-origin")
367
- .and_then(|allowed| allowed.to_str().ok())
368
- .is_some_and(|allowed| allowed == "*" || allowed == origin) =>
369
- {
370
- host_reachability::Outcome::Answered {
371
- elapsed_ms: timer.elapsed_ms() as i64,
372
- }
373
- }
374
- _ => host_reachability::Outcome::Rejected,
375
- });
376
- }
377
- observed.host_reachability = Some(outcomes);
378
- }
379
- Some(Operation::WebSocketEcho { fields }) => {
380
- observed.websocket_echo = Some(echo_websocket(session, fields, options).await?);
322
+ for (probe, &planned_ms) in plan.probes().iter().zip(planned) {
323
+ let requests = matches!(
324
+ probe.operation,
325
+ Some(
326
+ Operation::PrivateToken { .. }
327
+ | Operation::WorkerFetch { .. }
328
+ | Operation::HostReachability { .. }
329
+ | Operation::WebSocketEcho { .. }
330
+ | Operation::Image { .. }
331
+ )
332
+ );
333
+ let finished = if requests {
334
+ timing::sleep_until(timeline.next_start).await;
335
+ request(session, probe, options, profile, observed, &mut fetches).await?;
336
+ Some(performance_clock::epoch_ms())
337
+ } else {
338
+ None
339
+ };
340
+ timeline.finish(planned_ms, finished);
341
+ }
342
+ Ok((timeline.schedule(profile.pending_ms.into()), fetches))
343
+ }
344
+
345
+ /// Makes `probe`'s requests as the iframe would. Records its measurement in `observed` and the
346
+ /// loads resource timing lists in `fetches`.
347
+ async fn request(
348
+ session: &WidgetSession<'_>,
349
+ probe: &Probe,
350
+ options: &Options,
351
+ profile: &Profile,
352
+ observed: &mut Observed,
353
+ fetches: &mut Vec<LoadTiming>,
354
+ ) -> Result<()> {
355
+ let (client, iframe) = (session.client, &session.fetched.iframe);
356
+ match &probe.operation {
357
+ Some(Operation::PrivateToken { fields }) => {
358
+ let url = fields.url(options, &probe.input, &iframe.url)?;
359
+ let started_ms = performance_clock::epoch_ms();
360
+ // The worker calls `r.text()` before posting results. Treat body-read failures
361
+ // like fetch failures because both reject the promise chain.
362
+ let fetched = http::fetch_from_worker(client, iframe, &url, FetchOptions::NoCache);
363
+ let outcome = async {
364
+ let response = fetched.await?;
365
+ let status = response.status();
366
+ anyhow::Ok(private_token::WorkerOutcome::Response {
367
+ ok: status.is_success(),
368
+ status: status.as_u16(),
369
+ text: response.text().await?,
370
+ })
381
371
  }
382
- Some(Operation::WorkerFetch { fields }) => {
383
- let url = fields.url(options)?;
372
+ .await
373
+ .unwrap_or_else(|error| private_token::WorkerOutcome::Rejected {
374
+ worker_error: error.to_string(),
375
+ });
376
+ observed.private_token = Some(private_token::Measurement {
377
+ outcome,
378
+ started_ms,
379
+ finished_ms: performance_clock::epoch_ms(),
380
+ });
381
+ }
382
+ Some(Operation::WorkerFetch { .. }) if profile.facts.worker_fetch_rejected => {
383
+ observed.worker_fetch = Some(worker_fetch::Measurement::Rejected);
384
+ }
385
+ Some(Operation::HostReachability { fields }) => {
386
+ // CORS rejects a response that does not allow the iframe's origin.
387
+ let origin = iframe.url.origin().ascii_serialization();
388
+ let mut outcomes = Vec::new();
389
+ for url in &fields.urls {
390
+ let url = Url::parse(url).context("parsing a host reachability URL")?;
391
+ let timer = Timer::start();
384
392
  let options = FetchOptions::NoStoreWithoutCredentials;
385
- let fetched = http::fetch_from_worker(client, iframe, &url, options).await;
386
- observed.worker_fetch = Some(match fetched {
387
- Ok(response) => worker_fetch::Measurement::Response {
388
- status: response.status().as_u16(),
389
- headers: http::text_headers(response.headers())
390
- .map(|(name, value)| (name.to_owned(), value))
391
- .collect(),
392
- },
393
- Err(_) => worker_fetch::Measurement::Rejected,
393
+ let fetched = http::fetch_from_worker(client, iframe, &url, options);
394
+ outcomes.push(match time::timeout(REACHABILITY_TIMEOUT, fetched).await {
395
+ Ok(Ok(response))
396
+ if response
397
+ .headers()
398
+ .get("access-control-allow-origin")
399
+ .and_then(|allowed| allowed.to_str().ok())
400
+ .is_some_and(|allowed| allowed == "*" || allowed == origin) =>
401
+ {
402
+ host_reachability::Outcome::Answered {
403
+ elapsed_ms: timer.elapsed_ms() as i64,
404
+ }
405
+ }
406
+ _ => host_reachability::Outcome::Rejected,
394
407
  });
395
408
  }
396
- Some(Operation::Image { fields }) => {
397
- let url = fields.url(options, &probe.input, &iframe.url)?;
398
- let timer = Timer::start();
399
- let response = http::fetch_image(client, iframe, &url).await?;
400
- let waiting_ms = timer.elapsed_ms();
401
- let ok = response.status().is_success();
402
- let (body, network) = http::read_encoded(response)
403
- .await
404
- .context("reading a challenge image")?;
405
- fetches.push(timer.phased(url.as_str(), waiting_ms, network.encoded_bytes));
406
- observed.image = Some(image::Measurement::loaded(
407
- ok.then_some(&body[..]),
408
- &profile.image.effective_type,
409
- profile.image.online,
410
- ));
411
- }
412
- _ => {}
409
+ observed.host_reachability = Some(outcomes);
413
410
  }
411
+ Some(Operation::WebSocketEcho { fields }) => {
412
+ observed.websocket_echo = Some(echo_websocket(session, fields, options).await?);
413
+ }
414
+ Some(Operation::WorkerFetch { fields }) => {
415
+ let url = fields.url(options)?;
416
+ let options = FetchOptions::NoStoreWithoutCredentials;
417
+ let fetched = http::fetch_from_worker(client, iframe, &url, options).await;
418
+ observed.worker_fetch = Some(match fetched {
419
+ Ok(response) => worker_fetch::Measurement::Response {
420
+ status: response.status().as_u16(),
421
+ headers: http::text_headers(response.headers())
422
+ .map(|(name, value)| (name.to_owned(), value))
423
+ .collect(),
424
+ },
425
+ Err(_) => worker_fetch::Measurement::Rejected,
426
+ });
427
+ }
428
+ Some(Operation::Image { fields }) => {
429
+ let url = fields.url(options, &probe.input, &iframe.url)?;
430
+ let timer = Timer::start();
431
+ let response = http::fetch_image(client, iframe, &url).await?;
432
+ let waiting_ms = timer.elapsed_ms();
433
+ let ok = response.status().is_success();
434
+ let (body, network) = http::read_encoded(response)
435
+ .await
436
+ .context("reading a challenge image")?;
437
+ fetches.push(timer.phased(url.as_str(), waiting_ms, network.encoded_bytes));
438
+ observed.image = Some(image::Measurement::loaded(
439
+ ok.then_some(&body[..]),
440
+ &profile.image.effective_type,
441
+ profile.image.online,
442
+ ));
443
+ }
444
+ _ => {}
414
445
  }
415
- Ok(fetches)
446
+ Ok(())
416
447
  }
417
448
 
418
449
  /// Builds probe records with scheduled timestamps. Stored answers use `stored` and preserve the
@@ -464,9 +495,13 @@ fn records(
464
495
  }
465
496
 
466
497
  /// Window dimensions and position already reported by the environment audit.
467
- fn window_geometry(replay: Option<&Replay<'_>>, payload: &Payload) -> BTreeMap<String, f64> {
498
+ fn window_geometry(
499
+ plan: &ProbePlan,
500
+ replay: Option<&Replay<'_>>,
501
+ payload: &Payload,
502
+ ) -> BTreeMap<String, f64> {
468
503
  let groups = replay
469
- .and_then(|replay| replay.audit_groups(payload))
504
+ .and_then(|replay| replay.audit_groups(plan, payload))
470
505
  .into_iter()
471
506
  .flatten();
472
507
  let mut geometry = BTreeMap::new();
@@ -79,32 +79,43 @@ pub(crate) struct Schedule {
79
79
  pub send_at: i64,
80
80
  }
81
81
 
82
- /// Schedules probes at least `startup_ms` after `arrived_ms`. Reserves one millisecond after
83
- /// each probe, then `pending_ms` before sending. The send time is no earlier than `now_ms`.
84
- /// Programs without probes wait only for startup. Uses signed durations to match record
85
- /// timestamp differences.
86
- pub(crate) fn schedule(
87
- durations: &[i64],
88
- startup_ms: i64,
89
- pending_ms: i64,
90
- arrived_ms: i64,
91
- now_ms: i64,
92
- ) -> Schedule {
93
- let span: i64 = durations.iter().map(|duration| duration + 1).sum();
94
- let busy = if durations.is_empty() {
95
- startup_ms
96
- } else {
97
- startup_ms + span + pending_ms
98
- };
99
- let send_at = now_ms.max(arrived_ms + busy);
100
- let mut start = send_at - pending_ms - span;
101
- let mut marks = Vec::with_capacity(durations.len());
102
- for &duration in durations {
103
- let end = start + duration;
104
- marks.push(ProbeMarks { start, end });
105
- start = end + 1;
82
+ /// Probe marks laid out as the probes run: in order, one millisecond apart. A probe lasts its
83
+ /// planned duration, or until the work it waits for, such as a request, ends.
84
+ pub(crate) struct Timeline {
85
+ /// When the next probe starts, in epoch milliseconds.
86
+ pub next_start: i64,
87
+ marks: Vec<ProbeMarks>,
88
+ }
89
+
90
+ impl Timeline {
91
+ #[must_use]
92
+ pub(crate) fn starting(next_start: i64) -> Self {
93
+ Self {
94
+ next_start,
95
+ marks: Vec::new(),
96
+ }
97
+ }
98
+
99
+ /// Ends the next probe `planned_ms` after it starts, or at `finished_ms` if its work ended
100
+ /// later.
101
+ pub(crate) fn finish(&mut self, planned_ms: i64, finished_ms: Option<i64>) {
102
+ let start = self.next_start;
103
+ let planned_end = start + planned_ms;
104
+ let end = finished_ms.map_or(planned_end, |finished| finished.max(planned_end));
105
+ self.marks.push(ProbeMarks { start, end });
106
+ self.next_start = end + 1;
107
+ }
108
+
109
+ /// Sends `pending_ms` after the last probe's next mark. Programs without probes send when
110
+ /// they start.
111
+ #[must_use]
112
+ pub(crate) fn schedule(self, pending_ms: i64) -> Schedule {
113
+ let pending_ms = if self.marks.is_empty() { 0 } else { pending_ms };
114
+ Schedule {
115
+ marks: self.marks,
116
+ send_at: self.next_start + pending_ms,
117
+ }
106
118
  }
107
- Schedule { marks, send_at }
108
119
  }
109
120
 
110
121
  #[cfg(test)]
@@ -112,13 +123,14 @@ mod tests {
112
123
  use super::*;
113
124
 
114
125
  #[test]
115
- fn probes_end_pending_ms_before_a_send_no_sooner_than_a_browser_could() {
116
- let schedule = schedule(&[3, 5], 400, 15, 1_000, 1_100);
117
- assert_eq!(
118
- schedule.send_at, 1_425,
119
- "400 ms startup, then 3 + 1 and 5 + 1 ms of probes, then 15 ms pending: a browser \
120
- could send 425 ms after the program arrived"
121
- );
126
+ fn probes_run_in_order_and_last_until_their_requests_end() {
127
+ let mut timeline = Timeline::starting(1_400);
128
+ timeline.finish(3, None);
129
+ // The request took longer than planned.
130
+ timeline.finish(5, Some(1_430));
131
+ // The request ended before the planned duration ran out.
132
+ timeline.finish(10, Some(1_433));
133
+ let schedule = timeline.schedule(15);
122
134
  assert_eq!(
123
135
  schedule.marks,
124
136
  [
@@ -128,35 +140,28 @@ mod tests {
128
140
  },
129
141
  ProbeMarks {
130
142
  start: 1_404,
131
- end: 1_409
143
+ end: 1_430
132
144
  },
133
- ],
134
- "the last probe's next mark is exactly `pending_ms` before sending"
145
+ ProbeMarks {
146
+ start: 1_431,
147
+ end: 1_441
148
+ },
149
+ ]
135
150
  );
136
-
137
- let late = super::schedule(&[3, 5], 400, 15, 1_000, 2_000);
138
- assert_eq!(late.send_at, 2_000);
139
151
  assert_eq!(
140
- late.marks[0].start, 1_975,
141
- "a late send moves every mark with it"
152
+ schedule.send_at, 1_457,
153
+ "the last probe's next mark is exactly `pending_ms` before sending"
142
154
  );
155
+ assert_eq!(Timeline::starting(900).schedule(15).send_at, 900);
143
156
  }
144
157
 
145
158
  #[test]
146
- fn a_program_without_probes_waits_for_its_startup_alone() {
147
- let schedule = schedule(&[], 400, 15, 1_000, 1_100);
159
+ fn signed_durations_are_kept() {
160
+ let mut timeline = Timeline::starting(0);
161
+ timeline.finish(-2, None);
148
162
  assert_eq!(
149
- schedule,
150
- Schedule {
151
- marks: Vec::new(),
152
- send_at: 1_400
153
- }
163
+ timeline.schedule(0).marks,
164
+ [ProbeMarks { start: 0, end: -2 }]
154
165
  );
155
166
  }
156
-
157
- #[test]
158
- fn signed_durations_are_kept() {
159
- let schedule = schedule(&[-2, 4], 0, 0, 0, 0);
160
- assert_eq!(schedule.marks[0].end - schedule.marks[0].start, -2);
161
- }
162
167
  }
@@ -11,7 +11,6 @@ use super::initial::{self, EmbeddingPage, Fetched, Merged};
11
11
  use super::timing::Timer;
12
12
  use super::{Sink, interaction, probes};
13
13
  use crate::browser::{Page, Solve};
14
- use crate::challenge::installed::Installed;
15
14
  use crate::challenge::orchestrator::ChallengePage;
16
15
  use crate::challenge::program::{ExtractedProgram, PreparedSession, SessionEncoder};
17
16
  use crate::challenge::source::InitialRequest;
@@ -128,7 +127,7 @@ enum Landing {
128
127
  /// A site's page with a Turnstile widget.
129
128
  Site(EmbeddingPage),
130
129
  /// A challenge page, whose orchestrator has rendered its widget.
131
- ChallengePage(OrchestratorSession, EmbeddingPage),
130
+ ChallengePage(Box<OrchestratorSession>, EmbeddingPage),
132
131
  }
133
132
 
134
133
  impl Landing {
@@ -177,7 +176,10 @@ async fn land(
177
176
  document.referrer_policy,
178
177
  )
179
178
  .await?;
180
- Ok(Landing::ChallengePage(orchestrator, embedding_page))
179
+ Ok(Landing::ChallengePage(
180
+ Box::new(orchestrator),
181
+ embedding_page,
182
+ ))
181
183
  }
182
184
 
183
185
  /// Starts the session of the widget rendered by `embedding_page` by sending its first payload.
@@ -212,7 +214,6 @@ async fn start_widget<'caller>(
212
214
  sink,
213
215
  fetched,
214
216
  request: program.request,
215
- installed: program.installed,
216
217
  encoder,
217
218
  embedding: embedding_page.embedding,
218
219
  merged,
@@ -316,8 +317,6 @@ pub(crate) struct WidgetSession<'caller> {
316
317
  pub fetched: Fetched,
317
318
  /// POST destination and options reported by the first payload.
318
319
  pub request: InitialRequest,
319
- /// Initial-program facts reused when refreshing captured probe answers.
320
- pub installed: Installed,
321
320
  pub encoder: SessionEncoder,
322
321
  pub embedding: Embedding,
323
322
  pub merged: Merged,