Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@ Notable changes to Chrono Mock, newest first. The format follows

### Fixed

- **Ending a session sent the application's timers back, and left its web pages on the session
date.** A session often ends while the application keeps running: `--ticks` ran out, or the
session was stopped from the window. The application was then handed back the real value of every
clock. For the date that is the point, but with timers sped up (`--scale-duration`, or "Also speed
up timers and countdowns inside the application", and `--scale-qpc`) the tick count, the
interrupt-time counter, `timeGetTime` and the high-resolution counter went back in one step by all
the time the session had added - 316 seconds after a five-second session at x60, on both 32 and
64 bit. Web pages inside the application (WebView2, Qt WebEngine) stayed on the session date and
kept running at the session speed for as long as they were open, even with no option set. Now the
application is let go properly: the date and the time zone return to the real ones, its tick
counts and elapsed-time counters carry on at normal speed from where the session left them, and
its pages are handed back to the real clock as well. A repeating timer set while timers were sped
up keeps its shorter interval until it is set again. The session says that it left the
application running and what that means, and warns when a page did not confirm it was handed
back.
- **A screen reader read the result's rows as code.** Every row of the audit's tables, every
warning, the cleanup list, the speed and jump buttons and the calculator's lists told assistive
technology what the row was built from rather than what it showed: a warning announced itself as
Expand Down
23 changes: 23 additions & 0 deletions crates/cli/src/cdp_attach.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,29 @@ impl Attacher {
}
}

/// Evaluate the release expression in every live context and count the ones that did not confirm
/// it. `ok` is a context let go, `no-shim` one that was never on the shim and has nothing to let go
/// of. Anything else - an error, no answer - is a page that may still be on the session clock,
/// which the caller has to say (rule 6).
pub(crate) fn release(&mut self, expr: &str) -> u32 {
let mut unconfirmed = 0;
for ctx in &self.contexts {
let reply = self.client.call(
"Runtime.evaluate",
json!({ "expression": expr, "returnByValue": true }),
Some(&ctx.session_id),
);
let confirmed = reply
.ok()
.and_then(|r| r["result"]["value"].as_str().map(|v| v == "ok" || v == "no-shim"))
.unwrap_or(false);
if !confirmed {
unconfirmed += 1;
}
}
unconfirmed
}

/// Evaluate a JS expression in one live context and return the string it produced, if any.
/// For a probe reading what a page shows - `document.title` - not for the session.
pub(crate) fn evaluate_string(&mut self, index: u32, expr: &str) -> Option<String> {
Expand Down
9 changes: 9 additions & 0 deletions crates/cli/src/cdp_clock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,15 @@ pub(crate) fn cdp_set_multiplier_expr(fake0: i64, real0: i64, mult: i64, dur: i6
)
}

/// The JS that lets a page go when the session ends and the application lives on: the wall back on the
/// real clock and the duration axis on from where it stands at rate 1, the same thing the hook does for
/// the host once its core is gone. It is a rate change like any other, so `performance.now` is
/// re-anchored at the old rate first and never steps back (rule 3). The origin is `0` on both sides on
/// purpose: with the rate at 1, any instant where fake equals real puts the wall on the real clock.
pub(crate) fn cdp_release_expr() -> String {
cdp_set_multiplier_expr(0, 0, 1, 1)
}

