@tishlang/tish-format 3.12.2 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/Cargo.toml +1 -0
  2. package/bin/tish-format +0 -0
  3. package/crates/tish/Cargo.toml +3 -1
  4. package/crates/tish/src/cli_help.rs +2 -1
  5. package/crates/tish/src/main.rs +21 -2
  6. package/crates/tish_bytecode/src/compiler.rs +6 -8
  7. package/crates/tish_compile/src/codegen.rs +93 -6
  8. package/crates/tish_compile/src/lib.rs +4 -0
  9. package/crates/tish_compile/src/resolve.rs +1 -0
  10. package/crates/tish_compile/tests/regr_ffi_module_emit.rs +120 -0
  11. package/crates/tish_core/src/lib.rs +43 -4
  12. package/crates/tish_cranelift_runtime/Cargo.toml +2 -0
  13. package/crates/tish_ffi/src/lib.rs +48 -25
  14. package/crates/tish_ffi/src/v2.rs +349 -0
  15. package/crates/tish_ffi/tests/fixtures/testmod_v2/Cargo.lock +282 -0
  16. package/crates/tish_ffi/tests/fixtures/testmod_v2/Cargo.toml +16 -0
  17. package/crates/tish_ffi/tests/fixtures/testmod_v2/src/lib.rs +111 -0
  18. package/crates/tish_ffi/tests/loader_v2.rs +159 -0
  19. package/crates/tish_ffi_guest/Cargo.toml +11 -0
  20. package/crates/tish_ffi_guest/src/lib.rs +333 -0
  21. package/crates/tish_fmt/Cargo.toml +1 -1
  22. package/crates/tish_native/src/build.rs +54 -15
  23. package/crates/tish_native/src/config.rs +10 -0
  24. package/crates/tish_native/src/lib.rs +7 -2
  25. package/crates/tish_runtime/Cargo.toml +4 -0
  26. package/crates/tish_runtime/src/lib.rs +21 -0
  27. package/crates/tish_vm/Cargo.toml +2 -0
  28. package/crates/tish_vm/src/vm.rs +61 -6
  29. package/crates/tish_vm/tests/ffi_load_module.rs +32 -0
  30. package/crates/tish_vm/tests/jit_off_leaves_jit_state.rs +24 -0
  31. package/crates/tish_vm/tests/jsx_lexical_resolution.rs +42 -0
  32. package/crates/tish_vm/tests/multi_vm_isolation.rs +123 -0
  33. package/package.json +1 -1
  34. package/platform/darwin-arm64/tish-fmt +0 -0
  35. package/platform/darwin-x64/tish-fmt +0 -0
  36. package/platform/linux-arm64/tish-fmt +0 -0
  37. package/platform/linux-x64/tish-fmt +0 -0
  38. package/platform/win32-x64/tish-fmt.exe +0 -0
