(WIP) agent-fuzz-harness #31

Merged
zacheryasc merged 23 commits from cfuzz into master 2026-02-13 07:11:25 +00:00
5 changed files with 1100 additions and 11 deletions
Showing only changes of commit 9b1518b46c - Show all commits

View file

@ -2,7 +2,7 @@
## Current Stage: Phase 1 — Research + First Improvement Cycle
### Status: Cycle 10 COMPLETE
### Status: Cycle 11 COMPLETE
## Plan Overview
1. **Phase 0**: Codebase audit — understand current swactor architecture, existing tests, benchmarks ✅
@ -124,6 +124,37 @@
- `stop_nonexistent_actor_returns_error` — stop on bad address returns Err
- **Result**: 82 tests pass, all workspace compiles
### Cycle 11: Property-Based Testing (proptest + fuzz extension)
- **Research**: Studied testing approaches across tokio (loom), Erlang (PropEr, QuickCheck, Concuerror),
Rust property-based testing (proptest vs quickcheck), cargo-fuzz, and actor-specific testing patterns.
- Ranked approaches: #1 proptest-state-machine (perfect fit for deterministic ticks),
#2 extend cargo-fuzz, #3 simple proptest, #4 shuttle, #5 loom, #6 DST
- Also researched remaining feature gaps: named actors, monitoring/death watch, groups, ask pattern
- **Implementation**: Property-based testing suite with proptest-state-machine
- Added `proptest` and `proptest-state-machine` to dev-dependencies
- New test file: `tests/proptest_runtime.rs` with 7 tests:
- `fifo_ordering_for_any_message_sequence` — FIFO preserved for 1-100 random messages
- `budget_limits_per_actor_processing` — budget caps per-tick processing for 2-10 actors
- `one_shot_timer_fires_at_correct_tick` — timer with delay 1-20 fires at exact right tick
- `interval_timer_fires_at_correct_period` — period 1-10, verifies 3 consecutive fires
- `bounded_mailbox_never_exceeds_capacity` — capacity 1-20, 1-200 messages, never exceeds
- `spawn_n_actors_all_tracked` — 1-50 actors, all unique, all in stats
- `swactor_state_machine` — stateful property test: random Spawn/Send/Tick/Stop/CheckStats
sequences (up to 40 transitions, 128 cases), verifies runtime invariants after each step
- State machine test defines SwactorModel (reference) vs SwactorTest (SUT) with:
- Reference model: HashMap<id, alive> tracking expected actor lifecycle
- Invariants checked after every transition: worker count, actor placement, mailbox safety
- Automatic shrinking finds minimal failing sequences
- Extended fuzz targets (fuzz_runtime.rs) with 4 new RawAction variants:
- `StopActor` — graceful stop via runtime.stop_actor
- `SpawnRestartable` — spawn_restartable with configurable max_restarts
- `ScheduleTimer` — one-shot timer via TimerSchedulerActor
- `ScheduleInterval` — interval timer via IntervalSchedulerActor
- Added 3 new actor types to fuzz: TimerSchedulerActor, IntervalSchedulerActor, RestartableEchoActor
- **Bug found**: State machine test immediately caught invariant mismatch: address map tracks spawned
actors immediately, but per-worker num_actors lags until first tick. Fixed invariant to use <= check.
- **Result**: 95 tests pass (88 behavioral + 7 proptest), fuzz targets compile, zero warnings
### Cycle 10: Actor Timers (Tick-Counting)
- **Research**: Studied timer/scheduling patterns across Erlang (timer:send_after, erlang:start_timer),
Akka (scheduleOnce, scheduler), Actix (ctx.run_later, ctx.run_interval), Kameo (tokio::time::sleep),
@ -226,8 +257,10 @@
- [x] **Cycle 8: Dead actor cleanup** ✅
- [x] **Cycle 9: Lifecycle hooks + graceful stop** ✅
- [x] **Cycle 10: Actor timers (tick-counting)** ✅
- [ ] **Cycle 11: Next improvement**
- Candidates: SmallBox optimization, property-based tests, named actors/registry, actor groups
- [x] **Cycle 11: Property-based testing (proptest-state-machine + fuzz extension)** ✅
- [ ] **Cycle 12: Next improvement**
- Candidates: named actors/registry (small effort, high value), actor monitoring/death watch,
actor groups/pub-sub, SmallBox optimization
- Priority messages REJECTED (lifecycle hooks cover 95% of cases)
- LIFO slot rejected (0-5% benefit for typical workloads, not worth complexity)

