Skip to content
Open
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
10 changes: 10 additions & 0 deletions apps/staged/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@ tiny-skia = "0.12"
tar = "0.4"
flate2 = "1"

[dev-dependencies]
# test-util: `#[tokio::test(start_paused = true)]` for the store-events
# coalescer tests, so the 50ms window is driven by the paused clock instead
# of a real sleep.
tokio = { version = "1.50.0", features = ["test-util"] }
# test: tauri's `MockRuntime`, so window_commands' failure paths can be driven
# without a real event loop. Feature unification enables it only when
# compiling tests; the shipped binary's tauri is unchanged.
tauri = { version = "2.10.2", features = ["test"] }

[features]
# no-block-npm-registry: downloads the managed Node.js runtime from upstream
# nodejs.org instead of Block's Artifactory mirror, and lets npm-backed
Expand Down
3 changes: 2 additions & 1 deletion apps/staged/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": ["main"],
"windows": ["main", "win-*"],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
Expand All @@ -13,6 +13,7 @@
"window-state:default",
"store:default",
"core:window:allow-set-badge-count",
"core:window:allow-set-title",
"dialog:default",
"process:allow-restart",
"updater:default",
Expand Down
361 changes: 329 additions & 32 deletions apps/staged/src-tauri/src/lib.rs

Large diffs are not rendered by default.

82 changes: 68 additions & 14 deletions apps/staged/src-tauri/src/pr_poll_scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,28 @@
//!
//! ## Per-client interest (Phase 2)
//!
//! Interest is tracked **per connected client** — the native Tauri window plus
//! Interest is tracked **per connected client** — each native Tauri window plus
//! each WebSocket browser session — keyed by a frontend-supplied `client_id`.
//! The cadence for a project is the union across all clients ([`PollState::any_focused`],
//! [`PollState::is_foreground`], [`PollState::project_has_pending`]), so a project
//! that any client cares about is polled at the appropriate tier; the *work*
//! bookkeeping (`last_polled_at`/`failures`/`stale`/`forced`) stays project-keyed
//! and shared, so N clients still trigger only one poll per project per tier.
//!
//! Clients are evicted on disconnect (clean WS close ⇒ [`PrPollScheduler::disconnect_client`])
//! and via a [`CLIENT_TTL_MS`] fallback for dirty drops ([`PollState::evict_stale_clients`],
//! swept each tick). The native window uses the fixed [`TAURI_CLIENT_ID`], which
//! is pre-seeded at launch and exempt from TTL eviction (process death is its
//! teardown), so single-client behaviour stays byte-for-byte equivalent to Phase 1.
//! Clients are evicted on disconnect (clean WS close or native window destroyed
//! ⇒ [`PrPollScheduler::disconnect_client`]) and via a [`CLIENT_TTL_MS`] fallback
//! for dirty drops ([`PollState::evict_stale_clients`], swept each tick). Native
//! windows use `tauri-{window label}` ids ([`TAURI_CLIENT_PREFIX`]), which are
//! exempt from TTL eviction — they have no WS heartbeat; the first window's id
//! ([`TAURI_CLIENT_ID`]) is pre-seeded at launch, so single-window behaviour
//! stays byte-for-byte equivalent to Phase 1.
//!
//! The TTL exemption is only sound because the `tauri-*` namespace is
//! *reserved*: [`is_reserved_client_id`] names the invariant, and the web
//! boundaries in `web_server.rs` (the `/api/events` WS `clientId` and the
//! PR-poll `/api/dispatch` verbs) reject ids that claim it. An exempt entry
//! must have a window-`Destroyed` teardown behind it, so the exemption and the
//! rejection are one invariant split across two files.
//!
//! Poll-state (last-polled timestamps, failure counts) is intentionally **not
//! persisted** — on restart everything is "due", matching the frontend's
Expand Down Expand Up @@ -68,9 +77,15 @@ const MAX_CONSECUTIVE_FAILURES: u32 = 3;
/// wake the loop immediately, so this only bounds the *periodic* re-poll delay.
const TICK_INTERVAL_SECS: u64 = 5;

