Skip to content

Commit ffaa409

Browse files
fiskusclaude
andcommitted
Count one expired session once, not once per package per tick
`autosync_login_required` fired on every backoff-due tick, for every package on the deployment — so one expiry became as many events as the user had packages, times as many ticks as they took to log back in. Bounded by backoff, unbounded in meaning. The episode is per **deployment**, not per package: one expired session blocks every package on that host, and they reach the code one per loop iteration. So `login_episode` asks whether any other namespace is already blocked on this host — before recording the failing one, or it would look like a continuation of itself — and only `Began` emits. The state it reads already existed; `login_blocked` is keyed by namespace with the host as its value. `LoginBlock` rather than a bool, because `report_login_required(host, true)` says nothing at a call site. The UI and log reporters take it and ignore it: they re-render the same affordance idempotently, so nothing changes about when anyone is *told* — only about what is counted. Tested on the classifier rather than through `run_once`, and the reason is worth recording: the loop prunes `login_blocked` to currently-installed namespaces on entry, so a sibling cannot be injected without a second installed package, and installation is not the property under test. My first attempt at a loop-level test failed exactly there. Three cases: fresh host begins, sibling on the same host continues, a different host begins its own. Plus one for the unattributed case — two unknowns collapse, but an unknown must never be taken for a known host. `Began` is still covered end-to-end by the existing login-required tick test. just lint 0, cargo fmt --check 0, 312 quilt-sync tests, workspace green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 93f77d2 commit ffaa409

4 files changed

Lines changed: 127 additions & 19 deletions

File tree

quilt-sync/src-tauri/src/autopull/reporter.rs

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,21 @@ use crate::telemetry::event::{
1515
};
1616
use crate::telemetry::prelude::*;
1717

