@tishlang/tish-format 3.10.4 → 3.10.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/bin/tish-format +0 -0
  2. package/crates/tish/src/cargo_native_registry.rs +4 -0
  3. package/crates/tish/src/main.rs +65 -25
  4. package/crates/tish/tests/integration_test.rs +3 -0
  5. package/crates/tish_builtins/src/globals.rs +3 -3
  6. package/crates/tish_builtins/src/symbol.rs +86 -4
  7. package/crates/tish_bytecode/src/compiler.rs +65 -25
  8. package/crates/tish_bytecode/tests/captured_param_slot_binding.rs +131 -0
  9. package/crates/tish_bytecode/tests/fundecl_slot_binding.rs +115 -0
  10. package/crates/tish_bytecode/tests/param_defaults_slot_binding.rs +86 -0
  11. package/crates/tish_core/src/shape.rs +55 -2
  12. package/crates/tish_core/src/value.rs +229 -13
  13. package/crates/tish_eval/src/timers.rs +66 -2
  14. package/crates/tish_ffi/src/lib.rs +74 -12
  15. package/crates/tish_ffi/tests/loader.rs +35 -0
  16. package/crates/tish_fmt/Cargo.toml +1 -1
  17. package/crates/tish_lsp/src/main.rs +91 -2
  18. package/crates/tish_pg/src/lib.rs +59 -8
  19. package/crates/tish_pg/src/statement_registry.rs +250 -0
  20. package/crates/tish_runtime/src/http.rs +19 -0
  21. package/crates/tish_runtime/src/http_fetch.rs +159 -65
  22. package/crates/tish_runtime/src/http_hyper.rs +18 -2
  23. package/crates/tish_runtime/src/lib.rs +4 -0
  24. package/crates/tish_runtime/src/net.rs +125 -97
  25. package/crates/tish_runtime/src/process_spawn.rs +326 -113
  26. package/crates/tish_runtime/src/promise.rs +521 -90
  27. package/crates/tish_runtime/src/promise_io.rs +29 -6
  28. package/crates/tish_runtime/src/pty.rs +161 -107
  29. package/crates/tish_runtime/src/stream_buf.rs +419 -0
  30. package/crates/tish_runtime/src/ws.rs +431 -52
  31. package/crates/tish_vm/src/jit.rs +473 -67
  32. package/crates/tish_vm/src/lib.rs +6 -0
  33. package/crates/tish_vm/src/vm.rs +8 -5
  34. package/crates/tish_vm/tests/captured_param_slot_mode.rs +210 -0
  35. package/crates/tish_vm/tests/captured_param_vm_slots_off.rs +64 -0
  36. package/crates/tish_vm/tests/fundecl_slot_no_frame_cycle.rs +152 -0
  37. package/crates/tish_vm/tests/param_defaults_slot_mode.rs +126 -0
  38. package/crates/tish_vm/tests/param_defaults_vm_slots_off.rs +44 -0
  39. package/package.json +1 -1
  40. package/platform/darwin-arm64/tish-fmt +0 -0
  41. package/platform/darwin-x64/tish-fmt +0 -0
  42. package/platform/linux-arm64/tish-fmt +0 -0
  43. package/platform/linux-x64/tish-fmt +0 -0
  44. package/platform/win32-x64/tish-fmt.exe +0 -0
@@ -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_fmt"
3
- version = "3.10.4"
3
+ version = "3.10.11"
4
4
  edition = "2021"
5
5
  description = "Opinionated formatter for Tish source (parse → pretty-print)"
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]
@@ -10,6 +10,9 @@ use tishlang_runtime::VmRef;
10
10
  mod error;
11
11
  pub use error::{format_pg_error, format_tish_pg_error, Result, TishPgError};
12
12
 
13
+ #[cfg(feature = "tish-bindings")]
14
+ mod statement_registry;
15
+
13
16
  use deadpool_postgres::{Manager, Pool, Runtime};
14
17
  use serde::{Deserialize, Serialize};
15
18
  use serde_json::{json, Map, Value as JsonValue};
