Skip to main content

glass/browser/session/
checkpoint.rs

1//! Cross-process session checkpoint export/import.
2//!
3//! Serializes a compact [`CheckpointV1`] containing the current URL, title,
4//! frame tree topology, and revision-tagged element references. Checkpoints
5//! are ≤ 4 KiB and designed for cross-process resume.
6
7use super::*;
8
9impl BrowserSession {
10    /// Return a bounded delta between the cached compact observation and a
11    /// fresh observation on the same route. Historical snapshots are not
12    /// retained beyond this request.
13    pub async fn observe_delta(&self) -> BrowserResult<ObservationDelta> {
14        let previous = self
15            .observation_cache
16            .lock()
17            .await
18            .as_ref()
19            .map(|cached| cached.context.clone())
20            .ok_or("observe_delta requires a prior compact observation")?;
21        let current = self.observe_fresh().await?;
22        if previous.page.target_id != current.page.target_id
23            || previous.page.frame_id != current.page.frame_id
24        {
25            return Err("observe_delta cannot compare different routes".into());
26        }
27
28        let old_by_backend: HashMap<_, _> = previous
29            .accessibility
30            .interactive
31            .iter()
32            .map(|control| (control.backend_dom_node_id, control))
33            .collect();
34        let new_by_backend: HashMap<_, _> = current
35            .accessibility
36            .interactive
37            .iter()
38            .map(|control| (control.backend_dom_node_id, control))
39            .collect();
40        let control = |value: &CompactInteractiveElement| DeltaControl {
41            reference: value.reference.clone(),
42            role: value.role.clone(),
43            name: value.name.clone(),
44        };
45        let mut added = Vec::new();
46        let mut removed = Vec::new();
47        let mut changed = Vec::new();
48        for value in &current.accessibility.interactive {
49            match old_by_backend.get(&value.backend_dom_node_id) {
50                None if added.len() < 8 => added.push(control(value)),
51                Some(old)
52                    if (old.role != value.role || old.name != value.name) && changed.len() < 8 =>
53                {
54                    changed.push(control(value));
55                }
56                _ => {}
57            }
58        }
59        for value in &previous.accessibility.interactive {
60            if !new_by_backend.contains_key(&value.backend_dom_node_id) && removed.len() < 8 {
61                removed.push(control(value));
62            }
63        }
64        let revision_delta = current
65            .accessibility
66            .revision
67            .saturating_sub(previous.accessibility.revision);
68        Ok(ObservationDelta {
69            from_revision: previous.accessibility.revision,
70            to_revision: current.accessibility.revision,
71            mutation_summary: MutationSummary {
72                url_changed: previous.page.url != current.page.url,
73                title_changed: previous.page.title != current.page.title,
74                revision_delta,
75                soft_navigation_suspected: revision_delta > 0
76                    && previous.page.url == current.page.url
77                    && previous.page.title == current.page.title,
78            },
79            added,
80            removed,
81            changed,
82            prior_incomplete: previous.incomplete,
83            current_incomplete: current.incomplete,
84        })
85    }
86
87    /// Reconcile prior references against the current page revision.
88    /// Maps old refs (`r<fromRevision>:b<id>`) to current refs via backend
89    /// node identity or stable role+name matching.
90    pub async fn reconcile_references(
91        &self,
92        from_revision: u64,
93        refs: &[String],
94    ) -> BrowserResult<ReconciliationOutcome> {
95        self.reconcile_references_with_options(
96            from_revision,
97            refs,
98            &ReconciliationOptions::default(),
99        )
100        .await
101    }
102
103    /// Reconcile revisioned references with bounded stable hints and an
104    /// optional scope. Hints are only used after backend identity and the
105    /// prior role/name identity fail; ambiguity always remains a loss.
106    pub async fn reconcile_references_with_options(
107        &self,
108        from_revision: u64,
109        refs: &[String],
110        options: &ReconciliationOptions,
111    ) -> BrowserResult<ReconciliationOutcome> {
112        if refs.len() > MAX_RECONCILE_REFS {
113            return Err(format!(
114                "too many refs to reconcile: {} (max {})",
115                refs.len(),
116                MAX_RECONCILE_REFS
117            )
118            .into());
119        }
120        if options.hints.len() > MAX_RECONCILE_HINTS {
121            return Err(format!(
122                "too many reconciliation hints: {} (max {})",
123                options.hints.len(),
124                MAX_RECONCILE_HINTS
125            )
126            .into());
127        }
128
129        let current_revision = self.page_revision.load(Ordering::Relaxed);
130        let prior_page = self
131            .observation_cache
132            .lock()
133            .await
134            .as_ref()
135            .filter(|cached| cached.revision == from_revision)
136            .map(|cached| cached.context.page.clone());
137        let prior = {
138            let cache = self.observation_cache.lock().await;
139            cache
140                .as_ref()
141                .filter(|cached| cached.revision == from_revision)
142                .map(|cached| {
143                    (
144                        cached.context.page.target_id.clone(),
145                        cached.context.page.frame_id.clone(),
146                        cached
147                            .context
148                            .accessibility
149                            .interactive
150                            .iter()
151                            .map(|control| {
152                                (
153                                    control.reference.clone(),
154                                    control.role.clone(),
155                                    control.name.clone(),
156                                    control.backend_dom_node_id,
157                                    control.ancestor_path.clone(),
158                                )
159                            })
160                            .collect::<Vec<_>>(),
161                    )
162                })
163        };
164        if current_revision == from_revision {
165            if prior.is_none() {
166                return bounded_reconciliation_outcome(ReconciliationOutcome {
167                    status: ReconciliationStatus::Complete,
168                    to_revision: current_revision,
169                    preserved: 0,
170                    relocated: 0,
171                    lost: refs.len(),
172                    mappings: refs
173                        .iter()
174                        .map(|old| ReferenceMapping::Lost {
175                            old: old.clone(),
176                            reason: ReferenceLostReason::StaleBoundary,
177                        })
178                        .collect(),
179                    mutation_summary: MutationSummary::default(),
180                    incomplete: vec![ObservationIncompleteReason::BoundaryScan],
181                });
182            }
183            // Same revision: validate the ref shape and revision before
184            // claiming preservation. A malformed or foreign-revision ref is
185            // never silently accepted.
186            let known_refs: HashSet<&str> = prior
187                .as_ref()
188                .map(|(_, _, controls)| {
189                    controls
190                        .iter()
191                        .map(|(reference, _, _, _, _)| reference.as_str())
192                        .collect()
193                })
194                .unwrap_or_default();
195            let scope_label = options.scope_ref.as_ref().and_then(|scope_ref| {
196                parse_revisioned_reference(scope_ref)
197                    .ok()
198                    .flatten()
199                    .filter(|scope| scope.revision == from_revision)
200                    .and_then(|scope| {
201                        prior.as_ref().and_then(|(_, _, controls)| {
202                            controls
203                                .iter()
204                                .find(|(_, _, _, backend, _)| *backend == scope.backend_dom_node_id)
205                                .map(|(_, role, name, _, _)| format!("{}:{}", role, name))
206                        })
207                    })
208            });
209            let scope_invalid = options.scope_ref.is_some() && scope_label.is_none();
210            let mappings: Vec<_> = refs
211                .iter()
212                .map(|old| {
213                    let valid = matches!(
214                        parse_revisioned_reference(old),
215                        Ok(Some(reference))
216                            if reference.revision == from_revision
217                                && known_refs.contains(old.as_str())
218                    );
219                    if !valid {
220                        return ReferenceMapping::Lost {
221                            old: old.clone(),
222                            reason: ReferenceLostReason::StaleBoundary,
223                        };
224                    }
225                    if scope_invalid
226                        || scope_label.as_deref().is_some_and(|scope| {
227                            !prior.as_ref().is_some_and(|(_, _, controls)| {
228                                controls.iter().any(|(reference, _, _, _, ancestors)| {
229                                    reference == old
230                                        && ancestors.iter().any(|ancestor| ancestor == scope)
231                                })
232                            })
233                        })
234                    {
235                        return ReferenceMapping::Lost {
236                            old: old.clone(),
237                            reason: ReferenceLostReason::OutOfScope,
238                        };
239                    }
240                    ReferenceMapping::Preserved {
241                        old: old.clone(),
242                        new: old.clone(),
243                    }
244                })
245                .collect();
246            let preserved = mappings
247                .iter()
248                .filter(|mapping| matches!(mapping, ReferenceMapping::Preserved { .. }))
249                .count();
250            let lost = mappings.len() - preserved;
251            return bounded_reconciliation_outcome(ReconciliationOutcome {
252                status: ReconciliationStatus::Complete,
253                to_revision: current_revision,
254                preserved,
255                relocated: 0,
256                lost,
257                mappings,
258                mutation_summary: MutationSummary::default(),
259                incomplete: Vec::new(),
260            });
261        }
262
263        // Get fresh compact observe to see current controls
264        let context = self.observe_fresh().await?;
265        let to_revision = context.accessibility.revision;
266
267        // Build lookup: backend_dom_node_id -> current ref
268        let mut current_by_backend: HashMap<i64, String> = HashMap::new();
269        let mut current_controls: Vec<(&str, &str, i64, &str, &[String])> = Vec::new();
270        let ax = &context.accessibility;
271        for c in &ax.interactive {
272            current_by_backend.insert(c.backend_dom_node_id, c.reference.clone());
273            current_controls.push((
274                &c.role,
275                &c.name,
276                c.backend_dom_node_id,
277                &c.reference,
278                &c.ancestor_path,
279            ));
280        }
281
282        // A cached observation is the only safe source of the old route and
283        // stable identity. If it is unavailable (for example after a process
284        // restart), do not guess relocation from a raw backend ID.
285        let route_changed = prior.as_ref().is_none_or(|(old_target, old_frame, _)| {
286            context.page.target_id != *old_target || context.page.frame_id != *old_frame
287        });
288
289        let scope_label = options.scope_ref.as_ref().and_then(|scope_ref| {
290            parse_revisioned_reference(scope_ref)
291                .ok()
292                .flatten()
293                .filter(|scope| scope.revision == from_revision)
294                .and_then(|scope| {
295                    current_controls
296                        .iter()
297                        .find(|(_, _, backend, _, _)| *backend == scope.backend_dom_node_id)
298                        .map(|(role, name, _, _, _)| format!("{}:{}", role, name))
299                })
300        });
301        let scope_invalid = options.scope_ref.is_some() && scope_label.is_none();
302
303        let mut mappings = Vec::with_capacity(refs.len());
304        let mut preserved = 0usize;
305        let mut relocated = 0usize;
306        let mut lost = 0usize;
307
308        for old_ref in refs {
309            // Parse old ref: "r<revision>:b<backend_id>"
310            let Ok(Some(reference)) = parse_revisioned_reference(old_ref) else {
311                mappings.push(ReferenceMapping::Lost {
312                    old: old_ref.clone(),
313                    reason: ReferenceLostReason::StaleBoundary,
314                });
315                lost += 1;
316                continue;
317            };
318            if reference.revision != from_revision || route_changed {
319                mappings.push(ReferenceMapping::Lost {
320                    old: old_ref.clone(),
321                    reason: ReferenceLostReason::StaleBoundary,
322                });
323                lost += 1;
324                continue;
325            }
326            let backend_id = reference.backend_dom_node_id;
327
328            if scope_invalid {
329                mappings.push(ReferenceMapping::Lost {
330                    old: old_ref.clone(),
331                    reason: ReferenceLostReason::OutOfScope,
332                });
333                lost += 1;
334                continue;
335            }
336
337            // Try preserved (same backend node ID), while honoring scope.
338            if let Some(new_ref) = current_by_backend.get(&backend_id) {
339                if let Some(scope) = scope_label.as_deref()
340                    && !current_controls
341                        .iter()
342                        .find(|control| control.3 == new_ref)
343                        .is_some_and(|control| control.4.iter().any(|ancestor| ancestor == scope))
344                {
345                    mappings.push(ReferenceMapping::Lost {
346                        old: old_ref.clone(),
347                        reason: ReferenceLostReason::OutOfScope,
348                    });
349                    lost += 1;
350                    continue;
351                }
352                mappings.push(ReferenceMapping::Preserved {
353                    old: old_ref.clone(),
354                    new: new_ref.clone(),
355                });
356                preserved += 1;
357                continue;
358            }
359
360            // First use the prior control's exact role/name identity.
361            let prior_control = prior.as_ref().and_then(|(_, _, controls)| {
362                controls
363                    .iter()
364                    .find(|(reference, _, _, _, _)| reference == old_ref)
365            });
366            let mut match_kind = ReferenceMatch::RoleAndName;
367            let mut matches: Vec<_> = prior_control
368                .map(|(_, role, name, _, _)| {
369                    current_controls
370                        .iter()
371                        .filter(|(current_role, current_name, _, _, _)| {
372                            current_role.eq_ignore_ascii_case(role)
373                                && current_name.eq_ignore_ascii_case(name)
374                        })
375                        .collect()
376                })
377                .unwrap_or_default();
378
379            // Stable hints are positional and only run after backend and
380            // prior role/name identity fail. This keeps the API bounded and
381            // preserves locator-chain ambiguity semantics.
382            if matches.is_empty()
383                && let Some(hint) = options
384                    .hints
385                    .get(refs.iter().position(|r| r == old_ref).unwrap_or(usize::MAX))
386            {
387                match_kind = match hint {
388                    Locator::AccessibleName(_) => ReferenceMatch::AccessibleName,
389                    _ => ReferenceMatch::Hint,
390                };
391                matches = current_controls
392                    .iter()
393                    .filter(|control| locator_matches_compact(hint, control))
394                    .collect();
395            }
396
397            if let Some(scope) = scope_label.as_deref() {
398                matches.retain(|(_, _, _, _, ancestors)| {
399                    ancestors.iter().any(|ancestor| ancestor == scope)
400                });
401                if matches.is_empty() && prior_control.is_some() {
402                    mappings.push(ReferenceMapping::Lost {
403                        old: old_ref.clone(),
404                        reason: ReferenceLostReason::OutOfScope,
405                    });
406                    lost += 1;
407                    continue;
408                }
409                if !matches.is_empty() {
410                    match_kind = ReferenceMatch::ScopedHint;
411                }
412            }
413            if matches.len() == 1 {
414                mappings.push(ReferenceMapping::Relocated {
415                    old: old_ref.clone(),
416                    new: matches[0].3.to_string(),
417                    matched_by: match_kind,
418                });
419                relocated += 1;
420                continue;
421            }
422            if matches.len() > 1 {
423                mappings.push(ReferenceMapping::Lost {
424                    old: old_ref.clone(),
425                    reason: ReferenceLostReason::Ambiguous {
426                        candidates: matches
427                            .iter()
428                            .take(AMBIGUOUS_CANDIDATE_LIMIT)
429                            .map(|(role, name, _, reference, _)| CandidateSummary {
430                                label: bounded_candidate_label(&format!("{} {}", role, name)),
431                                reference: Some((*reference).to_string()),
432                            })
433                            .collect(),
434                    },
435                });
436                lost += 1;
437                continue;
438            }
439            mappings.push(ReferenceMapping::Lost {
440                old: old_ref.clone(),
441                reason: ReferenceLostReason::NotFound,
442            });
443            lost += 1;
444        }
445
446        let mutation_summary = prior_page
447            .as_ref()
448            .map(|prior| MutationSummary {
449                url_changed: prior.url != context.page.url,
450                title_changed: prior.title != context.page.title,
451                revision_delta: to_revision.saturating_sub(from_revision),
452                soft_navigation_suspected: to_revision > from_revision
453                    && prior.url == context.page.url
454                    && prior.title == context.page.title,
455            })
456            .unwrap_or_default();
457        bounded_reconciliation_outcome(ReconciliationOutcome {
458            status: if route_changed {
459                ReconciliationStatus::RouteChanged
460            } else {
461                ReconciliationStatus::Complete
462            },
463            to_revision,
464            mappings,
465            preserved,
466            relocated,
467            lost,
468            mutation_summary,
469            incomplete: context.incomplete,
470        })
471    }
472
473    /// Export a session checkpoint for cross-process resume.
474    /// Returns JSON bounded to ≤ 4 KiB. No cookies, passwords, or form values.
475    pub async fn export_checkpoint(&self) -> BrowserResult<CheckpointV1> {
476        let page = self.page_info().await?;
477        let revision = self.page_revision.load(Ordering::Relaxed);
478
479        let last_refs: Vec<String> = self
480            .observation_cache
481            .lock()
482            .await
483            .as_ref()
484            .map(|cached| {
485                cached
486                    .context
487                    .accessibility
488                    .interactive
489                    .iter()
490                    .take(8)
491                    .map(|c| c.reference.clone())
492                    .collect()
493            })
494            .unwrap_or_default();
495
496        let checkpoint = CheckpointV1 {
497            schema_version: 1,
498            glass_version: env!("CARGO_PKG_VERSION").to_string(),
499            exported_at: chrono::Utc::now().to_rfc3339(),
500            profile: self.profile.clone(),
501            attach_mode: self.chrome.is_none(),
502            topology: CheckpointTopology {
503                target_id: Some(page.target_id),
504                frame_id: Some(page.frame_id),
505                url: bounded_checkpoint_text(&page.url, 1024),
506                title: bounded_checkpoint_text(&page.title, 1024),
507            },
508            observation: CheckpointObservation {
509                revision,
510                last_refs,
511            },
512            policy: format!("{:?}", self.policy.preset()).to_lowercase(),
513        };
514        if serde_json::to_vec(&checkpoint)?.len() > 4 * 1024 {
515            return Err("checkpoint exceeds the 4 KiB serialized limit".into());
516        }
517        Ok(checkpoint)
518    }
519
520    /// Import a checkpoint and validate its topology.
521    /// Does NOT auto-click — only restores target/frame selection context.
522    pub async fn import_checkpoint(&self, checkpoint: &CheckpointV1) -> BrowserResult<()> {
523        if checkpoint.schema_version != 1 {
524            return Err(CheckpointError::SchemaVersionMismatch {
525                expected: 1,
526                found: checkpoint.schema_version,
527            }
528            .into());
529        }
530
531        if let Some(ref target_id) = checkpoint.topology.target_id {
532            let targets = self.list_targets().await?;
533            if !targets.iter().any(|t| t.id == *target_id) {
534                return Err(CheckpointError::TargetClosed.into());
535            }
536            self.select_target(target_id).await?;
537        }
538
539        if let Some(ref frame_id) = checkpoint.topology.frame_id {
540            let frames = self.list_frames().await?;
541            if !frames.iter().any(|f| f.id == *frame_id) {
542                return Err(CheckpointError::Stale.into());
543            }
544            self.select_frame(frame_id).await?;
545        }
546
547        Ok(())
548    }
549}
550
551fn bounded_reconciliation_outcome(
552    outcome: ReconciliationOutcome,
553) -> BrowserResult<ReconciliationOutcome> {
554    let bytes = serde_json::to_vec(&outcome)?.len();
555    if bytes > MAX_RECONCILIATION_BYTES {
556        return Err(format!(
557            "reconciliation response exceeds {} bytes; retry with fewer refs",
558            MAX_RECONCILIATION_BYTES
559        )
560        .into());
561    }
562    Ok(outcome)
563}
564
565fn locator_matches_compact(
566    locator: &Locator,
567    control: &(&str, &str, i64, &str, &[String]),
568) -> bool {
569    match locator {
570        Locator::AccessibleName(name) => control.1.eq_ignore_ascii_case(name),
571        Locator::RoleAndName { role, name } => {
572            control.0.eq_ignore_ascii_case(role) && control.1.eq_ignore_ascii_case(name)
573        }
574        Locator::Text(text) => control.1.eq_ignore_ascii_case(text),
575        Locator::Reference(reference) => control.3 == reference,
576        Locator::Css(_) | Locator::Ordinal(_) => false,
577    }
578}
579
580fn bounded_checkpoint_text(value: &str, max_bytes: usize) -> String {
581    if value.len() <= max_bytes {
582        return value.to_string();
583    }
584    let mut end = max_bytes;
585    while end > 0 && !value.is_char_boundary(end) {
586        end -= 1;
587    }
588    value[..end].to_string()
589}