From 74aa6c1d0a0f356adcb0082abd839986c94b71ed Mon Sep 17 00:00:00 2001 From: Nils Lehnen <30603423+iderex@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:21:12 +0200 Subject: [PATCH] Hold 0114's two acts, and put the local half of a sign-out before the request 0114 decides that signing out and forgetting a server are two acts rather than one, that the local half of a sign-out completes whatever the network is doing, that four kinds of work in flight each end in a named state, and that the queue survives a sign-out. None of it was in the tree. `src/session/sign_out.rs` holds the part a type, a table and a pair of counts settle. The order is a type rather than a sentence. `TellingTheServer` is produced by `LocalHalf::what_is_left_for_the_server` and by nothing else, so the request cannot be reached before the local half is a value the caller holds. What that prevents is the failure the whole act exists against: a person hands the device to somebody else believing they signed out, and the token is still in memory because a request timed out. The two acts are kept apart by what each takes away. A sign-out drops the token and forgets the secret; a forget does both and then removes the cache entries, the queue and the index rows for them. Collapsing them is what every client that was not told they differ does, and either direction costs something real: a sign-out that removed the cache refetches a whole library over whatever connection somebody has, and a forget that did not would leave no act at all behind 0068's promise to an operator. A removal reports how far it reached and has no success value. An operator who asked for their data to be removed and was told it was gone has no reason ever to ask again, so the report carries what was removed and what could not be reached rather than a verdict. A sign-out forced by a refused renewal reaches the same state with the server half skipped, because the server has already refused that token, and it is always the plain act: a refusal says nothing about whether somebody wants their library removed. #114 stays open. Its three conditions sign in against two servers at once, and nothing in this tree signs in, which is #27 for the request and #115 for a core to hold a session in. Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com> --- .github/coverage/pinned-surface | 1 + src/session/mod.rs | 7 + src/session/sign_out.rs | 654 ++++++++++++++++++++++++++++++++ tests/thread_statements.rs | 21 + 4 files changed, 683 insertions(+) create mode 100644 src/session/sign_out.rs diff --git a/.github/coverage/pinned-surface b/.github/coverage/pinned-surface index 62db27a..b6d5aa4 100644 --- a/.github/coverage/pinned-surface +++ b/.github/coverage/pinned-surface @@ -73,6 +73,7 @@ module src/session/delegated.rs The value that ties a delegated sign-in atte module src/session/renewal.rs The generation a rejection is answered against and the moment a renewal is due. A defect here is twenty renewals where 0034 fixes one, or a session signed out because a network dropped rather than because a server refused it. module src/session/password.rs 0030's password: the one reading that spends it, the account name kept as it was typed, and the closure that refuses an answer missing one of the three facts 0005 says a session holds. A defect here is a credential readable twice or printable, or a session built around a token a server never sent. module src/session/quick_connect.rs 0031's cadence for asking about a Quick Connect exchange, its four endings, and which of the two values the server issued crosses to the client. A defect here is a backoff nobody decided on a route where the answer arrives from a person, a denial reported as a failure, or the value the core presents handed out beside the code. +module src/session/sign_out.rs 0114's two acts: what each of them takes away, the order that puts the local half of a sign-out before the request to the server, how work in flight ends, and what a removal that could not be completed reports. A defect here leaves a token in memory on a device somebody handed over, empties a library on an ordinary evening act, or tells an operator their data is gone when it is not. module src/session/device.rs The device identity, whose identifier is one part of the cache key 0041 derives and one half of the key a server puts a live session under, so a defect here reaches two other surfaces on this list. module src/server/mod.rs The server module and what it places at its own boundary. module src/server/address.rs The address a person typed, parsed and refused where it is not usable. diff --git a/src/session/mod.rs b/src/session/mod.rs index 6ef63fd..e7d349c 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -31,6 +31,12 @@ //! crosses to the client. It asks no server anything, for the same reason //! [`delegated`] does not. //! +//! [`sign_out`] holds the part of 0114 a type, a table and a pair of counts +//! settle: that the server half of a sign-out cannot be reached before the local +//! half is done, which of the two acts removes what, how each kind of work in +//! flight ends, and what a removal that could not be completed reports. It signs +//! nothing out, because there is no session in this tree to end. +//! //! [`renewal`] holds the part of 0034 a counter and one clock reading settle: //! the generation a rejection is answered against, which rejection starts the //! session's one renewal and which joins it, what each renewal outcome does, and @@ -42,6 +48,7 @@ pub mod device; pub mod password; pub mod quick_connect; pub mod renewal; +pub mod sign_out; /// One signed-in session against one server. /// diff --git a/src/session/sign_out.rs b/src/session/sign_out.rs new file mode 100644 index 0000000..c352c10 --- /dev/null +++ b/src/session/sign_out.rs @@ -0,0 +1,654 @@ +//! The two acts, the order a sign-out takes, and what each one removes. +//! +//! `docs/decisions/0114-signing-out-and-forgetting-a-server.md` is the record +//! and #114 is the issue. 0114 decides that signing out and forgetting a server +//! are two acts rather than one, that the local half of a sign-out completes +//! whatever the network is doing, that four kinds of work in flight each end in +//! a named state, and that the queue survives a sign-out because an undelivered +//! action is the one thing somebody did that has no copy anywhere else. +//! +//! # What is here, and what is deliberately not +//! +//! What is here is everything of that a type, a table and a pair of counts +//! settle: that the server half cannot be reached before the local half is done, +//! which act removes what, how each kind of work in flight ends, and what a +//! removal that could not be completed reports. +//! +//! WHAT IS NOT HERE IS THE SESSION. [`crate::session::Session`] holds nothing, +//! there is no token in this tree to drop from memory, and no store is called by +//! anything below: the secret store and the byte store are traits a client +//! implements and nothing here holds one. So this module says what a sign-out +//! does and performs none of it. +//! +//! WHAT IS ALSO NOT HERE IS THE SET A FORGET REMOVES. 0114 makes it the entries +//! whose first three parts are that server, that account and that device +//! identity, and 0041's keys are digests over exactly those, so naming the set +//! is a question for the index 0042 holds. [`Removal`] counts what an act +//! reached and what it could not, which is the half 0114 requires be reported, +//! and it computes no set. +//! +//! # The order is a type rather than a rule +//! +//! 0114 states the order as the thing this act exists against: reversing it, so +//! that the local half waits on the server half, produces a person handing the +//! device to somebody else believing they signed out while the token is still in +//! memory because a request timed out. +//! +//! So [`TellingTheServer`] is produced by [`LocalHalf::what_is_left_for_the_server`] +//! and by nothing else. A caller cannot reach the request before the local half +//! is a value it holds, which is the same construction +//! [`crate::failure::Constructed`] and [`crate::session::delegated::Relayable`] +//! use, for the same reason. +//! +//! NOTHING IN THIS TREE CONSUMES A [`TellingTheServer`], because the request it +//! stands for needs the transport in #27. The type is the seam that request will +//! take, and until it lands this constrains an order at compile time and proves +//! nothing about a network. + +use super::renewal::HowTheRenewalEnded; + +/// Which of the two acts a client asked for. +/// +/// Two and no third. 0114's whole first section is that a client which was not +/// told they differ collapses them into one button, and which of the two that +/// button does is then decided by whoever wrote it. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Act { + /// The next person to pick this device up must not be me. Ordinary, and it + /// happens on a television in a house every evening. + SignOut, + /// This device should stop holding anything about that server at all. What + /// somebody does before they sell the device, or when they have finished + /// with a server they were visiting. + ForgetTheServer, +} + +impl Act { + /// Both acts, so a caller reads the set out of the crate rather than keeping + /// a copy of it. + #[must_use] + pub const fn all() -> &'static [Self] { + &[Self::SignOut, Self::ForgetTheServer] + } + + /// The name this act is reported under. + #[must_use] + pub const fn declared_name(self) -> &'static str { + match self { + Self::SignOut => "sign-out", + Self::ForgetTheServer => "forget-the-server", + } + } +} + +/// One thing an act takes away. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum WhatIsTakenAway { + /// The token leaves memory. Nothing further is sent under it by this core. + TheTokenFromMemory, + /// The secret is forgotten through 0033's interface. Forgetting one that is + /// not there succeeds, so a session whose secret was never written signs out + /// without a failure. + TheSecretFromItsStore, + /// Every cache entry under the key space for that server and that account, + /// in both tiers of 0054. + TheCacheEntries, + /// The queue 0047 holds for that session, including its standing count of + /// what it dropped. + TheQueue, + /// The rows the eviction index 0042 holds for the entries removed. 0040 + /// gives the store no listing and 0041's keys are digests, so that index is + /// the only route to the set, and a removal that left it holding rows for + /// entries that are gone would leave the bound counting bytes that are gone. + TheIndexRowsForThoseEntries, +} + +/// What an act takes away, in 0114's own list. +/// +/// A SIGN-OUT REMOVES NEITHER THE CACHE ENTRIES NOR THE QUEUE, and both are the +/// near miss rather than an omission. #114's own list of the parts of a sign-out +/// puts cache removal inside it; 0006 and 0068 landed the other direction with +/// their reasons and 0114 follows them, because the entries are keyed per +/// server, per account and per device, so leaving them costs nobody else's +/// privacy and signing back in on a device somebody already used is the case the +/// cache exists for. The queue is stronger still: its contents are what somebody +/// did that has no copy anywhere else, and a sign-out that discarded it would +/// lose a fortnight of positions to an ordinary evening act. +/// +/// Forgetting a server is strictly a sign-out plus removal, which is why the +/// second list opens with the whole of the first. +#[must_use] +pub const fn what_it_takes_away(act: Act) -> &'static [WhatIsTakenAway] { + match act { + Act::SignOut => &[ + WhatIsTakenAway::TheTokenFromMemory, + WhatIsTakenAway::TheSecretFromItsStore, + ], + Act::ForgetTheServer => &[ + WhatIsTakenAway::TheTokenFromMemory, + WhatIsTakenAway::TheSecretFromItsStore, + WhatIsTakenAway::TheCacheEntries, + WhatIsTakenAway::TheQueue, + WhatIsTakenAway::TheIndexRowsForThoseEntries, + ], + } +} + +/// A kind of work that can be running when a sign-out arrives. +/// +/// Four, from 0114, and each ends in a named state rather than in silence, which +/// is 0009's refusal to report a cancelled or undelivered thing as something it +/// is not applied to one moment. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum WorkInFlight { + /// A request already sent on that session. + ARequestAlreadySent, + /// A decode running for that session. + ADecodeRunning, + /// A read or a write already begun through the byte store. + ACallInsideTheClientsStore, + /// An action 0047's queue is holding for that session. + AQueuedAction, +} + +impl WorkInFlight { + /// Every kind, so a condition applies a rule to the whole of it rather than + /// to whichever member somebody remembered. + #[must_use] + pub const fn all() -> &'static [Self] { + &[ + Self::ARequestAlreadySent, + Self::ADecodeRunning, + Self::ACallInsideTheClientsStore, + Self::AQueuedAction, + ] + } +} + +/// How one kind of work in flight ends when a sign-out arrives. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum HowItEnds { + /// Cancelled, ending as `cancelled` from 0004. AN ANSWER THAT ARRIVES + /// AFTERWARDS IS DISCARDED RATHER THAN DELIVERED and is not written to the + /// cache, because it was fetched under a session that has ended. + CancelledAndALateAnswerDiscarded, + /// Ended at the next step boundary 0009 and 0115 fix, because a decoder is + /// not interruptible at an arbitrary instruction. Bytes already handed to a + /// caller are the caller's and are not reached, which is the boundary 0042 + /// draws for eviction. + EndedAtTheNextStepBoundary, + /// Runs to completion. That is 0115's rule and it holds here without + /// exception: a sign-out does not abandon a call inside somebody else's + /// implementation. + CompletesInsideTheClientsCode, + /// Stays where it is. A later sign-in to the same server, account and device + /// finds the queue in its own order and drains it before anything else is + /// sent. + StaysQueued, +} + +/// How each kind of work in flight ends, from 0114. +/// +/// CANCELLING IS BY SESSION AND NEVER BY LANE, and 0114 names that as the part +/// that will be got wrong. 0009 gives the core two lanes shared by every +/// session, so the unit of work on a lane carries the session it belongs to. A +/// sign-out that cancelled a lane's work would stop the other account's tile +/// wall on a television with two people signed in, and the person who lost their +/// screen did nothing. Nothing in this signature can express a lane, which is +/// the whole of what it can do about that. +#[must_use] +pub const fn how_it_ends(work: WorkInFlight) -> HowItEnds { + match work { + WorkInFlight::ARequestAlreadySent => HowItEnds::CancelledAndALateAnswerDiscarded, + WorkInFlight::ADecodeRunning => HowItEnds::EndedAtTheNextStepBoundary, + WorkInFlight::ACallInsideTheClientsStore => HowItEnds::CompletesInsideTheClientsCode, + WorkInFlight::AQueuedAction => HowItEnds::StaysQueued, + } +} + +/// Why the sign-out is happening. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WhySigningOut { + /// A client asked for one of the two acts. + TheClientAskedFor(Act), + /// 0034's renewal ended in a refusal, which signs the session out. The + /// server half is skipped, because the server has already refused that + /// token, and 0005 requires the answer to be the same whether the sign-out + /// was asked for or forced. + ARenewalWasRefused, +} + +/// The local half of a sign-out, once it is done. +/// +/// It cannot fail, and the absence of a failure is the decision: 0114 makes the +/// server half the only one that may, so a sign-out on a train is a sign-out and +/// reports that the server was not told rather than refusing to complete. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LocalHalf { + act: Act, + tell_the_server: bool, + changed_anything: bool, +} + +/// What is left to do at the server, once the local half is done. +/// +/// It is produced by [`LocalHalf::what_is_left_for_the_server`] and by nothing +/// else, which is how the order in 0114 is held. See the module documentation. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TellingTheServer { + _produced_by_the_local_half: (), +} + +impl LocalHalf { + /// Completes the local half. + /// + /// `already_signed_out` is the caller's reading of the session it named. A + /// sign-out of a session that is already signed out succeeds and changes + /// nothing, which is 0114's own sentence and is why this is not a failure. + /// + /// A forced sign-out is always the plain act rather than a forget: 0034 + /// refusing a renewal says nothing about whether somebody wants their cache + /// removed, and a refusal that emptied a person's library would be the + /// stronger act arriving without anybody asking for it. + #[must_use] + pub const fn completed(why: WhySigningOut, already_signed_out: bool) -> Self { + let act = match why { + WhySigningOut::TheClientAskedFor(act) => act, + WhySigningOut::ARenewalWasRefused => Act::SignOut, + }; + let forced = matches!(why, WhySigningOut::ARenewalWasRefused); + Self { + act, + tell_the_server: !forced && !already_signed_out, + changed_anything: !already_signed_out, + } + } + + /// The same, for a renewal that ended in a refusal. + /// + /// It is a second constructor rather than a caller writing the variant, + /// because [`HowTheRenewalEnded`] is where the other side of this decision + /// lives and a caller reading a refusal out of that enum should not have to + /// know which of 0114's cases it maps onto. Any other ending signs nothing + /// out and answers `None`. + #[must_use] + pub const fn after_a_renewal( + ended: HowTheRenewalEnded, + already_signed_out: bool, + ) -> Option { + match ended { + HowTheRenewalEnded::TheServerRefusedIt => Some(Self::completed( + WhySigningOut::ARenewalWasRefused, + already_signed_out, + )), + _ => None, + } + } + + /// Which act this was. + #[must_use] + pub const fn act(self) -> Act { + self.act + } + + /// Whether anything moved. A sign-out of a session already signed out + /// succeeds and changes nothing. + #[must_use] + pub const fn changed_anything(self) -> bool { + self.changed_anything + } + + /// The request that is left, where there is one. + /// + /// `None` where the server has already refused the token, and where the + /// session was already signed out and there is nothing to end. + #[must_use] + pub const fn what_is_left_for_the_server(self) -> Option { + if self.tell_the_server { + Some(TellingTheServer { + _produced_by_the_local_half: (), + }) + } else { + None + } + } +} + +/// What a client is told about the server half. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum WhatTheClientIsTold { + /// The server was told and the token is finished there. + TheServerWasTold, + /// There was nothing to tell it: the token was already refused, or the + /// session was already signed out. + ThereWasNothingToTell, + /// The attempt did not succeed, AND THE TOKEN MAY STILL BE LIVE AT THE + /// SERVER. That is a thing an operator can act on from the server's own side + /// and cannot act on if nobody said it, which is why it is a state of its + /// own rather than a silence beside a completed sign-out. + TheServerWasNotToldAndTheTokenMayStillBeLive, +} + +/// What to report once the server half has been attempted or skipped. +/// +/// `attempt_succeeded` is `None` where no attempt was made. The local half has +/// completed in every case here, which is what having a [`LocalHalf`] to pass +/// means. +#[must_use] +pub const fn what_the_client_is_told( + local: LocalHalf, + attempt_succeeded: Option, +) -> WhatTheClientIsTold { + match (local.tell_the_server, attempt_succeeded) { + (false, _) | (true, None) => WhatTheClientIsTold::ThereWasNothingToTell, + (true, Some(true)) => WhatTheClientIsTold::TheServerWasTold, + (true, Some(false)) => WhatTheClientIsTold::TheServerWasNotToldAndTheTokenMayStillBeLive, + } +} + +/// How far a removal reached. +/// +/// THERE IS NO SUCCESS VALUE HERE, and that is 0114's decision rather than an +/// unfinished type. Where the index is absent or was refused by 0105 the set is +/// not reachable, and the core reports that it removed what it could and names +/// how many entries it could not reach; it does not report a removal it did not +/// make. An operator who asked for their data to be removed and was told it was +/// gone has no reason ever to ask again, so an honest count is worth more than a +/// success value. +/// +/// Repeating a removal is safe: removing an entry that is not there succeeds +/// under 0040's rule, and the index says what is left. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. It is not +/// shared between threads by anything here. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Removal { + removed: u32, + could_not_be_reached: u32, +} + +impl Removal { + /// A removal that has not begun. + #[must_use] + pub const fn nothing_yet() -> Self { + Self { + removed: 0, + could_not_be_reached: 0, + } + } + + /// Records one entry removed through 0040's remove operation. + /// + /// One at a time, because that is what the store interface offers: the core + /// removes what it wrote, does not reach past the interface, and does not + /// ask a client to delete a directory. + pub const fn removed_one(&mut self) { + self.removed = self.removed.saturating_add(1); + } + + /// Records entries the act could not reach, because the index was absent or + /// because the store stopped answering part way. + pub const fn could_not_reach(&mut self, how_many: u32) { + self.could_not_be_reached = self.could_not_be_reached.saturating_add(how_many); + } + + /// How many were removed. + #[must_use] + pub const fn removed(self) -> u32 { + self.removed + } + + /// How many were not. + #[must_use] + pub const fn could_not_be_reached(self) -> u32 { + self.could_not_be_reached + } + + /// Whether everything the act named was reached. + /// + /// It is a question about this report rather than a promise about the + /// device: 0068 refuses to promise what an uninstall leaves behind and this + /// record does not promise it either. + #[must_use] + pub const fn reached_everything_it_named(self) -> bool { + self.could_not_be_reached == 0 + } +} + +#[cfg(test)] +mod tests { + //! 0114's two acts and the order one of them takes, asked of the values. + //! + //! What these cannot ask is #114's own three conditions. Each signs in + //! against a server, and nothing in this tree signs in. + + use super::{ + Act, HowItEnds, LocalHalf, Removal, WhatIsTakenAway, WhatTheClientIsTold, WhySigningOut, + WorkInFlight, how_it_ends, what_it_takes_away, what_the_client_is_told, + }; + use crate::session::renewal::HowTheRenewalEnded; + + fn asked_for(act: Act) -> LocalHalf { + LocalHalf::completed(WhySigningOut::TheClientAskedFor(act), false) + } + + /// The two acts differ in exactly the way 0114 says they do, and the near + /// miss is the one button: a sign-out that removed the cache entries, which + /// refetches a whole library over whatever connection somebody has the next + /// time they sign in. + #[test] + fn a_sign_out_leaves_the_cache_and_the_queue_and_a_forget_does_not() { + let signing_out = what_it_takes_away(Act::SignOut); + assert!(!signing_out.contains(&WhatIsTakenAway::TheCacheEntries)); + assert!(!signing_out.contains(&WhatIsTakenAway::TheQueue)); + + let forgetting = what_it_takes_away(Act::ForgetTheServer); + assert!(forgetting.contains(&WhatIsTakenAway::TheCacheEntries)); + assert!(forgetting.contains(&WhatIsTakenAway::TheQueue)); + assert!(forgetting.contains(&WhatIsTakenAway::TheIndexRowsForThoseEntries)); + } + + /// Forgetting a server is strictly a sign-out plus removal, which is the + /// sentence that keeps the two from drifting apart one field at a time. + #[test] + fn forgetting_a_server_is_everything_a_sign_out_does_and_then_removal() { + let forgetting = what_it_takes_away(Act::ForgetTheServer); + for part in what_it_takes_away(Act::SignOut) { + assert!( + forgetting.contains(part), + "{part:?} is part of a sign-out and not part of a forget" + ); + } + assert!(what_it_takes_away(Act::SignOut).len() < forgetting.len()); + } + + /// Both acts drop the token and forget the secret, which is the half that + /// makes either of them a sign-out at all. + #[test] + fn every_act_drops_the_token_and_forgets_the_secret() { + for act in Act::all() { + let parts = what_it_takes_away(*act); + assert!(parts.contains(&WhatIsTakenAway::TheTokenFromMemory)); + assert!(parts.contains(&WhatIsTakenAway::TheSecretFromItsStore)); + } + } + + /// The four kinds of work and their four endings, each different from the + /// others. The near miss is a sign-out that abandons a call inside the + /// client's own store, which 0115 refuses without exception. + #[test] + fn each_kind_of_work_in_flight_ends_in_its_own_named_state() { + assert_eq!( + how_it_ends(WorkInFlight::ARequestAlreadySent), + HowItEnds::CancelledAndALateAnswerDiscarded + ); + assert_eq!( + how_it_ends(WorkInFlight::ADecodeRunning), + HowItEnds::EndedAtTheNextStepBoundary + ); + assert_eq!( + how_it_ends(WorkInFlight::ACallInsideTheClientsStore), + HowItEnds::CompletesInsideTheClientsCode + ); + assert_eq!( + how_it_ends(WorkInFlight::AQueuedAction), + HowItEnds::StaysQueued + ); + + let mut endings: Vec = WorkInFlight::all() + .iter() + .map(|w| how_it_ends(*w)) + .collect(); + endings.sort_unstable(); + endings.dedup(); + assert_eq!( + endings.len(), + WorkInFlight::all().len(), + "two kinds of work share an ending, which is the silence 0009 refuses" + ); + } + + /// The order. The local half is a value before there is anything to send, + /// and the request cannot be reached without it. + /// + /// THE NEAR MISS HERE IS A COMPILE FAILURE RATHER THAN A RED LINE: + /// `TellingTheServer` has no public constructor and no public field, so a + /// caller that wanted to send before completing the local half has nothing + /// to build. + #[test] + fn the_request_to_the_server_comes_out_of_the_local_half_and_from_nowhere_else() { + let local = asked_for(Act::SignOut); + assert!(local.changed_anything()); + assert!(local.what_is_left_for_the_server().is_some()); + } + + /// A sign-out on a train is a sign-out. It reports that the server was not + /// told, with the fact an operator can act on, rather than refusing to + /// complete. + #[test] + fn a_failed_attempt_reports_that_the_token_may_still_be_live() { + let local = asked_for(Act::SignOut); + assert_eq!( + what_the_client_is_told(local, Some(false)), + WhatTheClientIsTold::TheServerWasNotToldAndTheTokenMayStillBeLive + ); + assert_eq!( + what_the_client_is_told(local, Some(true)), + WhatTheClientIsTold::TheServerWasTold + ); + assert_eq!( + what_the_client_is_told(local, None), + WhatTheClientIsTold::ThereWasNothingToTell + ); + } + + /// A sign-out forced by a refused renewal reaches the same state with the + /// server half skipped, which is what 0005 requires when it says the answer + /// must be the same whether the sign-out was asked for or forced. + #[test] + fn a_refused_renewal_signs_out_without_telling_the_server() { + let forced = LocalHalf::completed(WhySigningOut::ARenewalWasRefused, false); + assert_eq!(forced.act(), Act::SignOut); + assert!(forced.changed_anything()); + assert!(forced.what_is_left_for_the_server().is_none()); + assert_eq!( + what_the_client_is_told(forced, None), + WhatTheClientIsTold::ThereWasNothingToTell + ); + } + + /// Only a refusal signs the session out, and it never turns into the + /// stronger act. The near miss is a refused renewal that emptied somebody's + /// library, which is a forget arriving without anybody asking for one. + #[test] + fn only_a_refusal_signs_out_and_it_is_never_the_stronger_act() { + let refused = LocalHalf::after_a_renewal(HowTheRenewalEnded::TheServerRefusedIt, false) + .expect("a refusal signs the session out"); + assert_eq!(refused.act(), Act::SignOut); + assert!(!what_it_takes_away(refused.act()).contains(&WhatIsTakenAway::TheCacheEntries)); + + for ending in [ + HowTheRenewalEnded::AFreshToken, + HowTheRenewalEnded::NothingAnswered, + ] { + assert!( + LocalHalf::after_a_renewal(ending, false).is_none(), + "{ending:?} signed a session out" + ); + } + } + + /// Signing out twice succeeds and changes nothing, and there is nothing left + /// to tell the server the second time. + #[test] + fn a_session_already_signed_out_succeeds_and_moves_nothing() { + let again = LocalHalf::completed(WhySigningOut::TheClientAskedFor(Act::SignOut), true); + assert!(!again.changed_anything()); + assert!(again.what_is_left_for_the_server().is_none()); + assert_eq!( + what_the_client_is_told(again, None), + WhatTheClientIsTold::ThereWasNothingToTell + ); + } + + /// A removal that reached nothing says so. The near miss is a success value, + /// which tells an operator their data is gone and gives them no reason ever + /// to ask again. + #[test] + fn a_removal_that_could_not_reach_the_set_reports_what_it_did_not_do() { + let mut removal = Removal::nothing_yet(); + removal.could_not_reach(42); + assert_eq!(removal.removed(), 0); + assert_eq!(removal.could_not_be_reached(), 42); + assert!(!removal.reached_everything_it_named()); + } + + /// A removal that stopped part way keeps what it removed and says how far it + /// reached. + #[test] + fn a_removal_that_stopped_part_way_keeps_both_numbers() { + let mut removal = Removal::nothing_yet(); + for _ in 0..3 { + removal.removed_one(); + } + removal.could_not_reach(2); + assert_eq!(removal.removed(), 3); + assert_eq!(removal.could_not_be_reached(), 2); + assert!(!removal.reached_everything_it_named()); + } + + /// A removal that reached everything it named is the only shape that answers + /// so. + #[test] + fn only_a_removal_that_left_nothing_unreached_says_it_reached_everything() { + let mut removal = Removal::nothing_yet(); + removal.removed_one(); + assert!(removal.reached_everything_it_named()); + removal.could_not_reach(1); + assert!(!removal.reached_everything_it_named()); + } + + /// The names are what a report carries, so they are asked for rather than + /// assumed from the variant. + #[test] + fn both_acts_have_their_own_declared_name() { + assert_eq!(Act::SignOut.declared_name(), "sign-out"); + assert_eq!(Act::ForgetTheServer.declared_name(), "forget-the-server"); + } +} diff --git a/tests/thread_statements.rs b/tests/thread_statements.rs index fdf5b43..ac84cd7 100644 --- a/tests/thread_statements.rs +++ b/tests/thread_statements.rs @@ -74,6 +74,10 @@ use flowfin_core::session::renewal::{ Generation, HowTheRenewalEnded, Rejection, RenewalRoute, RenewalSchedule, Renewals, WhatARejectedCallDoes, WhatTheOutcomeDoes, }; +use flowfin_core::session::sign_out::{ + Act, HowItEnds, LocalHalf, Removal, TellingTheServer, WhatIsTakenAway, WhatTheClientIsTold, + WhySigningOut, WorkInFlight, +}; use flowfin_core::session::{SecretStore, Session}; /// Compiles only for a type that is safe to use from any thread. @@ -93,6 +97,23 @@ fn a_session_handle_is_safe_from_any_thread() { const _: () = any_thread::(); } +#[test] +fn the_two_acts_that_end_a_session_are_safe_from_any_thread() { + const _: () = any_thread::(); + const _: () = any_thread::(); + const _: () = any_thread::(); + const _: () = any_thread::(); +} + +#[test] +fn the_halves_of_a_sign_out_are_safe_from_any_thread() { + const _: () = any_thread::(); + const _: () = any_thread::(); + const _: () = any_thread::(); + const _: () = any_thread::(); + const _: () = any_thread::(); +} + #[test] fn a_device_identity_is_safe_from_any_thread() { const _: () = any_thread::();