@tishlang/tish-format 3.10.4 → 3.10.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tish-format +0 -0
- package/crates/tish/src/cargo_native_registry.rs +4 -0
- package/crates/tish/src/main.rs +65 -25
- package/crates/tish/tests/integration_test.rs +3 -0
- package/crates/tish_builtins/src/globals.rs +3 -3
- package/crates/tish_builtins/src/symbol.rs +86 -4
- package/crates/tish_bytecode/src/compiler.rs +65 -25
- package/crates/tish_bytecode/tests/captured_param_slot_binding.rs +131 -0
- package/crates/tish_bytecode/tests/fundecl_slot_binding.rs +115 -0
- package/crates/tish_bytecode/tests/param_defaults_slot_binding.rs +86 -0
- package/crates/tish_core/src/shape.rs +55 -2
- package/crates/tish_core/src/value.rs +229 -13
- package/crates/tish_eval/src/timers.rs +66 -2
- package/crates/tish_ffi/src/lib.rs +74 -12
- package/crates/tish_ffi/tests/loader.rs +35 -0
- package/crates/tish_fmt/Cargo.toml +1 -1
- package/crates/tish_lsp/src/main.rs +91 -2
- package/crates/tish_pg/src/lib.rs +59 -8
- package/crates/tish_pg/src/statement_registry.rs +250 -0
- package/crates/tish_runtime/src/http.rs +19 -0
- package/crates/tish_runtime/src/http_fetch.rs +159 -65
- package/crates/tish_runtime/src/http_hyper.rs +18 -2
- package/crates/tish_runtime/src/lib.rs +4 -0
- package/crates/tish_runtime/src/net.rs +125 -97
- package/crates/tish_runtime/src/process_spawn.rs +326 -113
- package/crates/tish_runtime/src/promise.rs +521 -90
- package/crates/tish_runtime/src/promise_io.rs +29 -6
- package/crates/tish_runtime/src/pty.rs +161 -107
- package/crates/tish_runtime/src/stream_buf.rs +419 -0
- package/crates/tish_runtime/src/ws.rs +431 -52
- package/crates/tish_vm/src/jit.rs +473 -67
- package/crates/tish_vm/src/lib.rs +6 -0
- package/crates/tish_vm/src/vm.rs +8 -5
- package/crates/tish_vm/tests/captured_param_slot_mode.rs +210 -0
- package/crates/tish_vm/tests/captured_param_vm_slots_off.rs +64 -0
- package/crates/tish_vm/tests/fundecl_slot_no_frame_cycle.rs +152 -0
- package/crates/tish_vm/tests/param_defaults_slot_mode.rs +126 -0
- package/crates/tish_vm/tests/param_defaults_vm_slots_off.rs +44 -0
- package/package.json +1 -1
- package/platform/darwin-arm64/tish-fmt +0 -0
- package/platform/darwin-x64/tish-fmt +0 -0
- package/platform/linux-arm64/tish-fmt +0 -0
- package/platform/linux-x64/tish-fmt +0 -0
- package/platform/win32-x64/tish-fmt.exe +0 -0
|
@@ -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]
|
|
@@ -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
|
-
|
|
543
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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)]
|