@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.
@@ -15,20 +15,43 @@ pub struct StringResultPromise {
15
15
  pub(crate) rx: Mutex<Option<oneshot::Receiver<Result<String, String>>>>,
16
16
  }
17
17
 
18
+ impl StringResultPromise {
19
+ fn convert(
20
+ r: Result<Result<String, String>, oneshot::error::RecvError>,
21
+ ) -> std::result::Result<Value, Value> {
22
+ match r {
23
+ Ok(Ok(s)) => Ok(Value::String(s.into())),
24
+ Ok(Err(e)) => Err(error_value(e)),
25
+ Err(_) => Err(Value::String("Promise dropped".into())),
26
+ }
27
+ }
28
+ }
29
+
18
30
  impl TishPromise for StringResultPromise {
19
31
  fn block_until_settled(&self) -> std::result::Result<Value, Value> {
20
32
  let rx = self.rx.lock().unwrap().take();
21
33
  if let Some(rx) = rx {
22
- let result = crate::http::block_on_http(rx);
23
- match result {
24
- Ok(Ok(s)) => Ok(Value::String(s.into())),
25
- Ok(Err(e)) => Err(error_value(e)),
26
- Err(_) => Err(Value::String("Promise dropped".into())),
27
- }
34
+ Self::convert(crate::http::block_on_http(rx))
28
35
  } else {
29
36
  Err(Value::String("Promise already consumed".into()))
30
37
  }
31
38
  }
39
+
40
+ /// Await the oneshot on the shared subscriber runtime instead of parking a
41
+ /// blocking thread (plus a per-call tokio runtime) per raced input (issue #702).
42
+ fn subscribe(
43
+ self: Arc<Self>,
44
+ on_settled: Box<dyn FnOnce(std::result::Result<Value, Value>) + Send>,
45
+ ) {
46
+ match self.rx.lock().unwrap().take() {
47
+ Some(rx) => {
48
+ crate::http::subscriber_runtime().spawn(async move {
49
+ on_settled(Self::convert(rx.await));
50
+ });
51
+ }
52
+ None => on_settled(Err(Value::String("Promise already consumed".into()))),
53
+ }
54
+ }
32
55
  }
33
56
 