/// The JS to push a new wall origin into a context's `__chronomock` for a jump - wall only - the rate
/// and the duration axis are untouched, so a backward jump never rewinds elapsed time (rule 3).
pub(crate) fn cdp_jump_expr(fake0: i64, real0: i64) -> String {
Expand Down
30 changes: 29 additions & 1 deletion crates/cli/src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -579,18 +579,36 @@ pub(crate) fn native_session_warnings(
warnings
}

/// The application was still running when the session ended: it is back on the real date, and its
/// timers and elapsed-time counters carry on at normal speed from where the session left them.
const KEY_LEFT_RUNNING: &str = "session.left_running";

/// Whether any process of the family the hook reached is still running as the session ends - the
/// application the tester is left with. A recycled pid reads as alive, which errs toward saying so
/// once too often, never toward keeping quiet about a process left behind.
fn family_left_running(session: &chrono_mech::Session, family_pids: &HashSet<u32>) -> bool {
session.is_alive() || family_pids.iter().any(|&pid| chrono_mech::process_is_alive(pid))
}

/// End the session and state what it did: one last fold so a late child still counts, the coverage
/// every process ENDED with, the family verdict and `ended`. Returns the family's exit code.
pub(crate) fn close_session(
mut session: chrono_mech::Session,
mut ledger: SessionLedger,
bridge: EmbeddedBridge,
mut bridge: EmbeddedBridge,
target_exit: Option<i32>,
) -> i32 {
// Final fold so a child that joined since the last heartbeat still counts in the family.
ledger.poll(&mut session);
let SessionLedger { mut family, family_pids, uncovered_children, clock_clamped, duration_clamped } =
ledger;
// The application may outlive the session - a `--ticks` cutoff, a Stop in the panel - and the
// session does not stop it (docs/01 section 8.4). It lets it go instead, and the pages have to be
// let go while their connections are still open, so this comes before `finish`.
let left_running = family_left_running(&session, &family_pids);
if left_running {
bridge.release_pages();
}
// What the pages inside the application did, folded into the family like any process: a page
// shimmed and reading time is covered, one refused or failed is not, and none at all judges
// nothing (docs/09 section 12.7).
Expand Down Expand Up @@ -632,6 +650,13 @@ pub(crate) fn close_session(
}
}
let zone_differs = session.state().tz_bias != chrono_mech::host_tz_bias_min();
// Rate 1 before the core leaves, after everything `ended` reports has been read. Each process of
// the family freezes its duration axes when its hook sees the core gone (`release_duration_axes`),
// and at rate 1 every one of them freezes on the same line whenever its watcher happens to wake -
// at the session rate they would disagree by the rate times the gap between two wake-ups.
if left_running {
session.set_multiplier(1);
}
session.end();
// The sticky flag OR the final sample, so a session too short to have emitted a heartbeat still
// reports a clamped clock.
Expand All @@ -645,6 +670,9 @@ pub(crate) fn close_session(
reconcile_engine_warnings(&mut children_warnings, pages.pages_reached());
session_warnings.extend(children_warnings);
session_warnings.extend(pages.session_warnings(zone_differs));
if left_running {
session_warnings.push(KEY_LEFT_RUNNING.to_string());
}
// The wire names the first UNCOVERED_CHILDREN_WIRE_MAX and carries the true total beside them.
// The image name is text from the target's world, so it passes the same sieve as everything
// else the target writes before it reaches a terminal or the panel.
Expand Down
28 changes: 25 additions & 3 deletions crates/cli/src/embedded_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use chrono_proto::{ReachedEngine, TargetSpec};

use crate::cdp;
use crate::cdp_attach::{Attacher, AttacherOutcome, Pumped, ShimOrigin};
use crate::cdp_clock::{cdp_jump_expr, cdp_set_multiplier_expr, drift_ms};
use crate::cdp_clock::{cdp_jump_expr, cdp_release_expr, cdp_set_multiplier_expr, drift_ms};
use crate::cdp_discover::{Discovered, Discovery, Notice};
use crate::embedded::engine_env;

Expand Down Expand Up @@ -59,6 +59,9 @@ pub(crate) const KEY_QT_PORT_TAKEN: &str = "embedded.qt_port_taken";
pub(crate) const KEY_ZONE_IS_HOST: &str = "embedded.zone_is_host";
/// A WebView2 policy value in the registry was hidden by the session's variable for its duration.
pub(crate) const KEY_REGISTRY_ARGUMENTS_HIDDEN: &str = "embedded.registry_arguments_hidden";
/// A page still open when the session ended did not confirm it was let go, so it may keep the session
/// clock until it is reloaded or closed.
const KEY_PAGES_NOT_RELEASED: &str = "embedded.pages_not_released";

/// What a native start needs from the channel before the target launches: the variables that make
/// an engine open its port, the port reserved for a Qt engine, and what there already is to say.
Expand Down Expand Up @@ -385,6 +388,23 @@ impl EmbeddedBridge {
self.pushed = Some(fresh);
}

/// Let every page go before the connections close, the way the hook lets the host go: the wall
/// back on the real clock and the duration axis on from where it stands at rate 1. For a session
/// whose application outlives it - the caller decides that, a page of an application that has
/// exited is gone and has nothing to let go of.
///
/// Measured before this existed (2026-09-24, WebView2 host at x60): the host went back to the real
/// clock at `end` and its page stayed on the session date, running on at the session rate for as
/// long as it lived - also with no opt-in at all, because this channel is on by default. A page
/// that does not confirm is named in the report rather than assumed let go (rule 6).
pub(crate) fn release_pages(&mut self) {
let expr = cdp_release_expr();
let unconfirmed: u32 = self.attachers.iter_mut().map(|a| a.release(&expr)).sum();
if unconfirmed > 0 {
self.warn(KEY_PAGES_NOT_RELEASED);
}
}

fn broadcast(&mut self, expr: &str) {
for attacher in &mut self.attachers {
attacher.broadcast(expr);
Expand All @@ -397,8 +417,10 @@ impl EmbeddedBridge {
}
}

/// Hand over what the channel covered. Closes every connection - the shims stay in the pages,
/// which follow the host, and the host keeps its hooks past `end` as well.
/// Hand over what the channel covered. Closes every connection. A page still open keeps its shim,
/// which is why an application that outlives the session gets `release_pages` first. A document
/// loaded after this starts without the shim: its registration dies with the connection (measured
/// 2026-09-24, a reload after the session came back on the real clock).
pub(crate) fn finish(mut self) -> Outcome {
self.poll_counts();
let mut outcome = Outcome {
Expand Down
12 changes: 12 additions & 0 deletions crates/cli/src/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,18 @@ pub(crate) fn describe_warning(key: &str) -> String {
"time.duration_axis_clamped" => {
"the monotonic counters (tick count, unbiased interrupt time, and QPC when scaled) reached the end of their range and stood there, so elapsed time inside the target stopped advancing even though the session went on"
}
// The session does not stop the application (docs/01 section 8.4), so the tester is left with
// one that changed clocks under their hands. Says which way each clock went, because "back to
// real time" alone reads as "the counters went back too" - and it was exactly that, a 316 s
// step back at x60, that letting go used to do. A repeating timer is the exception, and it is
// named: its period was shortened once, when it was set, and the release changes only what is
// read or set from then on.
"session.left_running" => {
"the application was still running when the session ended, so it is back on the real date and time now - its tick counts and elapsed-time counters carry on at normal speed from where the session left them instead of jumping back, a repeating timer it set while its timers were sped up keeps the shorter interval it was given, and a restart gives it a clean run on the real clock"
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"embedded.pages_not_released" => {
"a page inside the application did not confirm it was handed back to the real clock when the session ended, so it may keep the session date until it is reloaded or closed"
}
"inheritance.child_not_injected" => {
"a child process could not be covered and ran on the REAL clock - usually a child of the other bitness; the process count below is short by that many"
}
Expand Down
7 changes: 7 additions & 0 deletions crates/cli/tests/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,13 @@ const ALLOWED: &[(&str, &str, &str)] = &[
session through the built binary, and reads the tick rate it wrote to a scratch file. \
Neither reaches past this machine",
),
(
"crates/cli/tests/session_end.rs",
"spawn",
"runs this test binary's own ignored probe twice, once alone as the control and once under a \
session that ends before the probe does, and reads the rates and steps it wrote to a scratch \
file. Neither reaches past this machine",
),
(
"crates/cli/tests/network_observer.rs",
"spawn",
Expand Down
Loading
Loading