@tishlang/tish-lint 3.10.4 → 3.10.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tish-lint +0 -0
- package/crates/tish/src/main.rs +65 -25
- 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 +22 -6
- package/crates/tish_bytecode/tests/fundecl_slot_binding.rs +115 -0
- package/crates/tish_core/src/shape.rs +55 -2
- package/crates/tish_core/src/value.rs +198 -9
- 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_lint/Cargo.toml +1 -1
- package/crates/tish_lsp/src/main.rs +91 -2
- 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/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 +3 -1
- package/crates/tish_vm/tests/fundecl_slot_no_frame_cycle.rs +152 -0
- package/package.json +1 -1
- package/platform/darwin-arm64/tish-lint +0 -0
- package/platform/darwin-x64/tish-lint +0 -0
- package/platform/linux-arm64/tish-lint +0 -0
- package/platform/linux-x64/tish-lint +0 -0
- package/platform/win32-x64/tish-lint.exe +0 -0
package/bin/tish-lint
CHANGED
|
Binary file
|
package/crates/tish/src/main.rs
CHANGED
|
@@ -57,13 +57,17 @@ fn vm_capabilities_for_cli_run(cli_features: &[String]) -> HashSet<String> {
|
|
|
57
57
|
/// `--feature` list for `tish build --target native`: same default as `tish run` (all linked-in caps).
|
|
58
58
|
fn native_build_features_from_cli(cli_features: &[String]) -> Vec<String> {
|
|
59
59
|
let mut v: Vec<String> = if cli_features.is_empty() {
|
|
60
|
-
tishlang_vm::all_compiled_capabilities()
|
|
60
|
+
tishlang_vm::all_compiled_capabilities()
|
|
61
|
+
.into_iter()
|
|
62
|
+
.collect()
|
|
61
63
|
} else {
|
|
62
64
|
// Normalize exactly like `tish run`: split comma-separated values (`--feature http,timers`)
|
|
63
65
|
// and expand `full`. Without this, a value like "http,timers,process" was treated as one
|
|
64
66
|
// opaque feature name, so `has_feature("http")` was false and the http runtime imports were
|
|
65
67
|
// never emitted — async `fetch` then failed to compile (issue #209).
|
|
66
|
-
normalize_capability_flags(cli_features)
|
|
68
|
+
normalize_capability_flags(cli_features)
|
|
69
|
+
.into_iter()
|
|
70
|
+
.collect()
|
|
67
71
|
};
|
|
68
72
|
v.sort();
|
|
69
73
|
v
|
|
@@ -73,8 +77,15 @@ fn native_build_features_from_cli(cli_features: &[String]) -> Vec<String> {
|
|
|
73
77
|
fn argv_with_implicit_run(mut argv: Vec<String>) -> Vec<String> {
|
|
74
78
|
if argv.len() >= 2 {
|
|
75
79
|
let first = argv[1].as_str();
|
|
76
|
-
const SUBCOMMANDS: &[&str] =
|
|
77
|
-
|
|
80
|
+
const SUBCOMMANDS: &[&str] = &[
|
|
81
|
+
"run",
|
|
82
|
+
"repl",
|
|
83
|
+
"test",
|
|
84
|
+
"build",
|
|
85
|
+
"compile-module",
|
|
86
|
+
"resolve-id",
|
|
87
|
+
"dump-ast",
|
|
88
|
+
];
|
|
78
89
|
let looks_like_file = !first.starts_with('-') && !SUBCOMMANDS.contains(&first);
|
|
79
90
|
if looks_like_file {
|
|
80
91
|
argv.insert(1, "run".to_string());
|
|
@@ -112,19 +123,26 @@ fn main() {
|
|
|
112
123
|
// Expose a node-shaped `process.argv` to the script: `[tish-exe, <file>, args...]`,
|
|
113
124
|
// so `tish run main.tish a b` gives the script `["…/tish", "main.tish", "a", "b"]`
|
|
114
125
|
// (not the `run` subcommand). Interp/VM read this via `tishlang_core::process_argv`. #88
|
|
115
|
-
let exe = std::env::args()
|
|
126
|
+
let exe = std::env::args()
|
|
127
|
+
.next()
|
|
128
|
+
.unwrap_or_else(|| "tish".to_string());
|
|
116
129
|
let mut argv = Vec::with_capacity(a.script_args.len() + 2);
|
|
117
130
|
argv.push(exe);
|
|
118
131
|
argv.push(a.file.clone());
|
|
119
132
|
argv.extend(a.script_args.iter().cloned());
|
|
120
133
|
tishlang_core::set_process_argv(argv);
|
|
121
|
-
run_file(
|
|
134
|
+
run_file(
|
|
135
|
+
&a.file,
|
|
136
|
+
&a.backend,
|
|
137
|
+
&a.features,
|
|
138
|
+
a.no_optimize || no_opt_env,
|
|
139
|
+
)
|
|
122
140
|
}
|
|
123
141
|
Some(Commands::Test(a)) => {
|
|
124
142
|
#[cfg(feature = "test-runner")]
|
|
125
143
|
{
|
|
126
|
-
use tishlang_test::{run_tests, TestOptions};
|
|
127
144
|
use tishlang_test::report::ReporterKind;
|
|
145
|
+
use tishlang_test::{run_tests, TestOptions};
|
|
128
146
|
|
|
129
147
|
let reporter = match a.reporter.as_str() {
|
|
130
148
|
"dots" => ReporterKind::Dots,
|
|
@@ -169,7 +187,9 @@ fn main() {
|
|
|
169
187
|
} else {
|
|
170
188
|
"`--backend native` is not implemented yet"
|
|
171
189
|
};
|
|
172
|
-
eprintln!(
|
|
190
|
+
eprintln!(
|
|
191
|
+
"error: unsupported --backend `{backend}` for tish test (use `vm`); {hint}"
|
|
192
|
+
);
|
|
173
193
|
std::process::exit(2);
|
|
174
194
|
}
|
|
175
195
|
let opts = TestOptions {
|
|
@@ -219,10 +239,9 @@ fn main() {
|
|
|
219
239
|
if let Some(mode) = &a.check {
|
|
220
240
|
std::env::set_var("TISH_CHECK", mode);
|
|
221
241
|
}
|
|
222
|
-
if let Err(e) =
|
|
223
|
-
a.platform.as_deref(),
|
|
224
|
-
|
|
225
|
-
) {
|
|
242
|
+
if let Err(e) =
|
|
243
|
+
tishlang_compile::apply_resolve_env(a.platform.as_deref(), a.surface.as_deref())
|
|
244
|
+
{
|
|
226
245
|
eprintln!("Error: {}", e);
|
|
227
246
|
std::process::exit(1);
|
|
228
247
|
}
|
|
@@ -241,10 +260,9 @@ fn main() {
|
|
|
241
260
|
)
|
|
242
261
|
}
|
|
243
262
|
Some(Commands::CompileModule(a)) => {
|
|
244
|
-
if let Err(e) =
|
|
245
|
-
a.platform.as_deref(),
|
|
246
|
-
|
|
247
|
-
) {
|
|
263
|
+
if let Err(e) =
|
|
264
|
+
tishlang_compile::apply_resolve_env(a.platform.as_deref(), a.surface.as_deref())
|
|
265
|
+
{
|
|
248
266
|
eprintln!("Error: {}", e);
|
|
249
267
|
std::process::exit(1);
|
|
250
268
|
}
|
|
@@ -371,7 +389,14 @@ fn run_file(
|
|
|
371
389
|
.parent()
|
|
372
390
|
.map(|p| p.to_path_buf())
|
|
373
391
|
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
|
374
|
-
run_program(
|
|
392
|
+
run_program(
|
|
393
|
+
&program,
|
|
394
|
+
&ffi_base,
|
|
395
|
+
backend,
|
|
396
|
+
no_optimize,
|
|
397
|
+
features,
|
|
398
|
+
Some(path),
|
|
399
|
+
)
|
|
375
400
|
}
|
|
376
401
|
|
|
377
402
|
/// Load every `ffi:<path>` cdylib the program imports, resolving each path relative to `base_dir`
|
|
@@ -382,6 +407,8 @@ fn load_ffi_modules(
|
|
|
382
407
|
base_dir: &Path,
|
|
383
408
|
) -> Result<Vec<(String, tishlang_core::ObjectMap)>, String> {
|
|
384
409
|
let mut out = Vec::new();
|
|
410
|
+
// `ffi_native_specs` yields each spec once (deduplicated in first-seen order); distinct
|
|
411
|
+
// spellings of the same library collapse inside `load_module`'s resolved-path cache (#711).
|
|
385
412
|
for spec in tishlang_compile::ffi_native_specs(program) {
|
|
386
413
|
let rel = spec.strip_prefix("ffi:").unwrap_or(spec.as_str());
|
|
387
414
|
let lib_path = base_dir.join(rel);
|
|
@@ -804,17 +831,27 @@ fn compile_to_js_esm(
|
|
|
804
831
|
.into(),
|
|
805
832
|
);
|
|
806
833
|
}
|
|
807
|
-
let modules =
|
|
808
|
-
|
|
809
|
-
|
|
834
|
+
let modules = tishlang_compile_js::compile_project_esm(
|
|
835
|
+
input_path,
|
|
836
|
+
project_root,
|
|
837
|
+
optimize,
|
|
838
|
+
jsx_import_source,
|
|
839
|
+
)
|
|
840
|
+
.map_err(|e| format!("{}", e))?;
|
|
810
841
|
let out_dir = Path::new(output_path);
|
|
811
|
-
fs::create_dir_all(out_dir)
|
|
812
|
-
|
|
842
|
+
fs::create_dir_all(out_dir).map_err(|e| {
|
|
843
|
+
format!(
|
|
844
|
+
"Cannot create output directory {}: {}",
|
|
845
|
+
out_dir.display(),
|
|
846
|
+
e
|
|
847
|
+
)
|
|
848
|
+
})?;
|
|
813
849
|
for module in &modules {
|
|
814
850
|
let out = out_dir.join(&module.relative_path);
|
|
815
851
|
if let Some(parent) = out.parent() {
|
|
816
|
-
fs::create_dir_all(parent)
|
|
817
|
-
|
|
852
|
+
fs::create_dir_all(parent).map_err(|e| {
|
|
853
|
+
format!("Cannot create output directory {}: {}", parent.display(), e)
|
|
854
|
+
})?;
|
|
818
855
|
}
|
|
819
856
|
fs::write(&out, &module.js)
|
|
820
857
|
.map_err(|e| format!("Cannot write {}: {}", out.display(), e))?;
|
|
@@ -1195,7 +1232,10 @@ mod cli_tests {
|
|
|
1195
1232
|
// `full` expands to every capability, matching `tish run`.
|
|
1196
1233
|
let full = native_build_features_from_cli(&["full".to_string()]);
|
|
1197
1234
|
for cap in ["http", "timers", "fs", "process", "regex", "ws", "tty"] {
|
|
1198
|
-
assert!(
|
|
1235
|
+
assert!(
|
|
1236
|
+
full.iter().any(|s| s == cap),
|
|
1237
|
+
"`full` missing `{cap}` in {full:?}"
|
|
1238
|
+
);
|
|
1199
1239
|
}
|
|
1200
1240
|
}
|
|
1201
1241
|
|
|
@@ -436,9 +436,9 @@ pub fn object_assign(args: &[Value]) -> Value {
|
|
|
436
436
|
};
|
|
437
437
|
if let Value::Object(src) = source {
|
|
438
438
|
let src_borrow = src.borrow();
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
439
|
+
// merge_from propagates dictionary-ness: copying from a DICT_SHAPE source (whose
|
|
440
|
+
// stored key order is not meaningful) must not re-mint tracked shape chains (#708).
|
|
441
|
+
target_mut.strings.merge_from(&src_borrow.strings);
|
|
442
442
|
if let Some(ss) = &src_borrow.symbols {
|
|
443
443
|
if target_mut.symbols.is_none() {
|
|
444
444
|
target_mut.symbols = Some(Default::default());
|
|
@@ -2,7 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
#[cfg(feature = "portable")]
|
|
4
4
|
#[allow(unused_imports)]
|
|
5
|
-
use alloc::{
|
|
5
|
+
use alloc::{
|
|
6
|
+
borrow::ToOwned,
|
|
7
|
+
boxed::Box,
|
|
8
|
+
format,
|
|
9
|
+
string::{String, ToString},
|
|
10
|
+
vec,
|
|
11
|
+
vec::Vec,
|
|
12
|
+
};
|
|
6
13
|
|
|
7
14
|
use tishlang_core::sync::{Mutex, OnceLock};
|
|
8
15
|
use tishlang_core::{alloc_symbol_id, AHashMap, Arc, ObjectMap, TishSymbol, Value};
|
|
@@ -33,7 +40,9 @@ fn symbol_new(args: &[Value]) -> Value {
|
|
|
33
40
|
if matches!(v, Value::Null) {
|
|
34
41
|
None
|
|
35
42
|
} else {
|
|
36
|
-
|
|
43
|
+
// ECMAScript ToString of the description (`Symbol({})` → "[object Object]"), not the
|
|
44
|
+
// console-inspect form (#715). Conformance-only here: unique symbols are refcounted.
|
|
45
|
+
Some(v.to_js_string().into())
|
|
37
46
|
}
|
|
38
47
|
});
|
|
39
48
|
Value::Symbol(TishSymbol::new_unique(desc))
|
|
@@ -54,10 +63,16 @@ fn symbol_key_for_impl(args: &[Value]) -> Value {
|
|
|
54
63
|
pub fn symbol_object() -> Value {
|
|
55
64
|
let call = Value::native(symbol_new);
|
|
56
65
|
let for_fn = Value::native(|args: &[Value]| {
|
|
66
|
+
// The registry key is the ECMAScript ToString of the argument (spec: `Symbol.for`
|
|
67
|
+
// coerces with ToString), NOT the console-inspect form (#715): every object keys the
|
|
68
|
+
// single "[object Object]" entry and an array keys its comma-joined form, exactly as
|
|
69
|
+
// in JS. The inspect form turned the immortal registry's program-text-bounded key set
|
|
70
|
+
// into a data-driven one — `Symbol.for({id: n})` interned one permanent entry per
|
|
71
|
+
// distinct rendered object. A missing argument keys "undefined" (ToString(undefined)).
|
|
57
72
|
let key = args
|
|
58
73
|
.first()
|
|
59
|
-
.map(|v| v.
|
|
60
|
-
.
|
|
74
|
+
.map(|v| v.to_js_string())
|
|
75
|
+
.unwrap_or_else(|| "undefined".to_string());
|
|
61
76
|
symbol_for_impl(&key)
|
|
62
77
|
});
|
|
63
78
|
let key_for = Value::native(symbol_key_for_impl);
|
|
@@ -80,4 +95,71 @@ mod tests {
|
|
|
80
95
|
let r = value_call(&o, &[Value::String("hi".into())]);
|
|
81
96
|
assert!(matches!(r, Value::Symbol(_)));
|
|
82
97
|
}
|
|
98
|
+
|
|
99
|
+
fn member(o: &Value, name: &str) -> Value {
|
|
100
|
+
match o {
|
|
101
|
+
Value::Object(obj) => obj
|
|
102
|
+
.borrow()
|
|
103
|
+
.strings
|
|
104
|
+
.get(name)
|
|
105
|
+
.cloned()
|
|
106
|
+
.unwrap_or_else(|| panic!("missing member {name}")),
|
|
107
|
+
other => panic!("expected object, got {other:?}"),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
fn as_symbol(v: Value) -> Arc<TishSymbol> {
|
|
112
|
+
match v {
|
|
113
|
+
Value::Symbol(s) => s,
|
|
114
|
+
other => panic!("expected symbol, got {other:?}"),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
fn test_object(n: f64) -> Value {
|
|
119
|
+
let mut m = ObjectMap::default();
|
|
120
|
+
m.insert(Arc::from("id"), Value::Number(n));
|
|
121
|
+
Value::object(m)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// #715: the registry keys on ToString, so two DISTINCT objects intern the SAME entry
|
|
125
|
+
// ("[object Object]") — previously each distinct inspect string ("{id: 1}", "{id: 2}", …)
|
|
126
|
+
// interned one immortal registry entry per call.
|
|
127
|
+
#[test]
|
|
128
|
+
fn symbol_for_keys_objects_by_tostring() {
|
|
129
|
+
let for_fn = member(&symbol_object(), "for");
|
|
130
|
+
let a = as_symbol(value_call(&for_fn, &[test_object(1.0)]));
|
|
131
|
+
let b = as_symbol(value_call(&for_fn, &[test_object(2.0)]));
|
|
132
|
+
assert!(
|
|
133
|
+
Arc::ptr_eq(&a, &b),
|
|
134
|
+
"distinct objects must intern one shared '[object Object]' registry entry"
|
|
135
|
+
);
|
|
136
|
+
assert_eq!(a.registry_key.as_deref(), Some("[object Object]"));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// String and number args keep their (spec-shaped) keys; ToString makes Symbol.for(1) and
|
|
140
|
+
// Symbol.for("1") the same symbol, as in JS.
|
|
141
|
+
#[test]
|
|
142
|
+
fn symbol_for_string_and_number_keys_unchanged() {
|
|
143
|
+
let for_fn = member(&symbol_object(), "for");
|
|
144
|
+
let s1 = as_symbol(value_call(&for_fn, &[Value::String("sym.key.715".into())]));
|
|
145
|
+
let s2 = as_symbol(value_call(&for_fn, &[Value::String("sym.key.715".into())]));
|
|
146
|
+
assert!(Arc::ptr_eq(&s1, &s2));
|
|
147
|
+
assert_eq!(s1.registry_key.as_deref(), Some("sym.key.715"));
|
|
148
|
+
|
|
149
|
+
let n = as_symbol(value_call(&for_fn, &[Value::Number(1.0)]));
|
|
150
|
+
let n_str = as_symbol(value_call(&for_fn, &[Value::String("1".into())]));
|
|
151
|
+
assert_eq!(n.registry_key.as_deref(), Some("1"));
|
|
152
|
+
assert!(
|
|
153
|
+
Arc::ptr_eq(&n, &n_str),
|
|
154
|
+
"Symbol.for(1) === Symbol.for(\"1\")"
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A missing argument keys "undefined" (ToString(undefined)), not "".
|
|
159
|
+
#[test]
|
|
160
|
+
fn symbol_for_missing_arg_keys_undefined() {
|
|
161
|
+
let for_fn = member(&symbol_object(), "for");
|
|
162
|
+
let s = as_symbol(value_call(&for_fn, &[]));
|
|
163
|
+
assert_eq!(s.registry_key.as_deref(), Some("undefined"));
|
|
164
|
+
}
|
|
83
165
|
}
|
|
@@ -2056,12 +2056,28 @@ impl<'a> Compiler<'a> {
|
|
|
2056
2056
|
self.emit(Opcode::LoadConst);
|
|
2057
2057
|
let idx = self.constant_idx(Constant::Closure(nested_idx));
|
|
2058
2058
|
self.chunk.write_u16(idx);
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2059
|
+
// #716: bind the function's NAME like any other local. `DeclareVar` inserts the
|
|
2060
|
+
// closure into the very `local_scope` map the closure just captured (vm.rs
|
|
2061
|
+
// `Constant::Closure` → `VmClosure.enclosing`), minting a deterministic
|
|
2062
|
+
// closure→scope→closure cycle per call — refcount-only, so the whole frame leaked.
|
|
2063
|
+
// In general slot mode an UNCAPTURED name gets a frame slot instead: the closure
|
|
2064
|
+
// never enters the captured map, and the frame drops normally on return. A name
|
|
2065
|
+
// referenced from inside ANY nested closure — the function's own body
|
|
2066
|
+
// (self-recursion) or a sibling (mutual recursion) — is in `slot_captured`
|
|
2067
|
+
// (`SlotScan` scans FunDecl bodies as closures) and stays on the `DeclareVar`
|
|
2068
|
+
// path, exactly like a captured `let` (see `Statement::VarDecl` above).
|
|
2069
|
+
// `TISH_VM_SLOTS=0` ⇒ `general_slots` is false ⇒ byte-identical output.
|
|
2070
|
+
if self.general_slots && !self.slot_captured.contains(name.as_ref()) {
|
|
2071
|
+
let slot = self.declare_slot(name);
|
|
2072
|
+
self.emit_u16(Opcode::StoreLocal, slot);
|
|
2073
|
+
} else {
|
|
2074
|
+
let idx = self.name_idx(name);
|
|
2075
|
+
self.emit_u16(Opcode::DeclareVar, idx);
|
|
2076
|
+
self.scope
|
|
2077
|
+
.last_mut()
|
|
2078
|
+
.unwrap()
|
|
2079
|
+
.insert(Arc::clone(name), false);
|
|
2080
|
+
}
|
|
2065
2081
|
}
|
|
2066
2082
|
Statement::DoWhile { body, cond, .. } => {
|
|
2067
2083
|
let body_lets = Self::loop_body_block_lets(body);
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
//! #716: how a body-level `function NAME` binds its name in general slot mode.
|
|
2
|
+
//!
|
|
3
|
+
//! An UNCAPTURED name must be slot-bound (`StoreLocal`) — `DeclareVar` would insert the
|
|
4
|
+
//! closure into the very `local_scope` map the closure captured, minting a per-call
|
|
5
|
+
//! closure→scope→closure cycle that leaks the whole frame (refcount-only runtime, no
|
|
6
|
+
//! cycle collector). A name referenced from inside ANY nested closure — the function's
|
|
7
|
+
//! own body (self-recursion) or a sibling (mutual recursion) — is in the captured set
|
|
8
|
+
//! and must STAY on the `DeclareVar` scope path, or the recursive name lookup breaks.
|
|
9
|
+
|
|
10
|
+
use tishlang_bytecode::{compile, Chunk, Opcode};
|
|
11
|
+
|
|
12
|
+
/// Count occurrences of `target` in `chunk.code`, walking real instruction boundaries.
|
|
13
|
+
fn count_opcode(chunk: &Chunk, target: Opcode) -> usize {
|
|
14
|
+
let code = &chunk.code;
|
|
15
|
+
let mut ip = 0usize;
|
|
16
|
+
let mut n = 0usize;
|
|
17
|
+
while ip < code.len() {
|
|
18
|
+
let op = Opcode::from_u8(code[ip]).expect("valid opcode");
|
|
19
|
+
if op == target {
|
|
20
|
+
n += 1;
|
|
21
|
+
}
|
|
22
|
+
ip += op.instruction_size(code, ip).expect("sized instruction");
|
|
23
|
+
}
|
|
24
|
+
n
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
fn compile_src(src: &str) -> Chunk {
|
|
28
|
+
let program = tishlang_parser::parse(src).expect("parse");
|
|
29
|
+
compile(&program).expect("compile")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// These tests assert slot-mode emission; running the suite with `TISH_VM_SLOTS=0`
|
|
33
|
+
/// deliberately exercises the legacy name-based path, where they do not apply.
|
|
34
|
+
fn slots_disabled() -> bool {
|
|
35
|
+
std::env::var("TISH_VM_SLOTS")
|
|
36
|
+
.map(|v| v == "0")
|
|
37
|
+
.unwrap_or(false)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#[test]
|
|
41
|
+
fn uncaptured_body_level_fn_is_slot_bound() {
|
|
42
|
+
if slots_disabled() {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
let chunk = compile_src(
|
|
46
|
+
r#"
|
|
47
|
+
function outer() {
|
|
48
|
+
function helper(a) { return a + 1 }
|
|
49
|
+
return helper(41)
|
|
50
|
+
}
|
|
51
|
+
console.log(outer())
|
|
52
|
+
"#,
|
|
53
|
+
);
|
|
54
|
+
let outer = &chunk.nested[0];
|
|
55
|
+
assert!(
|
|
56
|
+
outer.slot_based,
|
|
57
|
+
"outer should compile in general slot mode"
|
|
58
|
+
);
|
|
59
|
+
assert_eq!(
|
|
60
|
+
count_opcode(outer, Opcode::DeclareVar),
|
|
61
|
+
0,
|
|
62
|
+
"uncaptured `function helper` must not DeclareVar into the captured scope (#716 cycle)"
|
|
63
|
+
);
|
|
64
|
+
assert!(
|
|
65
|
+
count_opcode(outer, Opcode::StoreLocal) >= 1,
|
|
66
|
+
"uncaptured `function helper` should be stored to a frame slot"
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#[test]
|
|
71
|
+
fn self_recursive_body_level_fn_stays_on_scope_path() {
|
|
72
|
+
if slots_disabled() {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
let chunk = compile_src(
|
|
76
|
+
r#"
|
|
77
|
+
function outer() {
|
|
78
|
+
function fact(k) { if (k < 2) { return 1 } return k * fact(k - 1) }
|
|
79
|
+
return fact(5)
|
|
80
|
+
}
|
|
81
|
+
console.log(outer())
|
|
82
|
+
"#,
|
|
83
|
+
);
|
|
84
|
+
let outer = &chunk.nested[0];
|
|
85
|
+
assert_eq!(
|
|
86
|
+
count_opcode(outer, Opcode::DeclareVar),
|
|
87
|
+
1,
|
|
88
|
+
"self-recursive `fact` references its own name from inside its body — that IS a \
|
|
89
|
+
capture, and the name must stay DeclareVar-bound in `local_scope`"
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#[test]
|
|
94
|
+
fn mutually_recursive_body_level_fns_stay_on_scope_path() {
|
|
95
|
+
if slots_disabled() {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
let chunk = compile_src(
|
|
99
|
+
r#"
|
|
100
|
+
function outer(n) {
|
|
101
|
+
function isEven(k) { if (k === 0) { return true } return isOdd(k - 1) }
|
|
102
|
+
function isOdd(k) { if (k === 0) { return false } return isEven(k - 1) }
|
|
103
|
+
return isEven(n)
|
|
104
|
+
}
|
|
105
|
+
console.log(outer(10))
|
|
106
|
+
"#,
|
|
107
|
+
);
|
|
108
|
+
let outer = &chunk.nested[0];
|
|
109
|
+
assert_eq!(
|
|
110
|
+
count_opcode(outer, Opcode::DeclareVar),
|
|
111
|
+
2,
|
|
112
|
+
"mutually recursive names are captured by the sibling closure and must both stay \
|
|
113
|
+
DeclareVar-bound"
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -31,10 +31,31 @@ pub const DICT_SHAPE: ShapeId = u32::MAX;
|
|
|
31
31
|
/// Edges are keyed by an `ahash` map, not the default SipHash one: `transition` is on the hot path of
|
|
32
32
|
/// every object construction (and `JSON.parse` — profiled as ~16% of parse, almost all SipHash), and
|
|
33
33
|
/// ahash is ~2–3× faster on short string keys. Random-seeded per process, so no HashDoS regression.
|
|
34
|
+
/// Growth backstops (#701/#706/#710). The registry has no eviction — a permanent `ShapeNode`
|
|
35
|
+
/// plus an `Arc` clone of the key per distinct `(parent, key)` edge — so every mint site must be
|
|
36
|
+
/// bounded. Three independent caps, all degrading to `DICT_SHAPE` (never an error):
|
|
37
|
+
///
|
|
38
|
+
/// - `MAX_TRANSITIONS_PER_NODE` bounds a node's fan-out. The hot pathology is node 0: every
|
|
39
|
+
/// fresh `{}` that takes a data-dependent first key (`o[requestId] = …`) adds one edge to
|
|
40
|
+
/// EMPTY_SHAPE's map, forever, under the global write lock.
|
|
41
|
+
/// - `MAX_CHAIN_DEPTH` bounds a chain's length. A single object accumulating keys one at a time
|
|
42
|
+
/// mints a linear chain (each node fan-out 1, so the fan-out cap never trips). `PropMap`
|
|
43
|
+
/// promotion demotes to dictionary mode at 9 keys, but `with_capacity`-built maps skip
|
|
44
|
+
/// promotion — the depth cap is their backstop.
|
|
45
|
+
/// - `MAX_SHAPES` is the absolute registry ceiling; past it nothing new is ever interned.
|
|
46
|
+
///
|
|
47
|
+
/// Values are far above anything a program's TEXT can mint (shapes are meant to be bounded by
|
|
48
|
+
/// code structure), and far below the ~750 GB where the u32 id-overflow guard would trip.
|
|
49
|
+
const MAX_TRANSITIONS_PER_NODE: usize = 256;
|
|
50
|
+
const MAX_CHAIN_DEPTH: u16 = 32;
|
|
51
|
+
const MAX_SHAPES: usize = 1 << 18; // 262_144 nodes ≈ tens of MB worst-case, orders above real use
|
|
52
|
+
|
|
34
53
|
#[cfg(not(feature = "portable"))]
|
|
35
54
|
#[derive(Default)]
|
|
36
55
|
struct ShapeNode {
|
|
37
56
|
transitions: AHashMap<Arc<str>, ShapeId>,
|
|
57
|
+
/// Distance from EMPTY_SHAPE (== number of keys on the path). Bounds chain length.
|
|
58
|
+
depth: u16,
|
|
38
59
|
}
|
|
39
60
|
|
|
40
61
|
#[cfg(not(feature = "portable"))]
|
|
@@ -85,6 +106,8 @@ fn transition_tracked(from: ShapeId, key: &Arc<str>) -> ShapeId {
|
|
|
85
106
|
return DICT_SHAPE;
|
|
86
107
|
}
|
|
87
108
|
// Fast path: the edge already exists (the common case after the first object of this shape).
|
|
109
|
+
// The caps are ALSO checked here, read-only: a saturated node keeps answering DICT_SHAPE
|
|
110
|
+
// from the read lock instead of convoying every miss through the write lock below.
|
|
88
111
|
{
|
|
89
112
|
let reg = registry().read().unwrap();
|
|
90
113
|
match reg.nodes.get(from as usize) {
|
|
@@ -92,6 +115,11 @@ fn transition_tracked(from: ShapeId, key: &Arc<str>) -> ShapeId {
|
|
|
92
115
|
if let Some(&next) = node.transitions.get(key.as_ref()) {
|
|
93
116
|
return next;
|
|
94
117
|
}
|
|
118
|
+
if node.transitions.len() >= MAX_TRANSITIONS_PER_NODE
|
|
119
|
+
|| node.depth >= MAX_CHAIN_DEPTH
|
|
120
|
+
{
|
|
121
|
+
return DICT_SHAPE;
|
|
122
|
+
}
|
|
95
123
|
}
|
|
96
124
|
None => return DICT_SHAPE, // out of range — should not happen; degrade safely
|
|
97
125
|
}
|
|
@@ -102,13 +130,38 @@ fn transition_tracked(from: ShapeId, key: &Arc<str>) -> ShapeId {
|
|
|
102
130
|
if let Some(&next) = reg.nodes[from as usize].transitions.get(key.as_ref()) {
|
|
103
131
|
return next;
|
|
104
132
|
}
|
|
133
|
+
// Re-check the caps under the write lock too (the read-path check can race).
|
|
134
|
+
let parent_depth = reg.nodes[from as usize].depth;
|
|
135
|
+
if reg.nodes[from as usize].transitions.len() >= MAX_TRANSITIONS_PER_NODE
|
|
136
|
+
|| parent_depth >= MAX_CHAIN_DEPTH
|
|
137
|
+
|| reg.nodes.len() >= MAX_SHAPES
|
|
138
|
+
{
|
|
139
|
+
return DICT_SHAPE;
|
|
140
|
+
}
|
|
105
141
|
let new_id = reg.nodes.len();
|
|
106
142
|
if new_id >= DICT_SHAPE as usize {
|
|
107
|
-
return DICT_SHAPE; //
|
|
143
|
+
return DICT_SHAPE; // id-space overflow guard — prevents u32 truncation aliasing live ids
|
|
108
144
|
}
|
|
109
|
-
reg.nodes.push(ShapeNode
|
|
145
|
+
reg.nodes.push(ShapeNode {
|
|
146
|
+
transitions: AHashMap::default(),
|
|
147
|
+
depth: parent_depth + 1,
|
|
148
|
+
});
|
|
110
149
|
reg.nodes[from as usize]
|
|
111
150
|
.transitions
|
|
112
151
|
.insert(Arc::clone(key), new_id as ShapeId);
|
|
113
152
|
new_id as ShapeId
|
|
114
153
|
}
|
|
154
|
+
|
|
155
|
+
/// Number of interned shape nodes — for regression tests asserting that a workload does NOT
|
|
156
|
+
/// grow the registry. Not part of the public surface.
|
|
157
|
+
#[doc(hidden)]
|
|
158
|
+
#[cfg(not(feature = "portable"))]
|
|
159
|
+
pub fn shape_count() -> usize {
|
|
160
|
+
registry().read().unwrap().nodes.len()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#[doc(hidden)]
|
|
164
|
+
#[cfg(feature = "portable")]
|
|
165
|
+
pub fn shape_count() -> usize {
|
|
166
|
+
0
|
|
167
|
+
}
|