1use std::collections::HashMap;
2use std::collections::HashSet;
3use std::sync::Arc;
4
5use tokio::sync::Mutex as TokioMutex;
6use tokio::sync::Notify;
7
8use crate::observe::structured::SpanId;
9use crate::report::result::ExecError;
10use crate::vm::Vm;
11use crate::vm::context::Scope;
12use relux_core::diagnostics::EffectId as DiagEffectId;
13use relux_core::pure::Env;
14use relux_ir::IrCleanupBlock;
15
16pub type ShellMap = HashMap<String, Arc<TokioMutex<Vm>>>;
19pub type VarMap = HashMap<String, String>;
20
21pub struct ExportedEffect {
25 pub key: EffectInstanceKey,
26 pub shells: ShellMap,
27 pub vars: VarMap,
28}
29
30pub struct AcquiredEffect {
32 pub shells: ShellMap,
33 pub vars: VarMap,
34}
35
36#[derive(Clone, Debug, PartialEq, Eq, Hash)]
39pub struct EffectInstanceKey {
40 pub effect_id: DiagEffectId,
41 pub evaluated_overlay: String,
42}
43
44impl EffectInstanceKey {
45 pub fn from_expects(
52 effect_id: DiagEffectId,
53 expect_names: &[&str],
54 evaluated_overlay: &Env,
55 ) -> Self {
56 let identity: String = expect_names
57 .iter()
58 .map(|name| {
59 let val = evaluated_overlay.get(name).unwrap_or("");
60 format!("{name}\0{val}")
61 })
62 .collect::<Vec<_>>()
63 .join("\0");
64 Self {
65 effect_id,
66 evaluated_overlay: identity,
67 }
68 }
69
70 pub fn marker(&self) -> String {
74 use relux_core::hash::StableHasher;
75 use std::hash::Hasher;
76 let mut hasher = StableHasher::new();
77 std::hash::Hash::hash(self, &mut hasher);
78 relux_core::diagnostics::format_mnemonic(hasher.finish())
79 }
80}
81
82#[derive(Clone, Debug, PartialEq, Eq, Hash)]
97pub enum ShellInstanceKey {
98 Effect {
99 effect: EffectInstanceKey,
100 shell_name: String,
101 },
102 Test {
103 shell_name: String,
104 },
105}
106
107impl ShellInstanceKey {
108 pub fn marker(&self) -> String {
111 use relux_core::hash::StableHasher;
112 use std::hash::Hasher;
113 let mut hasher = StableHasher::new();
114 std::hash::Hash::hash(self, &mut hasher);
115 relux_core::diagnostics::format_mnemonic(hasher.finish())
116 }
117}
118
119pub struct EffectHandle {
122 pub scope: Scope,
123 pub shells: ShellMap,
125 pub exposed: HashSet<String>,
127 pub exposed_vars: VarMap,
129 pub dep_guards: Vec<EffectGuard>,
133 pub cleanup: Option<IrCleanupBlock>,
134 pub setup_span: SpanId,
140 pub key: EffectInstanceKey,
145 pub marker: String,
149 pub alias: Option<String>,
153}
154
155impl EffectHandle {
156 pub fn exposed_shells(&self) -> ShellMap {
158 self.shells
159 .iter()
160 .filter(|(name, _)| self.exposed.contains(name.as_str()))
161 .map(|(k, v)| (k.clone(), v.clone()))
162 .collect()
163 }
164
165 pub fn exposed_vars(&self) -> &VarMap {
167 &self.exposed_vars
168 }
169}
170
171pub enum EffectSlot {
174 Empty,
175 Loading(Arc<Notify>),
180 Ready {
181 refcount: usize,
182 handle: Box<EffectHandle>,
183 },
184 Failed(ExecError),
185}
186
187pub enum ReleaseOutcome {
201 LastHolder {
202 handle: Box<EffectHandle>,
203 },
204 Deferred {
205 effect: String,
206 alias: Option<String>,
207 setup_span: SpanId,
208 marker: String,
209 },
210 Drift,
211}
212
213pub struct EffectGuard {
225 slot: Arc<TokioMutex<EffectSlot>>,
226}
227
228impl EffectGuard {
229 pub(crate) fn new(slot: Arc<TokioMutex<EffectSlot>>) -> Self {
234 Self { slot }
235 }
236
237 pub async fn release(self) -> ReleaseOutcome {
248 let mut guard = self.slot.lock().await;
249 match &mut *guard {
250 EffectSlot::Ready { refcount, handle } => {
251 *refcount -= 1;
252 if *refcount == 0 {
253 let taken = std::mem::replace(&mut *guard, EffectSlot::Empty);
254 match taken {
255 EffectSlot::Ready { handle, .. } => ReleaseOutcome::LastHolder { handle },
256 _ => unreachable!("matched Ready above"),
257 }
258 } else {
259 ReleaseOutcome::Deferred {
260 effect: handle.scope.name().to_string(),
261 alias: handle.alias.clone(),
262 setup_span: handle.setup_span,
263 marker: handle.marker.clone(),
264 }
265 }
266 }
267 EffectSlot::Empty | EffectSlot::Loading(_) | EffectSlot::Failed(_) => {
268 debug_assert!(
269 false,
270 r#"EffectGuard::release on non-Ready slot indicates a refcount/outstanding-guard drift; every guard must point at a Ready slot until it is consumed"#,
271 );
272 ReleaseOutcome::Drift
273 }
274 }
275 }
276}
277
278pub struct EffectRegistry {
281 slots: std::sync::Mutex<HashMap<EffectInstanceKey, Arc<TokioMutex<EffectSlot>>>>,
282}
283
284impl Default for EffectRegistry {
285 fn default() -> Self {
286 Self::new()
287 }
288}
289
290impl EffectRegistry {
291 pub fn new() -> Self {
292 Self {
293 slots: std::sync::Mutex::new(HashMap::new()),
294 }
295 }
296
297 pub fn slot(&self, key: &EffectInstanceKey) -> Arc<TokioMutex<EffectSlot>> {
300 self.slots
301 .lock()
302 .expect("slot map mutex poisoned")
303 .entry(key.clone())
304 .or_insert_with(|| Arc::new(TokioMutex::new(EffectSlot::Empty)))
305 .clone()
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 fn test_key(name: &str) -> EffectInstanceKey {
314 EffectInstanceKey {
315 effect_id: DiagEffectId {
316 module: relux_core::diagnostics::ModulePath("test.relux".into()),
317 name: relux_core::diagnostics::EffectName(name.to_string()),
318 },
319 evaluated_overlay: String::new(),
320 }
321 }
322
323 fn test_key_with_overlay(name: &str, overlay: &str) -> EffectInstanceKey {
324 EffectInstanceKey {
325 effect_id: DiagEffectId {
326 module: relux_core::diagnostics::ModulePath("test.relux".into()),
327 name: relux_core::diagnostics::EffectName(name.to_string()),
328 },
329 evaluated_overlay: overlay.to_string(),
330 }
331 }
332
333 fn stub_handle() -> EffectHandle {
334 use crate::vm::context::Scope;
335 use relux_core::pure::VarScope;
336 EffectHandle {
337 scope: Scope::Test {
338 name: "stub".into(),
339 vars: Arc::new(TokioMutex::new(VarScope::new())),
340 timeout: None,
341 },
342 shells: HashMap::new(),
343 exposed: HashSet::new(),
344 exposed_vars: HashMap::new(),
345 dep_guards: Vec::new(),
346 cleanup: None,
347 setup_span: 0u64,
348 key: test_key("stub"),
349 marker: "stub-marker-0000".into(),
350 alias: None,
351 }
352 }
353
354 fn ready_slot(refcount: usize) -> Arc<TokioMutex<EffectSlot>> {
355 Arc::new(TokioMutex::new(EffectSlot::Ready {
356 refcount,
357 handle: Box::new(stub_handle()),
358 }))
359 }
360
361 #[test]
362 fn key_equality_same() {
363 let k1 = test_key("Db");
364 let k2 = test_key("Db");
365 assert_eq!(k1, k2);
366 }
367
368 #[test]
369 fn key_equality_different_name() {
370 let k1 = test_key("Db");
371 let k2 = test_key("Redis");
372 assert_ne!(k1, k2);
373 }
374
375 #[test]
376 fn key_equality_different_overlay() {
377 let k1 = test_key_with_overlay("Db", "PORT=5432");
378 let k2 = test_key_with_overlay("Db", "PORT=5433");
379 assert_ne!(k1, k2);
380 }
381
382 #[test]
383 fn key_hash_consistent() {
384 use relux_core::hash::StableHasher;
385 use std::hash::Hash;
386 use std::hash::Hasher;
387 let k1 = test_key("Db");
388 let k2 = test_key("Db");
389 let mut h1 = StableHasher::new();
390 let mut h2 = StableHasher::new();
391 k1.hash(&mut h1);
392 k2.hash(&mut h2);
393 assert_eq!(h1.finish(), h2.finish());
394 }
395
396 #[test]
397 fn registry_new_is_empty() {
398 let reg = EffectRegistry::new();
399 assert!(reg.slots.lock().unwrap().is_empty());
400 }
401
402 #[tokio::test]
403 async fn slot_creates_empty_on_first_access() {
404 let reg = EffectRegistry::new();
405 let key = test_key("Db");
406 let slot = reg.slot(&key);
407 let guard = slot.lock().await;
408 assert!(matches!(*guard, EffectSlot::Empty));
409 }
410
411 #[tokio::test]
412 async fn slot_returns_same_arc_for_same_key() {
413 let reg = EffectRegistry::new();
414 let key = test_key("Db");
415 let s1 = reg.slot(&key);
416 let s2 = reg.slot(&key);
417 assert!(Arc::ptr_eq(&s1, &s2));
418 }
419
420 #[tokio::test]
421 async fn slot_returns_different_arcs_for_different_keys() {
422 let reg = EffectRegistry::new();
423 let k1 = test_key("Db");
424 let k2 = test_key("Redis");
425 let s1 = reg.slot(&k1);
426 let s2 = reg.slot(&k2);
427 assert!(!Arc::ptr_eq(&s1, &s2));
428 }
429
430 #[tokio::test]
431 async fn release_deferred_carries_metadata_and_decrements() {
432 let slot = ready_slot(2);
433 let g = EffectGuard::new(slot.clone());
434 match g.release().await {
435 ReleaseOutcome::Deferred { marker, .. } => {
436 assert_eq!(marker, "stub-marker-0000");
437 }
438 _ => panic!("non-last release should produce Deferred"),
439 }
440 let guard = slot.lock().await;
441 match &*guard {
442 EffectSlot::Ready { refcount, .. } => assert_eq!(*refcount, 1),
443 _ => panic!("slot should remain Ready with refcount 1"),
444 }
445 }
446
447 #[tokio::test]
448 async fn release_last_holder_returns_handle_and_empties_slot() {
449 let slot = ready_slot(1);
450 let g = EffectGuard::new(slot.clone());
451 match g.release().await {
452 ReleaseOutcome::LastHolder { handle } => {
453 assert_eq!(handle.marker, "stub-marker-0000");
454 }
455 _ => panic!("last release should produce LastHolder"),
456 }
457 let guard = slot.lock().await;
458 assert!(matches!(*guard, EffectSlot::Empty), "slot should be Empty");
459 }
460
461 #[tokio::test]
462 async fn concurrent_releases_serialize_via_slot_mutex() {
463 let slot = ready_slot(2);
464 let g1 = EffectGuard::new(slot.clone());
465 let g2 = EffectGuard::new(slot.clone());
466 let (a, b) = tokio::join!(g1.release(), g2.release());
467 let mut last_holder = 0usize;
468 let mut deferred = 0usize;
469 for outcome in [a, b] {
470 match outcome {
471 ReleaseOutcome::LastHolder { .. } => last_holder += 1,
472 ReleaseOutcome::Deferred { .. } => deferred += 1,
473 ReleaseOutcome::Drift => panic!("unexpected Drift"),
474 }
475 }
476 assert_eq!(last_holder, 1, "exactly one releaser is the last holder");
477 assert_eq!(deferred, 1, "exactly one releaser is deferred");
478 let guard = slot.lock().await;
479 assert!(matches!(*guard, EffectSlot::Empty));
480 }
481
482 #[test]
483 fn from_expects_no_collision_when_value_contains_separator() {
484 use std::collections::HashMap;
488 let effect_id = DiagEffectId {
489 module: relux_core::diagnostics::ModulePath("test.relux".into()),
490 name: relux_core::diagnostics::EffectName("E".to_string()),
491 };
492
493 let mut overlay1 = HashMap::new();
494 overlay1.insert("A".into(), "x,B=y".into());
495 let env1 = relux_core::pure::Env::from_map(overlay1);
496
497 let mut overlay2 = HashMap::new();
498 overlay2.insert("A".into(), "x".into());
499 overlay2.insert("B".into(), "y".into());
500 let env2 = relux_core::pure::Env::from_map(overlay2);
501
502 let expects = &["A"];
503 let k1 = EffectInstanceKey::from_expects(effect_id.clone(), expects, &env1);
504 let k2 = EffectInstanceKey::from_expects(effect_id, expects, &env2);
505 assert_ne!(
506 k1, k2,
507 "different expect values must produce different keys"
508 );
509 }
510
511 #[test]
512 fn from_expects_uses_only_expected_keys() {
513 use std::collections::HashMap;
516 let effect_id = DiagEffectId {
517 module: relux_core::diagnostics::ModulePath("test.relux".into()),
518 name: relux_core::diagnostics::EffectName("E".to_string()),
519 };
520
521 let mut overlay1 = HashMap::new();
522 overlay1.insert("PORT".into(), "5432".into());
523 overlay1.insert("EXTRA".into(), "foo".into());
524 let env1 = relux_core::pure::Env::from_map(overlay1);
525
526 let mut overlay2 = HashMap::new();
527 overlay2.insert("PORT".into(), "5432".into());
528 overlay2.insert("EXTRA".into(), "bar".into());
529 let env2 = relux_core::pure::Env::from_map(overlay2);
530
531 let expects = &["PORT"];
532 let k1 = EffectInstanceKey::from_expects(effect_id.clone(), expects, &env1);
533 let k2 = EffectInstanceKey::from_expects(effect_id, expects, &env2);
534 assert_eq!(
535 k1, k2,
536 "extra overlay keys beyond expects should not affect identity"
537 );
538 }
539
540 #[test]
541 fn from_expects_declaration_order_is_stable() {
542 use std::collections::HashMap;
543 let effect_id = DiagEffectId {
544 module: relux_core::diagnostics::ModulePath("test.relux".into()),
545 name: relux_core::diagnostics::EffectName("E".to_string()),
546 };
547
548 let mut overlay = HashMap::new();
549 overlay.insert("A".into(), "1".into());
550 overlay.insert("B".into(), "2".into());
551 let env = relux_core::pure::Env::from_map(overlay);
552
553 let k1 = EffectInstanceKey::from_expects(effect_id.clone(), &["A", "B"], &env);
555 let k2 = EffectInstanceKey::from_expects(effect_id, &["A", "B"], &env);
556 assert_eq!(k1, k2);
557 }
558
559 #[test]
560 fn from_expects_empty_expects_produces_equal_keys() {
561 use std::collections::HashMap;
562 let effect_id = DiagEffectId {
563 module: relux_core::diagnostics::ModulePath("test.relux".into()),
564 name: relux_core::diagnostics::EffectName("E".to_string()),
565 };
566
567 let mut overlay1 = HashMap::new();
568 overlay1.insert("X".into(), "1".into());
569 let env1 = relux_core::pure::Env::from_map(overlay1);
570 let env2 = relux_core::pure::Env::from_map(HashMap::new());
571
572 let expects: &[&str] = &[];
573 let k1 = EffectInstanceKey::from_expects(effect_id.clone(), expects, &env1);
574 let k2 = EffectInstanceKey::from_expects(effect_id, expects, &env2);
575 assert_eq!(
576 k1, k2,
577 "effects with no expects should always share identity"
578 );
579 }
580
581 #[test]
582 fn marker_is_stable_for_same_key() {
583 let k = test_key("FX");
584 assert_eq!(k.marker(), k.marker());
585 }
586
587 #[test]
588 fn marker_differs_for_different_overlay() {
589 let a = test_key_with_overlay("FX", "alpha");
590 let b = test_key_with_overlay("FX", "beta");
591 assert_ne!(a.marker(), b.marker());
592 }
593
594 #[test]
595 fn effect_overlay_source_uses_instance_marker() {
596 use std::sync::Arc;
597
598 use relux_core::pure::Env;
599 use relux_core::pure::LayeredEnv;
600 use relux_core::pure::LayeredEnvSource;
601 let marker = "brave-yak-0001".to_string();
604 let parent = Arc::new(LayeredEnv::root(Env::new()));
605 let env = LayeredEnv::child_with_source(
606 parent,
607 Env::new(),
608 LayeredEnvSource::EffectOverlay(marker.clone()),
609 );
610 assert_eq!(env.source(), &LayeredEnvSource::EffectOverlay(marker));
611 }
612
613 #[test]
614 fn marker_matches_mnemonic_format() {
615 let k = test_key("FX");
616 let m = k.marker();
617 let parts: Vec<&str> = m.split('-').collect();
618 assert_eq!(parts.len(), 3, "marker {m:?} should be adj-noun-NNNN");
619 assert!(parts[0].chars().all(|c| c.is_ascii_lowercase()));
620 assert!(parts[1].chars().all(|c| c.is_ascii_lowercase()));
621 assert_eq!(parts[2].len(), 4);
622 assert!(parts[2].chars().all(|c| c.is_ascii_digit()));
623 }
624
625 #[test]
626 fn shell_key_effect_marker_is_stable() {
627 let key = ShellInstanceKey::Effect {
628 effect: test_key("Db"),
629 shell_name: "redis".into(),
630 };
631 assert_eq!(key.marker(), key.marker());
632 }
633
634 #[test]
635 fn shell_key_test_marker_is_stable() {
636 let key = ShellInstanceKey::Test {
637 shell_name: "default".into(),
638 };
639 assert_eq!(key.marker(), key.marker());
640 }
641
642 #[test]
643 fn shell_key_marker_matches_mnemonic_format() {
644 let key = ShellInstanceKey::Test {
645 shell_name: "default".into(),
646 };
647 let m = key.marker();
648 let parts: Vec<&str> = m.split('-').collect();
649 assert_eq!(parts.len(), 3, "marker {m:?} should be adj-noun-NNNN");
650 assert_eq!(parts[2].len(), 4);
651 assert!(parts[2].chars().all(|c| c.is_ascii_digit()));
652 }
653
654 #[test]
655 fn shell_key_effect_vs_test_dont_collide_for_same_name() {
656 let effect_key = ShellInstanceKey::Effect {
657 effect: test_key("Db"),
658 shell_name: "default".into(),
659 };
660 let test_key_shell = ShellInstanceKey::Test {
661 shell_name: "default".into(),
662 };
663 assert_ne!(effect_key.marker(), test_key_shell.marker());
664 }
665
666 #[test]
667 fn shell_key_different_shell_names_produce_distinct_markers() {
668 let a = ShellInstanceKey::Effect {
669 effect: test_key("Db"),
670 shell_name: "redis".into(),
671 };
672 let b = ShellInstanceKey::Effect {
673 effect: test_key("Db"),
674 shell_name: "postgres".into(),
675 };
676 assert_ne!(a.marker(), b.marker());
677 }
678
679 #[test]
680 fn shell_key_cleanup_distinct_per_effect_instance() {
681 let a = ShellInstanceKey::Effect {
682 effect: test_key("Db"),
683 shell_name: "__cleanup".into(),
684 };
685 let b = ShellInstanceKey::Effect {
686 effect: test_key("Redis"),
687 shell_name: "__cleanup".into(),
688 };
689 assert_ne!(a.marker(), b.marker());
690 }
691}