package/Cargo.toml CHANGED
@@ -22,6 +22,7 @@ members = [
22
22
  "crates/tish_vm",
23
23
  "crates/tish_llvm",
24
24
  "crates/tish_ffi",
25
+ "crates/tish_ffi_guest",
25
26
  "crates/tish_native",
26
27
  "crates/tish_wasm",
27
28
  "crates/tish",
package/bin/tish-format CHANGED
Binary file
@@ -25,7 +25,7 @@ path = "src/main.rs"
25
25
  default = ["full", "fast-alloc"]
26
26
  # Alias: same dependency edges as `default` (kept for `--features full` in CI and docs).
27
27
  # Includes `pg` so `tish run` can resolve `import … from '@tishlang/pg'` (`cargo:tish_pg`) on the bytecode VM.
28
- full = ["http", "fs", "process", "regex", "ws", "timers", "tty", "pg", "test-runner"]
28
+ full = ["http", "fs", "process", "regex", "ws", "timers", "tty", "ffi", "pg", "test-runner"]
29
29
  # Fast allocator (mimalloc) as the process `#[global_allocator]`. tish's object/array/string workloads
30
30
  # are allocation-bound (a sampling profile of object-heavy code spends ~14%+ in system malloc/free);
31
31
  # mimalloc is markedly faster for the many-small-allocations pattern — the same reason JSC ships bmalloc.
@@ -43,6 +43,8 @@ process = ["tishlang_eval/process", "tishlang_runtime/process", "tishlang_compil
43
43
  regex = ["tishlang_eval/regex", "tishlang_runtime/regex", "tishlang_compile/regex", "tishlang_vm/regex"]
44
44
  ws = ["tishlang_eval/ws", "tishlang_runtime/ws", "tishlang_compile/ws", "tishlang_vm/ws"]
45
45
  tty = ["tishlang_eval/tty", "tishlang_runtime/tty", "tishlang_compile/tty", "tishlang_vm/tty"]
46
+ # `tish:ffi` loadModule (runtime loading of `--crate-type cdylib` modules); static `ffi:` imports need no feature.
47
+ ffi = ["tishlang_runtime/ffi", "tishlang_vm/ffi"]
46
48
  # GPU compute — Apple Silicon only
47
49
  [dependencies]
48
50
  rustyline = { version = "17", features = ["with-file-history"] }
@@ -556,7 +556,8 @@ pub(crate) struct BuildArgs {
556
556
  /// Cross-compile to an Apple iOS triple (e.g. `aarch64-apple-ios-sim`). Implies `--crate-type staticlib`.
557
557
  #[arg(long, value_name = "TRIPLE", help_heading = "Options")]
558
558
  pub ios_triple: Option<String>,
559
- /// Output artifact for `--target native` (default: `bin`; use `staticlib` for embedded iOS).
559
+ /// Output artifact for `--target native` (default: `bin`; `staticlib` for embedded iOS; `cdylib`
560
+ /// for a dynamically loadable tish FFI module whose `export fn`s any tish host can load).
560
561
  #[arg(long, value_name = "TYPE", default_value = "bin", help_heading = "Options")]
561
562
  pub crate_type: String,
562
563
  #[arg(long, help_heading = "Options")]
@@ -30,7 +30,7 @@ fn normalize_capability_flags(features: &[String]) -> HashSet<String> {
30
30
  for s in features {
31
31
  for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) {
32
32
  if part == "full" {
33
- for name in ["http", "timers", "fs", "process", "regex", "ws", "tty"] {
33
+ for name in ["http", "timers", "fs", "process", "regex", "ws", "tty", "ffi"] {
34
34
  out.insert(name.to_string());
35
35
  }
36
36
  } else {
@@ -1077,14 +1077,29 @@ fn build_file(
1077
1077
  cargo_target: None,
1078
1078
  emit_mode: tishlang_compile::NativeEmitMode::EmbeddedLib,
1079
1079
  }
1080
+ } else if crate_type == "cdylib" {
1081
+ if is_js {
1082
+ return Err("--crate-type cdylib requires a .tish entry file.".to_string());
1083
+ }
1084
+ tishlang_native::NativeBuildConfig::ffi_module()
1080
1085
  } else if crate_type != "bin" {
1081
1086
  return Err(format!(
1082
- "Unknown --crate-type: {}. Use 'bin' or 'staticlib'.",
1087
+ "Unknown --crate-type: {}. Use 'bin', 'staticlib' or 'cdylib'.",
1083
1088
  crate_type
1084
1089
  ));
1085
1090
  } else {
1086
1091
  tishlang_native::NativeBuildConfig::desktop()
1087
1092
  };
1093
+ // Release builds name the triple so the binary targets the platform baseline instead of the
1094
+ // build machine's CPU, and so one host can build each slice of a universal binary.
1095
+ let mut build_config = build_config;
1096
+ if build_config.cargo_target.is_none() {
1097
+ if let Ok(triple) = std::env::var("TISH_NATIVE_CARGO_TARGET") {
1098
+ if !triple.is_empty() {
1099
+ build_config.cargo_target = Some(triple);
1100
+ }
1101
+ }
1102
+ }
1088
1103
 
1089
1104
  if is_js {
1090
1105
  let source = fs::read_to_string(&input_path).map_err(|e| format!("{}", e))?;
@@ -1121,6 +1136,10 @@ fn build_file(
1121
1136
  if build_config.artifact == tishlang_native::NativeArtifact::RustLib {
1122
1137
  return Ok(());
1123
1138
  }
1139
+ if build_config.artifact == tishlang_native::NativeArtifact::FfiModule {
1140
+ println!("Built tish FFI module: {}", output_path);
1141
+ return Ok(());
1142
+ }
1124
1143
 
1125
1144
  let out_name = Path::new(output_path)
1126
1145
  .file_stem()
@@ -3086,8 +3086,9 @@ impl<'a> Compiler<'a> {
3086
3086
  props: &[JsxProp],
3087
3087
  children: &[JsxChild],
3088
3088
  ) -> Result<(), CompileError> {
3089
- let h_idx = self.name_idx(&Arc::from("h"));
3090
- self.emit_u16(Opcode::LoadGlobal, h_idx);
3089
+ // `h`, `Fragment` and component tags resolve lexically (imports, module `fn`s, locals)
3090
+ // and fall back to globals, so embedder-injected `h` via `set_global` still works.
3091
+ self.emit_var_load(&Arc::from("h"));
3091
3092
  let tag_str = tag.as_ref();
3092
3093
  let is_component = tag_str
3093
3094
  .chars()
@@ -3095,8 +3096,7 @@ impl<'a> Compiler<'a> {
3095
3096
  .map(|c| c.is_uppercase())
3096
3097
  .unwrap_or(false);
3097
3098
  if is_component {
3098
- let tag_idx = self.name_idx(tag);
3099
- self.emit_u16(Opcode::LoadGlobal, tag_idx);
3099
+ self.emit_var_load(tag);
3100
3100
  } else {
3101
3101
  let tag_const = self.constant_idx(Constant::String(Arc::from(tag_str)));
3102
3102
  self.emit(Opcode::LoadConst);
@@ -3109,10 +3109,8 @@ impl<'a> Compiler<'a> {
3109
3109
  }
3110
3110
 
3111
3111
  fn compile_jsx_fragment(&mut self, children: &[JsxChild]) -> Result<(), CompileError> {
3112
- let h_idx = self.name_idx(&Arc::from("h"));
3113
- self.emit_u16(Opcode::LoadGlobal, h_idx);
3114
- let fragment_idx = self.name_idx(&Arc::from("Fragment"));
3115
- self.emit_u16(Opcode::LoadGlobal, fragment_idx);
3112
+ self.emit_var_load(&Arc::from("h"));
3113
+ self.emit_var_load(&Arc::from("Fragment"));
3116
3114
  let null_idx = self.constant_idx(Constant::Null);
3117
3115
  self.emit(Opcode::LoadConst);
3118
3116
  self.chunk.write_u16(null_idx);
@@ -2446,6 +2446,10 @@ impl Codegen {
2446
2446
  "entries" => Some("Value::native(|args: &[Value]| tishlang_runtime::zip_entries(args))"),
2447
2447
  _ => None,
2448
2448
  },
2449
+ "tish:ffi" if self.has_feature("ffi") => match export_name {
2450
+ "loadModule" => Some("Value::native(|args: &[Value]| tishlang_runtime::ffi_load_module(args))"),
2451
+ _ => None,
2452
+ },
2449
2453
  _ => return None,
2450
2454
  };
2451
2455
  init.map(String::from)
@@ -2456,7 +2460,7 @@ impl Codegen {
2456
2460
  matches!(
2457
2461
  name,
2458
2462
  "http" | "timers" | "fs" | "process" | "regex" | "ws" | "tty" | "pty" | "net"
2459
- | "encoding" | "crypto" | "zip"
2463
+ | "encoding" | "crypto" | "zip" | "ffi"
2460
2464
  )
2461
2465
  } else {
2462
2466
  self.features.contains(name)
@@ -3150,7 +3154,7 @@ impl Codegen {
3150
3154
  // `VmRef`) from THIS crate and cannot version-skew them against a separate `tishlang_runtime`
3151
3155
  // dependency of their own. Also avoids a duplicate-import error, since `Value` is already in
3152
3156
  // this list and could not be re-exported a second time at the crate root.
3153
- if self.emit_mode == crate::NativeEmitMode::RustLib {
3157
+ if self.is_lib_surface() {
3154
3158
  self.write("pub ");
3155
3159
  }
3156
3160
  self.write("use tishlang_runtime::{console_debug as tish_console_debug, console_info as tish_console_info, console_log as tish_console_log, console_warn as tish_console_warn, console_error as tish_console_error, boolean as tish_boolean, decode_uri as tish_decode_uri, encode_uri as tish_encode_uri, encode_uri_component as tish_encode_uri_component, decode_uri_component as tish_decode_uri_component, string_escape_html_impl as tish_escape_html, in_operator as tish_in_operator, is_finite as tish_is_finite, is_nan as tish_is_nan, json_parse as tish_json_parse, json_stringify as tish_json_stringify, math_abs as tish_math_abs, math_ceil as tish_math_ceil, math_floor as tish_math_floor, math_max as tish_math_max, math_min as tish_math_min, math_round as tish_math_round, math_sqrt as tish_math_sqrt, parse_float as tish_parse_float, parse_int as tish_parse_int, math_random as tish_math_random, math_pow as tish_math_pow, math_sin as tish_math_sin, math_cos as tish_math_cos, math_tan as tish_math_tan, math_log as tish_math_log, math_exp as tish_math_exp, math_sign as tish_math_sign, math_trunc as tish_math_trunc, math_imul as tish_math_imul, math_sinh as tish_math_sinh, math_cosh as tish_math_cosh, math_tanh as tish_math_tanh, math_asinh as tish_math_asinh, math_acosh as tish_math_acosh, math_atanh as tish_math_atanh, math_cbrt as tish_math_cbrt, math_log2 as tish_math_log2, math_log10 as tish_math_log10, math_expm1 as tish_math_expm1, math_log1p as tish_math_log1p, math_clz32 as tish_math_clz32, math_fround as tish_math_fround, math_hypot as tish_math_hypot, math_atan2 as tish_math_atan2, math_asin as tish_math_asin, math_acos as tish_math_acos, math_atan as tish_math_atan, array_is_array as tish_array_is_array, array_of as tish_array_of, array_from as tish_array_from, object_is as tish_object_is, object_has_own as tish_object_has_own, structured_clone as tish_structured_clone, array_construct as tish_array_construct, string_from_char_code as tish_string_from_char_code, string_convert as tish_string_convert, number_convert as tish_number_convert, number_is_integer as tish_number_is_integer, number_is_safe_integer as tish_number_is_safe_integer, number_is_nan as tish_number_is_nan, number_is_finite as tish_number_is_finite, object_assign as tish_object_assign, object_freeze as tish_object_freeze, object_is_frozen as tish_object_is_frozen, object_keys as tish_object_keys, object_values as tish_object_values, object_entries as tish_object_entries, object_from_entries as tish_object_from_entries, symbol_object as tish_symbol_object, tish_construct, tish_error_constructor, tish_date_constructor, tish_set_constructor, tish_map_constructor, map_get as tish_map_get, map_has as tish_map_has, map_set as tish_map_set, map_values as tish_map_values, tish_float64_array_constructor, tish_float32_array_constructor, tish_int8_array_constructor, tish_uint8_array_constructor, tish_uint8_clamped_array_constructor, tish_int16_array_constructor, tish_uint16_array_constructor, tish_int32_array_constructor, tish_uint32_array_constructor, tish_audio_context_constructor, ObjectMap, ObjectData, PropMap, TishError, Value, VmRef};\n");
@@ -3268,7 +3272,7 @@ impl Codegen {
3268
3272
  self.writeln("}");
3269
3273
  self.writeln("");
3270
3274
  }
3271
- if self.emit_mode == crate::NativeEmitMode::RustLib {
3275
+ if self.is_lib_surface() {
3272
3276
  self.emit_rust_lib_surface(program)?;
3273
3277
  }
3274
3278
  // M5 (default-on via native_opts_enabled; TISH_NATIVE_OPT=0 opts out): emit a parallel
@@ -3432,6 +3436,15 @@ impl Codegen {
3432
3436
  for s in &program.statements {
3433
3437
  Self::collect_value_used_stmt(s, &mut self.value_used_names);
3434
3438
  }
3439
+ // A library surface publishes each exported binding as a `Value`
3440
+ // (`__tish_publish_exports`), so an exported fn is value-used even if the module only
3441
+ // ever calls it. Without this a numeric `export fn add(a, b)` lost its boxed wrapper
3442
+ // and the published list named a binding that didn't exist (E0308 / E0425).
3443
+ if self.is_lib_surface() {
3444
+ for e in self.rust_lib_exports(program) {
3445
+ self.value_used_names.insert(e.local_name.to_string());
3446
+ }
3447
+ }
3435
3448
  // Which of the survivors carry the all-`i32` signature. Computed AFTER the fixpoint,
3436
3449
  // so a function the proof rejected never reaches the i32 ABI either.
3437
3450
  self.native_fns_i32 = program
@@ -3571,7 +3584,7 @@ impl Codegen {
3571
3584
  .unwrap_or(self.emit_mode == crate::NativeEmitMode::Gba || !threads_possible);
3572
3585
  // `RustLib` publishes its exports by naming the `run()` local, so its bindings must stay
3573
3586
  // locals; the whole point there is that the frame outlives the call anyway.
3574
- if self.emit_mode == crate::NativeEmitMode::RustLib {
3587
+ if self.is_lib_surface() {
3575
3588
  self.module_statics_on = false;
3576
3589
  }
3577
3590
  if crate::module_statics_flag().unwrap_or(true) {
@@ -4084,7 +4097,7 @@ impl Codegen {
4084
4097
  // `Rc`, so the closure — and every module-level cell it captured — stays alive and shared.
4085
4098
  // That is what makes module state survive between `pub fn` calls (and stay mutable through
4086
4099
  // `clear*`-style reassignment, since captures are `VmRef` cells, not copies).
4087
- if self.emit_mode == crate::NativeEmitMode::RustLib {
4100
+ if self.is_lib_surface() {
4088
4101
  let pairs: Vec<String> = self
4089
4102
  .rust_lib_exports(program)
4090
4103
  .iter()
@@ -4171,6 +4184,51 @@ impl Codegen {
4171
4184
  /// unless the `send-values` feature is on (it follows `http`), so a `static` would not compile
4172
4185
  /// for the common configuration. Per-thread instances also match how a consumer uses this:
4173
4186
  /// register into the module's registries, then call it, on one thread.
4187
+ /// Library emit modes: the entry module's exports become a crate surface (no `fn main`).
4188
+ fn is_lib_surface(&self) -> bool {
4189
+ matches!(
4190
+ self.emit_mode,
4191
+ crate::NativeEmitMode::RustLib | crate::NativeEmitMode::FfiModule
4192
+ )
4193
+ }
4194
+
4195
+ /// The tish FFI ABI v2 surface for [`crate::NativeEmitMode::FfiModule`]: one `extern "C"`
4196
+ /// trampoline per export (marshaling through `tishlang_ffi_guest`) and
4197
+ /// `tish_module_register_v2`, which installs the host's API table and returns the export table.
4198
+ fn emit_ffi_module_surface(&mut self, export_names: &[String]) {
4199
+ self.writeln("// ── tish FFI module (ABI v2) ──────────────────────────────────────────");
4200
+ self.writeln("mod __tish_ffi {");
4201
+ self.indent += 1;
4202
+ self.writeln("use super::*;");
4203
+ self.writeln("use tishlang_ffi_guest as guest;");
4204
+ for (i, name) in export_names.iter().enumerate() {
4205
+ self.writeln(&format!(
4206
+ "extern \"C\" fn __tish_ffi_export_{i}(args: *const guest::TishValueRef, argc: usize) -> guest::TishValueRef {{"
4207
+ ));
4208
+ self.writeln(&format!(
4209
+ " unsafe {{ guest::export_call(args, argc, |a| __tish_call_export({name:?}, &a), tish_last_throw) }}"
4210
+ ));
4211
+ self.writeln("}");
4212
+ }
4213
+ self.writeln("#[no_mangle]");
4214
+ self.writeln("pub unsafe extern \"C\" fn tish_module_register_v2(api: *const guest::TishHostApi) -> *const guest::TishExportTable {");
4215
+ self.indent += 1;
4216
+ self.writeln("if !guest::install(api) { return ::std::ptr::null(); }");
4217
+ self.writeln("guest::export_table(&[");
4218
+ for (i, name) in export_names.iter().enumerate() {
4219
+ let cname = format!("{name}\\0");
4220
+ self.writeln(&format!(
4221
+ " (b\"{cname}\", __tish_ffi_export_{i} as guest::TishNativeFn),"
4222
+ ));
4223
+ }
4224
+ self.writeln("])");
4225
+ self.indent -= 1;
4226
+ self.writeln("}");
4227
+ self.indent -= 1;
4228
+ self.writeln("}");
4229
+ self.writeln("");
4230
+ }
4231
+
4174
4232
  fn emit_rust_lib_surface(&mut self, program: &Program) -> Result<(), CompileError> {
4175
4233
  if self.is_async {
4176
4234
  return Err(CompileError::new(
@@ -4299,7 +4357,32 @@ impl Codegen {
4299
4357
  self.writeln("}");
4300
4358
  self.writeln("");
4301
4359
 
4360
+ // Items the surface itself defines: a same-named `pub fn` would not compile.
4361
+ let reserved = |name: &str| {
4362
+ matches!(name, "run" | "main" | "tish_last_throw" | "runtime") || name.starts_with("__tish")
4363
+ };
4364
+ let clashing: Vec<&str> = exports
4365
+ .iter()
4366
+ .map(|e| e.exported_name.as_str())
4367
+ .filter(|n| reserved(n))
4368
+ .collect();
4369
+ if !clashing.is_empty() && self.emit_mode == crate::NativeEmitMode::RustLib {
4370
+ return Err(CompileError::new(
4371
+ format!(
4372
+ "--target rust-lib: the export name(s) `{}` clash with items the generated crate \
4373
+ defines. Rename them (e.g. `export {{ {} as start }}`).",
4374
+ clashing.join("`, `"),
4375
+ clashing[0]
4376
+ ),
4377
+ None,
4378
+ ));
4379
+ }
4380
+
4302
4381
  for e in &exports {
4382
+ // FFI modules export by string name, so a clashing Rust wrapper is simply not emitted.
4383
+ if reserved(&e.exported_name) {
4384
+ continue;
4385
+ }
4303
4386
  // Checked above: every remaining export is a `fn`, so the arity is present.
4304
4387
  let n = e.arity.unwrap_or(0);
4305
4388
  let params: Vec<String> = (0..n).map(|i| format!("a{}: Value", i)).collect();
@@ -4319,6 +4402,10 @@ impl Codegen {
4319
4402
  self.writeln("}");
4320
4403
  }
4321
4404
  self.writeln("");
4405
+ if self.emit_mode == crate::NativeEmitMode::FfiModule {
4406
+ let names: Vec<String> = exports.iter().map(|e| e.exported_name.clone()).collect();
4407
+ self.emit_ffi_module_surface(&names);
4408
+ }
4322
4409
  Ok(())
4323
4410
  }
4324
4411
 
@@ -25239,7 +25326,7 @@ impl Codegen {
25239
25326
  // exported fn that got de-virtualized would leave the emitted `__tish_publish_exports` call
25240
25327
  // referencing a binding that no longer exists. The unboxed path is a numeric fast path;
25241
25328
  // a correct public surface outranks it.
25242
- if self.emit_mode == crate::NativeEmitMode::RustLib {
25329
+ if self.is_lib_surface() {
25243
25330
  return;
25244
25331
  }
25245
25332
  let dbg = std::env::var("TISH_AGG_DEBUG").is_ok();
@@ -73,6 +73,10 @@ pub enum NativeEmitMode {
73
73
  /// `thread_local!` (rather than a global) is forced by `Value`: `send-values` is off unless
74
74
  /// `http` is enabled, so `VmRef` is `Rc<RefCell<_>>` and `Value` is `!Send`.
75
75
  RustLib,
76
+ /// [`NativeEmitMode::RustLib`] plus a tish FFI ABI v2 surface: one `extern "C"` trampoline per
77
+ /// `export fn` and `tish_module_register_v2`, via `tishlang_ffi_guest`. Built as a `cdylib`
78
+ /// that any tish host loads at runtime (`ffi:` import / `tishlang_ffi::load_module`).
79
+ FfiModule,
76
80
  /// Game Boy Advance ROM: `#![no_std]`, `#[agb::entry] fn agb_main(gba)`, links the
77
81
  /// `tishlang_runtime_gba` facade. Numbers/async lowering diverge; see codegen `emit_program`.
78
82
  Gba,
@@ -123,6 +123,7 @@ pub fn is_builtin_native_spec(spec: &str) -> bool {
123
123
  | "tish:encoding"
124
124
  | "tish:crypto"
125
125
  | "tish:zip"
126
+ | "tish:ffi"
126
127
  | "tish:test"
127
128
  | "tish:assert"
128
129
  ) || matches!(
@@ -0,0 +1,120 @@
1
+ //! `--crate-type cdylib` (`NativeEmitMode::FfiModule`): the RustLib surface plus a `tish_ffi` ABI v2
2
+ //! entry point, so the result can be loaded at runtime via `import { .. } from "ffi:./x.lib"`.
3
+ //! The loader behaviour itself is covered by `crates/tish_ffi/tests/loader_v2.rs`.
4
+ use std::path::{Path, PathBuf};
5
+ use tishlang_compile::{compile_project_full_emit, CompileError, NativeEmitMode};
6
+
7
+ fn compile(path: &Path, mode: NativeEmitMode) -> Result<String, CompileError> {
8
+ compile_project_full_emit(path, path.parent(), &[], true, mode, None).map(|r| r.0)
9
+ }
10
+
11
+ fn fixture(rel: &str) -> PathBuf {
12
+ let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
13
+ manifest.join("../..").join(rel).canonicalize().unwrap()
14
+ }
15
+
16
+ fn temp_module(name: &str, src: &str) -> PathBuf {
17
+ let dir = std::env::temp_dir().join(name);
18
+ std::fs::create_dir_all(&dir).unwrap();
19
+ let path = dir.join("index.tish");
20
+ std::fs::write(&path, src).unwrap();
21
+ path
22
+ }
23
+
24
+ #[test]
25
+ fn ffi_module_registers_every_export_by_name() {
26
+ let rust = compile(&fixture("tests/regression/rust_lib_emit.tish"), NativeEmitMode::FfiModule)
27
+ .expect("ffi module emit");
28
+ assert!(
29
+ rust.contains("pub unsafe extern \"C\" fn tish_module_register_v2("),
30
+ "missing the v2 entry point:\n{rust}"
31
+ );
32
+ for name in ["register", "lookup", "clearAll", "initRuns", "add"] {
33
+ assert!(
34
+ rust.contains(&format!("(b\"{name}\\0\", ")),
35
+ "`{name}` missing from the export table:\n{rust}"
36
+ );
37
+ assert!(
38
+ rust.contains(&format!("__tish_call_export(\"{name}\", &a)")),
39
+ "`{name}` has no trampoline:\n{rust}"
40
+ );
41
+ }
42
+ // Still a library: the Rust API stays available to in-process consumers.
43
+ assert!(rust.contains("pub fn add(a0: Value, a1: Value) -> Value"));
44
+ assert!(!rust.contains("fn main()"));
45
+ }
46
+
47
+ #[test]
48
+ fn rust_lib_gains_no_ffi_entry_point() {
49
+ let rust = compile(&fixture("tests/regression/rust_lib_emit.tish"), NativeEmitMode::RustLib)
50
+ .expect("rust-lib emit");
51
+ assert!(
52
+ !rust.contains("tish_module_register_v2") && !rust.contains("tishlang_ffi_guest"),
53
+ "the FFI entry point must be FfiModule-only:\n{rust}"
54
+ );
55
+ }
56
+
57
+ #[test]
58
+ fn an_export_named_like_a_generated_item_is_still_exported_over_ffi() {
59
+ let path = temp_module(
60
+ "tish_ffimodule_reserved_name",
61
+ "export fn run(cb) { return cb(1) }\nexport fn other() { return 2 }\n",
62
+ );
63
+ let rust = compile(&path, NativeEmitMode::FfiModule).expect("ffi module emit");
64
+ assert!(
65
+ !rust.contains("pub fn run(a0: Value)"),
66
+ "a `pub fn run` would clash with the module's own `run()`:\n{rust}"
67
+ );
68
+ assert!(
69
+ rust.contains("(b\"run\\0\", ") && rust.contains("__tish_call_export(\"run\", &a)"),
70
+ "`run` must still be reachable by name over FFI:\n{rust}"
71
+ );
72
+ }
73
+
74
+ #[test]
75
+ fn tish_ffi_load_module_lowers_to_the_runtime_loader() {
76
+ let path = temp_module(
77
+ "tish_ffi_load_module_emit",
78
+ "import { loadModule } from \"tish:ffi\"\nlet m = loadModule(\"./x.lib\")\nconsole.log(m)\n",
79
+ );
80
+ let rust = compile_project_full_emit(
81
+ &path,
82
+ path.parent(),
83
+ &["ffi".to_string()],
84
+ true,
85
+ NativeEmitMode::DesktopBin,
86
+ None,
87
+ )
88
+ .expect("tish:ffi should compile with the ffi feature")
89
+ .0;
90
+ assert!(
91
+ rust.contains("tishlang_runtime::ffi_load_module(args)"),
92
+ "loadModule must call the runtime loader:\n{rust}"
93
+ );
94
+ }
95
+
96
+ #[test]
97
+ fn rust_lib_rejects_an_export_named_like_a_generated_item() {
98
+ let path = temp_module("tish_rustlib_reserved_name", "export fn run() { return 1 }\n");
99
+ let err = compile(&path, NativeEmitMode::RustLib).expect_err("`run` cannot be a Rust pub fn here");
100
+ assert!(
101
+ err.message.contains("clash") && err.message.contains("`run`"),
102
+ "the error should name the clashing export: {}",
103
+ err.message
104
+ );
105
+ }
106
+
107
+ #[test]
108
+ fn a_purely_numeric_export_keeps_its_boxed_binding() {
109
+ // `add` is only ever called, and its params are numeric, so the native fast path used to drop
110
+ // its boxed `Value` wrapper. A library publishes every export as a `Value`, so it must stay.
111
+ let path = temp_module(
112
+ "tish_lib_numeric_export",
113
+ "export fn add(a, b) {\n return a + b\n}\nexport fn greet(name) {\n return \"hi \" + name\n}\n",
114
+ );
115
+ for mode in [NativeEmitMode::FfiModule, NativeEmitMode::RustLib] {
116
+ let rust = compile(&path, mode).expect("lib emit");
117
+ assert!(rust.contains("(\"add\", add.clone())"), "add is not published:\n{rust}");
118
+ assert!(rust.contains("let add = "), "add's boxed binding was elided ({mode:?}):\n{rust}");
119
+ }
120
+ }
@@ -158,19 +158,26 @@ pub fn take_pending_throw() -> Option<Value> {
158
158
  // deadline *after* the body returns, which never happens. Backends poll
159
159
  // [`execution_deadline_exceeded`] on loop back-edges and abort with a catchable error.
160
160
  //
161
- // Disarmed is the default and costs one relaxed load — `tish run` never arms it. Armed,
161
+ // Two deadlines can be armed, and the earlier one wins: the process-wide one
162
+ // ([`set_execution_deadline`], `tish test`) and a per-thread one
163
+ // ([`set_thread_execution_deadline`]) for embedders that budget each call into a guest VM
164
+ // pinned to a thread, without affecting VMs on other threads.
165
+ //
166
+ // Disarmed is the default and costs two relaxed loads — `tish run` never arms either. Armed,
162
167
  // the clock is read once every `DEADLINE_POLL_MASK + 1` back-edges so a hot loop is not
163
168
  // paying a `now()` per iteration.
164
169
  // ---------------------------------------------------------------------------
165
170
 
166
171
  #[cfg(not(feature = "portable"))]
167
172
  mod deadline {
168
- use core::sync::atomic::{AtomicU64, Ordering};
173
+ use core::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
169
174
  use std::sync::LazyLock;
170
175
  use std::time::Instant;
171
176
 
172
177
  /// Nanoseconds since [`START`] at which execution must stop. 0 = disarmed.
173
178
  static DEADLINE_NS: AtomicU64 = AtomicU64::new(0);
179
+ /// Threads with a per-thread deadline armed, so the disarmed path skips the TLS read.
180
+ static THREADS_ARMED: AtomicUsize = AtomicUsize::new(0);
174
181
  static START: LazyLock<Instant> = LazyLock::new(Instant::now);
175
182
 
176
183
  const DEADLINE_POLL_MASK: u32 = 0x3FF;
@@ -178,6 +185,26 @@ mod deadline {
178
185
  thread_local! {
179
186
  static POLL_TICK: core::cell::Cell<u32> = const { core::cell::Cell::new(0) };
180
187
  static TRIPPED: core::cell::Cell<bool> = const { core::cell::Cell::new(false) };
188
+ static THREAD_DEADLINE_NS: core::cell::Cell<u64> = const { core::cell::Cell::new(0) };
189
+ }
190
+
191
+ /// Arm a deadline `millis` from now for the calling thread only, or disarm it with `None`.
192
+ /// Clears any previous trip on this thread. Unlike [`set_execution_deadline`], other threads
193
+ /// are unaffected.
194
+ pub fn set_thread_execution_deadline(millis: Option<u64>) {
195
+ TRIPPED.with(|t| t.set(false));
196
+ POLL_TICK.with(|t| t.set(0));
197
+ let at = millis.map_or(0, |ms| (START.elapsed().as_nanos() as u64 + ms.saturating_mul(1_000_000)).max(1));
198
+ let was = THREAD_DEADLINE_NS.with(|d| d.replace(at));
199
+ match (was != 0, at != 0) {
200
+ (false, true) => {
201
+ THREADS_ARMED.fetch_add(1, Ordering::Relaxed);
202
+ }
203
+ (true, false) => {
204
+ THREADS_ARMED.fetch_sub(1, Ordering::Relaxed);
205
+ }
206
+ _ => {}
207
+ }
181
208
  }
182
209
 
183
210
  /// Arm the deadline `millis` from now, or disarm with `None`. Clears any previous trip.
@@ -197,7 +224,16 @@ mod deadline {
197
224
  /// Has the armed deadline passed? Cheap and false when disarmed.
198
225
  #[inline]
199
226
  pub fn execution_deadline_exceeded() -> bool {
200
- let at = DEADLINE_NS.load(Ordering::Relaxed);
227
+ let global = DEADLINE_NS.load(Ordering::Relaxed);
228
+ if global == 0 && THREADS_ARMED.load(Ordering::Relaxed) == 0 {
229
+ return false;
230
+ }
231
+ let thread = THREAD_DEADLINE_NS.with(|d| d.get());
232
+ let at = match (global, thread) {
233
+ (0, t) => t,
234
+ (g, 0) => g,
235
+ (g, t) => g.min(t),
236
+ };
201
237
  if at == 0 {
202
238
  return false;
203
239
  }
@@ -229,6 +265,7 @@ mod deadline {
229
265
  #[cfg(feature = "portable")]
230
266
  mod deadline {
231
267
  pub fn set_execution_deadline(_millis: Option<u64>) {}
268
+ pub fn set_thread_execution_deadline(_millis: Option<u64>) {}
232
269
  #[inline]
233
270
  pub fn execution_deadline_exceeded() -> bool {
234
271
  false
@@ -238,7 +275,9 @@ mod deadline {
238
275
  }
239
276
  }
240
277
 
241
- pub use deadline::{execution_deadline_exceeded, execution_deadline_tripped, set_execution_deadline};
278
+ pub use deadline::{
279
+ execution_deadline_exceeded, execution_deadline_tripped, set_execution_deadline, set_thread_execution_deadline,
280
+ };
242
281
 
243
282
  /// Message backends raise when [`execution_deadline_exceeded`] trips.
244
283
  pub const DEADLINE_ERROR: &str = "Test exceeded its timeout and was aborted";
@@ -16,6 +16,8 @@ timers = ["tishlang_vm/timers"]
16
16
  ws = ["tishlang_vm/ws"]
17
17
  tty = ["tishlang_vm/tty"]
18
18
  regex = ["tishlang_vm/regex"]
19
+ # tish:ffi loadModule; builds with every capability (`full`) pass it through like the others.
20
+ ffi = ["tishlang_vm/ffi"]
19
21
 
20
22
  [lib]
21
23
  crate-type = ["staticlib", "rlib"]
@@ -25,6 +25,12 @@ use std::ffi::{c_char, c_void, CStr, CString};
25
25
 
26
26
  use tishlang_core::{ObjectData, Value, VmRef};
27
27
 
28
+ mod v2;
29
+ pub use v2::{
30
+ TishCallbackFn, TishCtxDropFn, TishHostApi, TISH_FFI_ABI_VERSION, TISH_HOST_API,
31
+ TISH_TAG_FUNCTION,
32
+ };
33
+
28
34
  /// Opaque handle to a tish value. Internally `*mut Value` (a leaked `Box`); never inspect or
29
35
  /// free it except through the accessors. `null` is a valid "no value" sentinel for fallible
30
36
  /// accessors (e.g. `tish_value_object_get` on a missing key returns a fresh null handle, not
@@ -279,28 +285,33 @@ pub unsafe extern "C" fn tish_value_drop(r: TishValueRef) {
279
285
  /// reference semantics, so an extension can mutate a passed array); the per-call handles and the
280
286
  /// result handle are dropped here.
281
287
  pub fn wrap_native_fn(func: TishNativeFn) -> Value {
282
- Value::native(move |args: &[Value]| -> Value {
283
- let handles: Vec<TishValueRef> = args.iter().map(|v| box_value(v.clone())).collect();
284
- // Calling an `extern "C"` fn pointer is a safe operation; the unsafety is in the accessors.
285
- let result = func(handles.as_ptr(), handles.len());
286
- // SAFETY: every `handles[i]` came from `box_value`; `result` is a freshly-owned handle
287
- // from a `_new_*`/`_clone` accessor (the documented return contract).
288
- unsafe {
289
- let out = as_value(result).cloned().unwrap_or(Value::Null);
290
- // #384: a buggy extension may violate the "return a fresh handle" contract and hand back
291
- // one of its INPUT handles. `out` has already been cloned out of it, so the underlying
292
- // value is safe — but dropping both `handles[i]` and `result` would then be a double-free
293
- // (heap corruption). Drop each input once; drop `result` only if it is not an input alias.
294
- let result_aliases_input = handles.iter().any(|h| std::ptr::eq(*h, result));
295
- for h in handles {
296
- tish_value_drop(h);
297
- }
298
- if !result_aliases_input {
299
- tish_value_drop(result);
300
- }
301
- out
288
+ Value::native(move |args: &[Value]| -> Value { call_through_handles(args, |argv, argc| func(argv, argc)) })
289
+ }
290
+
291
+ /// Box `args` as owned handles, invoke `call`, unwrap the returned handle, and release them all.
292
+ pub(crate) fn call_through_handles(
293
+ args: &[Value],
294
+ call: impl FnOnce(*const TishValueRef, usize) -> TishValueRef,
295
+ ) -> Value {
296
+ let handles: Vec<TishValueRef> = args.iter().map(|v| box_value(v.clone())).collect();
297
+ let result = call(handles.as_ptr(), handles.len());
298
+ // SAFETY: every `handles[i]` came from `box_value`; `result` is a freshly-owned handle
299
+ // from a `_new_*`/`_clone` accessor (the documented return contract).
300
+ unsafe {
301
+ let out = as_value(result).cloned().unwrap_or(Value::Null);
302
+ // #384: a buggy extension may violate the "return a fresh handle" contract and hand back
303
+ // one of its INPUT handles. `out` has already been cloned out of it, so the underlying
304
+ // value is safe — but dropping both `handles[i]` and `result` would then be a double-free
305
+ // (heap corruption). Drop each input once; drop `result` only if it is not an input alias.
306
+ let result_aliases_input = handles.iter().any(|h| std::ptr::eq(*h, result));
307
+ for h in handles {
308
+ tish_value_drop(h);
309
+ }
310
+ if !result_aliases_input {
311
+ tish_value_drop(result);
302
312
  }
303
- })
313
+ out
314
+ }
304
315
  }
305
316
 
306
317
  /// Cached exports of one loaded module: name → C-ABI fn pointer. Fn pointers — unlike `Value`,
@@ -366,10 +377,22 @@ pub fn load_module(path: &str) -> Result<tishlang_core::ObjectMap, String> {
366
377
  let exports: CachedExports = unsafe {
367
378
  let lib =
368
379
  libloading::Library::new(path).map_err(|e| format!("ffi: load {}: {}", path, e))?;
369
- let register: libloading::Symbol<unsafe extern "C" fn() -> *const TishExportTable> = lib
370
- .get(b"tish_module_register")
371
- .map_err(|e| format!("ffi: {}: no tish_module_register: {}", path, e))?;
372
- let table = register();
380
+ // v2 modules receive the host API table; v1 modules resolve exported accessors.
381
+ let table = match lib.get::<unsafe extern "C" fn(*const TishHostApi) -> *const TishExportTable>(
382
+ b"tish_module_register_v2",
383
+ ) {
384
+ Ok(register_v2) => register_v2(&TISH_HOST_API),
385
+ Err(_) => {
386
+ let register: libloading::Symbol<unsafe extern "C" fn() -> *const TishExportTable> =
387
+ lib.get(b"tish_module_register").map_err(|e| {
388
+ format!(
389
+ "ffi: {}: no tish_module_register_v2 or tish_module_register: {}",
390
+ path, e
391
+ )
392
+ })?;
393
+ register()
394
+ }
395
+ };
373
396
  if table.is_null() {
374
397
  return Err(format!("ffi: {}: tish_module_register returned null", path));
375
398
  }