@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.
- package/bin/tish-format +0 -0
- package/crates/tish/src/cargo_native_registry.rs +4 -0
- package/crates/tish/src/main.rs +65 -25
- package/crates/tish/tests/integration_test.rs +3 -0
- package/crates/tish_builtins/src/globals.rs +3 -3
- package/crates/tish_builtins/src/symbol.rs +86 -4
- package/crates/tish_bytecode/src/compiler.rs +65 -25
- package/crates/tish_bytecode/tests/captured_param_slot_binding.rs +131 -0
- package/crates/tish_bytecode/tests/fundecl_slot_binding.rs +115 -0
- package/crates/tish_bytecode/tests/param_defaults_slot_binding.rs +86 -0
- package/crates/tish_core/src/shape.rs +55 -2
- package/crates/tish_core/src/value.rs +229 -13
- package/crates/tish_eval/src/timers.rs +66 -2
- package/crates/tish_ffi/src/lib.rs +74 -12
- package/crates/tish_ffi/tests/loader.rs +35 -0
- package/crates/tish_fmt/Cargo.toml +1 -1
- package/crates/tish_lsp/src/main.rs +91 -2
- package/crates/tish_pg/src/lib.rs +59 -8
- package/crates/tish_pg/src/statement_registry.rs +250 -0
- package/crates/tish_runtime/src/http.rs +19 -0
- package/crates/tish_runtime/src/http_fetch.rs +159 -65
- package/crates/tish_runtime/src/http_hyper.rs +18 -2
- package/crates/tish_runtime/src/lib.rs +4 -0
- package/crates/tish_runtime/src/net.rs +125 -97
- package/crates/tish_runtime/src/process_spawn.rs +326 -113
- package/crates/tish_runtime/src/promise.rs +521 -90
- package/crates/tish_runtime/src/promise_io.rs +29 -6
- package/crates/tish_runtime/src/pty.rs +161 -107
- package/crates/tish_runtime/src/stream_buf.rs +419 -0
- package/crates/tish_runtime/src/ws.rs +431 -52
- package/crates/tish_vm/src/jit.rs +473 -67
- package/crates/tish_vm/src/lib.rs +6 -0
- package/crates/tish_vm/src/vm.rs +8 -5
- package/crates/tish_vm/tests/captured_param_slot_mode.rs +210 -0
- package/crates/tish_vm/tests/captured_param_vm_slots_off.rs +64 -0
- package/crates/tish_vm/tests/fundecl_slot_no_frame_cycle.rs +152 -0
- package/crates/tish_vm/tests/param_defaults_slot_mode.rs +126 -0
- package/crates/tish_vm/tests/param_defaults_vm_slots_off.rs +44 -0
- package/package.json +1 -1
- package/platform/darwin-arm64/tish-fmt +0 -0
- package/platform/darwin-x64/tish-fmt +0 -0
- package/platform/linux-arm64/tish-fmt +0 -0
- package/platform/linux-x64/tish-fmt +0 -0
- 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; //
|
|
143
|
+
return DICT_SHAPE; // id-space overflow guard — prevents u32 truncation aliasing live ids
|
|
108
144
|
}
|
|
109
|
-
reg.nodes.push(ShapeNode
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
854
|
-
//
|
|
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
|
-
|
|
990
|
-
|
|
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
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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::*;
|