392
Cargo.lock generated
View file

@ -29,6 +29,12 @@ version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anyhow"
version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
[[package]]
name = "ascii"
version = "1.1.0"
@ -47,6 +53,21 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitflags"
version = "2.10.0"
@ -277,7 +298,7 @@ dependencies = [
"crossterm_winapi",
"mio",
"parking_lot",
"rustix",
"rustix 0.38.44",
"signal-hook",
"signal-hook-mio",
"winapi",
@ -417,7 +438,7 @@ name = "distribution"
version = "0.1.0"
dependencies = [
"ed25519-dalek",
"rand_core",
"rand_core 0.6.4",
"serde",
"serde_json",
"swactor",
@ -441,7 +462,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"rand_core",
"rand_core 0.6.4",
"serde",
"sha2",
"subtle",
@ -470,12 +491,24 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
@ -503,6 +536,31 @@ dependencies = [
"wasi",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
"wasip3",
]
[[package]]
name = "half"
version = "2.7.1"
@ -549,6 +607,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ident_case"
version = "1.0.1"
@ -563,6 +627,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
"serde",
"serde_core",
]
[[package]]
@ -638,6 +704,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.181"
@ -650,6 +722,12 @@ version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "lock_api"
version = "0.4.14"
@ -837,6 +915,25 @@ version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@ -846,6 +943,34 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "proptest"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532"
dependencies = [
"bit-set",
"bit-vec",
"bitflags",
"num-traits",
"rand",
"rand_chacha",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
"tempfile",
"unarray",
]
[[package]]
name = "proptest-state-machine"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e943d140e09d07740fb496487c51fb8eb31c70389ac4a2e9dcd8a0d9fdf228d4"
dependencies = [
"proptest",
]
[[package]]
name = "pyo3"
version = "0.23.5"
@ -917,6 +1042,12 @@ dependencies = [
"swactor",
]
[[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.44"
@ -926,13 +1057,57 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_xorshift"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
dependencies = [
"rand_core 0.9.5",
]
[[package]]
@ -1049,16 +1224,41 @@ dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"linux-raw-sys 0.4.15",
"windows-sys 0.59.0",
]
[[package]]
name = "rustix"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys 0.11.0",
"windows-sys 0.61.2",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[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 = "ryu"
version = "1.0.23"
@ -1195,7 +1395,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core",
"rand_core 0.6.4",
]
[[package]]
@ -1203,7 +1403,7 @@ name = "simulation"
version = "0.1.0"
dependencies = [
"distribution",
"getrandom",
"getrandom 0.2.17",
"log",
"serde",
"serde_json",
@ -1285,7 +1485,9 @@ dependencies = [
"criterion",
"crossbeam-queue",
"crossbeam-utils",
"getrandom",
"getrandom 0.2.17",
"proptest",
"proptest-state-machine",
"serde",
"tracing",
]
@ -1307,6 +1509,19 @@ version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tempfile"
version = "3.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
dependencies = [
"fastrand",
"getrandom 0.4.1",
"once_cell",
"rustix 1.1.3",
"windows-sys 0.61.2",
]
[[package]]
name = "thread_local"
version = "1.1.9"
@ -1442,6 +1657,12 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicode-ident"
version = "1.0.23"
@ -1477,6 +1698,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unindent"
version = "0.2.4"
@ -1495,6 +1722,15 @@ 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"
@ -1511,6 +1747,24 @@ 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.2+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm"
version = "0.1.0"
@ -1564,6 +1818,40 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "web-sys"
version = "0.3.85"
@ -1702,6 +1990,94 @@ dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "zerocopy"
version = "0.8.39"

View file

@ -32,6 +32,8 @@ crossbeam-utils = "0.8.21"
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
proptest = "1"
proptest-state-machine = "0.3"
[[bench]]
name = "runtime_benchmarks"

View file

@ -144,6 +144,51 @@ impl ActorInterface for WrongTypeActor {
fn handle(&mut self, _ctx: &Ctx, _msg: WrongTypeMsg) {}
}
/// Timer actor: on message, schedules a one-shot timer to deliver the message
/// to the given target after `delay` ticks.
struct TimerSchedulerActor {
target: ActorAddress,
delay: u64,
}
impl ActorInterface for TimerSchedulerActor {
type Incoming = FuzzMsg;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: FuzzMsg) {
ctx.send_after_ticks(self.target, msg, self.delay);
}
}
/// Interval timer actor: on start, schedules an interval timer to fire
/// to the target every `period` ticks.
struct IntervalSchedulerActor {
target: ActorAddress,
period: u64,
}
impl ActorInterface for IntervalSchedulerActor {
type Incoming = FuzzMsg;
type Response = ();
fn on_start(&mut self, ctx: &Ctx) {
ctx.send_interval_ticks(self.target, FuzzMsg { value: 0, reply_to_idx: None }, self.period);
}
fn handle(&mut self, _ctx: &Ctx, _msg: FuzzMsg) {}
}
/// Restartable echo: panics on value=0, otherwise echoes.
struct RestartableEchoActor;
impl ActorInterface for RestartableEchoActor {
type Incoming = FuzzMsg;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: FuzzMsg) {
if msg.value == 0 {
panic!("fuzz: intentional panic for restart test");
}
if let Some(idx) = msg.reply_to_idx {
let reply = FuzzMsg { value: msg.value, reply_to_idx: None };
let _ = ctx.send(INBOX_ADDRS.lock_or_default().get(idx as usize), reply);
}
}
}
// ─── Shared Inbox Address Table ─────────────────────────────────────────────
struct InboxAddrs(Vec<ActorAddress>);
@ -205,6 +250,14 @@ enum RawAction {
DrainAll,
Tick,
TickN { n: u8 },
/// Graceful stop an actor
StopActor { actor_idx: u8 },
/// Spawn a restartable echo actor (max_restarts = n)
SpawnRestartable { max_restarts: u8 },
/// Schedule a one-shot timer from an actor to an inbox
ScheduleTimer { delay: u8 },
/// Schedule an interval timer from an actor to an inbox
ScheduleInterval { actor_idx: u8, period: u8 },
}
#[derive(Debug, Arbitrary)]
@ -753,6 +806,55 @@ impl FuzzState {
RawAction::DrainAll => { self.drain_all_inboxes(); }
RawAction::Tick => { self.tick(); }
RawAction::TickN { n } => { self.tick_n((*n).max(1).min(64) as usize); }
RawAction::StopActor { actor_idx } => {
if let Some(addr) = self.resolve_actor(*actor_idx) {
let label = self.actor_label(addr);
let _ = self.runtime.stop_actor(addr);
self.log(format_args!("[STOP] {label}"));
}
}
RawAction::SpawnRestartable { max_restarts } => {
let restarts = (*max_restarts).min(5) as u32;
if let Ok(addr) = self.runtime.spawn_restartable(
RestartableEchoActor,
|| RestartableEchoActor,
restarts,
) {
let id = self.actors.len();
self.actors.push((addr, ActorKind::Echo));
self.total_spawned += 1;
self.log(format_args!("[SPAWN] Restartable(max={restarts}) -> actor#{id}"));
}
}
RawAction::ScheduleTimer { delay } => {
// Create a timer scheduler actor, send it a message to trigger scheduling
let delay = (*delay).max(1).min(10) as u64;
if self.inboxes.is_empty() { self.new_inbox(); }
if let Some(idx) = self.resolve_inbox_idx(0) {
let target = *self.inboxes[idx].addr();
if let Ok(addr) = self.runtime.spawn(TimerSchedulerActor { target, delay }) {
let id = self.actors.len();
self.actors.push((addr, ActorKind::Echo));
self.total_spawned += 1;
self.tick(); // bring actor alive
self.send_msg(addr, FuzzMsg { value: 42, reply_to_idx: None });
self.log(format_args!("[TIMER] actor#{id} -> inbox#{idx} delay={delay}"));
}
}
}
RawAction::ScheduleInterval { actor_idx, period } => {
let period = (*period).max(1).min(5) as u64;
if self.inboxes.is_empty() { self.new_inbox(); }
if let Some(idx) = self.resolve_inbox_idx(*actor_idx) {
let target = *self.inboxes[idx].addr();
if let Ok(addr) = self.runtime.spawn(IntervalSchedulerActor { target, period }) {
let id = self.actors.len();
self.actors.push((addr, ActorKind::Echo));
self.total_spawned += 1;
self.log(format_args!("[INTVL] actor#{id} -> inbox#{idx} period={period}"));
}
}
}
}
}
}

