@tishlang/tish-format 3.10.4 → 3.10.9
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/main.rs +65 -25
- 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 +22 -6
- package/crates/tish_bytecode/tests/fundecl_slot_binding.rs +115 -0
- package/crates/tish_core/src/shape.rs +55 -2
- package/crates/tish_core/src/value.rs +198 -9
- 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_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/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 +3 -1
- package/crates/tish_vm/tests/fundecl_slot_no_frame_cycle.rs +152 -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
|
@@ -847,11 +847,15 @@ impl PropMap {
|
|
|
847
847
|
// Update existing key → value changes, layout (shape) does not.
|
|
848
848
|
return Some(core::mem::replace(&mut slot.1, val));
|
|
849
849
|
}
|
|
850
|
-
// New key (inline storage) → transition the shape away from the current one.
|
|
851
|
-
self.shape = crate::shape::transition(self.shape, &key);
|
|
852
850
|
if self.inline.len() >= PROPMAP_INLINE {
|
|
853
|
-
// Promote inline storage to an insertion-ordered map
|
|
854
|
-
//
|
|
851
|
+
// Promote inline storage to an insertion-ordered map — and DEMOTE to dictionary
|
|
852
|
+
// mode. An object growing past PROPMAP_INLINE keys one insert at a time is being
|
|
853
|
+
// used as a map (`cache[k] = v`), and continuing to transition would intern a
|
|
854
|
+
// permanent registry node per novel key, forever (#701). Inline caches lose only
|
|
855
|
+
// the 9+-key tail, whose slot lookups already go through the IndexMap.
|
|
856
|
+
// NOTE: the transition for THIS key is deliberately skipped (checked before the
|
|
857
|
+
// old transition call site so the 9th-key edge is never minted).
|
|
858
|
+
self.shape = crate::shape::DICT_SHAPE;
|
|
855
859
|
let mut m: IndexMap<Arc<str>, Value, RandomState> =
|
|
856
860
|
IndexMap::with_capacity_and_hasher(self.inline.len() + 1, RandomState::default());
|
|
857
861
|
for (k, v) in self.inline.drain(..) {
|
|
@@ -861,6 +865,8 @@ impl PropMap {
|
|
|
861
865
|
self.map = Some(Box::new(m));
|
|
862
866
|
return None;
|
|
863
867
|
}
|
|
868
|
+
// New key (inline storage) → transition the shape away from the current one.
|
|
869
|
+
self.shape = crate::shape::transition(self.shape, &key);
|
|
864
870
|
self.inline.push((key, val));
|
|
865
871
|
None
|
|
866
872
|
}
|
|
@@ -972,6 +978,13 @@ impl PropMap {
|
|
|
972
978
|
if incoming == 0 {
|
|
973
979
|
return;
|
|
974
980
|
}
|
|
981
|
+
// Dictionary-ness PROPAGATES: a DICT_SHAPE source stores its keys in whatever order it
|
|
982
|
+
// was built with (for hashed-map sources, a random per-instance order — see
|
|
983
|
+
// `from_unordered`). Walking that order through tracked `insert` re-minted a fresh
|
|
984
|
+
// permanent shape chain per merge (#708: `{ ...headers, id }` at 100 req/s ≈ 950 MB/h).
|
|
985
|
+
// Copies of dictionaries are dictionaries; copies of tracked objects stay tracked.
|
|
986
|
+
let dict = self.shape == crate::shape::DICT_SHAPE
|
|
987
|
+
|| other.shape == crate::shape::DICT_SHAPE;
|
|
975
988
|
// If the combined worst-case size escapes inline storage, promote once up front so the
|
|
976
989
|
// per-key inserts below never reallocate or re-promote.
|
|
977
990
|
if self.map.is_none() && self.inline.len() + incoming > PROPMAP_INLINE {
|
|
@@ -986,8 +999,15 @@ impl PropMap {
|
|
|
986
999
|
} else if let Some(m) = &mut self.map {
|
|
987
1000
|
m.reserve(incoming);
|
|
988
1001
|
}
|
|
989
|
-
|
|
990
|
-
|
|
1002
|
+
if dict {
|
|
1003
|
+
for (k, v) in other.iter() {
|
|
1004
|
+
self.insert_untracked(Arc::clone(k), v.clone());
|
|
1005
|
+
}
|
|
1006
|
+
self.shape = crate::shape::DICT_SHAPE;
|
|
1007
|
+
} else {
|
|
1008
|
+
for (k, v) in other.iter() {
|
|
1009
|
+
self.insert(Arc::clone(k), v.clone());
|
|
1010
|
+
}
|
|
991
1011
|
}
|
|
992
1012
|
}
|
|
993
1013
|
}
|
|
@@ -1314,9 +1334,12 @@ pub fn value_call(callee: &Value, args: &[Value]) -> Value {
|
|
|
1314
1334
|
pub fn merge_object_data(left: &VmRef<ObjectData>, right: &VmRef<ObjectData>) -> ObjectData {
|
|
1315
1335
|
let l = left.borrow();
|
|
1316
1336
|
let r = right.borrow();
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1337
|
+
// Seed from a CLONE of the left side: its shape id and storage carry over, so spreading
|
|
1338
|
+
// the same left never re-walks (or re-mints) its chain — the old rebuild-from-empty walked
|
|
1339
|
+
// BOTH sides' stored orders through tracked inserts, which for dictionary-mode sources
|
|
1340
|
+
// minted a fresh permanent shape chain on every single spread (#708).
|
|
1341
|
+
let mut strings = l.strings.clone();
|
|
1342
|
+
strings.merge_from(&r.strings);
|
|
1320
1343
|
let mut symbols: Option<AHashMap<u64, Value>> = None;
|
|
1321
1344
|
if let Some(ls) = &l.symbols {
|
|
1322
1345
|
symbols = Some(ls.clone());
|
|
@@ -2067,6 +2090,172 @@ mod number_to_string_tests {
|
|
|
2067
2090
|
}
|
|
2068
2091
|
}
|
|
2069
2092
|
|
|
2093
|
+
#[cfg(test)]
|
|
2094
|
+
mod shape_backstop_tests {
|
|
2095
|
+
use super::*;
|
|
2096
|
+
|
|
2097
|
+
// The registry is process-global, so these tests serialize on one lock (parallel
|
|
2098
|
+
// siblings would observe each other's minting) and still use small margins for
|
|
2099
|
+
// unrelated tests running elsewhere in the binary. The leaks these guard against
|
|
2100
|
+
// mint THOUSANDS of nodes per loop, so the margins cannot mask a regression.
|
|
2101
|
+
static REGISTRY_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
|
2102
|
+
pub(super) fn locked() -> std::sync::MutexGuard<'static, ()> {
|
|
2103
|
+
REGISTRY_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
/// #701 pathology A: fresh `{}` objects each taking a novel first key. Pre-fix this
|
|
2107
|
+
/// minted one EMPTY_SHAPE edge per iteration; the fan-out cap must bound it.
|
|
2108
|
+
#[test]
|
|
2109
|
+
fn fresh_object_novel_first_key_is_bounded() {
|
|
2110
|
+
let _g = locked();
|
|
2111
|
+
let before = crate::shape::shape_count();
|
|
2112
|
+
for i in 0..10_000 {
|
|
2113
|
+
let mut pm = PropMap::new();
|
|
2114
|
+
pm.insert(format!("first-key-backstop-a-{i}").into(), Value::Number(i as f64));
|
|
2115
|
+
}
|
|
2116
|
+
let grown = crate::shape::shape_count() - before;
|
|
2117
|
+
assert!(grown <= 256 + 64, "fan-out cap failed: registry grew by {grown}");
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
/// #701 pathology B: one object accumulating novel keys (`cache[k] = v`). Promotion at
|
|
2121
|
+
/// PROPMAP_INLINE must demote to DICT_SHAPE and stop minting entirely.
|
|
2122
|
+
#[test]
|
|
2123
|
+
fn growing_dictionary_demotes_and_stops_minting() {
|
|
2124
|
+
let _g = locked();
|
|
2125
|
+
let mut pm = PropMap::new();
|
|
2126
|
+
for i in 0..8 {
|
|
2127
|
+
pm.insert(format!("dict-backstop-warm-{i}").into(), Value::Number(i as f64));
|
|
2128
|
+
}
|
|
2129
|
+
let before = crate::shape::shape_count();
|
|
2130
|
+
for i in 0..10_000 {
|
|
2131
|
+
pm.insert(format!("dict-backstop-b-{i}").into(), Value::Number(i as f64));
|
|
2132
|
+
}
|
|
2133
|
+
assert_eq!(pm.shape(), crate::shape::DICT_SHAPE, "promotion must demote to dict mode");
|
|
2134
|
+
let grown = crate::shape::shape_count() - before;
|
|
2135
|
+
assert!(grown <= 64, "dictionary keys kept minting: registry grew by {grown}");
|
|
2136
|
+
assert_eq!(pm.len(), 10_008);
|
|
2137
|
+
assert!(matches!(pm.get("dict-backstop-b-9999"), Some(Value::Number(_))));
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
/// #706 shape of the JSON.parse pathology: repeated small objects whose key SET varies
|
|
2141
|
+
/// per iteration. Depth/fan-out caps must bound total registry growth.
|
|
2142
|
+
#[test]
|
|
2143
|
+
fn novel_key_pairs_are_bounded() {
|
|
2144
|
+
let _g = locked();
|
|
2145
|
+
let before = crate::shape::shape_count();
|
|
2146
|
+
for i in 0..10_000 {
|
|
2147
|
+
let mut pm = PropMap::new();
|
|
2148
|
+
pm.insert(format!("json-backstop-{i}").into(), Value::Number(1.0));
|
|
2149
|
+
pm.insert("b".into(), Value::Number(2.0));
|
|
2150
|
+
}
|
|
2151
|
+
let grown = crate::shape::shape_count() - before;
|
|
2152
|
+
assert!(grown <= 2 * 256 + 64, "novel key pairs unbounded: registry grew by {grown}");
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
/// The intended fast path must still intern and DEDUPE: repeating the same fixed-order
|
|
2156
|
+
/// construction mints its chain once, and the objects keep a real (non-dict) shape.
|
|
2157
|
+
#[test]
|
|
2158
|
+
fn fixed_shape_construction_still_dedupes_and_tracks() {
|
|
2159
|
+
let _g = locked();
|
|
2160
|
+
let mk = || {
|
|
2161
|
+
let mut pm = PropMap::new();
|
|
2162
|
+
pm.insert("fixed-backstop-x".into(), Value::Number(1.0));
|
|
2163
|
+
pm.insert("fixed-backstop-y".into(), Value::Number(2.0));
|
|
2164
|
+
pm.insert("fixed-backstop-z".into(), Value::Number(3.0));
|
|
2165
|
+
pm
|
|
2166
|
+
};
|
|
2167
|
+
let first = mk();
|
|
2168
|
+
let before = crate::shape::shape_count();
|
|
2169
|
+
let mut last_shape = first.shape();
|
|
2170
|
+
for _ in 0..5_000 {
|
|
2171
|
+
let pm = mk();
|
|
2172
|
+
assert_ne!(pm.shape(), crate::shape::DICT_SHAPE, "small fixed objects must stay tracked");
|
|
2173
|
+
last_shape = pm.shape();
|
|
2174
|
+
}
|
|
2175
|
+
assert_eq!(last_shape, first.shape(), "identical construction must share one shape");
|
|
2176
|
+
let grown = crate::shape::shape_count() - before;
|
|
2177
|
+
assert!(grown <= 96, "fixed-shape loop should not mint: registry grew by {grown}");
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
#[cfg(test)]
|
|
2182
|
+
mod dict_propagation_tests {
|
|
2183
|
+
use super::*;
|
|
2184
|
+
|
|
2185
|
+
// Shares the backstop tests' lock: all registry-count assertions serialize together.
|
|
2186
|
+
use super::shape_backstop_tests::locked;
|
|
2187
|
+
|
|
2188
|
+
fn dict_source(n: usize, tag: &str) -> VmRef<ObjectData> {
|
|
2189
|
+
let mut m = ObjectMap::default();
|
|
2190
|
+
for i in 0..n {
|
|
2191
|
+
m.insert(format!("hdr-{tag}-{i}").into(), Value::Number(i as f64));
|
|
2192
|
+
}
|
|
2193
|
+
let Value::Object(od) = Value::object(m) else { unreachable!() };
|
|
2194
|
+
od
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
/// #708: spreading a dictionary-mode source must not mint shape chains, and the result
|
|
2198
|
+
/// must itself be dictionary-mode (so downstream copies stay clean too).
|
|
2199
|
+
#[test]
|
|
2200
|
+
fn spread_from_dict_source_is_flat_and_dict() {
|
|
2201
|
+
let _g = locked();
|
|
2202
|
+
let left = dict_source(12, "left");
|
|
2203
|
+
let right = dict_source(12, "right");
|
|
2204
|
+
let before = crate::shape::shape_count();
|
|
2205
|
+
let mut last = None;
|
|
2206
|
+
for _ in 0..5_000 {
|
|
2207
|
+
last = Some(merge_object_data(&left, &right));
|
|
2208
|
+
}
|
|
2209
|
+
let grown = crate::shape::shape_count() - before;
|
|
2210
|
+
assert!(grown <= 64, "dict spread minted shapes: registry grew by {grown}");
|
|
2211
|
+
let merged = last.unwrap();
|
|
2212
|
+
assert_eq!(merged.strings.shape(), crate::shape::DICT_SHAPE);
|
|
2213
|
+
assert_eq!(merged.strings.len(), 24);
|
|
2214
|
+
assert!(matches!(merged.strings.get("hdr-right-11"), Some(Value::Number(_))));
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
/// Tracked x tracked spreads must still dedupe to one chain and keep a tracked result —
|
|
2218
|
+
/// the fast path is preserved, not blanket-demoted.
|
|
2219
|
+
#[test]
|
|
2220
|
+
fn spread_tracked_sources_stays_tracked_and_dedupes() {
|
|
2221
|
+
let _g = locked();
|
|
2222
|
+
let mk = |a: &str, b: &str| {
|
|
2223
|
+
let mut pm = PropMap::new();
|
|
2224
|
+
pm.insert(a.into(), Value::Number(1.0));
|
|
2225
|
+
pm.insert(b.into(), Value::Number(2.0));
|
|
2226
|
+
VmRef::new(ObjectData { strings: pm, symbols: None, frozen: false })
|
|
2227
|
+
};
|
|
2228
|
+
let left = mk("prop-track-a", "prop-track-b");
|
|
2229
|
+
let right = mk("prop-track-c", "prop-track-d");
|
|
2230
|
+
let first = merge_object_data(&left, &right);
|
|
2231
|
+
assert_ne!(first.strings.shape(), crate::shape::DICT_SHAPE);
|
|
2232
|
+
let before = crate::shape::shape_count();
|
|
2233
|
+
let mut last_shape = first.strings.shape();
|
|
2234
|
+
for _ in 0..5_000 {
|
|
2235
|
+
let m = merge_object_data(&left, &right);
|
|
2236
|
+
last_shape = m.strings.shape();
|
|
2237
|
+
}
|
|
2238
|
+
assert_eq!(last_shape, first.strings.shape(), "repeated identical spread must share one shape");
|
|
2239
|
+
let grown = crate::shape::shape_count() - before;
|
|
2240
|
+
assert!(grown <= 96, "tracked spread re-minted: registry grew by {grown}");
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
/// Last-write-wins override order must be unchanged by the reseeded merge.
|
|
2244
|
+
#[test]
|
|
2245
|
+
fn spread_override_semantics_preserved() {
|
|
2246
|
+
let mut lm = ObjectMap::default();
|
|
2247
|
+
lm.insert("shared-key".into(), Value::Number(1.0));
|
|
2248
|
+
lm.insert("only-left".into(), Value::Number(2.0));
|
|
2249
|
+
let Value::Object(l) = Value::object(lm) else { unreachable!() };
|
|
2250
|
+
let mut rm = ObjectMap::default();
|
|
2251
|
+
rm.insert("shared-key".into(), Value::Number(9.0));
|
|
2252
|
+
let Value::Object(r) = Value::object(rm) else { unreachable!() };
|
|
2253
|
+
let m = merge_object_data(&l, &r);
|
|
2254
|
+
assert!(matches!(m.strings.get("shared-key"), Some(Value::Number(n)) if *n == 9.0));
|
|
2255
|
+
assert!(matches!(m.strings.get("only-left"), Some(Value::Number(n)) if *n == 2.0));
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2070
2259
|
#[cfg(test)]
|
|
2071
2260
|
mod dict_shape_leak_tests {
|
|
2072
2261
|
use super::*;
|
|
@@ -26,13 +26,38 @@ fn next_id() -> u64 {
|
|
|
26
26
|
NEXT_ID.fetch_add(1, Ordering::SeqCst)
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/// Maximum number of LIVE (registered, not-yet-fired) timers per worker thread. A program that keeps
|
|
30
|
+
/// scheduling `setTimeout`/`setInterval` without them draining would otherwise grow the registry (and
|
|
31
|
+
/// its retained callbacks + closed-over data) without bound (#384, ported to this twin by #699). Past
|
|
32
|
+
/// the cap a new timer is dropped rather than registered. Override with `TISH_MAX_TIMERS`; default
|
|
33
|
+
/// 100k — far above any real timer workload, low enough to bound memory.
|
|
34
|
+
fn max_live_timers() -> usize {
|
|
35
|
+
// Read per call (timer registration is not a hot path, and the cap exists precisely to bound the
|
|
36
|
+
// pathological caller): keeps the limit overridable per test without a process-global cache.
|
|
37
|
+
std::env::var("TISH_MAX_TIMERS")
|
|
38
|
+
.ok()
|
|
39
|
+
.and_then(|v| v.parse().ok())
|
|
40
|
+
.filter(|&n| n > 0)
|
|
41
|
+
.unwrap_or(100_000)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#[cfg(test)]
|
|
45
|
+
fn registry_len() -> usize {
|
|
46
|
+
REGISTRY.with(|r| r.borrow().len())
|
|
47
|
+
}
|
|
48
|
+
|
|
29
49
|
/// Register a one-shot timer. Returns immediately with timer id.
|
|
30
50
|
#[allow(non_snake_case)]
|
|
31
51
|
pub fn setTimeout(callback: Value, args: Vec<Value>, delay_ms: u64) -> u64 {
|
|
32
52
|
let id = next_id();
|
|
33
53
|
let due = Instant::now() + Duration::from_millis(delay_ms);
|
|
34
54
|
REGISTRY.with(|r| {
|
|
35
|
-
r.borrow_mut()
|
|
55
|
+
let mut reg = r.borrow_mut();
|
|
56
|
+
// #384: bound the live-timer count so a runaway scheduler can't grow the registry unbounded.
|
|
57
|
+
if reg.len() >= max_live_timers() {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
reg.insert(
|
|
36
61
|
id,
|
|
37
62
|
TimerEntry {
|
|
38
63
|
due,
|
|
@@ -51,7 +76,12 @@ pub fn setInterval(callback: Value, args: Vec<Value>, delay_ms: u64) -> u64 {
|
|
|
51
76
|
let id = next_id();
|
|
52
77
|
let due = Instant::now() + Duration::from_millis(delay_ms);
|
|
53
78
|
REGISTRY.with(|r| {
|
|
54
|
-
r.borrow_mut()
|
|
79
|
+
let mut reg = r.borrow_mut();
|
|
80
|
+
// #384: bound the live-timer count (see setTimeout).
|
|
81
|
+
if reg.len() >= max_live_timers() {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
reg.insert(
|
|
55
85
|
id,
|
|
56
86
|
TimerEntry {
|
|
57
87
|
due,
|
|
@@ -91,6 +121,10 @@ pub fn take_due_timers() -> Vec<(u64, Value, Vec<Value>, u64)> {
|
|
|
91
121
|
}
|
|
92
122
|
|
|
93
123
|
/// Re-register an interval timer (called after running its callback).
|
|
124
|
+
///
|
|
125
|
+
/// Deliberately uncapped, matching the tish_runtime twin: this re-inserts an entry the drain
|
|
126
|
+
/// phase just removed, so it cannot grow the registry beyond what registration allowed, and
|
|
127
|
+
/// capping it would silently kill a live interval whenever the registry sits at the cap.
|
|
94
128
|
pub fn re_register_interval(id: u64, callback: Value, args: Vec<Value>, interval_ms: u64) {
|
|
95
129
|
let due = Instant::now() + Duration::from_millis(interval_ms);
|
|
96
130
|
REGISTRY.with(|r| {
|
|
@@ -118,3 +152,33 @@ pub fn next_due_instant() -> Option<Instant> {
|
|
|
118
152
|
reg.values().map(|e| e.due).min()
|
|
119
153
|
})
|
|
120
154
|
}
|
|
155
|
+
|
|
156
|
+
#[cfg(test)]
|
|
157
|
+
mod timer_cap_tests_699 {
|
|
158
|
+
use super::*;
|
|
159
|
+
|
|
160
|
+
fn noop_callback() -> Value {
|
|
161
|
+
Value::Native(|_| Ok(Value::Null))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#[test]
|
|
165
|
+
fn registration_is_bounded_by_max_timers() {
|
|
166
|
+
// Each `#[test]` runs on its own thread, so REGISTRY (thread_local) starts empty here.
|
|
167
|
+
// One test (not one per entry point): TISH_MAX_TIMERS is process-global, and parallel
|
|
168
|
+
// tests mutating it would race.
|
|
169
|
+
std::env::set_var("TISH_MAX_TIMERS", "5");
|
|
170
|
+
for _ in 0..20 {
|
|
171
|
+
let _ = setTimeout(noop_callback(), Vec::new(), 10_000);
|
|
172
|
+
}
|
|
173
|
+
assert_eq!(
|
|
174
|
+
registry_len(),
|
|
175
|
+
5,
|
|
176
|
+
"live timers must be capped at TISH_MAX_TIMERS"
|
|
177
|
+
);
|
|
178
|
+
for _ in 0..20 {
|
|
179
|
+
let _ = setInterval(noop_callback(), Vec::new(), 10_000);
|
|
180
|
+
}
|
|
181
|
+
assert_eq!(registry_len(), 5, "setInterval must honor the same cap");
|
|
182
|
+
std::env::remove_var("TISH_MAX_TIMERS");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -43,8 +43,7 @@ pub const TISH_TAG_OTHER: i32 = 6;
|
|
|
43
43
|
|
|
44
44
|
/// An `extern "C"` native function: receives a borrowed array of argument handles (owned by the
|
|
45
45
|
/// host for the call) and returns a freshly-owned result handle (the host drops it).
|
|
46
|
-
pub type TishNativeFn =
|
|
47
|
-
extern "C" fn(args: *const TishValueRef, argc: usize) -> TishValueRef;
|
|
46
|
+
pub type TishNativeFn = extern "C" fn(args: *const TishValueRef, argc: usize) -> TishValueRef;
|
|
48
47
|
|
|
49
48
|
/// One named export in a module's table.
|
|
50
49
|
#[repr(C)]
|
|
@@ -304,6 +303,33 @@ pub fn wrap_native_fn(func: TishNativeFn) -> Value {
|
|
|
304
303
|
})
|
|
305
304
|
}
|
|
306
305
|
|
|
306
|
+
/// Cached exports of one loaded module: name → C-ABI fn pointer. Fn pointers — unlike `Value`,
|
|
307
|
+
/// whose containers may be `Rc` — are `Send + Sync`, so the process-global cache stores the raw
|
|
308
|
+
/// export list and re-wraps it into fresh `Value::native`s per `load_module` call.
|
|
309
|
+
#[cfg(not(target_arch = "wasm32"))]
|
|
310
|
+
type CachedExports = Vec<(String, TishNativeFn)>;
|
|
311
|
+
|
|
312
|
+
/// Process-global module cache keyed by RESOLVED path (#711): repeated loads of the same cdylib in
|
|
313
|
+
/// one process (an embedder host running programs back to back) reuse the first dlopen mapping
|
|
314
|
+
/// instead of leaking a freshly forgotten `Library` handle per call, and canonicalizing the key
|
|
315
|
+
/// collapses distinct spellings of one path into one entry.
|
|
316
|
+
#[cfg(not(target_arch = "wasm32"))]
|
|
317
|
+
fn module_cache(
|
|
318
|
+
) -> &'static std::sync::Mutex<std::collections::HashMap<std::path::PathBuf, CachedExports>> {
|
|
319
|
+
static LOADED_MODULES: std::sync::OnceLock<
|
|
320
|
+
std::sync::Mutex<std::collections::HashMap<std::path::PathBuf, CachedExports>>,
|
|
321
|
+
> = std::sync::OnceLock::new();
|
|
322
|
+
LOADED_MODULES.get_or_init(Default::default)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/// Number of distinct modules in the process-global cache. Test-only accessor (the integration
|
|
326
|
+
/// loader test asserts a re-spelled path hits the cache instead of re-mapping).
|
|
327
|
+
#[cfg(not(target_arch = "wasm32"))]
|
|
328
|
+
#[doc(hidden)]
|
|
329
|
+
pub fn loaded_module_count() -> usize {
|
|
330
|
+
module_cache().lock().unwrap().len()
|
|
331
|
+
}
|
|
332
|
+
|
|
307
333
|
/// Load a native C-ABI extension (`cdylib`) and return its exports as a name→`Value::native` map,
|
|
308
334
|
/// ready for the interpreter's `with_modules` or the VM's `register_native_module`. The module
|
|
309
335
|
/// must export `extern "C" fn tish_module_register() -> *const TishExportTable`.
|
|
@@ -311,12 +337,33 @@ pub fn wrap_native_fn(func: TishNativeFn) -> Value {
|
|
|
311
337
|
/// The extension imports the host's `tish_value_*` accessors, so the host must export them at link
|
|
312
338
|
/// time (`-rdynamic` / `-Wl,-export_dynamic`). The loaded library is intentionally leaked so the
|
|
313
339
|
/// function pointers stay valid for the process — the load-once model (matches the JIT module).
|
|
340
|
+
/// "Once" is enforced by [`module_cache`]: only the first load of a given resolved path maps the
|
|
341
|
+
/// library; every later call re-wraps the cached export list (#711).
|
|
314
342
|
#[cfg(not(target_arch = "wasm32"))]
|
|
315
343
|
pub fn load_module(path: &str) -> Result<tishlang_core::ObjectMap, String> {
|
|
316
344
|
use tishlang_core::ObjectMap;
|
|
345
|
+
|
|
346
|
+
fn wrap_exports(exports: &CachedExports) -> ObjectMap {
|
|
347
|
+
let mut map = ObjectMap::default();
|
|
348
|
+
for (name, func) in exports {
|
|
349
|
+
map.insert(name.as_str().into(), wrap_native_fn(*func));
|
|
350
|
+
}
|
|
351
|
+
map
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// #711: key on the RESOLVED path so `./lib.dylib` and its absolute spelling share one entry.
|
|
355
|
+
// When canonicalize fails (e.g. a bare dyld/ld.so-searched name), the literal path is still a
|
|
356
|
+
// correct — merely less collapsing — key. Failed loads are never cached.
|
|
357
|
+
let key = std::fs::canonicalize(path).unwrap_or_else(|_| std::path::PathBuf::from(path));
|
|
358
|
+
|
|
359
|
+
if let Some(cached) = module_cache().lock().unwrap().get(&key) {
|
|
360
|
+
return Ok(wrap_exports(cached));
|
|
361
|
+
}
|
|
362
|
+
|
|
317
363
|
// SAFETY: dlopen of a caller-supplied path; the export table is validated (null checks) and the
|
|
318
|
-
// function pointers are wrapped behind the marshaling shim.
|
|
319
|
-
|
|
364
|
+
// function pointers are wrapped behind the marshaling shim. The dlopen runs WITHOUT the cache
|
|
365
|
+
// lock held — it may execute arbitrary static initializers.
|
|
366
|
+
let exports: CachedExports = unsafe {
|
|
320
367
|
let lib =
|
|
321
368
|
libloading::Library::new(path).map_err(|e| format!("ffi: load {}: {}", path, e))?;
|
|
322
369
|
let register: libloading::Symbol<unsafe extern "C" fn() -> *const TishExportTable> = lib
|
|
@@ -330,20 +377,29 @@ pub fn load_module(path: &str) -> Result<tishlang_core::ObjectMap, String> {
|
|
|
330
377
|
if table.count > 0 && table.exports.is_null() {
|
|
331
378
|
return Err(format!("ffi: {}: null export table", path));
|
|
332
379
|
}
|
|
333
|
-
let
|
|
334
|
-
let mut
|
|
335
|
-
for exp in
|
|
380
|
+
let raw = std::slice::from_raw_parts(table.exports, table.count);
|
|
381
|
+
let mut exports = CachedExports::with_capacity(raw.len());
|
|
382
|
+
for exp in raw {
|
|
336
383
|
if exp.name.is_null() {
|
|
337
384
|
continue;
|
|
338
385
|
}
|
|
339
386
|
let name = CStr::from_ptr(exp.name)
|
|
340
387
|
.to_str()
|
|
341
388
|
.map_err(|_| format!("ffi: {}: non-UTF-8 export name", path))?;
|
|
342
|
-
|
|
389
|
+
exports.push((name.to_owned(), exp.func));
|
|
343
390
|
}
|
|
391
|
+
// Still deliberate: the cached fn pointers must outlive every wrapped value, and dlopened
|
|
392
|
+
// extensions are immortal by contract. The #711 fix is not re-leaking a fresh mapping per
|
|
393
|
+
// call, not making the first one reclaimable.
|
|
344
394
|
std::mem::forget(lib); // keep symbols live for the process lifetime
|
|
345
|
-
|
|
346
|
-
}
|
|
395
|
+
exports
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
// Re-check under the lock: a concurrent first load of the same path only bumps the OS dlopen
|
|
399
|
+
// refcount, and whichever insert wins becomes the canonical copy.
|
|
400
|
+
let mut cache = module_cache().lock().unwrap();
|
|
401
|
+
let entry = cache.entry(key).or_insert(exports);
|
|
402
|
+
Ok(wrap_exports(entry))
|
|
347
403
|
}
|
|
348
404
|
|
|
349
405
|
#[cfg(test)]
|
|
@@ -463,8 +519,14 @@ mod tests {
|
|
|
463
519
|
tish_value_drop(b);
|
|
464
520
|
// A module table referencing it type-checks (the `tish_module_register` shape).
|
|
465
521
|
let name = CString::new("add").unwrap();
|
|
466
|
-
let exports = [TishExport {
|
|
467
|
-
|
|
522
|
+
let exports = [TishExport {
|
|
523
|
+
name: name.as_ptr(),
|
|
524
|
+
func: add_fn,
|
|
525
|
+
}];
|
|
526
|
+
let table = TishExportTable {
|
|
527
|
+
exports: exports.as_ptr(),
|
|
528
|
+
count: 1,
|
|
529
|
+
};
|
|
468
530
|
assert_eq!(table.count, 1);
|
|
469
531
|
}
|
|
470
532
|
}
|
|
@@ -63,3 +63,38 @@ fn load_and_call_real_cdylib() {
|
|
|
63
63
|
other => panic!("make_pair export = {other:?}"),
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
|
+
|
|
67
|
+
// #711: repeated loads of one library — including under a distinct spelling of its path — must
|
|
68
|
+
// hit the process-global cache rather than dlopen (and leak) a fresh mapping per call.
|
|
69
|
+
#[test]
|
|
70
|
+
fn repeated_loads_share_one_cache_entry() {
|
|
71
|
+
let lib = build_and_locate_fixture();
|
|
72
|
+
let first = tishlang_ffi::load_module(lib.to_str().unwrap())
|
|
73
|
+
.unwrap_or_else(|e| panic!("first load_module: {e}"));
|
|
74
|
+
let cached_modules = tishlang_ffi::loaded_module_count();
|
|
75
|
+
|
|
76
|
+
// Same file, different spelling: `<dir>/./<name>` canonicalizes to the same resolved path.
|
|
77
|
+
let respelled = lib
|
|
78
|
+
.parent()
|
|
79
|
+
.expect("fixture path has a parent")
|
|
80
|
+
.join(".")
|
|
81
|
+
.join(lib.file_name().expect("fixture path has a file name"));
|
|
82
|
+
let second = tishlang_ffi::load_module(respelled.to_str().unwrap())
|
|
83
|
+
.unwrap_or_else(|e| panic!("re-spelled load_module: {e}"));
|
|
84
|
+
assert_eq!(
|
|
85
|
+
tishlang_ffi::loaded_module_count(),
|
|
86
|
+
cached_modules,
|
|
87
|
+
"a re-spelled path of an already-loaded library must not add a cache entry"
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// Exports wrapped from the cached copy still call through correctly.
|
|
91
|
+
for module in [&first, &second] {
|
|
92
|
+
match module.get("triple") {
|
|
93
|
+
Some(Value::Function(f)) => match f.call(&[Value::Number(7.0)]) {
|
|
94
|
+
Value::Number(n) => assert_eq!(n, 21.0),
|
|
95
|
+
other => panic!("triple(7) = {other:?}"),
|
|
96
|
+
},
|
|
97
|
+
other => panic!("triple export = {other:?}"),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -447,7 +447,10 @@ impl LanguageServer for Backend {
|
|
|
447
447
|
// files so an externally edited declaration (`.d.tish`) or source file refreshes the
|
|
448
448
|
// workspace-symbol index without a server restart (#161). The client only delivers these
|
|
449
449
|
// events for globs the server registers; a `.tish` glob does NOT cover `.d.tish` (it has a
|
|
450
|
-
// compound extension), so both are registered explicitly.
|
|
450
|
+
// compound extension), so both are registered explicitly. Also watch the manifests that
|
|
451
|
+
// drive `cargo:` resolution — `tish.rustDependencies` lives in package.json, and a path
|
|
452
|
+
// dep resolves through its Cargo.toml/Cargo.lock — so a dependency edit can invalidate
|
|
453
|
+
// `cargo_src_cache` (#714); without these globs no event could ever reach it.
|
|
451
454
|
let watchers = |glob: &str| FileSystemWatcher {
|
|
452
455
|
glob_pattern: GlobPattern::String(glob.to_string()),
|
|
453
456
|
kind: None, // create | change | delete
|
|
@@ -456,7 +459,13 @@ impl LanguageServer for Backend {
|
|
|
456
459
|
id: "tish-watch-d-tish".to_string(),
|
|
457
460
|
method: "workspace/didChangeWatchedFiles".to_string(),
|
|
458
461
|
register_options: serde_json::to_value(DidChangeWatchedFilesRegistrationOptions {
|
|
459
|
-
watchers: vec![
|
|
462
|
+
watchers: vec![
|
|
463
|
+
watchers("**/*.tish"),
|
|
464
|
+
watchers("**/*.d.tish"),
|
|
465
|
+
watchers("**/package.json"),
|
|
466
|
+
watchers("**/Cargo.toml"),
|
|
467
|
+
watchers("**/Cargo.lock"),
|
|
468
|
+
],
|
|
460
469
|
})
|
|
461
470
|
.ok(),
|
|
462
471
|
};
|
|
@@ -485,6 +494,10 @@ impl LanguageServer for Backend {
|
|
|
485
494
|
let mut roots = self.roots.write().unwrap();
|
|
486
495
|
apply_workspace_folder_changes(&mut roots, ¶ms.event);
|
|
487
496
|
}
|
|
497
|
+
// The cargo-crate-root cache goes with the old folder set (#714): `cargo:` resolution is
|
|
498
|
+
// root-relative, entries are cheap to recompute on the next goto-definition/hover, and
|
|
499
|
+
// clearing here mirrors how the folder change already re-scopes the symbol index.
|
|
500
|
+
self.cargo_src_cache.write().unwrap().clear();
|
|
488
501
|
self.client
|
|
489
502
|
.log_message(MessageType::INFO, "tish-lsp: workspace folders updated")
|
|
490
503
|
.await;
|
|
@@ -510,6 +523,25 @@ impl LanguageServer for Backend {
|
|
|
510
523
|
}
|
|
511
524
|
}
|
|
512
525
|
}
|
|
526
|
+
// #714: a manifest change re-routes `cargo:` resolution — `tish.rustDependencies` lives in
|
|
527
|
+
// package.json, and a path dep resolves through its Cargo.toml/Cargo.lock — so drop the
|
|
528
|
+
// whole cargo-crate-root cache when any watched manifest changes. Whole-cache rather than
|
|
529
|
+
// per-root pruning: a path dep's manifest may live outside every project root, entries are
|
|
530
|
+
// a few hundred bytes, and they are recomputed per (human-rate) goto-definition/hover.
|
|
531
|
+
let manifest_changed = params.changes.iter().any(|change| {
|
|
532
|
+
change
|
|
533
|
+
.uri
|
|
534
|
+
.to_file_path()
|
|
535
|
+
.ok()
|
|
536
|
+
.and_then(|p| {
|
|
537
|
+
p.file_name()
|
|
538
|
+
.map(|f| f == "package.json" || f == "Cargo.toml" || f == "Cargo.lock")
|
|
539
|
+
})
|
|
540
|
+
.unwrap_or(false)
|
|
541
|
+
});
|
|
542
|
+
if manifest_changed {
|
|
543
|
+
self.cargo_src_cache.write().unwrap().clear();
|
|
544
|
+
}
|
|
513
545
|
// Re-lint any OPEN buffer whose declarations may now have changed. A `.d.tish` supplies
|
|
514
546
|
// ambient `declare` bindings other buffers depend on, so a change there can flip
|
|
515
547
|
// unresolved-name diagnostics; recompute open docs against the new on-disk state.
|
|
@@ -2558,6 +2590,63 @@ mod jsonrpc_integration_tests {
|
|
|
2558
2590
|
std::fs::remove_dir_all(&dir).ok();
|
|
2559
2591
|
}
|
|
2560
2592
|
|
|
2593
|
+
/// Seed `cargo_src_cache` the way a successful goto-definition would.
|
|
2594
|
+
fn seed_cargo_src_cache(service: &LspService<Backend>) {
|
|
2595
|
+
service.inner().cargo_src_cache.write().unwrap().insert(
|
|
2596
|
+
(PathBuf::from("/proj"), "cargo:serde".to_string()),
|
|
2597
|
+
PathBuf::from("/old/crate/root"),
|
|
2598
|
+
);
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
fn cargo_src_cache_len(service: &LspService<Backend>) -> usize {
|
|
2602
|
+
service.inner().cargo_src_cache.read().unwrap().len()
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
fn did_change_watched_files_request(uri: &str) -> Request {
|
|
2606
|
+
Request::build("workspace/didChangeWatchedFiles")
|
|
2607
|
+
.params(serde_json::json!({ "changes": [ { "uri": uri, "type": 2 } ] }))
|
|
2608
|
+
.finish()
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
// #714: a workspace-folder change must drop the cargo-crate-root cache along with the roots
|
|
2612
|
+
// rewrite — previously nothing ever invalidated it, so a stale crate root was served for the
|
|
2613
|
+
// rest of the session.
|
|
2614
|
+
#[tokio::test]
|
|
2615
|
+
async fn workspace_folder_change_clears_cargo_src_cache() {
|
|
2616
|
+
let mut service = new_service();
|
|
2617
|
+
initialize(&mut service).await;
|
|
2618
|
+
seed_cargo_src_cache(&service);
|
|
2619
|
+
|
|
2620
|
+
let dir = crate::test_fs::unique_temp_dir("cargocache");
|
|
2621
|
+
let uri = Url::from_file_path(&dir).unwrap().to_string();
|
|
2622
|
+
let _ = call(&mut service, did_change_workspace_folders(&[&uri], &[])).await;
|
|
2623
|
+
assert_eq!(cargo_src_cache_len(&service), 0, "folder change must clear the cargo src cache");
|
|
2624
|
+
|
|
2625
|
+
std::fs::remove_dir_all(&dir).ok();
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
// #714: a watched manifest change (package.json / Cargo.toml / Cargo.lock — the files that
|
|
2629
|
+
// drive `cargo:` resolution) must drop the cargo-crate-root cache; a plain `.tish` change
|
|
2630
|
+
// must leave it alone.
|
|
2631
|
+
#[tokio::test]
|
|
2632
|
+
async fn manifest_change_clears_cargo_src_cache() {
|
|
2633
|
+
let mut service = new_service();
|
|
2634
|
+
initialize(&mut service).await;
|
|
2635
|
+
|
|
2636
|
+
// A `.tish` event leaves the cargo cache untouched…
|
|
2637
|
+
seed_cargo_src_cache(&service);
|
|
2638
|
+
let _ = call(&mut service, did_change_watched_files_request("file:///proj/a.tish")).await;
|
|
2639
|
+
assert_eq!(cargo_src_cache_len(&service), 1, "a .tish change must not clear the cargo src cache");
|
|
2640
|
+
|
|
2641
|
+
// …while any manifest event drops it.
|
|
2642
|
+
for name in ["package.json", "Cargo.toml", "Cargo.lock"] {
|
|
2643
|
+
seed_cargo_src_cache(&service);
|
|
2644
|
+
let req = did_change_watched_files_request(&format!("file:///proj/{name}"));
|
|
2645
|
+
let _ = call(&mut service, req).await;
|
|
2646
|
+
assert_eq!(cargo_src_cache_len(&service), 0, "a {name} change must clear the cargo src cache");
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2561
2650
|
// #162 protocol edge cases the client is allowed to send: adding the same folder twice must not
|
|
2562
2651
|
// change the result, and removing a folder that was never added must not evict a tracked root.
|
|
2563
2652
|
#[tokio::test]
|