Skip to main content

aft/hashline/integration/
binding.rs

1//! Session-keyed hashline bindings: registration, effective mode, and lifetime.
2//!
3//! Registration computes edit-slot eligibility independently of schema selection,
4//! derives `effective = configured_enabled AND edit_slot_survives`, and installs
5//! the binding for `(canonical project root, session id)`. Request handlers capture
6//! a binding guard for the duration of the call so concurrent sessions under one
7//! root never share tags, stores, or schemas. Effective-value changes drain
8//! in-flight guards before clearing stores.
9
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::{Arc, Condvar, Mutex, MutexGuard};
13
14use crate::hashline::apply::RegisterStore;
15use crate::hashline::snapshot::SnapshotStore;
16
17/// Stable identity for one session under one project root.
18#[derive(Clone, Debug, Eq, Hash, PartialEq)]
19pub struct SessionKey {
20    pub root: PathBuf,
21    pub session_id: String,
22}
23
24impl SessionKey {
25    pub fn new(root: impl Into<PathBuf>, session_id: impl Into<String>) -> Self {
26        Self {
27            root: root.into(),
28            session_id: session_id.into(),
29        }
30    }
31}
32
33/// Configure-channel warning emitted when hashline is configured on but a slot
34/// the hashline surface depends on did not survive final surface selection.
35#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct DowngradeWarning {
37    pub code: &'static str,
38    pub reason: &'static str,
39}
40
41impl DowngradeWarning {
42    pub const EDIT_NOT_REGISTERED: Self = Self {
43        code: "hashline_downgraded",
44        reason: "edit_not_registered",
45    };
46
47    /// The `edit` slot survived but the tagged `read` slot did not, so nothing
48    /// in the session could mint the `[path#TAG]` snapshots a patch addresses.
49    pub const TAGGED_READ_UNAVAILABLE: Self = Self {
50        code: "hashline_downgraded",
51        reason: "tagged_read_unavailable",
52    };
53
54    /// JSON object for the configure-warnings channel.
55    pub fn to_json(&self) -> serde_json::Value {
56        serde_json::json!({
57            "code": self.code,
58            "reason": self.reason,
59        })
60    }
61}
62
63/// Inputs the host supplies when registering (or re-registering) a session.
64#[derive(Clone, Copy, Debug, Eq, PartialEq)]
65pub struct RegistrationRequest {
66    /// Resolved `hashline.enabled` from Rust config_resolve.
67    pub configured_enabled: bool,
68    /// Host-computed flag: `edit` survived final surface selection, pruning,
69    /// hoisting, and `disabled_tools`. Sessions without a host pruning layer
70    /// (MCP, daemon-direct) default this to `true`.
71    pub edit_slot_survives: bool,
72    /// Whether the tagged `read` slot survived the same filtering. Only a tagged
73    /// read mints the snapshots a hashline patch addresses, so an edit slot on
74    /// its own is not a usable hashline surface.
75    pub read_slot_survives: bool,
76}
77
78impl RegistrationRequest {
79    pub const fn effective(self) -> bool {
80        self.configured_enabled && self.edit_slot_survives && self.read_slot_survives
81    }
82
83    pub const fn should_downgrade(self) -> bool {
84        self.configured_enabled && !(self.edit_slot_survives && self.read_slot_survives)
85    }
86
87    /// Which missing slot to report.
88    ///
89    /// The read slot is reported first because it is the harder failure to
90    /// diagnose: a host can leave a perfectly good `edit` tool registered next
91    /// to its own untagged read, and the resulting "I never got a hashline"
92    /// symptom points at the edit tool, which is not the missing piece.
93    pub const fn downgrade_warning(self) -> Option<DowngradeWarning> {
94        if !self.should_downgrade() {
95            return None;
96        }
97        if self.read_slot_survives {
98            Some(DowngradeWarning::EDIT_NOT_REGISTERED)
99        } else {
100            Some(DowngradeWarning::TAGGED_READ_UNAVAILABLE)
101        }
102    }
103}
104
105/// Outcome of a completed registration attempt.
106#[derive(Clone, Debug, Eq, PartialEq)]
107pub struct RegistrationOutcome {
108    pub configured_enabled: bool,
109    pub edit_slot_survives: bool,
110    pub read_slot_survives: bool,
111    pub effective: bool,
112    /// Present when configured on but edit was not registered.
113    pub downgrade: Option<DowngradeWarning>,
114    /// True when stores were cleared because the effective value changed.
115    pub stores_cleared: bool,
116    /// True when same-effective re-registration preserved snapshot/register state.
117    pub stores_preserved: bool,
118}
119
120/// Session-owned hashline state installed atomically at registration.
121#[derive(Debug)]
122pub struct HashlineBinding {
123    key: SessionKey,
124    configured_enabled: bool,
125    edit_slot_survives: bool,
126    read_slot_survives: bool,
127    effective: bool,
128    snapshots: SnapshotStore,
129    registers: RegisterStore,
130    /// In-flight request guards holding this binding.
131    in_flight: usize,
132}
133
134impl HashlineBinding {
135    fn new(key: SessionKey, request: RegistrationRequest) -> Self {
136        Self {
137            key,
138            configured_enabled: request.configured_enabled,
139            edit_slot_survives: request.edit_slot_survives,
140            read_slot_survives: request.read_slot_survives,
141            effective: request.effective(),
142            snapshots: SnapshotStore::new(),
143            registers: RegisterStore::new(),
144            in_flight: 0,
145        }
146    }
147
148    pub fn key(&self) -> &SessionKey {
149        &self.key
150    }
151
152    pub fn configured_enabled(&self) -> bool {
153        self.configured_enabled
154    }
155
156    pub fn edit_slot_survives(&self) -> bool {
157        self.edit_slot_survives
158    }
159
160    pub fn read_slot_survives(&self) -> bool {
161        self.read_slot_survives
162    }
163
164    pub fn effective(&self) -> bool {
165        self.effective
166    }
167
168    pub fn snapshots(&self) -> &SnapshotStore {
169        &self.snapshots
170    }
171
172    pub fn snapshots_mut(&mut self) -> &mut SnapshotStore {
173        &mut self.snapshots
174    }
175
176    pub fn registers(&self) -> &RegisterStore {
177        &self.registers
178    }
179
180    pub fn registers_mut(&mut self) -> &mut RegisterStore {
181        &mut self.registers
182    }
183
184    /// Borrow both session stores for one atomic request pipeline.
185    pub fn stores_mut(&mut self) -> (&mut SnapshotStore, &mut RegisterStore) {
186        (&mut self.snapshots, &mut self.registers)
187    }
188
189    pub fn in_flight(&self) -> usize {
190        self.in_flight
191    }
192
193    fn clear_stores(&mut self) {
194        self.snapshots.clear();
195        *self.registers_mut() = RegisterStore::new();
196    }
197}
198
199/// Keep each condition variable beside the only mutex it may ever wait on.
200/// A registry-wide condition variable cannot drain multiple session mutexes:
201/// `std::sync::Condvar` permanently binds to the first mutex it observes.
202#[derive(Debug)]
203struct BindingSlot {
204    binding: Mutex<HashlineBinding>,
205    drain: Condvar,
206}
207
208impl BindingSlot {
209    fn new(binding: HashlineBinding) -> Self {
210        Self {
211            binding: Mutex::new(binding),
212            drain: Condvar::new(),
213        }
214    }
215
216    fn lock(&self) -> MutexGuard<'_, HashlineBinding> {
217        self.binding
218            .lock()
219            .unwrap_or_else(std::sync::PoisonError::into_inner)
220    }
221
222    fn drain_in_flight(&self) {
223        let mut binding = self.lock();
224        while binding.in_flight > 0 {
225            binding = self
226                .drain
227                .wait(binding)
228                .unwrap_or_else(std::sync::PoisonError::into_inner);
229        }
230    }
231
232    fn release_guard(&self) {
233        {
234            let mut binding = self.lock();
235            binding.in_flight = binding.in_flight.saturating_sub(1);
236        }
237        self.drain.notify_all();
238    }
239}
240
241/// Shared handle to an installed binding. Capture one per request.
242#[derive(Clone, Debug)]
243pub struct BindingHandle {
244    inner: Arc<BindingSlot>,
245}
246
247impl BindingHandle {
248    pub fn with_binding<R>(&self, f: impl FnOnce(&HashlineBinding) -> R) -> R {
249        let guard = self.inner.lock();
250        f(&guard)
251    }
252
253    pub fn with_binding_mut<R>(&self, f: impl FnOnce(&mut HashlineBinding) -> R) -> R {
254        let mut guard = self.inner.lock();
255        f(&mut guard)
256    }
257
258    pub fn effective(&self) -> bool {
259        self.with_binding(|b| b.effective())
260    }
261
262    pub fn session_key(&self) -> SessionKey {
263        self.with_binding(|b| b.key().clone())
264    }
265}
266
267/// RAII guard that keeps a binding alive for one request and participates in
268/// the rebind drain refcount.
269pub struct BindingGuard {
270    handle: BindingHandle,
271}
272
273impl BindingGuard {
274    pub fn handle(&self) -> &BindingHandle {
275        &self.handle
276    }
277
278    pub fn effective(&self) -> bool {
279        self.handle.effective()
280    }
281
282    pub fn with_binding<R>(&self, f: impl FnOnce(&HashlineBinding) -> R) -> R {
283        self.handle.with_binding(f)
284    }
285
286    pub fn with_binding_mut<R>(&self, f: impl FnOnce(&mut HashlineBinding) -> R) -> R {
287        self.handle.with_binding_mut(f)
288    }
289}
290
291impl Drop for BindingGuard {
292    fn drop(&mut self) {
293        self.handle.inner.release_guard();
294    }
295}
296
297struct BindingRegistryInner {
298    state: Mutex<RegistryState>,
299}
300
301#[derive(Default)]
302struct RegistryState {
303    bindings: HashMap<SessionKey, Arc<BindingSlot>>,
304}
305
306/// Process-wide (or test-local) registry of session hashline bindings.
307pub struct BindingRegistry {
308    inner: Arc<BindingRegistryInner>,
309}
310
311impl Default for BindingRegistry {
312    fn default() -> Self {
313        Self::new()
314    }
315}
316
317impl BindingRegistry {
318    pub fn new() -> Self {
319        Self {
320            inner: Arc::new(BindingRegistryInner {
321                state: Mutex::new(RegistryState::default()),
322            }),
323        }
324    }
325
326    fn lock(&self) -> MutexGuard<'_, RegistryState> {
327        self.inner
328            .state
329            .lock()
330            .unwrap_or_else(|poisoned| poisoned.into_inner())
331    }
332
333    /// Atomically install or replace the binding for one session.
334    ///
335    /// Same-effective re-registration preserves snapshot and register stores.
336    /// Effective-value changes drain in-flight guards, clear stores, then install.
337    /// Failed callers must not call this with a partial request — the install is
338    /// all-or-nothing once invoked.
339    pub fn register(
340        &self,
341        root: impl AsRef<Path>,
342        session_id: impl Into<String>,
343        request: RegistrationRequest,
344    ) -> RegistrationOutcome {
345        let key = SessionKey::new(root.as_ref().to_path_buf(), session_id.into());
346        self.register_key(key, request, || {})
347    }
348
349    fn register_key(
350        &self,
351        key: SessionKey,
352        request: RegistrationRequest,
353        after_existing_read: impl FnOnce(),
354    ) -> RegistrationOutcome {
355        let effective = request.effective();
356        let downgrade = request.downgrade_warning();
357
358        // Serialize the existing-value read, comparison, and binding update. A
359        // guard may finish while this lock is held because guard release only
360        // takes the binding lock and signals the drain condition variable.
361        let mut state = self.lock();
362        let existing = state.bindings.get(&key).cloned();
363        let previous_effective = existing.as_ref().map(|binding| binding.lock().effective());
364        after_existing_read();
365
366        let (stores_cleared, stores_preserved) = if let Some(existing) = existing {
367            if previous_effective != Some(effective) {
368                self.drain_in_flight(&existing);
369                {
370                    let mut binding = existing.lock();
371                    binding.configured_enabled = request.configured_enabled;
372                    binding.edit_slot_survives = request.edit_slot_survives;
373                    binding.read_slot_survives = request.read_slot_survives;
374                    binding.effective = effective;
375                    binding.clear_stores();
376                }
377                state.bindings.insert(key, existing);
378                (true, false)
379            } else {
380                {
381                    let mut binding = existing.lock();
382                    binding.configured_enabled = request.configured_enabled;
383                    binding.edit_slot_survives = request.edit_slot_survives;
384                    binding.read_slot_survives = request.read_slot_survives;
385                    // effective unchanged; stores preserved.
386                }
387                state.bindings.insert(key, existing);
388                (false, true)
389            }
390        } else {
391            let binding = Arc::new(BindingSlot::new(HashlineBinding::new(key.clone(), request)));
392            state.bindings.insert(key, binding);
393            (false, false)
394        };
395
396        RegistrationOutcome {
397            configured_enabled: request.configured_enabled,
398            edit_slot_survives: request.edit_slot_survives,
399            read_slot_survives: request.read_slot_survives,
400            effective,
401            downgrade,
402            stores_cleared,
403            stores_preserved,
404        }
405    }
406
407    /// Capture the installed binding for one request. Unregistered sessions
408    /// yield `None` and must behave as effective-off.
409    pub fn capture(
410        &self,
411        root: impl AsRef<Path>,
412        session_id: impl Into<String>,
413    ) -> Option<BindingGuard> {
414        let key = SessionKey::new(root.as_ref().to_path_buf(), session_id.into());
415        let handle = {
416            let state = self.lock();
417            let arc = state.bindings.get(&key)?.clone();
418            {
419                let mut binding = arc.lock();
420                binding.in_flight = binding.in_flight.saturating_add(1);
421            }
422            BindingHandle { inner: arc }
423        };
424        Some(BindingGuard { handle })
425    }
426
427    /// Look up without incrementing the in-flight refcount (diagnostics only).
428    pub fn peek(
429        &self,
430        root: impl AsRef<Path>,
431        session_id: impl Into<String>,
432    ) -> Option<BindingHandle> {
433        let key = SessionKey::new(root.as_ref().to_path_buf(), session_id.into());
434        let state = self.lock();
435        state
436            .bindings
437            .get(&key)
438            .map(|arc| BindingHandle { inner: arc.clone() })
439    }
440
441    /// Remove one session binding (teardown / restart). In-flight guards drain first.
442    pub fn teardown(&self, root: impl AsRef<Path>, session_id: impl Into<String>) -> bool {
443        let key = SessionKey::new(root.as_ref().to_path_buf(), session_id.into());
444        let existing = {
445            let mut state = self.lock();
446            state.bindings.remove(&key)
447        };
448        let Some(existing) = existing else {
449            return false;
450        };
451
452        // Remove the slot before waiting so no new request can capture the
453        // binding while teardown is draining its existing guards. A concurrent
454        // registration may install a new slot without being removed afterward.
455        self.drain_in_flight(&existing);
456        true
457    }
458
459    /// Number of installed bindings (test/diagnostics).
460    pub fn len(&self) -> usize {
461        self.lock().bindings.len()
462    }
463
464    pub fn is_empty(&self) -> bool {
465        self.len() == 0
466    }
467
468    fn drain_in_flight(&self, binding: &Arc<BindingSlot>) {
469        binding.drain_in_flight();
470    }
471}
472
473/// Effective mode for a request: unregistered sessions are always off.
474pub fn effective_for_capture(guard: Option<&BindingGuard>) -> bool {
475    guard.map(|g| g.effective()).unwrap_or(false)
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use crate::hashline::scan::scan_bytes;
482    use std::sync::mpsc::{self, RecvTimeoutError};
483    use std::thread;
484    use std::time::Duration;
485
486    #[test]
487    fn draining_two_sessions_uses_each_sessions_mutex_partner() {
488        let registry = BindingRegistry::new();
489        let root = Path::new("/tmp/hashline-condvar-partners");
490        registry.register(
491            root,
492            "first",
493            RegistrationRequest {
494                configured_enabled: true,
495                edit_slot_survives: true,
496                read_slot_survives: true,
497            },
498        );
499        registry.register(
500            root,
501            "second",
502            RegistrationRequest {
503                configured_enabled: true,
504                edit_slot_survives: true,
505                read_slot_survives: true,
506            },
507        );
508        let first = registry.peek(root, "first").expect("first binding");
509        let second = registry.peek(root, "second").expect("second binding");
510
511        for handle in [&first, &second] {
512            let guard = handle.inner.lock();
513            let (_guard, timeout) = handle
514                .inner
515                .drain
516                .wait_timeout(guard, Duration::from_millis(1))
517                .unwrap_or_else(std::sync::PoisonError::into_inner);
518            assert!(timeout.timed_out());
519        }
520    }
521
522    #[test]
523    fn teardown_without_a_binding_returns_immediately() {
524        let registry = BindingRegistry::new();
525        assert!(!registry.teardown("/tmp/hashline-no-binding", "missing"));
526    }
527
528    #[test]
529    fn teardown_notifies_each_session_through_its_own_slot() {
530        let registry = Arc::new(BindingRegistry::new());
531        let root = Path::new("/tmp/hashline-session-drains");
532        for session in ["first", "second"] {
533            registry.register(
534                root,
535                session,
536                RegistrationRequest {
537                    configured_enabled: true,
538                    edit_slot_survives: true,
539                    read_slot_survives: true,
540                },
541            );
542        }
543        let first_guard = registry.capture(root, "first").expect("first guard");
544        let second_guard = registry.capture(root, "second").expect("second guard");
545
546        let (done_tx, done_rx) = mpsc::channel();
547        let first_registry = Arc::clone(&registry);
548        let first_done_tx = done_tx.clone();
549        let first_root = root.to_path_buf();
550        let first_teardown = thread::spawn(move || {
551            assert!(first_registry.teardown(&first_root, "first"));
552            first_done_tx.send("first").expect("signal first teardown");
553        });
554        let second_registry = Arc::clone(&registry);
555        let second_root = root.to_path_buf();
556        let second_teardown = thread::spawn(move || {
557            assert!(second_registry.teardown(&second_root, "second"));
558            done_tx.send("second").expect("signal second teardown");
559        });
560
561        let deadline = std::time::Instant::now() + Duration::from_secs(2);
562        while (registry.peek(root, "first").is_some() || registry.peek(root, "second").is_some())
563            && std::time::Instant::now() < deadline
564        {
565            thread::yield_now();
566        }
567        assert!(registry.peek(root, "first").is_none());
568        assert!(registry.peek(root, "second").is_none());
569
570        drop(second_guard);
571        assert_eq!(
572            done_rx
573                .recv_timeout(Duration::from_secs(2))
574                .expect("second teardown completes"),
575            "second"
576        );
577        assert!(matches!(
578            done_rx.recv_timeout(Duration::from_millis(50)),
579            Err(RecvTimeoutError::Timeout)
580        ));
581
582        drop(first_guard);
583        assert_eq!(
584            done_rx
585                .recv_timeout(Duration::from_secs(2))
586                .expect("first teardown completes"),
587            "first"
588        );
589        first_teardown.join().expect("first teardown thread");
590        second_teardown.join().expect("second teardown thread");
591    }
592
593    #[test]
594    fn concurrent_same_session_registration_serializes_read_compare_write() {
595        let registry = Arc::new(BindingRegistry::new());
596        let key = SessionKey::new("/tmp/hashline-register-race", "shared-session");
597        registry.register(
598            &key.root,
599            key.session_id.clone(),
600            RegistrationRequest {
601                configured_enabled: true,
602                edit_slot_survives: true,
603                read_slot_survives: true,
604            },
605        );
606        registry
607            .peek(&key.root, key.session_id.clone())
608            .expect("initial binding")
609            .with_binding_mut(|binding| {
610                binding
611                    .snapshots_mut()
612                    .publish("race.rs", scan_bytes(b"before race\n"));
613            });
614
615        let (first_read_tx, first_read_rx) = mpsc::channel();
616        let (release_first_tx, release_first_rx) = mpsc::channel();
617        let first_registry = Arc::clone(&registry);
618        let first_key = key.clone();
619        let first = thread::spawn(move || {
620            first_registry.register_key(
621                first_key,
622                RegistrationRequest {
623                    configured_enabled: false,
624                    edit_slot_survives: true,
625                    read_slot_survives: true,
626                },
627                || {
628                    first_read_tx.send(()).expect("signal first read");
629                    release_first_rx.recv().expect("release first registration");
630                },
631            )
632        });
633
634        first_read_rx
635            .recv()
636            .expect("first registration read existing binding");
637        let (second_started_tx, second_started_rx) = mpsc::channel();
638        let (second_read_tx, second_read_rx) = mpsc::channel();
639        let (second_done_tx, second_done_rx) = mpsc::channel();
640        let second_registry = Arc::clone(&registry);
641        let second_key = key.clone();
642        let second = thread::spawn(move || {
643            second_started_tx.send(()).expect("signal second start");
644            let outcome = second_registry.register_key(
645                second_key,
646                RegistrationRequest {
647                    configured_enabled: true,
648                    edit_slot_survives: false,
649                    read_slot_survives: true,
650                },
651                || second_read_tx.send(()).expect("signal second read"),
652            );
653            second_done_tx.send(outcome).expect("send second outcome");
654        });
655
656        second_started_rx
657            .recv()
658            .expect("second registration started");
659        assert!(matches!(
660            second_read_rx.recv_timeout(Duration::from_secs(1)),
661            Err(RecvTimeoutError::Timeout)
662        ));
663        release_first_tx
664            .send(())
665            .expect("release first registration");
666
667        let first_outcome = first.join().expect("first registration");
668        let second_outcome = second_done_rx
669            .recv_timeout(Duration::from_secs(2))
670            .expect("second registration completes after first");
671        second.join().expect("second registration");
672
673        assert!(first_outcome.stores_cleared);
674        assert!(second_outcome.stores_preserved);
675        let final_binding = registry
676            .peek(&key.root, key.session_id)
677            .expect("final binding");
678        final_binding.with_binding(|binding| {
679            assert!(binding.configured_enabled());
680            assert!(!binding.edit_slot_survives());
681            assert!(!binding.effective());
682            assert!(binding.snapshots().is_empty());
683        });
684    }
685}