/// Well-known id for the native Tauri window. It has no WS heartbeat (the
/// process dying is its teardown), so it is pre-seeded at launch and exempt from
/// TTL eviction. Must match `TAURI_CLIENT_ID` in `prPollingService.ts`.
/// Id prefix for native Tauri windows: `tauri-{window label}`. Native windows
/// have no WS heartbeat, so ids with this prefix are exempt from TTL eviction —
/// their teardown is the window being destroyed (the `on_window_event` hook in
/// `lib.rs` calls [`PrPollScheduler::disconnect_client`]) or the process dying.
/// Must match the prefix used in `prPollingService.ts`.
pub const TAURI_CLIENT_PREFIX: &str = "tauri-";

/// Well-known id for the first native window (label `main`). Pre-seeded at
/// launch as focused so the very first tick polls before any hint arrives.
const TAURI_CLIENT_ID: &str = "tauri-main";

/// How long a client's interest survives without a heartbeat before the tick
Expand All @@ -80,6 +95,20 @@ const TAURI_CLIENT_ID: &str = "tauri-main";
/// counted client to ≲6. The Tauri id is exempt.
const CLIENT_TTL_MS: i64 = 90_000;

/// Whether a caller-supplied client id claims the native-window namespace.
///
/// `tauri-*` ids are exempt from TTL eviction ([`PollState::evict_stale_clients`]),
/// which is only safe when the id was minted by native window code — teardown is
/// then guaranteed by the window-`Destroyed` hook in `lib.rs`. Web boundaries (the
/// `/api/events` WS `clientId`, the PR-poll `/api/dispatch` verbs) must reject
/// these: a web client claiming one would leak its interest forever on a dirty
/// drop (nothing evicts it, and no window exists to be destroyed), or spoof a real
/// window's entry. Legitimate web clients use a UUID, so rejecting the namespace
/// can never hit one.
pub fn is_reserved_client_id(id: &str) -> bool {
id.starts_with(TAURI_CLIENT_PREFIX)
}

// ---------------------------------------------------------------------------
// Poll-state — pure decision logic, no clock / store / Tauri handles
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -310,12 +339,14 @@ impl PollState {
self.clients.remove(client_id);
}

/// Dirty-drop fallback: evict clients not heard from within `ttl_ms`. The
/// Tauri id is exempt (the native window has no WS heartbeat; the process
/// dying tears it down).
/// Dirty-drop fallback: evict clients not heard from within `ttl_ms`.
/// Native window ids ([`is_reserved_client_id`]) are exempt — they have no WS
/// heartbeat; window destruction or process death tears them down. That
/// guarantee holds only because the web boundaries reject the reserved
/// namespace, so an exempt id here is always a real window's.
fn evict_stale_clients(&mut self, now: i64, ttl_ms: i64) {
self.clients
.retain(|id, c| id == TAURI_CLIENT_ID || now.saturating_sub(c.last_seen) <= ttl_ms);
.retain(|id, c| is_reserved_client_id(id) || now.saturating_sub(c.last_seen) <= ttl_ms);
}
}

Expand Down Expand Up @@ -959,16 +990,39 @@ mod tests {
st.set_focus("web", true, 0);
st.set_foreground("web", Some("p".into()), 0);
assert!(st.is_foreground("p"));
// A second native window: no heartbeat, idle since launch.
st.set_foreground("tauri-win-2", Some("q".into()), 0);

// Sweep well past the TTL relative to last_seen = 0.
st.evict_stale_clients(CLIENT_TTL_MS + 1, CLIENT_TTL_MS);

// The stale web client is gone; its interest no longer counts.
assert!(!st.clients.contains_key("web"));
assert!(!st.is_foreground("p"));
// The Tauri client is exempt despite last_seen = 0, and stays focused.
// Native window ids are exempt despite last_seen = 0: the first window
// stays focused and the idle second window keeps its foreground.
assert!(st.clients.contains_key(TAURI_CLIENT_ID));
assert!(st.any_focused());
assert!(st.is_foreground("q"));

// A destroyed native window is torn down via explicit disconnect.
st.disconnect_client("tauri-win-2");
assert!(!st.is_foreground("q"));
}