18+
/// Whether a deployment's session has *just* become unusable, or was already.
19+
///
20+
/// The distinction exists for telemetry: the loop rediscovers an expired session
21+
/// on every backoff-due tick, for every package on that deployment, so reporting
22+
/// each discovery would count one expiry many times over. The UI wants the
23+
/// opposite — it re-renders the same affordance idempotently and does not care —
24+
/// so this informs telemetry without changing when anyone is told.
25+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26+
pub enum LoginBlock {
27+
/// No package on this deployment was blocked before. The episode starts here.
28+
Began,
29+
/// Another package on the same deployment is already blocked, or this one was.
30+
Continues,
31+
}
32+
1833
/// Event names. Kept in lockstep with the UI's `listen(...)` calls.
1934
pub const STATUS_EVENT: &str = "package-status-changed";
2035
pub const LOGIN_REQUIRED_EVENT: &str = "autosync-login-required";
@@ -201,7 +216,10 @@ pub trait StatusReporter: Send + Sync + 'static {
201216
/// lineage has no origin before doing work, so an outcome it reports always
202217
/// concerns a known deployment.
203218
fn report_paused(&self, namespace: &Namespace, host: &Host, reason: PausedReason);
204-
fn report_login_required(&self, host: Option<&Host>);
219+
/// `block` distinguishes the deployment's session *becoming* unusable from
220+
/// the loop retrying while it stays that way. Only the transition is worth
221+
/// counting; the retries are a log line.
222+
fn report_login_required(&self, host: Option<&Host>, block: LoginBlock);
205223
fn report_subscriber_error(&self, event: SubscriberErrorEvent) {
206224
warn!(
207225
"fswatcher: kind={} namespace={:?} message={}",
@@ -233,11 +251,11 @@ impl StatusReporter for LogReporter {
233251
info!("autosync: paused namespace={namespace} host={host} reason={reason:?}");
234252
}
235253

236-
fn report_login_required(&self, host: Option<&Host>) {
254+
fn report_login_required(&self, host: Option<&Host>, block: LoginBlock) {
237255
if let Some(h) = host {
238-
warn!("autosync: login required for {h}");
256+
warn!("autosync: login required for {h} ({block:?})");
239257
} else {
240-
warn!("autosync: login required");
258+
warn!("autosync: login required ({block:?})");
241259
}
242260
}
243261
}
@@ -298,11 +316,16 @@ impl StatusReporter for TelemetryReporter {
298316
self.inner.report_paused(namespace, host, reason);
299317
}
300318

301-
fn report_login_required(&self, host: Option<&Host>) {
302-
self.emit(MixpanelEvent::AutosyncLoginRequired(AutosyncAuthEvent {
303-
host: host.cloned(),
304-
}));
305-
self.inner.report_login_required(host);
319+
fn report_login_required(&self, host: Option<&Host>, block: LoginBlock) {
320+
// Once per deployment per episode. The loop rediscovers the same expired
321+
// session on every backoff-due tick and for every package on that host, so
322+
// counting discoveries would report one expiry as many.
323+
if block == LoginBlock::Began {
324+
self.emit(MixpanelEvent::AutosyncLoginRequired(AutosyncAuthEvent {
325+
host: host.cloned(),
326+
}));
327+
}
328+
self.inner.report_login_required(host, block);
306329
}
307330

308331
fn report_subscriber_error(&self, event: SubscriberErrorEvent) {
@@ -356,11 +379,11 @@ impl StatusReporter for TauriEventReporter {
356379
}
357380
}
358381

359-
fn report_login_required(&self, host: Option<&Host>) {
382+
fn report_login_required(&self, host: Option<&Host>, block: LoginBlock) {
360383
if let Some(h) = host {
361-
warn!("autosync: login required for {h}");
384+
warn!("autosync: login required for {h} ({block:?})");
362385
} else {
363-
warn!("autosync: login required");
386+
warn!("autosync: login required ({block:?})");
364387
}
365388
// TODO(autosync/03-merge-conflicts.md): no UI listener yet.
366389
let payload = LoginRequiredEvent {
@@ -611,6 +634,9 @@ pub(crate) mod test_support {
611634
pub statuses: Mutex<Vec<(Namespace, PackageStatusEvent)>>,
612635
pub paused: Mutex<Vec<(Namespace, PausedReason)>>,
613636
pub logins: Mutex<Vec<Option<Host>>>,
637+
/// Whether each login report was the start of an episode or a repeat, so a
638+
/// test can assert one expiry is counted once.
639+
pub login_blocks: Mutex<Vec<LoginBlock>>,
614640
pub subscriber_errors: Mutex<Vec<SubscriberErrorEvent>>,
615641
pub published: Mutex<Vec<(Namespace, String)>>,
616642
/// Hosts seen on the outcomes that carry one, so a test can assert the
@@ -635,7 +661,8 @@ pub(crate) mod test_support {
635661
.push((namespace.clone(), reason));
636662
}
637663

638-
fn report_login_required(&self, host: Option<&Host>) {
664+
fn report_login_required(&self, host: Option<&Host>, block: LoginBlock) {
665+
self.login_blocks.lock().unwrap().push(block);
639666
self.logins.lock().unwrap().push(host.cloned());
640667
}
641668

quilt-sync/src-tauri/src/autopull/tick.rs

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use quilt_uri::Namespace;
1010
use crate::Error;
1111
use crate::autopull::PausedReason;
1212
use crate::autopull::WatcherInner;
13+
use crate::autopull::reporter::LoginBlock;
1314
use crate::autopull::reporter::PackageStatusEvent;
1415
use crate::autopull::reporter::clean_uptodate_fingerprint;
1516
use crate::autopull::reporter::status_fingerprint;
@@ -53,6 +54,28 @@ impl RefreshOutcome {
5354
}
5455
}
5556

57+
/// Whether a login failure *starts* an episode for its deployment, or joins one.
58+
///
59+
/// Per deployment rather than per package, and that is the whole point: one expired
60+
/// session blocks every package on the host, and they reach this code one per loop
61+
/// iteration. A per-package answer would report one expiry as many.
62+
///
63+
/// Asked *before* the failing namespace is recorded, so its own entry cannot make
64+
/// it look like a continuation of itself.
65+
fn login_episode(
66+
blocked: &std::collections::BTreeMap<Namespace, Option<Host>>,
67+
host: Option<&Host>,
68+
) -> LoginBlock {
69+
if blocked
70+
.values()
71+
.any(|blocked_host| blocked_host.as_ref() == host)
72+
{
73+
LoginBlock::Continues
74+
} else {
75+
LoginBlock::Began
76+
}
77+
}
78+
5679
#[derive(Debug)]
5780
pub(crate) enum WatchError {
5881
Conflict(PausedReason),
@@ -515,12 +538,18 @@ pub(crate) async fn run_once(
515538
Err(WatchError::LoginRequired(host)) => {
516539
// Backoff until the user re-auths; the Ok arm clears it.
517540
bump_backoff(&mut *inner.backoff.write().await, &namespace, now);
518-
inner
519-
.login_blocked
520-
.write()
521-
.await
522-
.insert(namespace.clone(), host.clone());
523-
inner.reporter.report_login_required(host.as_ref());
541+
// The episode is per *deployment*, not per package: one expired
542+
// session blocks every package on that host, and they arrive one
543+
// per loop iteration. Asking whether any other namespace is
544+
// already blocked on this host — before inserting this one — is
545+
// what makes it countable once.
546+
let block = {
547+
let mut blocked = inner.login_blocked.write().await;
548+
let block = login_episode(&blocked, host.as_ref());
549+
blocked.insert(namespace.clone(), host.clone());
550+
block
551+
};
552+
inner.reporter.report_login_required(host.as_ref(), block);
524553
inner.aggregator.note_login_required(&namespace, host);
525554
}
526555
Err(WatchError::Conflict(reason)) => {

quilt-sync/src-tauri/src/autopull/tick/tests.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,3 +1053,50 @@ async fn conflict_emit_carries_stable_fingerprint() -> Result<(), Error> {
10531053
assert!(!event.fingerprint.is_empty());
10541054
Ok(())
10551055
}
1056+
1057+
/// One expired session is **one** episode, however many packages it blocks.
1058+
///
1059+
/// Tested on the classifier rather than through `run_once`, because the loop
1060+
/// prunes `login_blocked` to currently-installed namespaces on entry — so a
1061+
/// sibling cannot be injected without a second installed package, and the
1062+
/// property under test is not about installation.
1063+
#[test]
1064+
fn login_episode_counts_per_deployment_not_per_package() {
1065+
let host: Host = "catalog.dev".parse().unwrap();
1066+
let other: Host = "elsewhere.dev".parse().unwrap();
1067+
1068+
let mut blocked = BTreeMap::new();
1069+
assert_eq!(
1070+
login_episode(&blocked, Some(&host)),
1071+
LoginBlock::Began,
1072+
"nothing is blocked yet, so this failure starts the episode"
1073+
);
1074+
1075+
blocked.insert(("acme", "first").into(), Some(host.clone()));
1076+
assert_eq!(
1077+
login_episode(&blocked, Some(&host)),
1078+
LoginBlock::Continues,
1079+
"a sibling on the same deployment means the session was already known bad"
1080+
);
1081+
assert_eq!(
1082+
login_episode(&blocked, Some(&other)),
1083+
LoginBlock::Began,
1084+
"a different deployment's session expiring is its own episode"
1085+
);
1086+
}
1087+
1088+
/// An unattributed failure is its own episode, and does not merge with an
1089+
/// attributed one — two unknowns are indistinguishable, so they collapse, but an
1090+
/// unknown must never be taken for a known host.
1091+
#[test]
1092+
fn an_unattributed_login_failure_does_not_join_a_hosts_episode() {
1093+
let host: Host = "catalog.dev".parse().unwrap();
1094+
let mut blocked = BTreeMap::new();
1095+
blocked.insert(("acme", "first").into(), Some(host));
1096+
1097+
assert_eq!(
1098+
login_episode(&blocked, None),
1099+
LoginBlock::Began,
1100+
"a failure that could not name its deployment is not that deployment's"
1101+
);
1102+
}

quilt-sync/src-tauri/src/autopull/tick/tests/publish.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,11 @@ async fn run_once_login_required_on_publish() -> Result<(), Error> {
682682
let logins = reporter.logins.lock().unwrap();
683683
assert_eq!(logins.len(), 1);
684684
assert_eq!(logins[0].as_ref(), Some(&host));
685+
assert_eq!(
686+
*reporter.login_blocks.lock().unwrap(),
687+
vec![LoginBlock::Began],
688+
"the first discovery starts the episode"
689+
);
685690
Ok(())
686691
}
687692

0 commit comments

Comments
 (0)