diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 20c35bd..28fabb1 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -95,6 +95,49 @@ jobs: cargo check --locked --all-features cargo check --locked --no-default-features + # `wasm32-unknown-unknown` has no threads, no clock and no system randomness, + # so a dependency reaching for one of them either fails to compile + # (`getrandom`) or panics at runtime; `wasm32-wasip1` has all three but no + # thread spawning. + wasm: + name: WebAssembly (${{ matrix.target }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: [wasm32-unknown-unknown, wasm32-wasip1] + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + - uses: dtolnay/rust-toolchain@d1031067263f94b142dd6c0ce24c5eb9d02d52a0 # master + with: + toolchain: stable + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + + - name: Build with default features + run: cargo build --locked --lib --target ${{ matrix.target }} + - name: Build with no default features + run: cargo build --locked --lib --no-default-features --target ${{ matrix.target }} + + # `wasm32-wasip1` is the only one of the two that can run the test + # harness: `wasm32-unknown-unknown` has no way to start a process or + # print to stdout, which libtest needs. The tests that panic on purpose + # are `ignore`d for `wasm`, which is `panic = abort`. + - uses: bytecodealliance/actions/wasmtime/setup@9152e710e9f7182e4c29ad218e4f335a7b203613 # v1 + if: matrix.target == 'wasm32-wasip1' + - name: Test with default features + if: matrix.target == 'wasm32-wasip1' + env: + # `--dir` grants the guest the `tests/data` the integration tests read. + CARGO_TARGET_WASM32_WASIP1_RUNNER: wasmtime --dir=. + run: cargo test --locked --tests --target ${{ matrix.target }} + - name: Test with no default features + if: matrix.target == 'wasm32-wasip1' + env: + CARGO_TARGET_WASM32_WASIP1_RUNNER: wasmtime --dir=. + run: cargo test --locked --tests --no-default-features --target ${{ matrix.target }} + package: name: Package runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index c2ba303..a571e99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this crate are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed +- On `wasm32-unknown-unknown`, `ahash` uses its `compile-time-rng` feature instead of the default `runtime-rng`, so `getrandom`, which otherwise requires a backend opt-in from the final binary on that target, is no longer compiled into the library and the crate builds there out of the box. Hash keys are drawn at build time rather than at process start; other targets are unchanged. +- `rayon` is no longer a dependency on `wasm` targets, whatever the features: the target has no threads, so `rayon` only ever ran its single-thread fallback there. The `parallel` feature stays enabled and compiles to the same sequential code as `--no-default-features`, so `wasm` consumers keep it out of their build even when another crate in the graph turns it on. `FastAutomaton::union_all_par` and `FastAutomaton::intersection_all_par` are therefore absent on `wasm`; every other item is unchanged, as is every non-`wasm` target. + +### Added +- `ExecutionProfileBuilder::clock` and `ExecutionProfile::with_clock`, plus the `execution_profile::Clock` type (`fn() -> Duration`): the monotonic clock the execution timeout is measured against. It defaults to `std::time::Instant`, so existing profiles behave as before. On `wasm32-unknown-unknown`, where the standard library cannot read the time and a timeout previously panicked inside `Instant::now`, the host supplies one (a binding to `performance.now()`, say) and the timeout works; setting a timeout there without a clock makes `run` panic with a message saying so. A custom clock also makes timeouts deterministic in tests, as the `ExecutionProfile` documentation shows. + +### Fixed +- On 32-bit targets (`wasm32`, `i686`, `armv7`, ...), `CharacterOrder::Shuffled` and `PathOrder::Shuffled` drew from the first 2^32 combinations of each path only, because the window indexing them was a `usize`: for `[a-z]{20}` every string shared its first 13 characters. The window is now 64-bit on every target; 64-bit targets are unchanged. + ## [1.0.1] - 2026-09-07 A maintenance release covering dependencies and packaging. The public API is unchanged, and no operation returns a different result. diff --git a/Cargo.lock b/Cargo.lock index 186de44..88af52c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,6 +9,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "const-random", "getrandom 0.3.4", "once_cell", "version_check", @@ -207,6 +208,26 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "criterion" version = "0.8.2" @@ -285,34 +306,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - [[package]] name = "find-msvc-tools" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "futures-core" version = "0.3.34" @@ -339,25 +338,25 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.4" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "r-efi 5.3.0", - "wasip2", + "wasi", ] [[package]] name = "getrandom" -version = "0.4.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", + "r-efi", + "wasip2", ] [[package]] @@ -425,12 +424,6 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "memchr" version = "2.8.3" @@ -543,17 +536,9 @@ dependencies = [ "rand_chacha", "rand_xorshift", "regex-syntax", - "rusty-fork", - "tempfile", "unarray", ] -[[package]] -name = "quick-error" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" - [[package]] name = "quote" version = "1.0.47" @@ -569,12 +554,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - [[package]] name = "rand" version = "0.9.5" @@ -687,37 +666,12 @@ dependencies = [ "tracing", ] -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", -] - [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" -[[package]] -name = "rusty-fork" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" -dependencies = [ - "fnv", - "quick-error", - "tempfile", - "wait-timeout", -] - [[package]] name = "same-file" version = "1.0.6" @@ -805,16 +759,12 @@ dependencies = [ ] [[package]] -name = "tempfile" -version = "3.27.0" +name = "tiny-keccak" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys", + "crunchy", ] [[package]] @@ -906,15 +856,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "wait-timeout" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" -dependencies = [ - "libc", -] - [[package]] name = "walkdir" version = "2.5.0" @@ -925,6 +866,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" diff --git a/Cargo.toml b/Cargo.toml index 96d9033..e0447aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,26 +27,39 @@ include = [ [dependencies] tracing = "0.1" -ahash = "0.8.11" regex-syntax = "0.8.11" -# `ucd-16` has to match the Unicode database `regex-syntax` carries: a pattern is -# parsed into ranges by `regex-syntax` and named back into a class by -# `regex-charclass`, so a mismatch turns `\p{Greek}` into a raw range list on the -# way out. Checked by `tests/public_api.rs`. +# The feature flag (ucd-*) has to match the Unicode version `regex-syntax` uses. regex-charclass = { version = "1.2.0", features = ["ucd-16"] } -rayon = { version = "1.10.0", optional = true } bit-set = "0.11.1" indexmap = "2.13.0" +# `wasm` has no threads: `rayon` would build, but only as its single-thread +# fallback. The `parallel` feature stays enabled there and compiles to the same +# sequential code as `--no-default-features`, minus the dependency. +[target.'cfg(not(target_family = "wasm"))'.dependencies] +rayon = { version = "1.10.0", optional = true } + +# `ahash` draws its hash keys from `getrandom`, which `wasm32-unknown-unknown` has +# no backend for; there it falls back to `compile-time-rng`, drawing them at build +# time instead. The keys being fixed per build makes it in theory vulnerable to +# HashDoS, acceptable as long as it does not serve untrusted data at scale. +[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies] +ahash = "0.8.11" + +[target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] +ahash = { version = "0.8.11", default-features = false, features = ["std", "compile-time-rng"] } + [features] default = ["parallel"] parallel = ["dep:rayon"] [dev-dependencies] -criterion = { version = "0.8", features = ["html_reports"] } -proptest = "1" +proptest = { version = "1", default-features = false, features = ["std", "bit-set"] } regex = "1.13.1" +[target.'cfg(not(target_family = "wasm"))'.dev-dependencies] +criterion = { version = "0.8", features = ["html_reports"] } + [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs"] diff --git a/README.md b/README.md index 9ab7700..2403a07 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Under the hood, every pattern compiles to a finite automaton: cargo add regexsolver ``` -The default `parallel` feature runs unions and intersections of more than 3 operands, and parts of the automaton-to-regex conversion, on [rayon](https://crates.io/crates/rayon). Turn it off for a leaner dependency tree on single-threaded workloads: +The default `parallel` feature runs unions and intersections of more than 3 operands, and parts of the automaton-to-regex conversion, on [rayon](https://crates.io/crates/rayon). It is a no-op on `wasm`, which has no threads and does not depend on rayon at all. Turn it off for a leaner dependency tree on single-threaded workloads: ```toml regexsolver = { version = "1", default-features = false } diff --git a/src/execution_profile.rs b/src/execution_profile.rs index 90e0175..04153b1 100644 --- a/src/execution_profile.rs +++ b/src/execution_profile.rs @@ -1,10 +1,34 @@ -use std::{ - cell::RefCell, - time::{Duration, Instant}, -}; +use std::{cell::RefCell, time::Duration}; +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +use std::{sync::OnceLock, time::Instant}; use crate::error::EngineError; +/// A monotonic clock: the time elapsed since a fixed but otherwise arbitrary +/// origin, such as process start or page load. +/// +/// The execution timeout is enforced by comparing readings of the profile's +/// clock. Every profile starts with a clock backed by [`std::time::Instant`], +/// except on `wasm32-unknown-unknown`, where the standard library cannot read +/// the time and the host has to supply one through +/// [`ExecutionProfileBuilder::clock`]. +pub type Clock = fn() -> Duration; + +/// [`std::time::Instant`], read as the time elapsed since the first reading. +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +fn std_clock() -> Duration { + static ORIGIN: OnceLock = OnceLock::new(); + ORIGIN.get_or_init(Instant::now).elapsed() +} + +/// `wasm32-unknown-unknown` has no clock the standard library can read +/// (`Instant::now` panics there), so profiles start without one and a timeout +/// needs a host-provided [`Clock`]. +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +const DEFAULT_CLOCK: Option = Some(std_clock); +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +const DEFAULT_CLOCK: Option = None; + /// Holds settings that constrain how operations execute within the engine. /// /// # Examples @@ -40,6 +64,39 @@ use crate::error::EngineError; /// }); /// ``` /// +/// ## Supplying the clock +/// +/// The timeout compares readings of a monotonic [`Clock`]. The default reads +/// [`std::time::Instant`]; on `wasm32-unknown-unknown`, where the standard +/// library has no clock, pass one from the host (a binding to +/// `performance.now()`, which is monotonic; `Date.now()` is not). Any monotonic +/// `fn() -> Duration` works, which also makes a timeout testable without +/// waiting for it: +/// +/// ``` +/// use std::sync::atomic::{AtomicU64, Ordering}; +/// use std::time::Duration; +/// use regexsolver::{Term, execution_profile::ExecutionProfileBuilder, error::EngineError, fast_automaton::GenerationOptions}; +/// +/// // Advances one millisecond per reading, so the deadline is hit after a +/// // fixed number of checks rather than after real time has passed. +/// static READINGS: AtomicU64 = AtomicU64::new(0); +/// fn ticking_clock() -> Duration { +/// Duration::from_millis(READINGS.fetch_add(1, Ordering::Relaxed)) +/// } +/// +/// let term = Term::from_pattern(".*abc.*cdef.*sqdsqf.*").unwrap(); +/// +/// let execution_profile = ExecutionProfileBuilder::new() +/// .execution_timeout(5) +/// .clock(ticking_clock) +/// .build(); +/// +/// execution_profile.run(|| { +/// assert_eq!(EngineError::OperationTimeOutError, term.generate_strings(100_000_000, 0, GenerationOptions::new()).unwrap_err()); +/// }); +/// ``` +/// /// ## Disabling implicit determinization /// /// [`FastAutomaton`](crate::fast_automaton::FastAutomaton) operations that @@ -90,9 +147,13 @@ pub struct ExecutionProfile { /// The longest an operation may run, in milliseconds. It is checked /// between steps, so the exact time is not guaranteed. execution_timeout: Option, - /// The instant past which [`EngineError::OperationTimeOutError`] is - /// returned. - execution_deadline: Option, + /// The [`clock`](ExecutionProfileBuilder::clock) reading past which + /// [`EngineError::OperationTimeOutError`] is returned. Set by + /// [`run`](Self::run), and only when a clock is available. + execution_deadline: Option, + /// The clock the timeout is measured against; `None` on a target without + /// one, until the host supplies it. + clock: Option, /// Whether [`FastAutomaton`](crate::fast_automaton::FastAutomaton) /// operations that require a deterministic automaton may determinize a /// non-deterministic input on their own (the default). When `false`, @@ -105,9 +166,9 @@ pub struct ExecutionProfile { } /// Equality compares the *configuration* (state limit, timeout, implicit -/// determinization) and deliberately ignores `execution_deadline`: two -/// profiles built alike compare equal whether or not one is currently -/// installed and running. +/// determinization) and deliberately ignores `execution_deadline` and the +/// clock: two profiles built alike compare equal whether or not one is +/// currently installed and running, and whichever clock they read. impl PartialEq for ExecutionProfile { fn eq(&self, other: &ExecutionProfile) -> bool { self.max_number_of_states == other.max_number_of_states @@ -137,15 +198,12 @@ impl ExecutionProfile { /// /// Return [`EngineError::OperationTimeOutError`] otherwise. pub fn assert_not_timed_out(&self) -> Result<(), EngineError> { - if let Some(execution_deadline) = self.execution_deadline { - if Instant::now() > execution_deadline { - Err(EngineError::OperationTimeOutError) - } else { - Ok(()) - } - } else { - Ok(()) + if let (Some(execution_deadline), Some(clock)) = (self.execution_deadline, self.clock) + && clock() > execution_deadline + { + return Err(EngineError::OperationTimeOutError); } + Ok(()) } /// Whether a maximum number of states is configured. When it is not, the @@ -211,7 +269,26 @@ impl ExecutionProfile { self } + /// Returns a copy of this profile reading the time from `clock`. See + /// [`ExecutionProfileBuilder::clock`]. + /// + /// A deadline already computed by [`run`](Self::run) is dropped, since it + /// was measured against the previous clock and the two origins are + /// unrelated. The next `run` computes one against `clock`. + pub fn with_clock(mut self, clock: Clock) -> Self { + self.clock = Some(clock); + self.execution_deadline = None; + self + } + /// Runs the given closure with this profile installed for the current thread, setting its start time to now. + /// + /// # Panics + /// + /// If an execution timeout is set but the profile has no [`Clock`]. That + /// only happens where the standard library cannot read the time and none + /// is installed by default; supply one with [`ExecutionProfileBuilder::clock`] + /// or [`with_clock`](Self::with_clock). pub fn run(&self, f: F) -> R where F: FnOnce() -> R, @@ -220,11 +297,15 @@ impl ExecutionProfile { let mut execution_profile = self.clone(); if let Some(execution_timeout) = execution_profile.execution_timeout { - // `Instant + Duration` overflow behavior is platform-dependent; a - // timeout so large the deadline is unrepresentable is equivalent + let clock = execution_profile.clock.expect( + "an execution timeout is set but the profile has no clock: this target cannot \ + read the time through the standard library, so supply one with \ + `ExecutionProfileBuilder::clock` (for instance a binding to `performance.now()`)", + ); + // A timeout so large the deadline is unrepresentable is equivalent // to no deadline at all. execution_profile.execution_deadline = - Instant::now().checked_add(Duration::from_millis(execution_timeout)); + clock().checked_add(Duration::from_millis(execution_timeout)); } ThreadLocalParams::set_execution_profile(&execution_profile); @@ -278,6 +359,9 @@ pub struct ExecutionProfileBuilder { /// Whether operations requiring a deterministic automaton may determinize /// a non-deterministic input on their own. Defaults to `true`. implicit_determinization: bool, + /// The clock the timeout is measured against. Defaults to the standard + /// library's, where it has one. + clock: Option, } impl Default for ExecutionProfileBuilder { fn default() -> Self { @@ -294,18 +378,35 @@ impl ExecutionProfileBuilder { max_number_of_states: None, execution_timeout: None, implicit_determinization: true, + clock: DEFAULT_CLOCK, } } /// Sets the longest time, in milliseconds, that an operation may run before /// it aborts with [`EngineError::OperationTimeOutError`]. Enforcement is /// best-effort (checked between internal steps), so the exact deadline is - /// not guaranteed. Unset by default (no timeout). + /// not guaranteed. Unset by default (no timeout). The time is read from + /// the profile's [`clock`](Self::clock). pub fn execution_timeout(mut self, execution_timeout_in_ms: u64) -> Self { self.execution_timeout = Some(execution_timeout_in_ms); self } + /// Sets the [`Clock`] the execution timeout is measured against: any + /// monotonic `fn() -> Duration`, compared by difference only. + /// + /// Defaults to [`std::time::Instant`], so this is only required where the + /// standard library cannot read the time: bind `performance.now()` from the + /// host and pass it here. A wall clock such as `Date.now()` does not + /// qualify: it steps when the system time is adjusted, which either times + /// an operation out early or never. Elsewhere it is a way to make the + /// timeout deterministic, as in the + /// [type-level example](ExecutionProfile#supplying-the-clock). + pub fn clock(mut self, clock: Clock) -> Self { + self.clock = Some(clock); + self + } + /// Caps the number of states an automaton may reach; operations that would /// exceed it abort with [`EngineError::AutomatonHasTooManyStates`]. This /// bounds the exponential blow-up of conversions such as determinization. @@ -335,6 +436,7 @@ impl ExecutionProfileBuilder { execution_timeout: self.execution_timeout, execution_deadline: None, implicit_determinization: self.implicit_determinization, + clock: self.clock, } } } @@ -343,9 +445,10 @@ struct ThreadLocalParams; impl ThreadLocalParams { thread_local! { static MAX_NUMBER_OF_STATES: RefCell> = const { RefCell::new(None) }; - static EXECUTION_DEADLINE: RefCell> = const { RefCell::new(None) }; + static EXECUTION_DEADLINE: RefCell> = const { RefCell::new(None) }; static EXECUTION_TIMEOUT: RefCell> = const { RefCell::new(None) }; static IMPLICIT_DETERMINIZATION: RefCell = const { RefCell::new(true) }; + static CLOCK: RefCell> = const { RefCell::new(DEFAULT_CLOCK) }; } /// Store on the current thread [`ExecutionProfile`]. @@ -365,16 +468,24 @@ impl ThreadLocalParams { ThreadLocalParams::IMPLICIT_DETERMINIZATION.with(|cell| { *cell.borrow_mut() = profile.implicit_determinization; }); + + ThreadLocalParams::CLOCK.with(|cell| { + *cell.borrow_mut() = profile.clock; + }); } fn get_max_number_of_states() -> Option { ThreadLocalParams::MAX_NUMBER_OF_STATES.with(|cell| *cell.borrow()) } - fn get_execution_deadline() -> Option { + fn get_execution_deadline() -> Option { ThreadLocalParams::EXECUTION_DEADLINE.with(|cell| *cell.borrow()) } + fn get_clock() -> Option { + ThreadLocalParams::CLOCK.with(|cell| *cell.borrow()) + } + fn get_execution_timeout() -> Option { ThreadLocalParams::EXECUTION_TIMEOUT.with(|cell| *cell.borrow()) } @@ -390,12 +501,19 @@ impl ThreadLocalParams { execution_deadline: Self::get_execution_deadline(), execution_timeout: Self::get_execution_timeout(), implicit_determinization: Self::get_implicit_determinization(), + clock: Self::get_clock(), } } } #[cfg(test)] mod tests { + use std::{ + cell::Cell, + sync::atomic::{AtomicU64, Ordering}, + time::Instant, + }; + use crate::{Term, fast_automaton::GenerationOptions, regex::RegularExpression}; use super::*; @@ -403,6 +521,119 @@ mod tests { fn assert_send() {} fn assert_sync() {} + thread_local! { + static FAKE_NOW: Cell = const { Cell::new(Duration::ZERO) }; + } + + /// A clock the test moves by hand. + fn fake_clock() -> Duration { + FAKE_NOW.get() + } + + /// A clock that advances one millisecond per reading. + fn ticking_clock() -> Duration { + static READINGS: AtomicU64 = AtomicU64::new(0); + Duration::from_millis(READINGS.fetch_add(1, Ordering::Relaxed)) + } + + #[test] + fn timeout_is_measured_against_the_profile_clock() { + FAKE_NOW.set(Duration::from_millis(1_000)); + ExecutionProfileBuilder::new() + .execution_timeout(10) + .clock(fake_clock) + .build() + .run(|| { + let profile = ExecutionProfile::get(); + assert!(profile.limits_execution_time()); + assert!(profile.assert_not_timed_out().is_ok()); + + FAKE_NOW.set(Duration::from_millis(1_010)); + assert!( + profile.assert_not_timed_out().is_ok(), + "the deadline itself is allowed" + ); + + FAKE_NOW.set(Duration::from_millis(1_011)); + assert_eq!( + profile.assert_not_timed_out().unwrap_err(), + EngineError::OperationTimeOutError + ); + }); + } + + #[test] + fn operation_times_out_on_an_injected_clock() { + let term = Term::from_pattern(".*abc.*def.*qdsqd.*sqdsqd.*qsdsqdsqdz").unwrap(); + ExecutionProfileBuilder::new() + .execution_timeout(5) + .clock(ticking_clock) + .build() + .run(|| { + assert_eq!( + EngineError::OperationTimeOutError, + term.generate_strings(100_000_000, 0, GenerationOptions::new()) + .unwrap_err() + ); + }); + } + + #[test] + fn apply_keeps_the_running_deadline() { + FAKE_NOW.set(Duration::ZERO); + ExecutionProfileBuilder::new() + .execution_timeout(10) + .clock(fake_clock) + .build() + .run(|| { + let running = ExecutionProfile::get(); + FAKE_NOW.set(Duration::from_millis(20)); + running.apply(|| { + assert_eq!( + ExecutionProfile::get().assert_not_timed_out().unwrap_err(), + EngineError::OperationTimeOutError + ); + }); + }); + } + + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] + #[test] + fn ambient_profile_has_the_default_clock() { + ExecutionProfile::get() + .with_execution_timeout(60_000) + .run(|| { + let profile = ExecutionProfile::get(); + assert!(profile.limits_execution_time()); + assert!(profile.assert_not_timed_out().is_ok()); + }); + } + + #[test] + #[cfg_attr( + target_family = "wasm", + ignore = "wasm is panic = abort: a panicking test aborts the whole binary" + )] + #[should_panic(expected = "no clock")] + fn run_without_a_clock_panics_clearly() { + let mut profile = ExecutionProfileBuilder::new().execution_timeout(10).build(); + profile.clock = None; + profile.run(|| {}); + } + + #[test] + fn run_without_a_timeout_needs_no_clock() { + let mut profile = ExecutionProfileBuilder::new() + .max_number_of_states(3) + .build(); + profile.clock = None; + profile.run(|| { + let profile = ExecutionProfile::get(); + assert!(!profile.limits_execution_time()); + assert!(profile.assert_not_timed_out().is_ok()); + }); + } + // `max_number_of_states(N)` allows exactly N states and only rejects N+1, // matching the documented "maximum an automaton may hold". #[test] @@ -430,6 +661,10 @@ mod tests { // closure panics: a leaked temporary profile would permanently poison // pooled (e.g. rayon) threads. #[test] + #[cfg_attr( + target_family = "wasm", + ignore = "wasm is panic = abort: a panicking test aborts the whole binary" + )] fn run_restores_previous_profile_on_panic() { let outer = ExecutionProfileBuilder::new() .max_number_of_states(123) diff --git a/src/fast_automaton/generate.rs b/src/fast_automaton/generate.rs index a81ee8c..5c242eb 100644 --- a/src/fast_automaton/generate.rs +++ b/src/fast_automaton/generate.rs @@ -387,7 +387,7 @@ impl FastAutomaton { // cursors; the shuffled one has to index them through the // permutation, which a window over everything is. let window = - (options.characters == CharacterOrder::Shuffled).then_some(0..usize::MAX); + (options.characters == CharacterOrder::Shuffled).then_some(0..u64::MAX); generation.walk(self, window.as_ref(), None)?; } PathOrder::Interleave | PathOrder::Shuffled => { @@ -399,7 +399,7 @@ impl FastAutomaton { // a pass finds nothing left to cover. Exhausting the automaton // also proves the paths finite and leaves them in `cache`, so // the passes after it replay them instead of searching again. - let mut window = 0..1; + let mut window = 0u64..1; let mut cache = PathCache::new(); loop { let covered = if cache.complete { @@ -636,9 +636,9 @@ impl<'a> Generation<'a> { fn walk( &mut self, automaton: &'a FastAutomaton, - window: Option<&Range>, + window: Option<&Range>, mut cache: Option<&mut PathCache>, - ) -> Result { + ) -> Result { let start_state = automaton.start_state(); // If the start state can't reach an accept state, exit immediately @@ -646,7 +646,7 @@ impl<'a> Generation<'a> { return Ok(0); } - let mut covered = 0usize; + let mut covered = 0u64; let mut q = BinaryHeap::new(); q.push(QueueItem { @@ -677,7 +677,7 @@ impl<'a> Generation<'a> { let resolved = resolve(&self.range_pool, &ranges); covered = covered.saturating_add(match window { Some(window) => self.emitter.emit_window(&resolved, &ranges, window)?, - None => self.emitter.emit_all(&resolved)?, + None => self.emitter.emit_all(&resolved)? as u64, }); if self.emitter.is_full() { @@ -772,8 +772,8 @@ impl<'a> Generation<'a> { /// Emits `window` from every path of a complete [`PathCache`], in the /// order the search popped them: what a [`walk`](Self::walk) pass would /// do, minus the search. - fn replay(&mut self, cache: &PathCache, window: &Range) -> Result { - let mut covered = 0usize; + fn replay(&mut self, cache: &PathCache, window: &Range) -> Result { + let mut covered = 0u64; for path in cache.paths() { self.emitter.execution_profile.assert_not_timed_out()?; @@ -903,8 +903,8 @@ impl Emitter { &mut self, ranges: &[&CharRange], path: &[u32], - window: &Range, - ) -> Result { + window: &Range, + ) -> Result { let range_lengths: Vec = ranges.iter().map(|r| r.get_cardinality() as u128).collect(); // `None` once the product stops fitting: such a path holds more @@ -912,16 +912,15 @@ impl Emitter { let total_combinations = range_lengths .iter() .try_fold(1u128, |total, &len| total.checked_mul(len)); - let bound = - total_combinations.map_or(usize::MAX, |total| total.min(usize::MAX as u128) as usize); + let bound = total_combinations.map_or(u64::MAX, |total| total.min(u64::MAX as u128) as u64); let covered = window.end.min(bound) - window.start.min(bound); - if self.offset >= covered { - self.offset -= covered; + if self.offset as u64 >= covered { + self.offset -= covered as usize; return Ok(covered); } - let first = window.start.min(bound) + self.offset; + let first = window.start.min(bound) + self.offset as u64; self.offset = 0; let tweak = self diff --git a/src/fast_automaton/mod.rs b/src/fast_automaton/mod.rs index bdad488..19cc83c 100644 --- a/src/fast_automaton/mod.rs +++ b/src/fast_automaton/mod.rs @@ -409,6 +409,10 @@ mod tests { } #[test] + #[cfg_attr( + target_family = "wasm", + ignore = "wasm is panic = abort: a panicking test aborts the whole binary" + )] #[should_panic(expected = "does not exist")] fn remove_states_panics_clearly_on_out_of_range() { let mut a = FastAutomaton::new_total(); @@ -418,6 +422,10 @@ mod tests { } #[test] + #[cfg_attr( + target_family = "wasm", + ignore = "wasm is panic = abort: a panicking test aborts the whole binary" + )] #[should_panic(expected = "does not exist")] fn remove_states_panics_clearly_on_tombstoned_id() { let mut a = FastAutomaton::new_empty(); diff --git a/src/fast_automaton/operation/intersection.rs b/src/fast_automaton/operation/intersection.rs index 0ba1fce..6d4b1a6 100644 --- a/src/fast_automaton/operation/intersection.rs +++ b/src/fast_automaton/operation/intersection.rs @@ -32,8 +32,9 @@ impl FastAutomaton { /// Computes in parallel the intersection of all automata in the given iterator. /// - /// Only available with the `parallel` feature (enabled by default). - #[cfg(feature = "parallel")] + /// Only available with the `parallel` feature (enabled by default), and not + /// on `wasm`, which has no threads and does not depend on `rayon`. + #[cfg(all(feature = "parallel", not(target_family = "wasm")))] #[tracing::instrument(level = "debug", skip_all)] pub fn intersection_all_par<'a, I: IntoParallelIterator>( automata: I, diff --git a/src/fast_automaton/operation/union.rs b/src/fast_automaton/operation/union.rs index 2e135d1..0380f00 100644 --- a/src/fast_automaton/operation/union.rs +++ b/src/fast_automaton/operation/union.rs @@ -46,8 +46,9 @@ impl FastAutomaton { /// Computes in parallel the union of all automata in the given iterator. /// - /// Only available with the `parallel` feature (enabled by default). - #[cfg(feature = "parallel")] + /// Only available with the `parallel` feature (enabled by default), and not + /// on `wasm`, which has no threads and does not depend on `rayon`. + #[cfg(all(feature = "parallel", not(target_family = "wasm")))] #[tracing::instrument(level = "debug", skip_all)] pub fn union_all_par<'a, I: IntoParallelIterator>( automata: I, diff --git a/src/lib.rs b/src/lib.rs index d0d4513..3abd7b1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -93,7 +93,10 @@ use std::{ use cardinality::Cardinality; use error::EngineError; use fast_automaton::{FastAutomaton, GenerationOptions}; -#[cfg(feature = "parallel")] +// `parallel` stays enabled on `wasm` but has nothing to enable there: the target +// has no threads, so `rayon` is not a dependency. Every gate below pairs the +// feature with the target for that reason. +#[cfg(all(feature = "parallel", not(target_family = "wasm")))] use rayon::prelude::*; use regex::RegularExpression; use regex_charclass::{char::Char, irange::RangeSet}; @@ -411,19 +414,20 @@ impl Term { } if has_automaton { - let parallel = cfg!(feature = "parallel") && terms.len() > 3; + let parallel = + cfg!(all(feature = "parallel", not(target_family = "wasm"))) && terms.len() > 3; let automaton_list = self.get_automata(&terms, parallel)?; let automaton_list = automaton_list.iter().map(AsRef::as_ref).collect::>(); - #[cfg(feature = "parallel")] + #[cfg(all(feature = "parallel", not(target_family = "wasm")))] let return_automaton = if parallel { FastAutomaton::union_all_par(automaton_list) } else { FastAutomaton::union_all(automaton_list) }?; - #[cfg(not(feature = "parallel"))] + #[cfg(any(not(feature = "parallel"), target_family = "wasm"))] let return_automaton = FastAutomaton::union_all(automaton_list)?; Ok(Term::Automaton(return_automaton)) @@ -461,19 +465,20 @@ impl Term { let terms: Vec<_> = terms.into_iter().collect(); let terms: Vec<&Term> = terms.iter().map(Borrow::borrow).collect(); - let parallel = cfg!(feature = "parallel") && terms.len() > 3; + let parallel = + cfg!(all(feature = "parallel", not(target_family = "wasm"))) && terms.len() > 3; let automaton_list = self.get_automata(&terms, parallel)?; let automaton_list = automaton_list.iter().map(AsRef::as_ref).collect::>(); - #[cfg(feature = "parallel")] + #[cfg(all(feature = "parallel", not(target_family = "wasm")))] let return_automaton = if terms.len() > 3 { FastAutomaton::intersection_all_par(automaton_list) } else { FastAutomaton::intersection_all(automaton_list) }?; - #[cfg(not(feature = "parallel"))] + #[cfg(any(not(feature = "parallel"), target_family = "wasm"))] let return_automaton = FastAutomaton::intersection_all(automaton_list)?; Ok(Term::Automaton(return_automaton)) @@ -1022,7 +1027,7 @@ impl Term { let mut automaton_list = Vec::with_capacity(terms.len() + 1); automaton_list.push(self.to_automaton()?); - #[cfg(feature = "parallel")] + #[cfg(all(feature = "parallel", not(target_family = "wasm")))] let mut terms_automata = if parallel { let execution_profile = ExecutionProfile::get(); terms @@ -1035,7 +1040,7 @@ impl Term { .map(|a| a.to_automaton()) .collect::, _>>() }?; - #[cfg(not(feature = "parallel"))] + #[cfg(any(not(feature = "parallel"), target_family = "wasm"))] let mut terms_automata = { let _ = parallel; terms