34
57
  pub fn string_result_promise(rx: oneshot::Receiver<Result<String, String>>) -> Value {
@@ -2350,10 +2350,11 @@ impl Vm {
2350
2350
  let names = &chunk.names;
2351
2351
 
2352
2352
  let mut ip = 0;
2353
- // Lazily allocated name-keyed scope. Slot-based chunks never WRITE it (params + body locals
2354
- // live in `slot_locals`; `StoreVar` checks-then-falls-through to globals; a slot-based chunk
2355
- // has no captured locals by construction), so on the hot slot-based call path we skip the
2356
- // `VmRef::new(Arc<Mutex<HashMap>>)` box entirely. Non-slot chunks need it eagerly for params.
2353
+ // Lazily allocated name-keyed scope. A slot-based chunk with no captured names never writes
2354
+ // it (params + body locals live in `slot_locals`), so the hot slot-based call path skips the
2355
+ // `VmRef::new(Arc<Mutex<HashMap>>)` box entirely; captured locals (`DeclareVar`) and captured
2356
+ // params (the #716 copy-in prologue's `DeclareVarPlain`) materialise it on first use.
2357
+ // Non-slot chunks need it eagerly for params.
2357
2358
  // `ls_get_or_init!()` lazily creates it on the first write/capture; reads treat `None` as empty.
2358
2359
  let mut local_scope: Option<ScopeMap> = if chunk.slot_based {
2359
2360
  None
@@ -0,0 +1,210 @@
1
+ //! #716 follow-up: functions whose PARAMS are captured by a nested closure were
2
+ //! disqualified from general slot mode entirely (`slot_analyze` returned `None`), so the
3
+ //! per-call frame-scope↔closure cycle from #716 persisted for them — the issue's exact
4
+ //! repro (`handleEvent(payload)` with a body helper reading `payload`) still leaked after
5
+ //! #728.
6
+ //!
7
+ //! The fix is a captured-param COPY-IN PROLOGUE: params land in slots 0..n as the VM's
8
+ //! calling convention requires, and each captured param is copied into `local_scope` at
9
+ //! entry (one `LoadLocal` + `DeclareVarPlain`). From then on the SCOPE is the single
10
+ //! source of truth for that param — every body read/write compiles name-based
11
+ //! (`LoadVar`/`StoreVar`), the very same map closures capture — so shared-cell mutation
12
+ //! semantics are preserved exactly. The chunk stays in slot mode, and #728's slot-binding
13
+ //! of the (uncaptured) helper NAME applies: no cycle, the frame drops on return.
14
+
15
+ use std::sync::Arc;
16
+ use tishlang_bytecode::compile;
17
+ use tishlang_core::{Value, VmRef};
18
+ use tishlang_vm::Vm;
19
+
20
+ fn run_src(src: &str) {
21
+ let program = tishlang_parser::parse(src).expect("parse");
22
+ let chunk = compile(&program).expect("compile");
23
+ tishlang_vm::run(&chunk).expect("run");
24
+ }
25
+
26
+ /// These tests pin slot-mode behavior; a suite run with `TISH_VM_SLOTS=0` deliberately
27
+ /// exercises the legacy name-based path (which still has the #716 cycle).
28
+ fn slots_disabled() -> bool {
29
+ std::env::var("TISH_VM_SLOTS")
30
+ .map(|v| v == "0")
31
+ .unwrap_or(false)
32
+ }
33
+
34
+ /// The leak regression — the #716 repro shape verbatim (helper captures the enclosing
35
+ /// PARAM): N calls must not retain the N frames. The probe is the payload argument, so
36
+ /// each leaked frame would hold one strong ref via its `local_scope` (`payload` →
37
+ /// probe). Pre-fix the chunk is name-based and `fmt`'s `DeclareVar` completes the
38
+ /// scope↔closure cycle: `VmRef::strong_count(&probe)` ends at `2 + N`.
39
+ #[test]
40
+ fn captured_param_frames_are_reclaimed() {
41
+ if slots_disabled() {
42
+ return;
43
+ }
44
+ let probe: VmRef<Vec<Value>> = VmRef::new(vec![Value::Number(1.0)]);
45
+ let mut vm = Vm::new();
46
+ vm.set_global(Arc::from("probe"), Value::Array(probe.clone()));
47
+ let src = r#"
48
+ function handleEvent(payload) {
49
+ function fmt() { return payload.length }
50
+ return fmt()
51
+ }
52
+ let i = 0
53
+ let acc = 0
54
+ while (i < 100) { acc = acc + handleEvent(probe); i = i + 1 }
55
+ if (acc !== 100) { throw "bad acc: " + acc }
56
+ "#;
57
+ let program = tishlang_parser::parse(src).expect("parse");
58
+ let chunk = compile(&program).expect("compile");
59
+ vm.run(&chunk).expect("run");
60
+ // Exactly two live references: this test's `probe` clone + the VM globals entry.
61
+ assert_eq!(
62
+ VmRef::strong_count(&probe),
63
+ 2,
64
+ "call frames retained the probe — the captured-param variant of the #716 \
65
+ frame-scope↔closure cycle is still leaking"
66
+ );
67
+ }
68
+
69
+ /// MUTATION SEMANTICS, direction 1: the body reassigns the param, then a closure reads
70
+ /// it. The scope must be the single source of truth (a stale copy would return 1).
71
+ #[test]
72
+ fn closure_reads_param_after_body_reassigns() {
73
+ run_src(
74
+ r#"
75
+ function outer(p) {
76
+ function get() { return p }
77
+ p = p + 1
78
+ let r = get()
79
+ return r
80
+ }
81
+ if (outer(1) !== 2) { throw "closure saw a stale param copy: " + outer(1) }
82
+ "#,
83
+ );
84
+ }
85
+
86
+ /// MUTATION SEMANTICS, direction 2: a closure reassigns the param (via a call), then the
87
+ /// body reads it. The body read must route to the shared scope (a slot read would see 1).
88
+ #[test]
89
+ fn body_reads_param_after_closure_reassigns() {
90
+ run_src(
91
+ r#"
92
+ function outer(p) {
93
+ function bump() { p = p + 10 }
94
+ bump()
95
+ let r = p
96
+ return r
97
+ }
98
+ if (outer(1) !== 11) { throw "body saw a stale param slot: " + outer(1) }
99
+ "#,
100
+ );
101
+ }
102
+
103
+ /// Both directions interleaved, plus a second UNCAPTURED param that must stay slotted.
104
+ #[test]
105
+ fn interleaved_mutation_and_mixed_params() {
106
+ run_src(
107
+ r#"
108
+ function outer(p, q) {
109
+ function bump() { p = p + 1 }
110
+ p = p * 2
111
+ bump()
112
+ let r = p + q
113
+ return r
114
+ }
115
+ if (outer(3, 10) !== 17) { throw "interleaved captured-param mutation broken: " + outer(3, 10) }
116
+ "#,
117
+ );
118
+ }
119
+
120
+ /// An ESCAPING closure keeps the captured param (including a body reassignment made
121
+ /// before the escape) alive after the frame returns.
122
+ #[test]
123
+ fn escaping_closure_keeps_reassigned_param() {
124
+ run_src(
125
+ r#"
126
+ function mk(p) {
127
+ function get() { return p }
128
+ p = p * 2
129
+ return get
130
+ }
131
+ let g = mk(4)
132
+ if (g() !== 8) { throw "escaping closure lost the reassigned param: " + g() }
133
+ "#,
134
+ );
135
+ }
136
+
137
+ /// A DEFAULT on a captured param: the copy-in must run before the defaults prologue so
138
+ /// the applied default (stored name-based) is not clobbered by the missing-arg null.
139
+ #[test]
140
+ fn captured_param_default_applies() {
141
+ run_src(
142
+ r#"
143
+ function withDefault(a, p = 5) {
144
+ function get() { return p }
145
+ let r = a + get()
146
+ return r
147
+ }
148
+ if (withDefault(1) !== 6) { throw "captured-param default lost: " + withDefault(1) }
149
+ if (withDefault(1, 2) !== 3) { throw "captured-param supplied arg broken: " + withDefault(1, 2) }
150
+ "#,
151
+ );
152
+ }
153
+
154
+ /// Recursion: every level gets a fresh frame scope; each closure sees its own level's
155
+ /// param.
156
+ #[test]
157
+ fn recursion_with_captured_param() {
158
+ run_src(
159
+ r#"
160
+ function recur(n) {
161
+ function dec() { return n - 1 }
162
+ if (n === 0) { return 0 }
163
+ let x = recur(dec())
164
+ return x + n
165
+ }
166
+ if (recur(3) !== 6) { throw "recursion with captured param broken: " + recur(3) }
167
+ "#,
168
+ );
169
+ }
170
+
171
+ /// A MISSING captured param reads null — matching the interpreter (and slot mode's
172
+ /// existing missing-arg behavior). The legacy name-based VM chunk left the name unbound
173
+ /// ("Undefined variable"), a pre-existing interp divergence this shape no longer hits.
174
+ #[test]
175
+ fn missing_captured_param_is_null() {
176
+ if slots_disabled() {
177
+ return;
178
+ }
179
+ run_src(
180
+ r#"
181
+ function m(a, b) {
182
+ function g() { return b === null }
183
+ let r = g()
184
+ return r
185
+ }
186
+ if (m(1) !== true) { throw "missing captured param not bound to null" }
187
+ "#,
188
+ );
189
+ }
190
+
191
+ /// A block-scoped `let` shadowing the captured param: the shadow is undone at block
192
+ /// exit and the param's (copied-in) binding is restored.
193
+ #[test]
194
+ fn block_shadow_of_captured_param_restores() {
195
+ run_src(
196
+ r#"
197
+ function outer(p) {
198
+ function get() { return p }
199
+ let inner = 0
200
+ {
201
+ let p = 100
202
+ inner = p
203
+ }
204
+ let r = get() + inner
205
+ return r
206
+ }
207
+ if (outer(7) !== 107) { throw "block shadow of captured param broken: " + outer(7) }
208
+ "#,
209
+ );
210
+ }
@@ -0,0 +1,64 @@
1
+ //! #716 follow-up parity guard: the captured-param mutation-semantics shapes must behave
2
+ //! identically with `TISH_VM_SLOTS=0` (the legacy name-based path). This file holds
3
+ //! exactly ONE test so the process-global env var cannot race a sibling test — cargo
4
+ //! builds each file in `tests/` as its own binary/process.
5
+ //!
6
+ //! (The missing-arg shape is deliberately absent: the legacy name-based path leaves a
7
+ //! missing param unbound — "Undefined variable" — a pre-existing divergence from the
8
+ //! interpreter that slot mode does not share.)
9
+
10
+ use tishlang_bytecode::compile;
11
+
12
+ fn run_src(src: &str) {
13
+ let program = tishlang_parser::parse(src).expect("parse");
14
+ let chunk = compile(&program).expect("compile");
15
+ tishlang_vm::run(&chunk).expect("run");
16
+ }
17
+
18
+ #[test]
19
+ fn captured_param_semantics_identical_with_slots_off() {
20
+ std::env::set_var("TISH_VM_SLOTS", "0");
21
+ run_src(
22
+ r#"
23
+ function outer(p) {
24
+ function get() { return p }
25
+ p = p + 1
26
+ let r = get()
27
+ return r
28
+ }
29
+ if (outer(1) !== 2) { throw "slots-off: closure saw stale param: " + outer(1) }
30
+
31
+ function outer2(p) {
32
+ function bump() { p = p + 10 }
33
+ bump()
34
+ let r = p
35
+ return r
36
+ }
37
+ if (outer2(1) !== 11) { throw "slots-off: body saw stale param: " + outer2(1) }
38
+
39
+ function mk(p) {
40
+ function get() { return p }
41
+ p = p * 2
42
+ return get
43
+ }
44
+ let g = mk(4)
45
+ if (g() !== 8) { throw "slots-off: escaping closure lost reassigned param: " + g() }
46
+
47
+ function withDefault(a, p = 5) {
48
+ function get() { return p }
49
+ let r = a + get()
50
+ return r
51
+ }
52
+ if (withDefault(1) !== 6) { throw "slots-off: captured-param default lost" }
53
+ if (withDefault(1, 2) !== 3) { throw "slots-off: captured-param supplied arg broken" }
54
+
55
+ function recur(n) {
56
+ function dec() { return n - 1 }
57
+ if (n === 0) { return 0 }
58
+ let x = recur(dec())
59
+ return x + n
60
+ }
61
+ if (recur(3) !== 6) { throw "slots-off: recursion with captured param broken" }
62
+ "#,
63
+ );
64
+ }
@@ -0,0 +1,126 @@
1
+ //! #727: VM general slot mode dropped simple-param DEFAULT values.
2
+ //!
3
+ //! `function f(a, b = 5) { let c = a + b; return c }` puts the chunk in GENERAL slot
4
+ //! mode (`let c` breaks the param-only fast path; nothing is captured, so `slot_analyze`
5
+ //! accepts). Params are slot-bound, and every READ of `b` resolves to `LoadLocal <slot>`
6
+ //! — but `emit_param_defaults_prologue` consulted only `slot_ctx` (the simple param-only
7
+ //! map, `None` here) and wrote the applied default via `DeclareVarPlain` into the
8
+ //! name-based scope map. The default was invisible: `b` read the uninitialized slot's
9
+ //! `Null`, so `f(1)` computed `1 + null` → NaN.
10
+ //!
11
+ //! These are behavior tests — they must hold identically with `TISH_VM_SLOTS=0` (the
12
+ //! name-based legacy path, correct all along) and with slots on (default).
13
+
14
+ use tishlang_bytecode::compile;
15
+
16
+ fn run_src(src: &str) {
17
+ let program = tishlang_parser::parse(src).expect("parse");
18
+ let chunk = compile(&program).expect("compile");
19
+ tishlang_vm::run(&chunk).expect("run");
20
+ }
21
+
22
+ /// The issue repro: a missing arg must get its default in general slot mode.
23
+ #[test]
24
+ fn general_slot_mode_missing_arg_gets_default() {
25
+ run_src(
26
+ r#"
27
+ function f(a, b = 5) {
28
+ let c = a + b
29
+ return c
30
+ }
31
+ if (f(1) !== 6) { throw "default lost in general slot mode: f(1) = " + f(1) }
32
+ "#,
33
+ );
34
+ }
35
+
36
+ /// A supplied arg must win over the default (and stay slot-read correct).
37
+ #[test]
38
+ fn general_slot_mode_supplied_arg_wins() {
39
+ run_src(
40
+ r#"
41
+ function f(a, b = 5) {
42
+ let c = a + b
43
+ return c
44
+ }
45
+ if (f(1, 2) !== 3) { throw "supplied arg broken: f(1, 2) = " + f(1, 2) }
46
+ "#,
47
+ );
48
+ }
49
+
50
+ /// A default referencing an EARLIER param evaluates against that param's slot and must
51
+ /// land in this param's slot.
52
+ #[test]
53
+ fn general_slot_mode_default_references_earlier_param() {
54
+ run_src(
55
+ r#"
56
+ function dep(a, b = a + 1) {
57
+ let c = a + b
58
+ return c
59
+ }
60
+ if (dep(3) !== 7) { throw "dependent default lost: dep(3) = " + dep(3) }
61
+ if (dep(3, 10) !== 13) { throw "dependent default should not fire: dep(3, 10) = " + dep(3, 10) }
62
+ "#,
63
+ );
64
+ }
65
+
66
+ /// A chain of dependent defaults: each later default sees the earlier ones' results.
67
+ #[test]
68
+ fn general_slot_mode_chained_dependent_defaults() {
69
+ run_src(
70
+ r#"
71
+ function chain(a, b = a * 2, c = b + a) {
72
+ let out = [a, b, c]
73
+ return out
74
+ }
75
+ let r = chain(3)
76
+ if (r[0] !== 3 || r[1] !== 6 || r[2] !== 9) { throw "chained defaults broken: " + r }
77
+ let r2 = chain(3, 10)
78
+ if (r2[1] !== 10 || r2[2] !== 13) { throw "chained defaults (partial) broken: " + r2 }
79
+ "#,
80
+ );
81
+ }
82
+
83
+ /// A fn whose PARAM is captured is disqualified from slot mode entirely — the name-based
84
+ /// `DeclareVarPlain` path was always correct and must stay so.
85
+ #[test]
86
+ fn slot_disqualified_captured_param_default_unchanged() {
87
+ run_src(
88
+ r#"
89
+ function g(a, b = 5) {
90
+ function h() { return b }
91
+ let c = a + h()
92
+ return c
93
+ }
94
+ if (g(1) !== 6) { throw "name-based default broken: g(1) = " + g(1) }
95
+ if (g(1, 2) !== 3) { throw "name-based supplied arg broken: g(1, 2) = " + g(1, 2) }
96
+ "#,
97
+ );
98
+ }
99
+
100
+ /// The simple param-only fast path (`slot_ctx`) was always correct and must stay so.
101
+ #[test]
102
+ fn simple_param_only_fast_path_default_unchanged() {
103
+ run_src(
104
+ r#"
105
+ function f(a, b = 5) { return a + b }
106
+ if (f(1) !== 6) { throw "param-only default broken: f(1) = " + f(1) }
107
+ if (f(1, 2) !== 3) { throw "param-only supplied arg broken: f(1, 2) = " + f(1, 2) }
108
+ "#,
109
+ );
110
+ }
111
+
112
+ /// An explicit `null` is NOT a missing arg: the default must not apply (tish has no
113
+ /// `undefined`; `ArgMissing(i)` is true iff `i >= argc`). Same in both slot modes.
114
+ #[test]
115
+ fn explicit_null_is_not_missing() {
116
+ run_src(
117
+ r#"
118
+ function f(a, b = 5) {
119
+ let isNull = b === null
120
+ return isNull
121
+ }
122
+ if (f(1, null) !== true) { throw "explicit null was replaced by the default" }
123
+ if (f(1) !== false) { throw "missing arg did not get the default" }
124
+ "#,
125
+ );
126
+ }
@@ -0,0 +1,44 @@
1
+ //! #727 parity guard: the same default-parameter shapes must behave identically with
2
+ //! `TISH_VM_SLOTS=0` (the legacy name-based path). This file holds exactly ONE test so
3
+ //! the process-global env var cannot race a sibling test — cargo builds each file in
4
+ //! `tests/` as its own binary/process.
5
+
6
+ use tishlang_bytecode::compile;
7
+
8
+ fn run_src(src: &str) {
9
+ let program = tishlang_parser::parse(src).expect("parse");
10
+ let chunk = compile(&program).expect("compile");
11
+ tishlang_vm::run(&chunk).expect("run");
12
+ }
13
+
14
+ #[test]
15
+ fn param_defaults_behave_identically_with_slots_off() {
16
+ std::env::set_var("TISH_VM_SLOTS", "0");
17
+ run_src(
18
+ r#"
19
+ function f(a, b = 5) {
20
+ let c = a + b
21
+ return c
22
+ }
23
+ if (f(1) !== 6) { throw "slots-off default broken: f(1) = " + f(1) }
24
+ if (f(1, 2) !== 3) { throw "slots-off supplied arg broken: f(1, 2) = " + f(1, 2) }
25
+
26
+ function dep(a, b = a + 1) {
27
+ let c = a + b
28
+ return c
29
+ }
30
+ if (dep(3) !== 7) { throw "slots-off dependent default broken: dep(3) = " + dep(3) }
31
+ if (dep(3, 10) !== 13) { throw "slots-off dependent supplied broken" }
32
+
33
+ function g(a, b = 5) {
34
+ function h() { return b }
35
+ let c = a + h()
36
+ return c
37
+ }
38
+ if (g(1) !== 6) { throw "slots-off captured-param default broken: g(1) = " + g(1) }
39
+
40
+ function po(a, b = 5) { return a + b }
41
+ if (po(1) !== 6) { throw "slots-off param-only default broken" }
42
+ "#,
43
+ );
44
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tishlang/tish-format",
3
- "version": "3.10.9",
3
+ "version": "3.10.11",
4
4
  "description": "Tish code formatter",
5
5
  "license": "PIF",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
Binary file