@tishlang/tish-lsp 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.
@@ -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 (keys + their order are preserved,
854
- // so the shape stays valid).
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
- for (k, v) in other.iter() {
990
- self.insert(Arc::clone(k), v.clone());
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
- 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())));
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().insert(
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().insert(
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
- unsafe {
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 exports = std::slice::from_raw_parts(table.exports, table.count);
334
- let mut map = ObjectMap::default();
335
- for exp in exports {
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
- map.insert(name.into(), wrap_native_fn(exp.func));
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
- Ok(map)
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 { name: name.as_ptr(), func: add_fn }];
467
- let table = TishExportTable { exports: exports.as_ptr(), count: 1 };
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
+ }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang_lsp"
3
- version = "3.10.4"
3
+ version = "3.10.9"
4
4
  edition = "2021"
5
5
  description = "Language Server Protocol implementation for Tish"
6
6
  license-file = { workspace = true }
@@ -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![watchers("**/*.tish"), watchers("**/*.d.tish")],
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, &params.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]