@@ -537,10 +540,15 @@ mod tish_sync {
537
540
  // worker threads each doing thousands of QPS, the prior `Mutex<Slab>`
538
541
  // serialised every query through one global lock; `RwLock` lets all
539
542
  // concurrent reads run lock-free against each other.
543
+ use crate::statement_registry::StatementRegistry;
540
544
  use std::sync::RwLock;
541
545
  static CLIENTS: Lazy<RwLock<Slab<PerWorkerClient>>> = Lazy::new(|| RwLock::new(Slab::new()));
542
- static STATEMENTS: Lazy<RwLock<Slab<(usize, Statement)>>> =
543
- Lazy::new(|| RwLock::new(Slab::new()));
546
+ // Statement lifecycle (see statement_registry.rs and tishlang/tish#712):
547
+ // entries are interned per (client, sql), validated against the owning
548
+ // client on every lookup, swept by `close`, and individually freed by
549
+ // `unprepare`.
550
+ static STATEMENTS: Lazy<RwLock<StatementRegistry<Statement>>> =
551
+ Lazy::new(|| RwLock::new(StatementRegistry::new()));
544
552
 
545
553
  /// Drive a future on our tokio runtime without panicking when called from
546
554
  /// inside another runtime's worker thread.
@@ -709,9 +717,13 @@ mod tish_sync {
709
717
  g.get(id as usize).cloned()
710
718
  }
711
719
 
712
- fn get_statement(id: f64) -> Option<(usize, Statement)> {
720
+ /// Resolve a statement handle **for a specific client**. A handle whose
721
+ /// stored client id does not match is a miss: after `close()` recycles a
722
+ /// CLIENTS slab index, a stale statement handle must not be replayed
723
+ /// against whichever new connection reused that index.
724
+ fn get_statement(id: f64, client_id: f64) -> Option<Statement> {
713
725
  let g = STATEMENTS.read().unwrap();
714
- g.get(id as usize).cloned()
726
+ g.get(id as usize, client_id as usize)
715
727
  }
716
728
 
717
729
  /// `prepare(client_handle, sql) -> statement_handle`.
@@ -726,10 +738,23 @@ mod tish_sync {
726
738
  return tish_err("prepare: unknown client handle");
727
739
  };
728
740
  let sql = sql.to_string();
741
+ // Idempotent per (client, sql): re-preparing identical SQL returns
742
+ // the interned handle without a server round-trip, so the statement
743
+ // table is bounded by distinct SQL text instead of prepare() calls.
744
+ {
745
+ let g = STATEMENTS.read().unwrap();
746
+ if let Some(id) = g.lookup(*client_id as usize, &sql) {
747
+ return TishValue::Number(id as f64);
748
+ }
749
+ }
729
750
  match block_on(client.prepare(&sql)) {
730
751
  Ok(stmt) => {
731
752
  let mut g = STATEMENTS.write().unwrap();
732
- let id = g.insert((*client_id as usize, stmt));
753
+ // insert() re-checks the intern map under the write lock: if
754
+ // a concurrent identical prepare won the race, its handle is
755
+ // reused and this fresh Statement drops (emitting the
756
+ // server-side DEALLOCATE).
757
+ let id = g.insert(*client_id as usize, &sql, stmt);
733
758
  TishValue::Number(id as f64)
734
759
  }
735
760
  Err(e) => tish_err(format!("prepare: {}", crate::format_tish_pg_error(&e))),
@@ -752,7 +777,7 @@ mod tish_sync {
752
777
  let Some(client) = get_client(*client_id) else {
753
778
  return tish_err("queryPrepared: unknown client");
754
779
  };
755
- let Some((_cid, stmt)) = get_statement(*stmt_id) else {
780
+ let Some(stmt) = get_statement(*stmt_id, *client_id) else {
756
781
  return tish_err("queryPrepared: unknown statement");
757
782
  };
758
783
  // Fast path: build `Value::Object` rows directly from `Row` so we
@@ -792,7 +817,7 @@ mod tish_sync {
792
817
  let Some(TishValue::Number(stmt_id)) = pair_b.first() else {
793
818
  return tish_err("queryAll: spec[0] must be a statement handle");
794
819
  };
795
- let Some((_cid, stmt)) = get_statement(*stmt_id) else {
820
+ let Some(stmt) = get_statement(*stmt_id, *client_id) else {
796
821
  return tish_err("queryAll: unknown statement");
797
822
  };
798
823
  let params = match pair_b.get(1) {
@@ -821,8 +846,18 @@ mod tish_sync {
821
846
  }
822
847
 
823
848
  /// `close(client_handle) -> null`.
849
+ ///
850
+ /// Also sweeps the client's prepared statements. The sweep runs first,
851
+ /// while the connection is still alive, so each dropped `Statement`
852
+ /// emits its server-side `DEALLOCATE`; purging the intern entries keeps
853
+ /// a later client that reuses this slab index from inheriting stale
854
+ /// handles.
824
855
  pub fn close(args: &[TishValue]) -> TishValue {
825
856
  if let Some(TishValue::Number(id)) = args.first() {
857
+ {
858
+ let mut s = STATEMENTS.write().unwrap();
859
+ s.sweep_client(*id as usize);
860
+ }
826
861
  let mut g = CLIENTS.write().unwrap();
827
862
  if g.contains(*id as usize) {
828
863
  g.remove(*id as usize);
@@ -831,6 +866,22 @@ mod tish_sync {
831
866
  TishValue::Null
832
867
  }
833
868
 
869
+ /// `unprepare(statement_handle) -> null`.
870
+ ///
871
+ /// Frees one prepared-statement entry. Dropping the `Statement` sends
872
+ /// the server-side `DEALLOCATE` (tokio-postgres emits a `Close` message
873
+ /// on drop) as long as the owning connection is still alive; if the
874
+ /// client was already closed the drop is inert and the server reclaimed
875
+ /// the statement on disconnect. Unknown handles are ignored, mirroring
876
+ /// `close`.
877
+ pub fn unprepare(args: &[TishValue]) -> TishValue {
878
+ if let Some(TishValue::Number(id)) = args.first() {
879
+ let mut g = STATEMENTS.write().unwrap();
880
+ g.remove(*id as usize);
881
+ }
882
+ TishValue::Null
883
+ }
884
+
834
885
  /// `migrate(client_handle, dir) -> { ok, applied: [name, …], error? }`.
835
886
  ///
836
887
  /// Reads `dir` for files matching `^\d+_.+\.sql$`, sorted lexically
@@ -940,7 +991,7 @@ mod tish_sync {
940
991
  // the Rust side (camelCase `queryAll` in .tish -> snake `query_all` here).
941
992
  #[cfg(feature = "tish-bindings")]
942
993
  pub use tish_sync::{
943
- close, connect, migrate, per_worker_client, prepare, query_all, query_prepared,
994
+ close, connect, migrate, per_worker_client, prepare, query_all, query_prepared, unprepare,
944
995
  };
945
996
 
946
997
  #[cfg(test)]