@tishlang/tish-format 3.10.4 → 3.10.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/bin/tish-format +0 -0
  2. package/crates/tish/src/cargo_native_registry.rs +4 -0
  3. package/crates/tish/src/main.rs +65 -25
  4. package/crates/tish/tests/integration_test.rs +3 -0
  5. package/crates/tish_builtins/src/globals.rs +3 -3
  6. package/crates/tish_builtins/src/symbol.rs +86 -4
  7. package/crates/tish_bytecode/src/compiler.rs +65 -25
  8. package/crates/tish_bytecode/tests/captured_param_slot_binding.rs +131 -0
  9. package/crates/tish_bytecode/tests/fundecl_slot_binding.rs +115 -0
  10. package/crates/tish_bytecode/tests/param_defaults_slot_binding.rs +86 -0
  11. package/crates/tish_core/src/shape.rs +55 -2
  12. package/crates/tish_core/src/value.rs +229 -13
  13. package/crates/tish_eval/src/timers.rs +66 -2
  14. package/crates/tish_ffi/src/lib.rs +74 -12
  15. package/crates/tish_ffi/tests/loader.rs +35 -0
  16. package/crates/tish_fmt/Cargo.toml +1 -1
  17. package/crates/tish_lsp/src/main.rs +91 -2
  18. package/crates/tish_pg/src/lib.rs +59 -8
  19. package/crates/tish_pg/src/statement_registry.rs +250 -0
  20. package/crates/tish_runtime/src/http.rs +19 -0
  21. package/crates/tish_runtime/src/http_fetch.rs +159 -65
  22. package/crates/tish_runtime/src/http_hyper.rs +18 -2
  23. package/crates/tish_runtime/src/lib.rs +4 -0
  24. package/crates/tish_runtime/src/net.rs +125 -97
  25. package/crates/tish_runtime/src/process_spawn.rs +326 -113
  26. package/crates/tish_runtime/src/promise.rs +521 -90
  27. package/crates/tish_runtime/src/promise_io.rs +29 -6
  28. package/crates/tish_runtime/src/pty.rs +161 -107
  29. package/crates/tish_runtime/src/stream_buf.rs +419 -0
  30. package/crates/tish_runtime/src/ws.rs +431 -52
  31. package/crates/tish_vm/src/jit.rs +473 -67
  32. package/crates/tish_vm/src/lib.rs +6 -0
  33. package/crates/tish_vm/src/vm.rs +8 -5
  34. package/crates/tish_vm/tests/captured_param_slot_mode.rs +210 -0
  35. package/crates/tish_vm/tests/captured_param_vm_slots_off.rs +64 -0
  36. package/crates/tish_vm/tests/fundecl_slot_no_frame_cycle.rs +152 -0
  37. package/crates/tish_vm/tests/param_defaults_slot_mode.rs +126 -0
  38. package/crates/tish_vm/tests/param_defaults_vm_slots_off.rs +44 -0
  39. package/package.json +1 -1
  40. package/platform/darwin-arm64/tish-fmt +0 -0
  41. package/platform/darwin-x64/tish-fmt +0 -0
  42. package/platform/linux-arm64/tish-fmt +0 -0
  43. package/platform/linux-x64/tish-fmt +0 -0
  44. package/platform/win32-x64/tish-fmt.exe +0 -0
