@tishlang/tish-format 3.10.9 → 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/tests/integration_test.rs +3 -0
- package/crates/tish_bytecode/src/compiler.rs +43 -19
- package/crates/tish_bytecode/tests/captured_param_slot_binding.rs +131 -0
- package/crates/tish_bytecode/tests/param_defaults_slot_binding.rs +86 -0
- package/crates/tish_core/src/value.rs +31 -4
- package/crates/tish_fmt/Cargo.toml +1 -1
- 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/promise.rs +521 -90
- package/crates/tish_runtime/src/promise_io.rs +29 -6
- package/crates/tish_vm/src/vm.rs +5 -4
- 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/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
package/bin/tish-format
CHANGED
|
Binary file
|
|
@@ -15,6 +15,10 @@ pub(crate) fn register_bytecode_native_modules(vm: &mut tishlang_vm::Vm) {
|
|
|
15
15
|
);
|
|
16
16
|
om.insert(Arc::from("connect"), Value::native(tishlang_pg::connect));
|
|
17
17
|
om.insert(Arc::from("prepare"), Value::native(tishlang_pg::prepare));
|
|
18
|
+
om.insert(
|
|
19
|
+
Arc::from("unprepare"),
|
|
20
|
+
Value::native(tishlang_pg::unprepare),
|
|
21
|
+
);
|
|
18
22
|
om.insert(
|
|
19
23
|
Arc::from("query_prepared"),
|
|
20
24
|
Value::native(tishlang_pg::query_prepared),
|
|
@@ -1068,6 +1068,9 @@ allSettled[1] rejected: boom
|
|
|
1068
1068
|
allSettled[2] ok: 30
|
|
1069
1069
|
race winner: A
|
|
1070
1070
|
any passthrough: 42
|
|
1071
|
+
race pending-loser winner: fast
|
|
1072
|
+
loser still awaitable: late
|
|
1073
|
+
re-await: 7 7
|
|
1071
1074
|
";
|
|
1072
1075
|
for backend_args in [vec!["run"], vec!["run", "--backend", "interp"]] {
|
|
1073
1076
|
let mut args = backend_args.clone();
|
|
@@ -844,8 +844,11 @@ impl SlotScan {
|
|
|
844
844
|
|
|
845
845
|
/// Capture-aware eligibility for general slot-based locals in a FUNCTION. Returns the captured-name set
|
|
846
846
|
/// (names that must stay name-based) when eligible, else `None` (compile name-based). Eligible iff the
|
|
847
|
-
/// flag is on, no rest param, all params simple, the body fully analysable
|
|
848
|
-
///
|
|
847
|
+
/// flag is on, no rest param, all params simple, and the body fully analysable. A CAPTURED param does
|
|
848
|
+
/// NOT disqualify the chunk (#716 follow-up): the VM still binds the arg into its positional slot, and
|
|
849
|
+
/// a copy-in prologue moves it into `local_scope` at entry — from then on the scope is that param's
|
|
850
|
+
/// single source of truth (its name stays out of the slot map, so every body read/write compiles
|
|
851
|
+
/// name-based to the very map closures capture — shared-cell mutation semantics preserved exactly).
|
|
849
852
|
fn slot_analyze(
|
|
850
853
|
params: &[FunParam],
|
|
851
854
|
has_rest: bool,
|
|
@@ -863,13 +866,6 @@ fn slot_analyze(
|
|
|
863
866
|
if !scan.stmt(body, false) {
|
|
864
867
|
return None;
|
|
865
868
|
}
|
|
866
|
-
for p in params {
|
|
867
|
-
if let FunParam::Simple(tp) = p {
|
|
868
|
-
if scan.captured.contains(&tp.name) {
|
|
869
|
-
return None;
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
869
|
Some(scan.captured)
|
|
874
870
|
}
|
|
875
871
|
|
|
@@ -1060,12 +1056,12 @@ impl<'a> Compiler<'a> {
|
|
|
1060
1056
|
self.emit_u16(Opcode::ArgMissing, i as u16);
|
|
1061
1057
|
let skip = self.emit_jump(Opcode::JumpIfFalse);
|
|
1062
1058
|
self.compile_expr(default_expr)?;
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
match
|
|
1059
|
+
// #727: resolve through BOTH slot maps (`resolve_slot`), not `slot_ctx` alone. In
|
|
1060
|
+
// GENERAL slot mode params live in `slot_scopes` (declared before this prologue) and
|
|
1061
|
+
// every read of the param lowers to `LoadLocal <slot>` — a `DeclareVarPlain` here wrote
|
|
1062
|
+
// the default into the name-based scope map no read resolves to, leaving the slot's
|
|
1063
|
+
// missing-arg `Null` visible (`f(a, b = 5); f(1)` → `1 + null` → NaN).
|
|
1064
|
+
match self.resolve_slot(tp.name.as_ref()) {
|
|
1069
1065
|
Some(slot) => self.emit_u16(Opcode::StoreLocal, slot),
|
|
1070
1066
|
None => {
|
|
1071
1067
|
let idx = self.name_idx(&tp.name);
|
|
@@ -2022,14 +2018,42 @@ impl<'a> Compiler<'a> {
|
|
|
2022
2018
|
inner_comp.emit_param_defaults_prologue(params)?;
|
|
2023
2019
|
inner_comp.compile_fn_body(body)?;
|
|
2024
2020
|
} else if let Some(cap) = captured {
|
|
2025
|
-
// Params
|
|
2026
|
-
//
|
|
2027
|
-
//
|
|
2021
|
+
// Params → slots 0..n (matching the VM's positional arg binding); uncaptured
|
|
2022
|
+
// body `let`s get fresh slots via the scope-aware allocator; captured locals
|
|
2023
|
+
// stay name-based in `local_scope` (which closures capture).
|
|
2024
|
+
//
|
|
2025
|
+
// #716 follow-up: a CAPTURED param no longer disqualifies the chunk. It keeps
|
|
2026
|
+
// its slot NUMBER (the VM binds args positionally, so numbering must stay
|
|
2027
|
+
// 0..n) but is NOT slot-resolvable: its name never enters `slot_scopes`, so
|
|
2028
|
+
// every body read/write compiles name-based (`LoadVar`/`StoreVar`) against
|
|
2029
|
+
// `local_scope` — the very map closures capture — keeping the scope the
|
|
2030
|
+
// single source of truth (shared-cell mutation semantics preserved exactly;
|
|
2031
|
+
// a body reassignment is visible to the closure and vice versa). The COPY-IN
|
|
2032
|
+
// prologue below moves each captured arg slot → scope once at entry, BEFORE
|
|
2033
|
+
// the defaults prologue so an applied default (stored name-based for a
|
|
2034
|
+
// captured param) overwrites the copied-in missing-arg null, never the other
|
|
2035
|
+
// way around. This keeps the chunk in slot mode, so the #716 fix (#728) slot-
|
|
2036
|
+
// binds an uncaptured body-level fn NAME and the per-call frame-scope↔closure
|
|
2037
|
+
// cycle never forms for the `handleEvent(payload){ function fmt(){…payload…} }`
|
|
2038
|
+
// shape.
|
|
2028
2039
|
inner_comp.general_slots = true;
|
|
2029
2040
|
inner_comp.slot_captured = cap;
|
|
2030
2041
|
inner_comp.slot_scopes.push(HashMap::new());
|
|
2042
|
+
let mut captured_param_copy_ins: Vec<(u16, Arc<str>)> = Vec::new();
|
|
2031
2043
|
for p in ¶m_names {
|
|
2032
|
-
inner_comp.
|
|
2044
|
+
if inner_comp.slot_captured.contains(p.as_ref()) {
|
|
2045
|
+
// Reserve the positional slot without making it resolvable.
|
|
2046
|
+
let slot = inner_comp.next_slot;
|
|
2047
|
+
inner_comp.next_slot += 1;
|
|
2048
|
+
captured_param_copy_ins.push((slot, Arc::clone(p)));
|
|
2049
|
+
} else {
|
|
2050
|
+
inner_comp.declare_slot(p);
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
for (slot, p) in &captured_param_copy_ins {
|
|
2054
|
+
inner_comp.emit_u16(Opcode::LoadLocal, *slot);
|
|
2055
|
+
let idx = inner_comp.name_idx(p);
|
|
2056
|
+
inner_comp.emit_u16(Opcode::DeclareVarPlain, idx);
|
|
2033
2057
|
}
|
|
2034
2058
|
inner_comp.emit_param_defaults_prologue(params)?;
|
|
2035
2059
|
inner_comp.compile_fn_body(body)?;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
//! #716 follow-up: a function whose PARAM is captured by a nested closure must still
|
|
2
|
+
//! compile in general slot mode, with a captured-param COPY-IN PROLOGUE (`LoadLocal
|
|
3
|
+
//! <param slot>` + `DeclareVarPlain <name>`) so the closure reads the param from
|
|
4
|
+
//! `local_scope` while the chunk keeps frame slots for everything uncaptured —
|
|
5
|
+
//! including the body-level helper NAME (#728), which is what breaks the per-call
|
|
6
|
+
//! frame-scope↔closure cycle for the #716 repro shape.
|
|
7
|
+
|
|
8
|
+
use tishlang_bytecode::{compile, Chunk, Opcode};
|
|
9
|
+
|
|
10
|
+
/// Count occurrences of `target` in `chunk.code`, walking real instruction boundaries.
|
|
11
|
+
fn count_opcode(chunk: &Chunk, target: Opcode) -> usize {
|
|
12
|
+
let code = &chunk.code;
|
|
13
|
+
let mut ip = 0usize;
|
|
14
|
+
let mut n = 0usize;
|
|
15
|
+
while ip < code.len() {
|
|
16
|
+
let op = Opcode::from_u8(code[ip]).expect("valid opcode");
|
|
17
|
+
if op == target {
|
|
18
|
+
n += 1;
|
|
19
|
+
}
|
|
20
|
+
ip += op.instruction_size(code, ip).expect("sized instruction");
|
|
21
|
+
}
|
|
22
|
+
n
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
fn compile_src(src: &str) -> Chunk {
|
|
26
|
+
let program = tishlang_parser::parse(src).expect("parse");
|
|
27
|
+
compile(&program).expect("compile")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// These tests assert slot-mode emission; running the suite with `TISH_VM_SLOTS=0`
|
|
31
|
+
/// deliberately exercises the legacy name-based path, where they do not apply.
|
|
32
|
+
fn slots_disabled() -> bool {
|
|
33
|
+
std::env::var("TISH_VM_SLOTS")
|
|
34
|
+
.map(|v| v == "0")
|
|
35
|
+
.unwrap_or(false)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// The #716 repro shape: the chunk must stay slot-based, copy the captured param in
|
|
39
|
+
/// exactly once, and slot-bind the (uncaptured) helper name — zero `DeclareVar`.
|
|
40
|
+
#[test]
|
|
41
|
+
fn captured_param_chunk_stays_slot_based_with_copy_in() {
|
|
42
|
+
if slots_disabled() {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
let chunk = compile_src(
|
|
46
|
+
r#"
|
|
47
|
+
function handleEvent(payload) {
|
|
48
|
+
function fmt(x) { return x + payload.tag }
|
|
49
|
+
return fmt("v")
|
|
50
|
+
}
|
|
51
|
+
console.log(handleEvent({ tag: "t" }))
|
|
52
|
+
"#,
|
|
53
|
+
);
|
|
54
|
+
let outer = &chunk.nested[0];
|
|
55
|
+
assert!(
|
|
56
|
+
outer.slot_based,
|
|
57
|
+
"a captured-param chunk must no longer be disqualified from general slot mode"
|
|
58
|
+
);
|
|
59
|
+
assert_eq!(
|
|
60
|
+
count_opcode(outer, Opcode::DeclareVarPlain),
|
|
61
|
+
1,
|
|
62
|
+
"exactly one copy-in store for the one captured param"
|
|
63
|
+
);
|
|
64
|
+
assert_eq!(
|
|
65
|
+
count_opcode(outer, Opcode::DeclareVar),
|
|
66
|
+
0,
|
|
67
|
+
"the uncaptured helper name must be slot-bound (#728), not DeclareVar-bound — \
|
|
68
|
+
that is the cycle"
|
|
69
|
+
);
|
|
70
|
+
assert!(
|
|
71
|
+
count_opcode(outer, Opcode::StoreLocal) >= 1,
|
|
72
|
+
"the helper closure should be stored to a frame slot"
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// Mixed params: only the CAPTURED one is copied in; the uncaptured one stays
|
|
77
|
+
/// slot-resolved (no name-based traffic for it).
|
|
78
|
+
#[test]
|
|
79
|
+
fn only_captured_params_are_copied_in() {
|
|
80
|
+
if slots_disabled() {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
let chunk = compile_src(
|
|
84
|
+
r#"
|
|
85
|
+
function outer(p, q) {
|
|
86
|
+
function get() { return p }
|
|
87
|
+
let r = get() + q
|
|
88
|
+
return r
|
|
89
|
+
}
|
|
90
|
+
console.log(outer(1, 2))
|
|
91
|
+
"#,
|
|
92
|
+
);
|
|
93
|
+
let outer = &chunk.nested[0];
|
|
94
|
+
assert!(outer.slot_based, "mixed-param chunk should be slot-based");
|
|
95
|
+
assert_eq!(
|
|
96
|
+
count_opcode(outer, Opcode::DeclareVarPlain),
|
|
97
|
+
1,
|
|
98
|
+
"only the captured param `p` gets a copy-in store; `q` stays slotted"
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// A self-recursive helper capturing the param: the helper NAME is captured (its own
|
|
103
|
+
/// body references it) and stays on the `DeclareVar` scope path, while the chunk itself
|
|
104
|
+
/// remains slot-based with the param copy-in.
|
|
105
|
+
#[test]
|
|
106
|
+
fn self_recursive_helper_keeps_scope_path_beside_copy_in() {
|
|
107
|
+
if slots_disabled() {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
let chunk = compile_src(
|
|
111
|
+
r#"
|
|
112
|
+
function outer(n) {
|
|
113
|
+
function fact(k) { if (k < 2) { return n } return k * fact(k - 1) }
|
|
114
|
+
return fact(4)
|
|
115
|
+
}
|
|
116
|
+
console.log(outer(1))
|
|
117
|
+
"#,
|
|
118
|
+
);
|
|
119
|
+
let outer = &chunk.nested[0];
|
|
120
|
+
assert!(outer.slot_based, "chunk should stay slot-based");
|
|
121
|
+
assert_eq!(
|
|
122
|
+
count_opcode(outer, Opcode::DeclareVarPlain),
|
|
123
|
+
1,
|
|
124
|
+
"the captured param `n` gets its copy-in"
|
|
125
|
+
);
|
|
126
|
+
assert_eq!(
|
|
127
|
+
count_opcode(outer, Opcode::DeclareVar),
|
|
128
|
+
1,
|
|
129
|
+
"the self-recursive helper name is captured and stays DeclareVar-bound"
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
//! #727: WHERE the default-parameter prologue stores its value in general slot mode.
|
|
2
|
+
//!
|
|
3
|
+
//! In a general-slot-mode chunk every read of a param resolves to `LoadLocal <slot>`, so
|
|
4
|
+
//! the applied default must be stored with `StoreLocal <slot>` — a `DeclareVarPlain`
|
|
5
|
+
//! writes it into the name-based scope map where no read will ever find it (the slot
|
|
6
|
+
//! keeps its missing-arg `Null`; `a + b` → NaN).
|
|
7
|
+
|
|
8
|
+
use tishlang_bytecode::{compile, Chunk, Opcode};
|
|
9
|
+
|
|
10
|
+
/// Count occurrences of `target` in `chunk.code`, walking real instruction boundaries.
|
|
11
|
+
fn count_opcode(chunk: &Chunk, target: Opcode) -> usize {
|
|
12
|
+
let code = &chunk.code;
|
|
13
|
+
let mut ip = 0usize;
|
|
14
|
+
let mut n = 0usize;
|
|
15
|
+
while ip < code.len() {
|
|
16
|
+
let op = Opcode::from_u8(code[ip]).expect("valid opcode");
|
|
17
|
+
if op == target {
|
|
18
|
+
n += 1;
|
|
19
|
+
}
|
|
20
|
+
ip += op.instruction_size(code, ip).expect("sized instruction");
|
|
21
|
+
}
|
|
22
|
+
n
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
fn compile_src(src: &str) -> Chunk {
|
|
26
|
+
let program = tishlang_parser::parse(src).expect("parse");
|
|
27
|
+
compile(&program).expect("compile")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// These tests assert slot-mode emission; running the suite with `TISH_VM_SLOTS=0`
|
|
31
|
+
/// deliberately exercises the legacy name-based path, where they do not apply.
|
|
32
|
+
fn slots_disabled() -> bool {
|
|
33
|
+
std::env::var("TISH_VM_SLOTS")
|
|
34
|
+
.map(|v| v == "0")
|
|
35
|
+
.unwrap_or(false)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#[test]
|
|
39
|
+
fn general_slot_mode_default_stores_to_slot() {
|
|
40
|
+
if slots_disabled() {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
let chunk = compile_src(
|
|
44
|
+
r#"
|
|
45
|
+
function f(a, b = 5) {
|
|
46
|
+
let c = a + b
|
|
47
|
+
return c
|
|
48
|
+
}
|
|
49
|
+
console.log(f(1))
|
|
50
|
+
"#,
|
|
51
|
+
);
|
|
52
|
+
let f = &chunk.nested[0];
|
|
53
|
+
assert!(f.slot_based, "f should compile in general slot mode");
|
|
54
|
+
assert_eq!(
|
|
55
|
+
count_opcode(f, Opcode::DeclareVarPlain),
|
|
56
|
+
0,
|
|
57
|
+
"the applied default must not be declared into the name-based scope map — \
|
|
58
|
+
reads of the param resolve to its frame slot (#727)"
|
|
59
|
+
);
|
|
60
|
+
assert!(
|
|
61
|
+
count_opcode(f, Opcode::StoreLocal) >= 1,
|
|
62
|
+
"the applied default should be stored to the param's frame slot"
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// The slot-DISQUALIFIED shape (captured param → name-based chunk) must keep the
|
|
67
|
+
/// `DeclareVarPlain` store: there is no slot, and closures read the param by name.
|
|
68
|
+
#[test]
|
|
69
|
+
fn name_based_chunk_default_still_declares_by_name() {
|
|
70
|
+
let chunk = compile_src(
|
|
71
|
+
r#"
|
|
72
|
+
function g(a, b = 5) {
|
|
73
|
+
function h() { return b }
|
|
74
|
+
let c = a + h()
|
|
75
|
+
return c
|
|
76
|
+
}
|
|
77
|
+
console.log(g(1))
|
|
78
|
+
"#,
|
|
79
|
+
);
|
|
80
|
+
let g = &chunk.nested[0];
|
|
81
|
+
assert_eq!(
|
|
82
|
+
count_opcode(g, Opcode::DeclareVarPlain),
|
|
83
|
+
1,
|
|
84
|
+
"captured-param chunk is name-based; its default must stay DeclareVarPlain-bound"
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -196,19 +196,46 @@ pub trait TishStruct {
|
|
|
196
196
|
|
|
197
197
|
/// Trait for Promise-like values that can be awaited (block until settled).
|
|
198
198
|
/// Implemented by the runtime for native compile; interpreter uses its own Promise.
|
|
199
|
-
|
|
199
|
+
///
|
|
200
|
+
/// (`'static` is part of the contract: promises are shared as `Arc<dyn TishPromise>`
|
|
201
|
+
/// inside `Value::Promise`, and settlement subscriptions outlive the subscribing frame.)
|
|
202
|
+
pub trait TishPromise: Send + Sync + 'static {
|
|
200
203
|
fn block_until_settled(&self) -> core::result::Result<Value, Value>;
|
|
201
204
|
/// Try to settle WITHOUT blocking. Returns `Some(result)` if the promise was already
|
|
202
205
|
/// settled before this call; returns `None` if it is still pending (a background thread
|
|
203
206
|
/// / I/O task has not completed yet). Default: always pending — implementors of async
|
|
204
|
-
/// promises (fetch
|
|
207
|
+
/// promises (fetch) leave this as `None`; settled/state-backed promises override it.
|
|
208
|
+
/// Must PEEK, not consume: a promise reporting `Some` here stays awaitable.
|
|
205
209
|
///
|
|
206
210
|
/// Used by `race`/`any`/`allSettled` to handle already-settled promises in input-order
|
|
207
|
-
/// (deterministic, JS-compatible) before
|
|
208
|
-
/// genuinely-pending ones.
|
|
211
|
+
/// (deterministic, JS-compatible) before subscribing to genuinely-pending ones.
|
|
209
212
|
fn try_settle(&self) -> Option<core::result::Result<Value, Value>> {
|
|
210
213
|
None
|
|
211
214
|
}
|
|
215
|
+
|
|
216
|
+
/// Register a callback to run exactly once when this promise settles (issue #702).
|
|
217
|
+
///
|
|
218
|
+
/// `Promise.race` / `Promise.any` / `Promise.allSettled` use this to wait on N
|
|
219
|
+
/// promises concurrently WITHOUT parking one OS thread per pending input: an
|
|
220
|
+
/// implementor that can store the callback (state-cell or channel-backed promises)
|
|
221
|
+
/// invokes it from whichever thread settles the promise, so the combinator's own
|
|
222
|
+
/// channel `recv` is the only blocking wait. A subscription that loses a race is
|
|
223
|
+
/// simply dropped after (or instead of) firing — no cancellation path is needed.
|
|
224
|
+
///
|
|
225
|
+
/// The default implementation is the legacy behavior — a detached waiter thread
|
|
226
|
+
/// blocking in [`Self::block_until_settled`] — so implementors that cannot register
|
|
227
|
+
/// callbacks keep working; they only miss the no-thread optimization until they
|
|
228
|
+
/// override this. Overrides MUST NOT block while the promise is pending.
|
|
229
|
+
#[cfg(feature = "send-values")]
|
|
230
|
+
fn subscribe(
|
|
231
|
+
self: Arc<Self>,
|
|
232
|
+
on_settled: Box<dyn FnOnce(core::result::Result<Value, Value>) + Send>,
|
|
233
|
+
) {
|
|
234
|
+
std::thread::spawn(move || {
|
|
235
|
+
let r = self.block_until_settled();
|
|
236
|
+
on_settled(r);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
212
239
|
}
|
|
213
240
|
|
|
214
241
|
/// JavaScript RegExp flags
|
|
@@ -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)]
|