#[test]
fn reserved_client_ids_are_the_tauri_namespace() {
// Exactly the ids the native windows mint (see `prPollingService.ts`).
assert!(is_reserved_client_id(TAURI_CLIENT_ID));
assert!(is_reserved_client_id("tauri-win-2"));
// Web ids never claim the namespace; the match is an exact, case-
// sensitive prefix, matching the frontend's lowercase minting.
assert!(!is_reserved_client_id("3f1a-uuid"));
assert!(!is_reserved_client_id(""));
assert!(!is_reserved_client_id("TAURI-main"));
assert!(!is_reserved_client_id("tauri"));
assert!(!is_reserved_client_id(" tauri-main"));
assert!(!is_reserved_client_id("web-tauri-main"));
}

#[test]
Expand Down
63 changes: 61 additions & 2 deletions apps/staged/src-tauri/src/store/branch_move.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
use rusqlite::{params, OptionalExtension};

use super::models::ProjectRepo;
use super::{now_timestamp, Store, StoreError};
use super::{now_timestamp, Store, StoreChange, StoreError};

/// Which `project_repos` row the moved branch points at once it lands.
///
Expand Down Expand Up @@ -153,6 +153,26 @@ impl Store {
elect_primary_repo(&tx, &mv.target_project_id, now)?;

tx.commit()?;
// A move mutates *two* projects' branch surfaces, and `project_id` on a
// `Branch` change means "this project's branch list is affected", not
// "this branch's current parent" — so name both. Without the source
// publish nothing in the feed says the branch left, and a consumer that
// scopes its invalidation to the named project would leave the branch
// listed under its old project too.
self.publish(StoreChange::Branch {
branch_id: mv.branch_id.clone(),
project_id: Some(mv.target_project_id.clone()),
});
self.publish(StoreChange::Branch {
branch_id: mv.branch_id.clone(),
project_id: Some(mv.source_project_id.clone()),
});
self.publish(StoreChange::Project {
project_id: Some(mv.source_project_id.clone()),
});
self.publish(StoreChange::Project {
project_id: Some(mv.target_project_id.clone()),
});
Ok(())
}
}
Expand Down Expand Up @@ -294,7 +314,12 @@ mod tests {
/// A branch on its own `project_repos` row in `source`, plus an empty
/// `target` project to move it into.
fn fixture() -> Fixture {
let store = Store::in_memory().unwrap();
fixture_in(Store::in_memory().unwrap())
}

/// [`fixture`] over a caller-supplied store, so the change-feed test can
/// build the same shape on a store with a sender attached.
fn fixture_in(store: Store) -> Fixture {
let source = Project::named("source").with_primary_repo("acme/widgets");
let target = Project::named("target");
store.create_project(&source).unwrap();
Expand Down Expand Up @@ -327,6 +352,40 @@ mod tests {
}
}

/// A move rewrites two projects' branch lists, so the feed names both. A
/// consumer that scopes its invalidation to the named project has no other
/// way to hear that the branch left the source — the branch row itself now
/// points at the target, so an enrichment lookup could never resolve it.
#[test]
fn change_feed_publishes_one_branch_change_per_touched_project() {
let (tx, mut rx) = tokio::sync::broadcast::channel(64);
let f = fixture_in(Store::in_memory().unwrap().with_change_sender(tx));
while rx.try_recv().is_ok() {}

f.store.move_branch_to_project(&reparent(&f, None)).unwrap();

let branch_change = |project_id: &str| StoreChange::Branch {
branch_id: f.branch.id.clone(),
project_id: Some(project_id.to_string()),
};
assert_eq!(rx.try_recv().unwrap(), branch_change(&f.target.id));
assert_eq!(rx.try_recv().unwrap(), branch_change(&f.source.id));
// The two `Project` changes cover the repo re-election on either side.
assert_eq!(
rx.try_recv().unwrap(),
StoreChange::Project {
project_id: Some(f.source.id.clone()),
}
);
assert_eq!(
rx.try_recv().unwrap(),
StoreChange::Project {
project_id: Some(f.target.id.clone()),
}
);
assert!(rx.try_recv().is_err());
}

#[test]
fn carries_the_branch_its_repo_row_and_its_images() {
let f = fixture();
Expand Down
Loading