@@ -0,0 +1,115 @@
1
+ //! #716: how a body-level `function NAME` binds its name in general slot mode.
2
+ //!
3
+ //! An UNCAPTURED name must be slot-bound (`StoreLocal`) — `DeclareVar` would insert the
4
+ //! closure into the very `local_scope` map the closure captured, minting a per-call
5
+ //! closure→scope→closure cycle that leaks the whole frame (refcount-only runtime, no
6
+ //! cycle collector). A name referenced from inside ANY nested closure — the function's
7
+ //! own body (self-recursion) or a sibling (mutual recursion) — is in the captured set
8
+ //! and must STAY on the `DeclareVar` scope path, or the recursive name lookup breaks.
9
+
10
+ use tishlang_bytecode::{compile, Chunk, Opcode};
11
+
12
+ /// Count occurrences of `target` in `chunk.code`, walking real instruction boundaries.
13
+ fn count_opcode(chunk: &Chunk, target: Opcode) -> usize {
14
+ let code = &chunk.code;
15
+ let mut ip = 0usize;
16
+ let mut n = 0usize;
17
+ while ip < code.len() {
18
+ let op = Opcode::from_u8(code[ip]).expect("valid opcode");
19
+ if op == target {
20
+ n += 1;
21
+ }
22
+ ip += op.instruction_size(code, ip).expect("sized instruction");
23
+ }
24
+ n
25
+ }
26
+
27
+ fn compile_src(src: &str) -> Chunk {
28
+ let program = tishlang_parser::parse(src).expect("parse");
29
+ compile(&program).expect("compile")
30
+ }
31
+
32
+ /// These tests assert slot-mode emission; running the suite with `TISH_VM_SLOTS=0`
33
+ /// deliberately exercises the legacy name-based path, where they do not apply.
34
+ fn slots_disabled() -> bool {
35
+ std::env::var("TISH_VM_SLOTS")
36
+ .map(|v| v == "0")
37
+ .unwrap_or(false)
38
+ }
39
+
40
+ #[test]
41
+ fn uncaptured_body_level_fn_is_slot_bound() {
42
+ if slots_disabled() {
43
+ return;
44
+ }
45
+ let chunk = compile_src(
46
+ r#"
47
+ function outer() {
48
+ function helper(a) { return a + 1 }
49
+ return helper(41)
50
+ }
51
+ console.log(outer())
52
+ "#,
53
+ );
54
+ let outer = &chunk.nested[0];
55
+ assert!(
56
+ outer.slot_based,
57
+ "outer should compile in general slot mode"
58
+ );
59
+ assert_eq!(
60
+ count_opcode(outer, Opcode::DeclareVar),
61
+ 0,
62
+ "uncaptured `function helper` must not DeclareVar into the captured scope (#716 cycle)"
63
+ );
64
+ assert!(
65
+ count_opcode(outer, Opcode::StoreLocal) >= 1,
66
+ "uncaptured `function helper` should be stored to a frame slot"
67
+ );
68
+ }
69
+
70
+ #[test]
71
+ fn self_recursive_body_level_fn_stays_on_scope_path() {
72
+ if slots_disabled() {
73
+ return;
74
+ }
75
+ let chunk = compile_src(
76
+ r#"
77
+ function outer() {
78
+ function fact(k) { if (k < 2) { return 1 } return k * fact(k - 1) }
79
+ return fact(5)
80
+ }
81
+ console.log(outer())
82
+ "#,
83
+ );
84
+ let outer = &chunk.nested[0];
85
+ assert_eq!(
86
+ count_opcode(outer, Opcode::DeclareVar),
87
+ 1,
88
+ "self-recursive `fact` references its own name from inside its body — that IS a \
89
+ capture, and the name must stay DeclareVar-bound in `local_scope`"
90
+ );
91
+ }
92
+
93
+ #[test]
94
+ fn mutually_recursive_body_level_fns_stay_on_scope_path() {
95
+ if slots_disabled() {
96
+ return;
97
+ }
98
+ let chunk = compile_src(
99
+ r#"
100
+ function outer(n) {
101
+ function isEven(k) { if (k === 0) { return true } return isOdd(k - 1) }
102
+ function isOdd(k) { if (k === 0) { return false } return isEven(k - 1) }
103
+ return isEven(n)
104
+ }
105
+ console.log(outer(10))
106
+ "#,
107
+ );
108
+ let outer = &chunk.nested[0];
109
+ assert_eq!(
110
+ count_opcode(outer, Opcode::DeclareVar),
111
+ 2,
112
+ "mutually recursive names are captured by the sibling closure and must both stay \
113
+ DeclareVar-bound"
114
+ );
115
+ }
@@ -0,0 +1,86 @@
1
+ //! #727: WHERE the default-parameter prologue stores its value in general slot mode.
2
+ //!
3
+ //! In a general-slot-mode chunk every read of a param resolves to `LoadLocal <slot>`, so
4
+ //! the applied default must be stored with `StoreLocal <slot>` — a `DeclareVarPlain`
5
+ //! writes it into the name-based scope map where no read will ever find it (the slot
6
+ //! keeps its missing-arg `Null`; `a + b` → NaN).
7
+
8
+ use tishlang_bytecode::{compile, Chunk, Opcode};
9
+
10
+ /// Count occurrences of `target` in `chunk.code`, walking real instruction boundaries.
11
+ fn count_opcode(chunk: &Chunk, target: Opcode) -> usize {
12
+ let code = &chunk.code;
13
+ let mut ip = 0usize;
14
+ let mut n = 0usize;
15
+ while ip < code.len() {
16
+ let op = Opcode::from_u8(code[ip]).expect("valid opcode");
17
+ if op == target {
18
+ n += 1;
19
+ }
20
+ ip += op.instruction_size(code, ip).expect("sized instruction");
21
+ }
22
+ n
23
+ }
24
+
25
+ fn compile_src(src: &str) -> Chunk {
26
+ let program = tishlang_parser::parse(src).expect("parse");
27
+ compile(&program).expect("compile")
28
+ }
29
+
30
+ /// These tests assert slot-mode emission; running the suite with `TISH_VM_SLOTS=0`
31
+ /// deliberately exercises the legacy name-based path, where they do not apply.
32
+ fn slots_disabled() -> bool {
33
+ std::env::var("TISH_VM_SLOTS")
34
+ .map(|v| v == "0")
35
+ .unwrap_or(false)
36
+ }
37
+
38
+ #[test]
39
+ fn general_slot_mode_default_stores_to_slot() {
40
+ if slots_disabled() {
41
+ return;
42
+ }
43
+ let chunk = compile_src(
44
+ r#"
45
+ function f(a, b = 5) {
46
+ let c = a + b
47
+ return c
48
+ }
49
+ console.log(f(1))
50
+ "#,
51
+ );
52
+ let f = &chunk.nested[0];
53
+ assert!(f.slot_based, "f should compile in general slot mode");
54
+ assert_eq!(
55
+ count_opcode(f, Opcode::DeclareVarPlain),
56
+ 0,
57
+ "the applied default must not be declared into the name-based scope map — \
58
+ reads of the param resolve to its frame slot (#727)"
59
+ );
60
+ assert!(
61
+ count_opcode(f, Opcode::StoreLocal) >= 1,
62
+ "the applied default should be stored to the param's frame slot"
63
+ );
64
+ }
65
+
66
+ /// The slot-DISQUALIFIED shape (captured param → name-based chunk) must keep the
67
+ /// `DeclareVarPlain` store: there is no slot, and closures read the param by name.
68
+ #[test]
69
+ fn name_based_chunk_default_still_declares_by_name() {
70
+ let chunk = compile_src(
71
+ r#"
72
+ function g(a, b = 5) {
73
+ function h() { return b }
74
+ let c = a + h()
75
+ return c
76
+ }
77
+ console.log(g(1))
78
+ "#,
79
+ );
80
+ let g = &chunk.nested[0];
81
+ assert_eq!(
82
+ count_opcode(g, Opcode::DeclareVarPlain),
83
+ 1,
84
+ "captured-param chunk is name-based; its default must stay DeclareVarPlain-bound"
85
+ );
86
+ }
@@ -31,10 +31,31 @@ pub const DICT_SHAPE: ShapeId = u32::MAX;
31
31
  /// Edges are keyed by an `ahash` map, not the default SipHash one: `transition` is on the hot path of
32
32
  /// every object construction (and `JSON.parse` — profiled as ~16% of parse, almost all SipHash), and
33
33
  /// ahash is ~2–3× faster on short string keys. Random-seeded per process, so no HashDoS regression.
34
+ /// Growth backstops (#701/#706/#710). The registry has no eviction — a permanent `ShapeNode`
35
+ /// plus an `Arc` clone of the key per distinct `(parent, key)` edge — so every mint site must be
36
+ /// bounded. Three independent caps, all degrading to `DICT_SHAPE` (never an error):
37
+ ///
38
+ /// - `MAX_TRANSITIONS_PER_NODE` bounds a node's fan-out. The hot pathology is node 0: every
39
+ /// fresh `{}` that takes a data-dependent first key (`o[requestId] = …`) adds one edge to
40
+ /// EMPTY_SHAPE's map, forever, under the global write lock.
41
+ /// - `MAX_CHAIN_DEPTH` bounds a chain's length. A single object accumulating keys one at a time
42
+ /// mints a linear chain (each node fan-out 1, so the fan-out cap never trips). `PropMap`
43
+ /// promotion demotes to dictionary mode at 9 keys, but `with_capacity`-built maps skip
44
+ /// promotion — the depth cap is their backstop.
45
+ /// - `MAX_SHAPES` is the absolute registry ceiling; past it nothing new is ever interned.
46
+ ///
47
+ /// Values are far above anything a program's TEXT can mint (shapes are meant to be bounded by
48
+ /// code structure), and far below the ~750 GB where the u32 id-overflow guard would trip.
49
+ const MAX_TRANSITIONS_PER_NODE: usize = 256;
50
+ const MAX_CHAIN_DEPTH: u16 = 32;
51
+ const MAX_SHAPES: usize = 1 << 18; // 262_144 nodes ≈ tens of MB worst-case, orders above real use
52
+
34
53
  #[cfg(not(feature = "portable"))]
35
54
  #[derive(Default)]
36
55
  struct ShapeNode {
37
56
  transitions: AHashMap<Arc<str>, ShapeId>,
57
+ /// Distance from EMPTY_SHAPE (== number of keys on the path). Bounds chain length.
58
+ depth: u16,
38
59
  }
39
60
 
40
61
  #[cfg(not(feature = "portable"))]
@@ -85,6 +106,8 @@ fn transition_tracked(from: ShapeId, key: &Arc<str>) -> ShapeId {
85
106
  return DICT_SHAPE;
86
107
  }
87
108
  // Fast path: the edge already exists (the common case after the first object of this shape).
109
+ // The caps are ALSO checked here, read-only: a saturated node keeps answering DICT_SHAPE
110
+ // from the read lock instead of convoying every miss through the write lock below.
88
111
  {
89
112
  let reg = registry().read().unwrap();
90
113
  match reg.nodes.get(from as usize) {
@@ -92,6 +115,11 @@ fn transition_tracked(from: ShapeId, key: &Arc<str>) -> ShapeId {
92
115
  if let Some(&next) = node.transitions.get(key.as_ref()) {
93
116
  return next;
94
117
  }
118
+ if node.transitions.len() >= MAX_TRANSITIONS_PER_NODE
119
+ || node.depth >= MAX_CHAIN_DEPTH
120
+ {
121
+ return DICT_SHAPE;
122
+ }
95
123
  }
96
124
  None => return DICT_SHAPE, // out of range — should not happen; degrade safely
97
125
  }
@@ -102,13 +130,38 @@ fn transition_tracked(from: ShapeId, key: &Arc<str>) -> ShapeId {
102
130
  if let Some(&next) = reg.nodes[from as usize].transitions.get(key.as_ref()) {
103
131
  return next;
104
132
  }
133
+ // Re-check the caps under the write lock too (the read-path check can race).
134
+ let parent_depth = reg.nodes[from as usize].depth;
135
+ if reg.nodes[from as usize].transitions.len() >= MAX_TRANSITIONS_PER_NODE
136
+ || parent_depth >= MAX_CHAIN_DEPTH
137
+ || reg.nodes.len() >= MAX_SHAPES
138
+ {
139
+ return DICT_SHAPE;
140
+ }
105
141
  let new_id = reg.nodes.len();
106
142
  if new_id >= DICT_SHAPE as usize {
107
- return DICT_SHAPE; // ran out of shape ids — extremely unlikely; degrade to dictionary mode
143
+ return DICT_SHAPE; // id-space overflow guard — prevents u32 truncation aliasing live ids
108
144
  }
109
- reg.nodes.push(ShapeNode::default());
145
+ reg.nodes.push(ShapeNode {
146
+ transitions: AHashMap::default(),
147
+ depth: parent_depth + 1,
148
+ });
110
149
  reg.nodes[from as usize]
111
150
  .transitions
112
151
  .insert(Arc::clone(key), new_id as ShapeId);
113
152
  new_id as ShapeId
114
153
  }
154
+
155
+ /// Number of interned shape nodes — for regression tests asserting that a workload does NOT
156
+ /// grow the registry. Not part of the public surface.
157
+ #[doc(hidden)]
158
+ #[cfg(not(feature = "portable"))]
159
+ pub fn shape_count() -> usize {
160
+ registry().read().unwrap().nodes.len()
161
+ }
162
+
163
+ #[doc(hidden)]
164
+ #[cfg(feature = "portable")]
165
+ pub fn shape_count() -> usize {
166
+ 0
167
+ }
@@ -196,19 +196,46 @@ pub trait TishStruct {
196
196
 
197
197
  /// Trait for Promise-like values that can be awaited (block until settled).
198
198
  /// Implemented by the runtime for native compile; interpreter uses its own Promise.
199
- pub trait TishPromise: Send + Sync {
199
+ ///
200
+ /// (`'static` is part of the contract: promises are shared as `Arc<dyn TishPromise>`
201
+ /// inside `Value::Promise`, and settlement subscriptions outlive the subscribing frame.)
202
+ pub trait TishPromise: Send + Sync + 'static {
200
203
  fn block_until_settled(&self) -> core::result::Result<Value, Value>;
201
204
  /// Try to settle WITHOUT blocking. Returns `Some(result)` if the promise was already
202
205
  /// settled before this call; returns `None` if it is still pending (a background thread
203
206
  /// / I/O task has not completed yet). Default: always pending — implementors of async
204
- /// promises (fetch, spawn) leave this as `None`; `ImmediateSettledPromise` overrides it.
207
+ /// promises (fetch) leave this as `None`; settled/state-backed promises override it.
208
+ /// Must PEEK, not consume: a promise reporting `Some` here stays awaitable.
205
209
  ///
206
210
  /// Used by `race`/`any`/`allSettled` to handle already-settled promises in input-order
207
- /// (deterministic, JS-compatible) before falling back to concurrent thread waiting for
208
- /// genuinely-pending ones.
211
+ /// (deterministic, JS-compatible) before subscribing to genuinely-pending ones.
209
212
  fn try_settle(&self) -> Option<core::result::Result<Value, Value>> {
210
213
  None
211
214
  }
215
+
216
+ /// Register a callback to run exactly once when this promise settles (issue #702).
217
+ ///
218
+ /// `Promise.race` / `Promise.any` / `Promise.allSettled` use this to wait on N
219
+ /// promises concurrently WITHOUT parking one OS thread per pending input: an
220
+ /// implementor that can store the callback (state-cell or channel-backed promises)
221
+ /// invokes it from whichever thread settles the promise, so the combinator's own
222
+ /// channel `recv` is the only blocking wait. A subscription that loses a race is
223
+ /// simply dropped after (or instead of) firing — no cancellation path is needed.
224
+ ///
225
+ /// The default implementation is the legacy behavior — a detached waiter thread
226
+ /// blocking in [`Self::block_until_settled`] — so implementors that cannot register
227
+ /// callbacks keep working; they only miss the no-thread optimization until they
228
+ /// override this. Overrides MUST NOT block while the promise is pending.
229
+ #[cfg(feature = "send-values")]
230
+ fn subscribe(
231
+ self: Arc<Self>,
232
+ on_settled: Box<dyn FnOnce(core::result::Result<Value, Value>) + Send>,
233
+ ) {
234
+ std::thread::spawn(move || {
235
+ let r = self.block_until_settled();
236
+ on_settled(r);
237
+ });
238
+ }
212
239
  }
213
240
 
214
241
  /// JavaScript RegExp flags
@@ -847,11 +874,15 @@ impl PropMap {
847
874
  // Update existing key → value changes, layout (shape) does not.
848
875
  return Some(core::mem::replace(&mut slot.1, val));
849
876
  }
850
- // New key (inline storage) → transition the shape away from the current one.
851
- self.shape = crate::shape::transition(self.shape, &key);
852
877
  if self.inline.len() >= PROPMAP_INLINE {
853
- // Promote inline storage to an insertion-ordered map (keys + their order are preserved,
854
- // so the shape stays valid).
878
+ // Promote inline storage to an insertion-ordered map — and DEMOTE to dictionary
879
+ // mode. An object growing past PROPMAP_INLINE keys one insert at a time is being
880
+ // used as a map (`cache[k] = v`), and continuing to transition would intern a
881
+ // permanent registry node per novel key, forever (#701). Inline caches lose only
882
+ // the 9+-key tail, whose slot lookups already go through the IndexMap.
883
+ // NOTE: the transition for THIS key is deliberately skipped (checked before the
884
+ // old transition call site so the 9th-key edge is never minted).
885
+ self.shape = crate::shape::DICT_SHAPE;
855
886
  let mut m: IndexMap<Arc<str>, Value, RandomState> =
856
887
  IndexMap::with_capacity_and_hasher(self.inline.len() + 1, RandomState::default());
857
888
  for (k, v) in self.inline.drain(..) {
@@ -861,6 +892,8 @@ impl PropMap {
861
892
  self.map = Some(Box::new(m));
862
893
  return None;
863
894
  }
895
+ // New key (inline storage) → transition the shape away from the current one.
896
+ self.shape = crate::shape::transition(self.shape, &key);
864
897
  self.inline.push((key, val));
865
898
  None
866
899
  }
@@ -972,6 +1005,13 @@ impl PropMap {
972
1005
  if incoming == 0 {
973
1006
  return;
974
1007
  }
1008
+ // Dictionary-ness PROPAGATES: a DICT_SHAPE source stores its keys in whatever order it
1009
+ // was built with (for hashed-map sources, a random per-instance order — see
1010
+ // `from_unordered`). Walking that order through tracked `insert` re-minted a fresh
1011
+ // permanent shape chain per merge (#708: `{ ...headers, id }` at 100 req/s ≈ 950 MB/h).
1012
+ // Copies of dictionaries are dictionaries; copies of tracked objects stay tracked.
1013
+ let dict = self.shape == crate::shape::DICT_SHAPE
1014
+ || other.shape == crate::shape::DICT_SHAPE;
975
1015
  // If the combined worst-case size escapes inline storage, promote once up front so the
976
1016
  // per-key inserts below never reallocate or re-promote.
977
1017
  if self.map.is_none() && self.inline.len() + incoming > PROPMAP_INLINE {
@@ -986,8 +1026,15 @@ impl PropMap {
986
1026
  } else if let Some(m) = &mut self.map {
987
1027
  m.reserve(incoming);
988
1028
  }
989
- for (k, v) in other.iter() {
990
- self.insert(Arc::clone(k), v.clone());
1029
+ if dict {
1030
+ for (k, v) in other.iter() {
1031
+ self.insert_untracked(Arc::clone(k), v.clone());
1032
+ }
1033
+ self.shape = crate::shape::DICT_SHAPE;
1034
+ } else {
1035
+ for (k, v) in other.iter() {
1036
+ self.insert(Arc::clone(k), v.clone());
1037
+ }
991
1038
  }
992
1039
  }
993
1040
  }
@@ -1314,9 +1361,12 @@ pub fn value_call(callee: &Value, args: &[Value]) -> Value {
1314
1361
  pub fn merge_object_data(left: &VmRef<ObjectData>, right: &VmRef<ObjectData>) -> ObjectData {
1315
1362
  let l = left.borrow();
1316
1363
  let r = right.borrow();
1317
- let mut strings = PropMap::with_capacity(l.strings.len() + r.strings.len());
1318
- strings.extend(l.strings.iter().map(|(k, v)| (Arc::clone(k), v.clone())));
1319
- strings.extend(r.strings.iter().map(|(k, v)| (Arc::clone(k), v.clone())));
1364
+ // Seed from a CLONE of the left side: its shape id and storage carry over, so spreading
1365
+ // the same left never re-walks (or re-mints) its chain — the old rebuild-from-empty walked
1366
+ // BOTH sides' stored orders through tracked inserts, which for dictionary-mode sources
1367
+ // minted a fresh permanent shape chain on every single spread (#708).
1368
+ let mut strings = l.strings.clone();
1369
+ strings.merge_from(&r.strings);
1320
1370
  let mut symbols: Option<AHashMap<u64, Value>> = None;
1321
1371
  if let Some(ls) = &l.symbols {
1322
1372
  symbols = Some(ls.clone());
@@ -2067,6 +2117,172 @@ mod number_to_string_tests {
2067
2117
  }
2068
2118
  }
2069
2119
 
2120
+ #[cfg(test)]
2121
+ mod shape_backstop_tests {
2122
+ use super::*;
2123
+
2124
+ // The registry is process-global, so these tests serialize on one lock (parallel
2125
+ // siblings would observe each other's minting) and still use small margins for
2126
+ // unrelated tests running elsewhere in the binary. The leaks these guard against
2127
+ // mint THOUSANDS of nodes per loop, so the margins cannot mask a regression.
2128
+ static REGISTRY_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2129
+ pub(super) fn locked() -> std::sync::MutexGuard<'static, ()> {
2130
+ REGISTRY_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
2131
+ }
2132
+
2133
+ /// #701 pathology A: fresh `{}` objects each taking a novel first key. Pre-fix this
2134
+ /// minted one EMPTY_SHAPE edge per iteration; the fan-out cap must bound it.
2135
+ #[test]
2136
+ fn fresh_object_novel_first_key_is_bounded() {
2137
+ let _g = locked();
2138
+ let before = crate::shape::shape_count();
2139
+ for i in 0..10_000 {
2140
+ let mut pm = PropMap::new();
2141
+ pm.insert(format!("first-key-backstop-a-{i}").into(), Value::Number(i as f64));
2142
+ }
2143
+ let grown = crate::shape::shape_count() - before;
2144
+ assert!(grown <= 256 + 64, "fan-out cap failed: registry grew by {grown}");
2145
+ }
2146
+
2147
+ /// #701 pathology B: one object accumulating novel keys (`cache[k] = v`). Promotion at
2148
+ /// PROPMAP_INLINE must demote to DICT_SHAPE and stop minting entirely.
2149
+ #[test]
2150
+ fn growing_dictionary_demotes_and_stops_minting() {
2151
+ let _g = locked();
2152
+ let mut pm = PropMap::new();
2153
+ for i in 0..8 {
2154
+ pm.insert(format!("dict-backstop-warm-{i}").into(), Value::Number(i as f64));
2155
+ }
2156
+ let before = crate::shape::shape_count();
2157
+ for i in 0..10_000 {
2158
+ pm.insert(format!("dict-backstop-b-{i}").into(), Value::Number(i as f64));
2159
+ }
2160
+ assert_eq!(pm.shape(), crate::shape::DICT_SHAPE, "promotion must demote to dict mode");
2161
+ let grown = crate::shape::shape_count() - before;
2162
+ assert!(grown <= 64, "dictionary keys kept minting: registry grew by {grown}");
2163
+ assert_eq!(pm.len(), 10_008);
2164
+ assert!(matches!(pm.get("dict-backstop-b-9999"), Some(Value::Number(_))));
2165
+ }
2166
+
2167
+ /// #706 shape of the JSON.parse pathology: repeated small objects whose key SET varies
2168
+ /// per iteration. Depth/fan-out caps must bound total registry growth.
2169
+ #[test]
2170
+ fn novel_key_pairs_are_bounded() {
2171
+ let _g = locked();
2172
+ let before = crate::shape::shape_count();
2173
+ for i in 0..10_000 {
2174
+ let mut pm = PropMap::new();
2175
+ pm.insert(format!("json-backstop-{i}").into(), Value::Number(1.0));
2176
+ pm.insert("b".into(), Value::Number(2.0));
2177
+ }
2178
+ let grown = crate::shape::shape_count() - before;
2179
+ assert!(grown <= 2 * 256 + 64, "novel key pairs unbounded: registry grew by {grown}");
2180
+ }
2181
+
2182
+ /// The intended fast path must still intern and DEDUPE: repeating the same fixed-order
2183
+ /// construction mints its chain once, and the objects keep a real (non-dict) shape.
2184
+ #[test]
2185
+ fn fixed_shape_construction_still_dedupes_and_tracks() {
2186
+ let _g = locked();
2187
+ let mk = || {
2188
+ let mut pm = PropMap::new();
2189
+ pm.insert("fixed-backstop-x".into(), Value::Number(1.0));
2190
+ pm.insert("fixed-backstop-y".into(), Value::Number(2.0));
2191
+ pm.insert("fixed-backstop-z".into(), Value::Number(3.0));
2192
+ pm
2193
+ };
2194
+ let first = mk();
2195
+ let before = crate::shape::shape_count();
2196
+ let mut last_shape = first.shape();
2197
+ for _ in 0..5_000 {
2198
+ let pm = mk();
2199
+ assert_ne!(pm.shape(), crate::shape::DICT_SHAPE, "small fixed objects must stay tracked");
2200
+ last_shape = pm.shape();
2201
+ }
2202
+ assert_eq!(last_shape, first.shape(), "identical construction must share one shape");
2203
+ let grown = crate::shape::shape_count() - before;
2204
+ assert!(grown <= 96, "fixed-shape loop should not mint: registry grew by {grown}");
2205
+ }
2206
+ }
2207
+
2208
+ #[cfg(test)]
2209
+ mod dict_propagation_tests {
2210
+ use super::*;
2211
+
2212
+ // Shares the backstop tests' lock: all registry-count assertions serialize together.
2213
+ use super::shape_backstop_tests::locked;
2214
+
2215
+ fn dict_source(n: usize, tag: &str) -> VmRef<ObjectData> {
2216
+ let mut m = ObjectMap::default();
2217
+ for i in 0..n {
2218
+ m.insert(format!("hdr-{tag}-{i}").into(), Value::Number(i as f64));
2219
+ }
2220
+ let Value::Object(od) = Value::object(m) else { unreachable!() };
2221
+ od
2222
+ }
2223
+
2224
+ /// #708: spreading a dictionary-mode source must not mint shape chains, and the result
2225
+ /// must itself be dictionary-mode (so downstream copies stay clean too).
2226
+ #[test]
2227
+ fn spread_from_dict_source_is_flat_and_dict() {
2228
+ let _g = locked();
2229
+ let left = dict_source(12, "left");
2230
+ let right = dict_source(12, "right");
2231
+ let before = crate::shape::shape_count();
2232
+ let mut last = None;
2233
+ for _ in 0..5_000 {
2234
+ last = Some(merge_object_data(&left, &right));
2235
+ }
2236
+ let grown = crate::shape::shape_count() - before;
2237
+ assert!(grown <= 64, "dict spread minted shapes: registry grew by {grown}");
2238
+ let merged = last.unwrap();
2239
+ assert_eq!(merged.strings.shape(), crate::shape::DICT_SHAPE);
2240
+ assert_eq!(merged.strings.len(), 24);
2241
+ assert!(matches!(merged.strings.get("hdr-right-11"), Some(Value::Number(_))));
2242
+ }
2243
+
2244
+ /// Tracked x tracked spreads must still dedupe to one chain and keep a tracked result —
2245
+ /// the fast path is preserved, not blanket-demoted.
2246
+ #[test]
2247
+ fn spread_tracked_sources_stays_tracked_and_dedupes() {
2248
+ let _g = locked();
2249
+ let mk = |a: &str, b: &str| {
2250
+ let mut pm = PropMap::new();
2251
+ pm.insert(a.into(), Value::Number(1.0));
2252
+ pm.insert(b.into(), Value::Number(2.0));
2253
+ VmRef::new(ObjectData { strings: pm, symbols: None, frozen: false })
2254
+ };
2255
+ let left = mk("prop-track-a", "prop-track-b");
2256
+ let right = mk("prop-track-c", "prop-track-d");
2257
+ let first = merge_object_data(&left, &right);
2258
+ assert_ne!(first.strings.shape(), crate::shape::DICT_SHAPE);
2259
+ let before = crate::shape::shape_count();
2260
+ let mut last_shape = first.strings.shape();
2261
+ for _ in 0..5_000 {
2262
+ let m = merge_object_data(&left, &right);
2263
+ last_shape = m.strings.shape();
2264
+ }
2265
+ assert_eq!(last_shape, first.strings.shape(), "repeated identical spread must share one shape");
2266
+ let grown = crate::shape::shape_count() - before;
2267
+ assert!(grown <= 96, "tracked spread re-minted: registry grew by {grown}");
2268
+ }
2269
+
2270
+ /// Last-write-wins override order must be unchanged by the reseeded merge.
2271
+ #[test]
2272
+ fn spread_override_semantics_preserved() {
2273
+ let mut lm = ObjectMap::default();
2274
+ lm.insert("shared-key".into(), Value::Number(1.0));
2275
+ lm.insert("only-left".into(), Value::Number(2.0));
2276
+ let Value::Object(l) = Value::object(lm) else { unreachable!() };
2277
+ let mut rm = ObjectMap::default();
2278
+ rm.insert("shared-key".into(), Value::Number(9.0));
2279
+ let Value::Object(r) = Value::object(rm) else { unreachable!() };
2280
+ let m = merge_object_data(&l, &r);
2281
+ assert!(matches!(m.strings.get("shared-key"), Some(Value::Number(n)) if *n == 9.0));
2282
+ assert!(matches!(m.strings.get("only-left"), Some(Value::Number(n)) if *n == 2.0));
2283
+ }
2284
+ }
2285
+
2070
2286
  #[cfg(test)]
2071
2287
  mod dict_shape_leak_tests {
2072
2288
  use super::*;