Skip to content

Commit c60660e

Browse files
verify lifecycle implementation with Kani and Shuttle (#98)
1 parent 90fd0ad commit c60660e

21 files changed

Lines changed: 667 additions & 3760 deletions

.github/workflows/formal.yaml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,13 @@ jobs:
1414
runs-on: ubuntu-latest
1515
steps:
1616
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
17+
- name: Verify lifecycle implementation with Kani
18+
uses: model-checking/kani-github-action@2534b7aeb3c6b4b0a5ecc3981bb3e63d47b5b126 # v1
19+
with:
20+
kani-version: "0.67.0"
21+
command: cargo-kani
22+
args: "-p wisp-lifecycle"
1723
- uses: ./.github/actions/setup-nix
18-
- run: nix build .#checks.x86_64-linux.formal --print-build-logs
24+
- uses: ./.github/actions/setup-rust-cache
25+
- name: Explore lifecycle interleavings with Shuttle
26+
run: nix develop .#ci --quiet --command cargo test -p wisp-lifecycle --test shuttle_lifecycle

Cargo.lock

Lines changed: 109 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ members = [
55
"crates/wisp-audiokit",
66
"crates/wisp-audiokit-sys",
77
"crates/wisp-core",
8+
"crates/wisp-lifecycle",
89
"crates/wisp-storage",
910
]
1011
resolver = "3"
@@ -23,6 +24,10 @@ tempfile = "3.27.0"
2324
thiserror = "2.0.18"
2425
uuid = { version = "1.23.1", features = ["v4"] }
2526

27+
[workspace.lints.rust.unexpected_cfgs]
28+
level = "warn"
29+
check-cfg = ["cfg(kani)"]
30+
2631
[workspace.lints.clippy]
2732
# The toolchain is pinned (see rust-toolchain.toml), so the exact set of
2833
# lints is deterministic. That lets us treat the well-behaved groups as a

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,15 @@ cargo build -p wisp-desktop --release
7474

7575
### Formal verification
7676

77-
The session worker protocol and the whole-app navigation/session workflow have
78-
executable TLA+ models plus symbolic one-step invariant proofs in Z3:
77+
The session worker protocol and navigation/session guards are checked against
78+
the production Rust implementation with Kani and Shuttle:
7979

8080
```bash
81-
nix develop .#formal --command bash formal/check.sh
81+
bash formal/check.sh
8282
```
8383

84-
See [`formal/README.md`](formal/README.md) for the verified properties,
85-
implementation mapping, assumptions, and extension workflow.
84+
See [`formal/README.md`](formal/README.md) for setup, verified properties, and
85+
the extension workflow.
8686

8787
See `.github/workflows/release.yaml` for how the release `.app` bundle is produced — pushing a `v*` tag builds `Wisp.app` on a macOS 26 runner.
8888

@@ -116,7 +116,7 @@ MCP hosts should run the bundled `wisp-mcp` binary over stdio, for example `/App
116116

117117
## Contributing
118118

119-
Issues and pull requests are welcome. Before sending a PR, please make sure `cargo fmt`, `cargo clippy --workspace --all-targets`, `cargo test --workspace`, and `nix develop .#formal --command bash formal/check.sh` pass under the same conditions as CI. For the Swift side, `make -C native/WispAudioKit` runs the equivalent checks.
119+
Issues and pull requests are welcome. Before sending a PR, please make sure `cargo fmt`, `cargo clippy --workspace --all-targets`, `cargo test --workspace`, and `bash formal/check.sh` pass under the same conditions as CI. For the Swift side, `make -C native/WispAudioKit` runs the equivalent checks.
120120

121121
## License
122122

apps/wisp-desktop/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ unsafe_code = "deny"
2525

2626
[dependencies]
2727
wisp-core = { path = "../../crates/wisp-core" }
28+
wisp-lifecycle = { path = "../../crates/wisp-lifecycle" }
2829
wisp-storage = { path = "../../crates/wisp-storage" }
2930
wisp-audiokit = { path = "../../crates/wisp-audiokit" }
3031

apps/wisp-desktop/src/app.rs

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use wisp_audiokit::{
1818
SessionError, SessionResult, SourceLabel, local_model_spec, local_model_status,
1919
};
2020
use wisp_core::{Session as StoredSession, SessionId};
21+
use wisp_lifecycle::{Phase, UpdateContext, ViewOwner, WorkerUpdate, can_replace_transcript};
2122

2223
#[derive(Debug, Clone)]
2324
pub enum AppError {
@@ -55,15 +56,51 @@ pub enum SessionState {
5556
}
5657

5758
impl SessionState {
59+
#[must_use]
60+
pub const fn phase(self) -> Phase {
61+
match self {
62+
Self::Idle => Phase::Idle,
63+
Self::Starting => Phase::Starting,
64+
Self::Recording { .. } => Phase::Recording,
65+
Self::Stopping => Phase::Stopping,
66+
Self::Failed => Phase::Failed,
67+
}
68+
}
69+
5870
/// Whether an audio session is running or changing lifecycle state.
5971
/// While this is true the live transcript is the persistence source and
6072
/// must not be replaced by another view's segments.
6173
#[must_use]
6274
pub const fn is_active(self) -> bool {
63-
matches!(
64-
self,
65-
Self::Starting | Self::Recording { .. } | Self::Stopping
66-
)
75+
self.phase().is_active()
76+
}
77+
78+
#[must_use]
79+
pub fn with_phase(
80+
self,
81+
phase: Phase,
82+
recording_started_at: Instant,
83+
) -> Self {
84+
match phase {
85+
Phase::Idle => Self::Idle,
86+
Phase::Starting => Self::Starting,
87+
Phase::Recording => Self::Recording {
88+
started_at: recording_started_at,
89+
},
90+
Phase::Stopping => Self::Stopping,
91+
Phase::Failed => Self::Failed,
92+
}
93+
}
94+
95+
#[must_use]
96+
pub const fn request_stop(self) -> Self {
97+
match self.phase().request_stop() {
98+
Phase::Stopping => Self::Stopping,
99+
Phase::Idle => Self::Idle,
100+
Phase::Starting => Self::Starting,
101+
Phase::Recording => self,
102+
Phase::Failed => Self::Failed,
103+
}
67104
}
68105
}
69106

@@ -292,6 +329,41 @@ pub struct AppModel {
292329
}
293330

294331
impl AppModel {
332+
fn view_owner(&self) -> ViewOwner {
333+
match self.view {
334+
View::Library => ViewOwner::Library,
335+
View::LiveSession => ViewOwner::Live,
336+
View::History { .. } => ViewOwner::History,
337+
}
338+
}
339+
340+
#[must_use]
341+
pub fn accepts_worker_update(
342+
&self,
343+
session_id: SessionId,
344+
) -> bool {
345+
UpdateContext {
346+
phase: self.state.phase(),
347+
view: self.view_owner(),
348+
current_session_id: self.current_session_id.map(SessionId::as_i64),
349+
}
350+
.accepts(session_id.as_i64())
351+
}
352+
353+
#[must_use]
354+
pub fn worker_update_phase(
355+
&self,
356+
session_id: SessionId,
357+
update: WorkerUpdate,
358+
) -> Option<Phase> {
359+
UpdateContext {
360+
phase: self.state.phase(),
361+
view: self.view_owner(),
362+
current_session_id: self.current_session_id.map(SessionId::as_i64),
363+
}
364+
.next_phase(session_id.as_i64(), update)
365+
}
366+
295367
pub fn new() -> Self {
296368
Self {
297369
state: SessionState::Idle,
@@ -332,9 +404,11 @@ impl AppModel {
332404
/// A retained database handle after a failed finalization is deliberately
333405
/// treated as unsettled so navigation cannot silently discard it.
334406
pub fn has_unsettled_session(&self) -> bool {
335-
self.state.is_active()
336-
|| self.pending_session_write.is_some()
337-
|| self.current_output_dir.is_some()
407+
!can_replace_transcript(
408+
self.state.phase(),
409+
self.pending_session_write.is_some(),
410+
self.current_output_dir.is_some(),
411+
)
338412
}
339413

340414
/// A stopped worker whose transcript transaction needs to be retried.

0 commit comments

Comments
 (0)