576
tests/proptest_runtime.rs Normal file
View file

@ -0,0 +1,576 @@
//! Property-based tests for the swactor runtime.
//!
//! Uses proptest for randomized testing and proptest-state-machine for
//! stateful property testing with automatic shrinking of failing sequences.
use std::collections::HashMap;
use proptest::prelude::*;
use proptest_state_machine::{prop_state_machine, ReferenceStateMachine, StateMachineTest};
use swactor::actor::{ActorAddress, ActorInterface};
use swactor::config::{MailboxOverflow, RuntimeConfig};
use swactor::runtime::{Ctx, Inbox, Runtime};
// ─── Shared Actor Types ────────────────────────────────────────────────────
#[derive(Clone, Debug)]
struct Ping(u64);
/// Echo: receives Ping, sends Ping back to reply_to address.
struct EchoActor {
reply_to: ActorAddress,
}
impl ActorInterface for EchoActor {
type Incoming = Ping;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
let _ = ctx.send(self.reply_to, msg);
}
}
/// Counter: tracks message count, replies with count.
struct CounterActor {
count: u64,
reply_to: ActorAddress,
}
impl ActorInterface for CounterActor {
type Incoming = Ping;
type Response = ();
fn handle(&mut self, ctx: &Ctx, _msg: Ping) {
self.count += 1;
let _ = ctx.send(self.reply_to, Ping(self.count));
}
}
/// PanicActor: panics on first message.
struct PanicActor;
impl ActorInterface for PanicActor {
type Incoming = Ping;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {
panic!("intentional panic");
}
}
/// Noop: discards all messages silently.
struct NoopActor;
impl ActorInterface for NoopActor {
type Incoming = Ping;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {}
}
// ─── Simple Property Tests ─────────────────────────────────────────────────
proptest! {
/// FIFO ordering is preserved for any sequence of numbered messages
/// sent from a single sender to a single actor.
#[test]
fn fifo_ordering_for_any_message_sequence(
values in proptest::collection::vec(0u64..10_000, 1..100)
) {
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<Ping>().unwrap();
let addr = rt.spawn(EchoActor { reply_to: *inbox.addr() }).unwrap();
// Send all messages
for &v in &values {
rt.send_to(addr, Ping(v)).unwrap();
}
// Tick enough to process all
let ticks_needed = (values.len() / 64) + 3; // budget=64 default
for _ in 0..ticks_needed { rt.tick(); }
// Verify FIFO ordering
let mut received = Vec::new();
while let Some(msg) = inbox.try_recv() {
received.push(msg.0);
}
prop_assert_eq!(&received, &values, "FIFO ordering violated");
}
/// Budget fairness: no actor processes more than budget messages per tick
/// when multiple actors have pending messages.
#[test]
fn budget_limits_per_actor_processing(
n_actors in 2usize..10,
msgs_per in 10usize..100,
budget in 1usize..32,
) {
let config = RuntimeConfig {
actor_message_budget: budget,
..Default::default()
};
let rt = Runtime::new(config);
let inbox = rt.new_inbox::<Ping>().unwrap();
let mut addrs = Vec::new();
for _ in 0..n_actors {
addrs.push(rt.spawn(CounterActor { count: 0, reply_to: *inbox.addr() }).unwrap());
}
rt.tick(); // on_start
// Send msgs_per messages to each actor
for addr in &addrs {
for v in 0..msgs_per as u64 {
rt.send_to(*addr, Ping(v)).unwrap();
}
}
// Single tick — each actor should process at most `budget` messages
rt.tick();
// Drain inbox to count replies per actor
// CounterActor replies with incrementing count, so max reply value = messages processed
let mut replies = Vec::new();
while let Some(msg) = inbox.try_recv() {
replies.push(msg.0);
}
// Total replies should be at most n_actors * budget
prop_assert!(
replies.len() <= n_actors * budget,
"Too many messages processed: {} > {} (n_actors={}, budget={})",
replies.len(), n_actors * budget, n_actors, budget,
);
}
/// One-shot timer fires at exactly the right tick for any delay.
#[test]
fn one_shot_timer_fires_at_correct_tick(delay in 1u64..20) {
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<Ping>().unwrap();
struct TimerActor { target: ActorAddress, delay: u64 }
impl ActorInterface for TimerActor {
type Incoming = Ping;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {}
fn on_start(&mut self, ctx: &Ctx) {
ctx.send_after_ticks(self.target, Ping(42), self.delay);
}
}
let _addr = rt.spawn(TimerActor { target: *inbox.addr(), delay }).unwrap();
// Tick up to the expected fire tick
for tick in 1..=(delay + 1) {
rt.tick();
let msg = inbox.try_recv();
if tick <= delay {
prop_assert!(msg.is_none(), "Timer fired too early at tick {}", tick);
} else {
prop_assert!(msg.is_some(), "Timer should have fired at tick {}", tick);
}
}
// No second fire (one-shot)
rt.tick();
prop_assert!(inbox.try_recv().is_none(), "One-shot timer fired twice");
}
/// Interval timer fires at correct periodic ticks for any period.
#[test]
fn interval_timer_fires_at_correct_period(period in 1u64..10) {
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<Ping>().unwrap();
struct IntervalActor { target: ActorAddress, period: u64 }
impl ActorInterface for IntervalActor {
type Incoming = Ping;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {}
fn on_start(&mut self, ctx: &Ctx) {
ctx.send_interval_ticks(self.target, Ping(1), self.period);
}
}
let _addr = rt.spawn(IntervalActor { target: *inbox.addr(), period }).unwrap();
// Verify 3 consecutive fires
let mut fire_count = 0;
// Timer scheduled on tick 1 (on_start). First fire at tick 1+period.
for tick in 1..=(period * 3 + 2) {
rt.tick();
if let Some(_) = inbox.try_recv() {
fire_count += 1;
// First fire should be at tick (period + 1)
// Subsequent fires every `period` ticks after that
let expected_tick = period + 1 + (fire_count - 1) * period;
prop_assert_eq!(tick, expected_tick,
"Fire #{} at wrong tick (period={})", fire_count, period);
}
}
prop_assert!(fire_count >= 3, "Expected 3+ fires, got {} (period={})", fire_count, period);
}
/// Bounded mailbox with DropNewest never exceeds capacity.
#[test]
fn bounded_mailbox_never_exceeds_capacity(
capacity in 1usize..20,
msg_count in 1usize..200,
) {
let config = RuntimeConfig {
default_mailbox_capacity: capacity,
mailbox_overflow: MailboxOverflow::DropNewest,
..Default::default()
};
let rt = Runtime::new(config);
let addr = rt.spawn(NoopActor).unwrap();
rt.tick(); // on_start
for v in 0..msg_count as u64 {
rt.send_to(addr, Ping(v)).unwrap();
}
let stats = rt.stats();
let worker = &stats.workers[0];
// Mailbox depth should never exceed capacity
prop_assert!(
worker.mailbox_depth <= capacity,
"Mailbox depth {} exceeds capacity {}",
worker.mailbox_depth, capacity,
);
}
/// Spawn N actors and verify all get unique addresses and appear in stats.
#[test]
fn spawn_n_actors_all_tracked(n in 1usize..50) {
let rt = Runtime::new(RuntimeConfig::default());
let mut addrs = Vec::new();
for _ in 0..n {
addrs.push(rt.spawn(NoopActor).unwrap());
}
rt.tick(); // process spawns
let stats = rt.stats();
prop_assert_eq!(stats.actors.len(), n, "Expected {} actors in stats", n);
// All addresses should be unique
let unique: std::collections::HashSet<_> = addrs.iter().collect();
prop_assert_eq!(unique.len(), n, "Duplicate addresses detected");
}
}
// ─── State Machine Test ────────────────────────────────────────────────────
//
// Reference model tracks expected runtime state. Transitions are random
// operations (spawn, send, tick, stop). After each transition, invariants
// are checked against the actual runtime.
#[derive(Clone, Debug)]
struct RefState {
/// actor_id -> is_alive (not stopped/poisoned)
actors: HashMap<usize, bool>,
/// actor_id -> messages sent (values, in order)
sent_messages: HashMap<usize, Vec<u64>>,
/// Number of ticks executed
tick_count: u64,
/// Next actor ID to assign
next_id: usize,
/// IDs of actors that will panic on first message
panic_actors: Vec<usize>,
}
#[derive(Clone, Debug)]
enum Transition {
/// Spawn a new echo actor
SpawnEcho,
/// Spawn a panic-on-first-message actor
SpawnPanic,
/// Send a numbered message to actor at index
Send { actor_idx: usize, value: u64 },
/// Run one tick
Tick,
/// Run N ticks
TickN(u8),
/// Stop actor at index gracefully
StopActor(usize),
/// Check runtime stats match reference
CheckStats,
}
struct SwactorModel;
impl ReferenceStateMachine for SwactorModel {
type State = RefState;
type Transition = Transition;
fn init_state() -> BoxedStrategy<Self::State> {
Just(RefState {
actors: HashMap::new(),
sent_messages: HashMap::new(),
tick_count: 0,
next_id: 0,
panic_actors: Vec::new(),
})
.boxed()
}
fn transitions(state: &Self::State) -> BoxedStrategy<Self::Transition> {
let has_actors = !state.actors.is_empty();
let has_alive = state.actors.values().any(|&alive| alive);
if !has_actors {
// Must spawn first
prop_oneof![
3 => Just(Transition::SpawnEcho),
1 => Just(Transition::SpawnPanic),
]
.boxed()
} else if !has_alive {
// All actors dead, spawn new ones or tick to clean up
prop_oneof![
3 => Just(Transition::SpawnEcho),
1 => Just(Transition::SpawnPanic),
1 => Just(Transition::Tick),
]
.boxed()
} else {
let n = state.actors.len();
prop_oneof![
3 => Just(Transition::SpawnEcho),
1 => Just(Transition::SpawnPanic),
10 => (0..n, 0u64..1000).prop_map(|(idx, val)| Transition::Send {
actor_idx: idx,
value: val,
}),
5 => Just(Transition::Tick),
2 => (1u8..5).prop_map(Transition::TickN),
2 => (0..n).prop_map(Transition::StopActor),
1 => Just(Transition::CheckStats),
]
.boxed()
}
}
fn apply(mut state: Self::State, transition: &Self::Transition) -> Self::State {
match transition {
Transition::SpawnEcho => {
let id = state.next_id;
state.next_id += 1;
state.actors.insert(id, true);
state.sent_messages.insert(id, Vec::new());
}
Transition::SpawnPanic => {
let id = state.next_id;
state.next_id += 1;
state.actors.insert(id, true);
state.sent_messages.insert(id, Vec::new());
state.panic_actors.push(id);
}
Transition::Send { actor_idx, value } => {
let alive_ids: Vec<usize> = state
.actors
.iter()
.filter(|(_, alive)| **alive)
.map(|(&id, _)| id)
.collect();
if !alive_ids.is_empty() {
let id = alive_ids[*actor_idx % alive_ids.len()];
state
.sent_messages
.entry(id)
.or_default()
.push(*value);
}
}
Transition::Tick => {
state.tick_count += 1;
}
Transition::TickN(n) => {
state.tick_count += *n as u64;
}
Transition::StopActor(idx) => {
let alive_ids: Vec<usize> = state
.actors
.iter()
.filter(|(_, alive)| **alive)
.map(|(&id, _)| id)
.collect();
if !alive_ids.is_empty() {
let id = alive_ids[*idx % alive_ids.len()];
state.actors.insert(id, false);
}
}
Transition::CheckStats => {}
}
state
}
fn preconditions(state: &Self::State, transition: &Self::Transition) -> bool {
match transition {
Transition::Send { .. } | Transition::StopActor(_) => {
state.actors.values().any(|&alive| alive)
}
_ => true,
}
}
}
// ─── Concrete System Under Test ────────────────────────────────────────────
struct SutState {
runtime: Runtime,
inbox: Inbox<Ping>,
/// Maps reference actor_id to actual ActorAddress
actor_map: HashMap<usize, ActorAddress>,
/// Reference IDs that are panic actors
panic_ids: Vec<usize>,
/// Tracks which actor IDs are alive (mirrors ref model)
alive: HashMap<usize, bool>,
/// Next ID for spawn
next_id: usize,
}
struct SwactorTest;
impl StateMachineTest for SwactorTest {
type SystemUnderTest = SutState;
type Reference = SwactorModel;
fn init_test(_ref_state: &RefState) -> Self::SystemUnderTest {
let rt = Runtime::new(RuntimeConfig::default());
let inbox = rt.new_inbox::<Ping>().unwrap();
SutState {
runtime: rt,
inbox,
actor_map: HashMap::new(),
panic_ids: Vec::new(),
alive: HashMap::new(),
next_id: 0,
}
}
fn apply(
mut sut: Self::SystemUnderTest,
_ref_state: &RefState,
transition: Transition,
) -> Self::SystemUnderTest {
match transition {
Transition::SpawnEcho => {
let id = sut.next_id;
sut.next_id += 1;
let addr = sut
.runtime
.spawn(EchoActor {
reply_to: *sut.inbox.addr(),
})
.unwrap();
sut.actor_map.insert(id, addr);
sut.alive.insert(id, true);
}
Transition::SpawnPanic => {
let id = sut.next_id;
sut.next_id += 1;
let addr = sut.runtime.spawn(PanicActor).unwrap();
sut.actor_map.insert(id, addr);
sut.alive.insert(id, true);
sut.panic_ids.push(id);
}
Transition::Send { actor_idx, value } => {
let alive_ids: Vec<usize> = sut
.alive
.iter()
.filter(|(_, alive)| **alive)
.map(|(&id, _)| id)
.collect();
if !alive_ids.is_empty() {
let id = alive_ids[actor_idx % alive_ids.len()];
if let Some(&addr) = sut.actor_map.get(&id) {
let _ = sut.runtime.send_to(addr, Ping(value));
}
}
}
Transition::Tick => {
sut.runtime.tick();
}
Transition::TickN(n) => {
for _ in 0..n {
sut.runtime.tick();
}
}
Transition::StopActor(idx) => {
let alive_ids: Vec<usize> = sut
.alive
.iter()
.filter(|(_, alive)| **alive)
.map(|(&id, _)| id)
.collect();
if !alive_ids.is_empty() {
let id = alive_ids[idx % alive_ids.len()];
sut.alive.insert(id, false);
if let Some(&addr) = sut.actor_map.get(&id) {
let _ = sut.runtime.stop_actor(addr);
}
}
}
Transition::CheckStats => {
let stats = sut.runtime.stats();
assert!(stats.num_workers >= 1);
for info in &stats.workers {
assert!(info.id < stats.num_workers);
}
}
}
sut
}
fn check_invariants(sut: &Self::SystemUnderTest, _ref_state: &RefState) {
let stats = sut.runtime.stats();
// Invariant 1: worker count is consistent
assert_eq!(stats.workers.len(), stats.num_workers);
// Invariant 2: all actors in stats are on valid workers
for (_, wid) in &stats.actors {
assert!(
*wid < stats.num_workers,
"Actor on worker {} but only {} workers",
wid,
stats.num_workers
);
}
// Invariant 3: per-worker actor count <= address map count
// (workers lag behind address map because they drain spawn queue on tick)
let worker_actor_count: usize = stats.workers.iter().map(|w| w.num_actors).sum();
assert!(
worker_actor_count <= stats.actors.len(),
"Worker actor count {} > address map count {}",
worker_actor_count,
stats.actors.len(),
);
// Invariant 4: inbox can be drained without panic
// (type-safety of inbox messages)
while let Some(_msg) = sut.inbox.try_recv() {
// Just verify no panic on try_recv
}
// Invariant 5: stats queries don't panic
let _total_processed: u64 = stats.workers.iter().map(|w| w.messages_processed).sum();
let _total_depth: usize = stats.workers.iter().map(|w| w.mailbox_depth).sum();
}
fn teardown(_sut: Self::SystemUnderTest) {
// Runtime drops normally
}
}
prop_state_machine! {
#![proptest_config(proptest::test_runner::Config {
cases: 128,
max_shrink_iters: 10_000,
.. proptest::test_runner::Config::default()
})]
/// Given a random sequence of spawn/send/tick/stop operations,
/// when applied to a swactor runtime,
/// then all invariants (worker consistency, mailbox safety, stats accuracy) hold.
#[test]
fn swactor_state_machine(sequential 1..40 => SwactorTest);
}