Skip to main content

slipcase_open/
table.rs

1//! The live sessions, and how a container is matched against them.
2//
3// Author: David M. Anderson
4// Built with AI assistance (Claude, Anthropic)
5//
6//! Concept 8: a container that already has a live session is not opened twice,
7//! because two sessions on one container would both repack it and the second
8//! write-back would overwrite the first with nothing said.
9//!
10//! ## Neither key is enough on its own
11//!
12//! §8 says to key on file identity rather than on a path, and gives the reason:
13//! a container reachable under two hard links is two paths and one file, and a
14//! canonical path cannot see that.
15//!
16//! What §8 does not account for — found while writing `identity.rs` — is that
17//! **write-back replaces the container by renaming a new file over it, so a
18//! container acquires a new inode every time a session saves.** An identity
19//! recorded when the session opened stops matching the file at that path after
20//! the first write-back, and the next invocation of the same container would
21//! find no entry and open the second session that all of this exists to
22//! prevent.
23//!
24//! So a lookup matches on either. The path is stable across replacement and
25//! blind to hard links; the identity is the opposite. Together they cover both,
26//! and the case where they disagree is handled correctly rather than by
27//! accident: after a write-back through one of two hard links, §7 says the
28//! other name still points at the original with the old contents, so the two
29//! really are different files by then — different path, different identity, no
30//! match, and a new session, which is right.
31
32use std::path::{Path, PathBuf};
33
34use crate::identity::{self, Identity};
35
36/// One live session and the two ways of finding it again.
37#[derive(Debug)]
38struct Entry<T> {
39    identity: Identity,
40    path: PathBuf,
41    held: T,
42}
43
44/// The sessions this instance is holding.
45#[derive(Debug)]
46pub struct Table<T> {
47    entries: Vec<Entry<T>>,
48}
49
50impl<T> Default for Table<T> {
51    fn default() -> Self {
52        Self {
53            entries: Vec::new(),
54        }
55    }
56}
57
58impl<T> Table<T> {
59    /// An empty table.
60    #[must_use]
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// How many sessions are open.
66    #[must_use]
67    pub fn len(&self) -> usize {
68        self.entries.len()
69    }
70
71    /// Whether this instance is holding nothing, which is half of concept 8's
72    /// exit rule.
73    #[must_use]
74    pub fn is_empty(&self) -> bool {
75        self.entries.is_empty()
76    }
77
78    /// Everything held, in the order it was opened.
79    pub fn iter(&self) -> impl Iterator<Item = &T> {
80        self.entries.iter().map(|e| &e.held)
81    }
82
83    /// Everything held, mutably.
84    pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut T> {
85        self.entries.iter_mut().map(|e| &mut e.held)
86    }
87
88    /// Take a session in, keyed on what `container` is now.
89    ///
90    /// # Errors
91    ///
92    /// Where the container's identity cannot be established.
93    pub fn insert(&mut self, container: &Path, held: T) -> std::io::Result<()> {
94        self.entries.push(Entry {
95            identity: identity::of(container)?,
96            path: std::fs::canonicalize(container)?,
97            held,
98        });
99        Ok(())
100    }
101
102    /// The session already open on this container, if there is one.
103    ///
104    /// A container that is not there matches nothing rather than failing: an
105    /// invocation naming a path that does not exist has a different problem,
106    /// and it is not this function's to report.
107    pub fn find_mut(&mut self, container: &Path) -> Option<&mut T> {
108        let identity = identity::of(container).ok();
109        let path = std::fs::canonicalize(container).ok();
110        self.entries
111            .iter_mut()
112            .find(|e| {
113                identity.as_ref() == Some(&e.identity) || path.as_deref() == Some(e.path.as_path())
114            })
115            .map(|e| &mut e.held)
116    }
117
118    /// Re-read the identity of a session's container.
119    ///
120    /// Called after a write-back, which renamed a new file over the container
121    /// and so gave it a new inode. Without this the entry keeps matching by
122    /// path and stops matching by identity, which quietly loses the hard-link
123    /// half of the guarantee for the rest of the session.
124    ///
125    /// A container that has gone keeps the identity it had. There is nothing
126    /// better to record, and the path arm still finds the session so that the
127    /// person can be told.
128    pub fn refresh(&mut self, container: &Path) {
129        let Ok(now) = identity::of(container) else {
130            return;
131        };
132        if let Some(entry) = self.entries.iter_mut().find(|e| {
133            e.path == container || Some(&e.path) == std::fs::canonicalize(container).ok().as_ref()
134        }) {
135            entry.identity = now;
136        }
137    }
138
139    /// Drop a session and hand it back.
140    pub fn remove(&mut self, container: &Path) -> Option<T> {
141        let identity = identity::of(container).ok();
142        let path = std::fs::canonicalize(container).ok();
143        let at = self.entries.iter().position(|e| {
144            identity.as_ref() == Some(&e.identity) || path.as_deref() == Some(e.path.as_path())
145        })?;
146        Some(self.entries.remove(at).held)
147    }
148
149    /// Take everything, for the shutdown that closes each in turn.
150    pub fn drain(&mut self) -> impl Iterator<Item = T> + '_ {
151        self.entries.drain(..).map(|e| e.held)
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::Table;
158    use std::fs;
159    use std::path::{Path, PathBuf};
160
161    fn a_file(at: &Path, name: &str) -> PathBuf {
162        let p = at.join(name);
163        fs::write(&p, b"container").unwrap();
164        p
165    }
166
167    #[test]
168    fn a_container_finds_its_own_session() {
169        let tmp = tempfile::tempdir().unwrap();
170        let c = a_file(tmp.path(), "report.slpc");
171        let mut table = Table::new();
172        table.insert(&c, "session".to_string()).unwrap();
173        assert_eq!(table.find_mut(&c).map(|s| s.as_str()), Some("session"));
174    }
175
176    #[test]
177    fn another_container_finds_nothing() {
178        let tmp = tempfile::tempdir().unwrap();
179        let a = a_file(tmp.path(), "a.slpc");
180        let b = a_file(tmp.path(), "b.slpc");
181        let mut table = Table::new();
182        table.insert(&a, "a".to_string()).unwrap();
183        assert!(table.find_mut(&b).is_none());
184    }
185
186    #[cfg(unix)]
187    #[test]
188    fn a_second_hard_link_finds_the_same_session() {
189        // Concept 8's stated reason for keying on identity: two names, one
190        // file, and a canonical path cannot tell.
191        let tmp = tempfile::tempdir().unwrap();
192        let a = a_file(tmp.path(), "a.slpc");
193        let b = tmp.path().join("b.slpc");
194        fs::hard_link(&a, &b).unwrap();
195
196        let mut table = Table::new();
197        table.insert(&a, "one session".to_string()).unwrap();
198        assert_eq!(table.find_mut(&b).map(|s| s.as_str()), Some("one session"));
199    }
200
201    #[cfg(unix)]
202    #[test]
203    fn a_symbolic_link_finds_the_same_session() {
204        let tmp = tempfile::tempdir().unwrap();
205        let a = a_file(tmp.path(), "a.slpc");
206        let link = tmp.path().join("link.slpc");
207        std::os::unix::fs::symlink(&a, &link).unwrap();
208
209        let mut table = Table::new();
210        table.insert(&a, "one session".to_string()).unwrap();
211        assert!(table.find_mut(&link).is_some());
212    }
213
214    #[test]
215    fn a_container_replaced_by_a_write_back_still_finds_its_session() {
216        // The case identity alone loses. `Destination::in_place` renames a new
217        // file over the container, so the inode changes on every save; an entry
218        // keyed only on the identity recorded at open would stop matching after
219        // the first write-back, and the next invocation would start the second
220        // session concept 8 exists to prevent.
221        let tmp = tempfile::tempdir().unwrap();
222        let c = a_file(tmp.path(), "report.slpc");
223        let mut table = Table::new();
224        table.insert(&c, "session".to_string()).unwrap();
225
226        let scratch = tmp.path().join("scratch");
227        fs::write(&scratch, b"repacked").unwrap();
228        fs::rename(&scratch, &c).unwrap();
229
230        assert_eq!(table.find_mut(&c).map(|s| s.as_str()), Some("session"));
231    }
232
233    #[cfg(unix)]
234    #[test]
235    fn the_other_hard_link_is_a_different_container_once_one_has_been_written_back() {
236        // §7 says a hard link to the original keeps pointing at the original,
237        // which now holds the old contents. So after a write-back through one
238        // name the two really are different files, and opening the other is a
239        // new session rather than a match. Correct rather than accidental: the
240        // path differs and so does the identity.
241        let tmp = tempfile::tempdir().unwrap();
242        let a = a_file(tmp.path(), "a.slpc");
243        let b = tmp.path().join("b.slpc");
244        fs::hard_link(&a, &b).unwrap();
245
246        let mut table = Table::new();
247        table.insert(&a, "session".to_string()).unwrap();
248
249        let scratch = tmp.path().join("scratch");
250        fs::write(&scratch, b"repacked").unwrap();
251        fs::rename(&scratch, &a).unwrap();
252        table.refresh(&a);
253
254        assert!(table.find_mut(&a).is_some());
255        assert!(
256            table.find_mut(&b).is_none(),
257            "the other link still holds the old contents and is its own container now"
258        );
259    }
260
261    #[test]
262    fn refreshing_keeps_the_identity_arm_working_after_a_save() {
263        let tmp = tempfile::tempdir().unwrap();
264        let c = a_file(tmp.path(), "report.slpc");
265        let mut table = Table::new();
266        table.insert(&c, "session".to_string()).unwrap();
267
268        let scratch = tmp.path().join("scratch");
269        fs::write(&scratch, b"repacked").unwrap();
270        fs::rename(&scratch, &c).unwrap();
271        table.refresh(&c);
272
273        // A fresh hard link to what the container is *now* finds the session,
274        // which it would not if the entry still held the identity from open.
275        #[cfg(unix)]
276        {
277            let link = tmp.path().join("link.slpc");
278            fs::hard_link(&c, &link).unwrap();
279            assert!(table.find_mut(&link).is_some());
280        }
281        assert!(table.find_mut(&c).is_some());
282    }
283
284    #[test]
285    fn a_container_that_is_not_there_matches_nothing_rather_than_failing() {
286        let tmp = tempfile::tempdir().unwrap();
287        let c = a_file(tmp.path(), "report.slpc");
288        let mut table = Table::new();
289        table.insert(&c, "session".to_string()).unwrap();
290        assert!(table.find_mut(&tmp.path().join("gone.slpc")).is_none());
291    }
292
293    #[test]
294    fn removing_hands_the_session_back_and_empties_the_table() {
295        let tmp = tempfile::tempdir().unwrap();
296        let c = a_file(tmp.path(), "report.slpc");
297        let mut table = Table::new();
298        table.insert(&c, "session".to_string()).unwrap();
299        assert_eq!(table.remove(&c), Some("session".to_string()));
300        assert!(table.is_empty());
301        assert!(table.remove(&c).is_none());
302    }
303}