1use std::collections::BTreeMap;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::sync::{Mutex, PoisonError};
15
16pub const DEFAULT_MAX_SESSION_BYTES: u64 = 16 * 1024 * 1024;
18pub const DEFAULT_MAX_VALUE_BYTES: u64 = 1024 * 1024;
20pub const DEFAULT_MAX_ENTRIES: u64 = 1024;
21pub const DEFAULT_MAX_KEY_UNITS: u64 = 256;
23
24const BYTES_PER_UNIT: u64 = 2;
26
27const ENTRY_OVERHEAD_BYTES: u64 = 64;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct SessionKvLimits {
33 pub max_session_bytes: u64,
34 pub max_value_bytes: u64,
35 pub max_entries: u64,
36 pub max_key_units: u64,
37}
38
39impl Default for SessionKvLimits {
40 fn default() -> Self {
41 Self {
42 max_session_bytes: DEFAULT_MAX_SESSION_BYTES,
43 max_value_bytes: DEFAULT_MAX_VALUE_BYTES,
44 max_entries: DEFAULT_MAX_ENTRIES,
45 max_key_units: DEFAULT_MAX_KEY_UNITS,
46 }
47 }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum SessionKvLimitKind {
54 KeyUnits {
55 units: u64,
56 limit: u64,
57 },
58 ValueBytes {
59 bytes: u64,
60 limit: u64,
61 },
62 PerSessionBytes {
64 requested: u64,
65 limit: u64,
66 },
67 AllSessionsBytes {
72 requested: u64,
73 limit: u64,
74 },
75 EntryCount {
76 limit: u64,
77 },
78}
79
80impl std::fmt::Display for SessionKvLimitKind {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 match self {
83 Self::KeyUnits { units, limit } => write!(
84 f,
85 "key length limit: {units} UTF-16 code units exceeds the {limit} allowed"
86 ),
87 Self::ValueBytes { bytes, limit } => write!(
88 f,
89 "value size limit: {bytes} serialized bytes exceeds the {limit} allowed"
90 ),
91 Self::PerSessionBytes { requested, limit } => write!(
92 f,
93 "session payload limit: {requested} retained bytes exceeds the {limit} this \
94 session may hold — remove entries this session no longer needs, or store \
95 less per key"
96 ),
97 Self::AllSessionsBytes { requested, limit } => write!(
98 f,
99 "server session-state budget: {requested} retained bytes exceeds the {limit} \
100 allowed across all live sessions — this session's own data is not what is \
101 in the way, so shrinking it need not help; the operator raises the budget \
102 with `--max-session-state-memory`"
103 ),
104 Self::EntryCount { limit } => {
105 write!(f, "entry count limit: the session already holds {limit}")
106 }
107 }
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum SessionKvError {
113 LimitExceeded {
116 operation: &'static str,
117 key: String,
118 limit: SessionKvLimitKind,
119 },
120 InvalidKey {
122 operation: &'static str,
123 key: String,
124 reason: &'static str,
125 },
126 Backend {
129 operation: &'static str,
130 message: String,
131 },
132}
133
134impl std::fmt::Display for SessionKvError {
135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136 match self {
137 Self::LimitExceeded {
138 operation,
139 key,
140 limit,
141 } => write!(f, "session.{operation}(\"{key}\") exceeded the {limit}"),
142 Self::InvalidKey {
143 operation,
144 key,
145 reason,
146 } => write!(f, "session.{operation}(\"{key}\"): {reason}"),
147 Self::Backend { operation, message } => {
148 write!(f, "session.{operation} failed: {message}")
149 }
150 }
151 }
152}
153
154impl std::error::Error for SessionKvError {}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct SessionKvEntry {
159 pub key: Vec<u16>,
160 pub size_bytes: u64,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct SessionKvPage {
170 pub entries: Vec<SessionKvEntry>,
171 pub last_scanned: Option<Vec<u16>>,
172 pub scanned_all: bool,
173}
174
175pub trait SessionKvStore: Send + Sync {
181 fn get(&self, key: &[u16]) -> Result<Option<Vec<u16>>, SessionKvError>;
184
185 fn has(&self, key: &[u16]) -> Result<bool, SessionKvError>;
186
187 fn set(&self, key: &[u16], payload: &[u16]) -> Result<(), SessionKvError>;
188
189 fn remove(&self, key: &[u16]) -> Result<bool, SessionKvError>;
191
192 fn scan(
196 &self,
197 after: Option<&[u16]>,
198 prefix: &[u16],
199 max_scan: usize,
200 ) -> Result<SessionKvPage, SessionKvError>;
201
202 fn limits(&self) -> SessionKvLimits;
203}
204
205pub struct InMemorySessionKv {
211 limits: SessionKvLimits,
212 state: Mutex<KvState>,
213 shared_bytes: Option<SharedKvBudget>,
214}
215
216#[derive(Debug, Clone)]
218pub struct SharedKvBudget {
219 used: Arc<AtomicU64>,
220 cap: u64,
221}
222
223impl SharedKvBudget {
224 pub fn new(cap: u64) -> Self {
225 Self {
226 used: Arc::new(AtomicU64::new(0)),
227 cap,
228 }
229 }
230
231 pub fn used(&self) -> u64 {
232 self.used.load(Ordering::Relaxed)
233 }
234
235 pub fn cap(&self) -> u64 {
236 self.cap
237 }
238
239 fn reserve(&self, delta: i64) -> Result<(), u64> {
242 let mut current = self.used.load(Ordering::Relaxed);
243 loop {
244 let next = apply_delta(current, delta);
245 if delta > 0 && next > self.cap {
246 return Err(next);
247 }
248 match self.used.compare_exchange_weak(
249 current,
250 next,
251 Ordering::Relaxed,
252 Ordering::Relaxed,
253 ) {
254 Ok(_) => return Ok(()),
255 Err(observed) => current = observed,
256 }
257 }
258 }
259}
260
261fn apply_delta(current: u64, delta: i64) -> u64 {
262 if delta >= 0 {
263 current.saturating_add(delta as u64)
264 } else {
265 current.saturating_sub(delta.unsigned_abs())
266 }
267}
268
269#[derive(Default)]
270struct KvState {
271 entries: BTreeMap<Vec<u16>, Vec<u16>>,
272 used_bytes: u64,
273}
274
275impl Default for InMemorySessionKv {
276 fn default() -> Self {
277 Self::new(SessionKvLimits::default())
278 }
279}
280
281impl InMemorySessionKv {
282 pub fn new(limits: SessionKvLimits) -> Self {
283 Self {
284 limits,
285 state: Mutex::new(KvState::default()),
286 shared_bytes: None,
287 }
288 }
289
290 pub fn with_shared_budget(limits: SessionKvLimits, budget: SharedKvBudget) -> Self {
291 Self {
292 limits,
293 state: Mutex::new(KvState::default()),
294 shared_bytes: Some(budget),
295 }
296 }
297
298 pub fn used_bytes(&self) -> u64 {
300 self.locked().map_or(0, |s| s.used_bytes)
301 }
302
303 pub fn len(&self) -> usize {
304 self.locked().map_or(0, |s| s.entries.len())
305 }
306
307 pub fn is_empty(&self) -> bool {
308 self.len() == 0
309 }
310
311 fn locked(&self) -> Result<std::sync::MutexGuard<'_, KvState>, SessionKvError> {
312 self.state.lock().map_err(|e: PoisonError<_>| {
313 let _ = e;
314 SessionKvError::Backend {
315 operation: "state",
316 message: "session store lock poisoned".into(),
317 }
318 })
319 }
320
321 fn check_key(&self, operation: &'static str, key: &[u16]) -> Result<(), SessionKvError> {
322 if key.is_empty() {
323 return Err(SessionKvError::InvalidKey {
324 operation,
325 key: String::new(),
326 reason: "key must not be empty",
327 });
328 }
329 let units = key.len() as u64;
330 if units > self.limits.max_key_units {
331 return Err(SessionKvError::LimitExceeded {
332 operation,
333 key: render_key(key),
334 limit: SessionKvLimitKind::KeyUnits {
335 units,
336 limit: self.limits.max_key_units,
337 },
338 });
339 }
340 Ok(())
341 }
342}
343
344fn entry_bytes(key: &[u16], payload: &[u16]) -> u64 {
345 (key.len() as u64 + payload.len() as u64) * BYTES_PER_UNIT + ENTRY_OVERHEAD_BYTES
346}
347
348fn render_key(key: &[u16]) -> String {
352 String::from_utf16_lossy(key)
353}
354
355impl Drop for InMemorySessionKv {
360 fn drop(&mut self) {
361 let Some(budget) = &self.shared_bytes else {
362 return;
363 };
364 let held = match self.state.get_mut() {
367 Ok(state) => state.used_bytes,
368 Err(poisoned) => poisoned.into_inner().used_bytes,
369 };
370 if held > 0 {
371 let _ = budget.reserve(-(held as i64));
372 }
373 }
374}
375
376impl SessionKvStore for InMemorySessionKv {
377 fn get(&self, key: &[u16]) -> Result<Option<Vec<u16>>, SessionKvError> {
378 self.check_key("get", key)?;
379 Ok(self.locked()?.entries.get(key).cloned())
380 }
381
382 fn has(&self, key: &[u16]) -> Result<bool, SessionKvError> {
383 self.check_key("has", key)?;
384 Ok(self.locked()?.entries.contains_key(key))
385 }
386
387 fn set(&self, key: &[u16], payload: &[u16]) -> Result<(), SessionKvError> {
388 self.check_key("set", key)?;
389
390 let payload_bytes = payload.len() as u64 * BYTES_PER_UNIT;
391 if payload_bytes > self.limits.max_value_bytes {
392 return Err(SessionKvError::LimitExceeded {
393 operation: "set",
394 key: render_key(key),
395 limit: SessionKvLimitKind::ValueBytes {
396 bytes: payload_bytes,
397 limit: self.limits.max_value_bytes,
398 },
399 });
400 }
401
402 let mut state = self.locked()?;
403 let previous_bytes = state
404 .entries
405 .get(key)
406 .map(|existing| entry_bytes(key, existing));
407
408 if previous_bytes.is_none() && state.entries.len() as u64 >= self.limits.max_entries {
409 return Err(SessionKvError::LimitExceeded {
410 operation: "set",
411 key: render_key(key),
412 limit: SessionKvLimitKind::EntryCount {
413 limit: self.limits.max_entries,
414 },
415 });
416 }
417
418 let new_bytes = entry_bytes(key, payload);
419 let released = previous_bytes.unwrap_or(0);
420 let next_used = state.used_bytes.saturating_sub(released) + new_bytes;
421 if next_used > self.limits.max_session_bytes {
422 return Err(SessionKvError::LimitExceeded {
423 operation: "set",
424 key: render_key(key),
425 limit: SessionKvLimitKind::PerSessionBytes {
426 requested: next_used,
427 limit: self.limits.max_session_bytes,
428 },
429 });
430 }
431
432 let delta = new_bytes as i64 - released as i64;
433 if let Some(budget) = &self.shared_bytes
434 && let Err(requested) = budget.reserve(delta)
435 {
436 return Err(SessionKvError::LimitExceeded {
437 operation: "set",
438 key: render_key(key),
439 limit: SessionKvLimitKind::AllSessionsBytes {
440 requested,
441 limit: budget.cap(),
442 },
443 });
444 }
445
446 state.entries.insert(key.to_vec(), payload.to_vec());
447 state.used_bytes = next_used;
448 Ok(())
449 }
450
451 fn remove(&self, key: &[u16]) -> Result<bool, SessionKvError> {
452 self.check_key("remove", key)?;
453 let mut state = self.locked()?;
454 let Some(previous) = state.entries.remove(key) else {
455 return Ok(false);
456 };
457 let released = entry_bytes(key, &previous);
458 state.used_bytes = state.used_bytes.saturating_sub(released);
459 if let Some(budget) = &self.shared_bytes {
460 let _ = budget.reserve(-(released as i64));
461 }
462 Ok(true)
463 }
464
465 fn scan(
466 &self,
467 after: Option<&[u16]>,
468 prefix: &[u16],
469 max_scan: usize,
470 ) -> Result<SessionKvPage, SessionKvError> {
471 let state = self.locked()?;
472 let lower = match after {
473 Some(key) => std::ops::Bound::Excluded(key.to_vec()),
474 None => std::ops::Bound::Unbounded,
475 };
476
477 let candidates = state
478 .entries
479 .range((lower, std::ops::Bound::Unbounded))
480 .take(max_scan);
481
482 let mut page = SessionKvPage {
483 entries: Vec::new(),
484 last_scanned: None,
485 scanned_all: true,
486 };
487 for (key, payload) in candidates {
488 page.last_scanned = Some(key.clone());
489 if key.starts_with(prefix) {
490 page.entries.push(SessionKvEntry {
491 key: key.clone(),
492 size_bytes: payload.len() as u64 * BYTES_PER_UNIT,
493 });
494 }
495 }
496 if let Some(last) = &page.last_scanned
499 && state
500 .entries
501 .range((
502 std::ops::Bound::Excluded(last.clone()),
503 std::ops::Bound::Unbounded,
504 ))
505 .next()
506 .is_some()
507 {
508 page.scanned_all = false;
509 }
510 Ok(page)
511 }
512
513 fn limits(&self) -> SessionKvLimits {
514 self.limits
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 fn u(s: &str) -> Vec<u16> {
523 s.encode_utf16().collect()
524 }
525
526 fn store() -> InMemorySessionKv {
527 InMemorySessionKv::default()
528 }
529
530 #[test]
531 fn set_then_get_returns_the_value_and_missing_keys_are_none() {
532 let kv = store();
533 kv.set(&u("a"), &u("{\"x\":1}")).expect("set");
534 assert_eq!(kv.get(&u("a")).expect("get"), Some(u("{\"x\":1}")));
535 assert_eq!(kv.get(&u("b")).expect("get"), None);
536 }
537
538 #[test]
539 fn stored_null_is_distinguishable_from_a_missing_key() {
540 let kv = store();
541 kv.set(&u("n"), &u("null")).expect("set");
542 assert_eq!(kv.get(&u("n")).expect("get"), Some(u("null")));
543 assert!(kv.has(&u("n")).expect("has"));
544 assert!(!kv.has(&u("missing")).expect("has"));
545 assert_eq!(kv.get(&u("missing")).expect("get"), None);
546 }
547
548 #[test]
549 fn empty_payload_is_a_present_value() {
550 let kv = store();
551 kv.set(&u("e"), &[]).expect("set");
552 assert!(kv.has(&u("e")).expect("has"));
553 assert_eq!(kv.get(&u("e")).expect("get"), Some(Vec::new()));
554 }
555
556 #[test]
557 fn replacement_charges_only_the_size_delta() {
558 let kv = store();
559 kv.set(&u("k"), &u("aaaa")).expect("set");
560 let after_first = kv.used_bytes();
561 kv.set(&u("k"), &u("aaaaaa")).expect("replace");
562 assert_eq!(kv.len(), 1);
563 assert_eq!(kv.used_bytes(), after_first + 4);
564 }
565
566 #[test]
567 fn remove_reports_existence_and_releases_capacity() {
568 let kv = store();
569 kv.set(&u("k"), &u("value")).expect("set");
570 let held = kv.used_bytes();
571 assert!(held > 0);
572 assert!(kv.remove(&u("k")).expect("remove"));
573 assert_eq!(kv.used_bytes(), 0);
574 assert!(!kv.remove(&u("k")).expect("remove"));
575 }
576
577 #[test]
578 fn oversized_value_leaves_the_previous_entry_intact() {
579 let kv = store();
580 kv.set(&u("k"), &u("kept")).expect("set");
581 let before = kv.used_bytes();
582 let huge = vec![b'x' as u16; (DEFAULT_MAX_VALUE_BYTES as usize / 2) + 1];
583 let err = kv.set(&u("k"), &huge).expect_err("must reject");
584 assert!(matches!(
585 err,
586 SessionKvError::LimitExceeded {
587 operation: "set",
588 limit: SessionKvLimitKind::ValueBytes { .. },
589 ..
590 }
591 ));
592 assert_eq!(kv.get(&u("k")).expect("get"), Some(u("kept")));
593 assert_eq!(kv.used_bytes(), before);
594 }
595
596 #[test]
597 fn entry_count_limit_rejects_without_partial_mutation() {
598 let kv = InMemorySessionKv::new(SessionKvLimits {
599 max_entries: 4,
600 ..SessionKvLimits::default()
601 });
602 for i in 0..4 {
603 kv.set(&u(&format!("k{i}")), &u("v")).expect("set");
604 }
605 let before = kv.used_bytes();
606 let err = kv.set(&u("overflow"), &u("v")).expect_err("must reject");
607 assert!(matches!(
608 err,
609 SessionKvError::LimitExceeded {
610 limit: SessionKvLimitKind::EntryCount { limit: 4 },
611 ..
612 }
613 ));
614 assert_eq!(kv.len(), 4);
615 assert_eq!(kv.used_bytes(), before);
616 assert!(!kv.has(&u("overflow")).expect("has"));
617 kv.set(&u("k0"), &u("vv")).expect("replace");
619 }
620
621 #[test]
622 fn session_payload_limit_names_operation_key_and_limit_but_not_the_value() {
623 let kv = InMemorySessionKv::new(SessionKvLimits {
624 max_session_bytes: 512,
625 ..SessionKvLimits::default()
626 });
627 let filler = vec![b'y' as u16; 300];
628 let err = kv.set(&u("bigkey"), &filler).expect_err("must reject");
629 let rendered = err.to_string();
630 assert!(rendered.contains("set"), "{rendered}");
631 assert!(rendered.contains("bigkey"), "{rendered}");
632 assert!(rendered.contains("512"), "{rendered}");
633 assert!(!rendered.contains("yyyy"), "{rendered}");
634 assert_eq!(kv.len(), 0);
635
636 kv.set(&u("held"), &vec![b'z' as u16; 200])
637 .expect("first write fits");
638 let secret = u("SUPER_SECRET_PAYLOAD");
639 let err = kv.set(&u("other"), &secret).expect_err("must reject");
640 assert!(!err.to_string().contains("SUPER_SECRET_PAYLOAD"));
641 assert_eq!(kv.len(), 1);
642 }
643
644 #[test]
645 fn key_at_the_unit_limit_is_accepted_and_one_over_is_rejected() {
646 let kv = store();
647 let at_limit = vec![b'k' as u16; DEFAULT_MAX_KEY_UNITS as usize];
648 kv.set(&at_limit, &u("v")).expect("256 units accepted");
649
650 let over = vec![b'k' as u16; DEFAULT_MAX_KEY_UNITS as usize + 1];
651 let err = kv.set(&over, &u("v")).expect_err("257 units rejected");
652 assert!(matches!(
653 err,
654 SessionKvError::LimitExceeded {
655 limit: SessionKvLimitKind::KeyUnits {
656 units: 257,
657 limit: 256
658 },
659 ..
660 }
661 ));
662 assert_eq!(kv.len(), 1);
663 }
664
665 #[test]
666 fn empty_key_is_rejected() {
667 let kv = store();
668 assert!(matches!(
669 kv.set(&[], &u("v")),
670 Err(SessionKvError::InvalidKey { .. })
671 ));
672 }
673
674 #[test]
675 fn lone_surrogates_survive_a_round_trip() {
676 let kv = store();
677 let key = vec![0x0061, 0xD800, 0x0062];
678 let payload = vec![0x0022, 0xDC00, 0xD83D, 0x0022];
679 kv.set(&key, &payload).expect("set");
680 assert_eq!(kv.get(&key).expect("get"), Some(payload.clone()));
681 assert_ne!(u(&String::from_utf16_lossy(&payload)), payload);
683 }
684
685 #[test]
686 fn scan_walks_keys_in_code_unit_order_and_reports_its_bound() {
687 let kv = store();
688 for key in ["a", "ab", "b", "ba", "c"] {
689 kv.set(&u(key), &u("v")).expect("set");
690 }
691 let all = kv.scan(None, &[], 10).expect("scan");
692 assert!(all.scanned_all);
693 let keys: Vec<Vec<u16>> = all.entries.iter().map(|e| e.key.clone()).collect();
694 assert_eq!(keys, vec![u("a"), u("ab"), u("b"), u("ba"), u("c")]);
695 assert_eq!(all.entries[0].size_bytes, 2);
696
697 let bounded = kv.scan(None, &u("z"), 2).expect("scan");
698 assert!(bounded.entries.is_empty());
699 assert!(!bounded.scanned_all);
700 assert_eq!(bounded.last_scanned, Some(u("ab")));
701
702 let after = kv.scan(Some(&u("ab")), &u("b"), 10).expect("scan");
703 assert!(after.scanned_all);
704 let keys: Vec<Vec<u16>> = after.entries.iter().map(|e| e.key.clone()).collect();
705 assert_eq!(keys, vec![u("b"), u("ba")]);
706 }
707
708 #[test]
709 fn concurrent_writes_to_one_key_resolve_last_committed_write() {
710 let kv = Arc::new(store());
711 let writers: Vec<_> = (0..8)
712 .map(|i| {
713 let kv = Arc::clone(&kv);
714 std::thread::spawn(move || {
715 for _ in 0..200 {
716 kv.set(&u("hot"), &u(&format!("writer-{i}"))).expect("set");
717 }
718 })
719 })
720 .collect();
721 for w in writers {
722 w.join().expect("writer thread");
723 }
724 let final_value = kv.get(&u("hot")).expect("get").expect("present");
725 let rendered = String::from_utf16_lossy(&final_value);
726 assert!(rendered.starts_with("writer-"), "{rendered}");
727 assert_eq!(kv.len(), 1);
728 assert_eq!(kv.used_bytes(), entry_bytes(&u("hot"), &final_value));
729 }
730
731 #[test]
735 fn dropping_a_store_returns_its_bytes_to_the_shared_budget() {
736 let budget = SharedKvBudget::new(entry_bytes(&u("k"), &u("aaaa")));
737 {
738 let held =
739 InMemorySessionKv::with_shared_budget(SessionKvLimits::default(), budget.clone());
740 held.set(&u("k"), &u("aaaa")).expect("first session fits");
741 assert_eq!(budget.used(), budget.cap());
742 }
743 assert_eq!(budget.used(), 0, "the ended session released its bytes");
744
745 let next = InMemorySessionKv::with_shared_budget(SessionKvLimits::default(), budget);
746 next.set(&u("k"), &u("aaaa"))
747 .expect("a later session reuses the capacity");
748 }
749
750 #[test]
751 fn shared_budget_bounds_the_sum_across_sessions() {
752 let budget = SharedKvBudget::new(entry_bytes(&u("k"), &u("aaaa")));
753 let a = InMemorySessionKv::with_shared_budget(SessionKvLimits::default(), budget.clone());
754 let b = InMemorySessionKv::with_shared_budget(SessionKvLimits::default(), budget.clone());
755 a.set(&u("k"), &u("aaaa")).expect("first session fits");
756 let err = b.set(&u("k"), &u("aaaa")).expect_err("second exceeds");
757 assert!(matches!(
758 err,
759 SessionKvError::LimitExceeded {
760 limit: SessionKvLimitKind::AllSessionsBytes { .. },
761 ..
762 }
763 ));
764 assert_eq!(b.len(), 0);
765 assert!(a.remove(&u("k")).expect("remove"));
766 assert_eq!(budget.used(), 0);
767 b.set(&u("k"), &u("aaaa")).expect("capacity released");
768 }
769
770 #[test]
775 fn the_per_session_and_server_wide_byte_limits_name_different_fixes() {
776 let per_session = InMemorySessionKv::new(SessionKvLimits {
777 max_session_bytes: 128,
778 ..SessionKvLimits::default()
779 });
780 let own = per_session
781 .set(&u("k"), &vec![b'x' as u16; 200])
782 .expect_err("over this session's own limit")
783 .to_string();
784
785 let budget = SharedKvBudget::new(entry_bytes(&u("n"), &u("v")));
788 let neighbour =
789 InMemorySessionKv::with_shared_budget(SessionKvLimits::default(), budget.clone());
790 neighbour.set(&u("n"), &u("v")).expect("neighbour fits");
791 let shared = InMemorySessionKv::with_shared_budget(SessionKvLimits::default(), budget);
792 let aggregate = shared
793 .set(&u("k"), &u("v"))
794 .expect_err("over the server-wide budget")
795 .to_string();
796
797 assert_ne!(own, aggregate);
798 assert!(own.contains("this session may hold"), "{own}");
799 assert!(
800 !own.contains("max-session-state-memory"),
801 "the per-session limit is not the operator's flag: {own}"
802 );
803 assert!(
804 aggregate.contains("across all live sessions"),
805 "{aggregate}"
806 );
807 assert!(
808 aggregate.contains("max-session-state-memory"),
809 "the aggregate refusal must name the flag that raises it: {aggregate}"
810 );
811 for rendered in [&own, &aggregate] {
813 assert!(!rendered.contains("xxxx"), "{rendered}");
814 }
815 }
816}