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