@tishlang/tish-lint 3.12.1 → 3.14.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 (37) hide show
  1. package/Cargo.toml +1 -0
  2. package/bin/tish-lint +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 +11 -2
  6. package/crates/tish_compile/src/codegen.rs +93 -6
  7. package/crates/tish_compile/src/lib.rs +4 -0
  8. package/crates/tish_compile/src/resolve.rs +1 -0
  9. package/crates/tish_compile/tests/regr_ffi_module_emit.rs +120 -0
  10. package/crates/tish_core/src/lib.rs +43 -4
  11. package/crates/tish_cranelift_runtime/Cargo.toml +2 -0
  12. package/crates/tish_ffi/src/lib.rs +48 -25
  13. package/crates/tish_ffi/src/v2.rs +349 -0
  14. package/crates/tish_ffi/tests/fixtures/testmod_v2/Cargo.lock +282 -0
  15. package/crates/tish_ffi/tests/fixtures/testmod_v2/Cargo.toml +16 -0
  16. package/crates/tish_ffi/tests/fixtures/testmod_v2/src/lib.rs +111 -0
  17. package/crates/tish_ffi/tests/loader_v2.rs +159 -0
  18. package/crates/tish_ffi_guest/Cargo.toml +11 -0
  19. package/crates/tish_ffi_guest/src/lib.rs +333 -0
  20. package/crates/tish_lint/Cargo.toml +1 -1
  21. package/crates/tish_native/src/build.rs +48 -13
  22. package/crates/tish_native/src/config.rs +10 -0
  23. package/crates/tish_runtime/Cargo.toml +4 -0
  24. package/crates/tish_runtime/src/http.rs +44 -0
  25. package/crates/tish_runtime/src/http_fetch.rs +89 -16
  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/multi_vm_isolation.rs +123 -0
  32. package/package.json +1 -1
  33. package/platform/darwin-arm64/tish-lint +0 -0
  34. package/platform/darwin-x64/tish-lint +0 -0
  35. package/platform/linux-arm64/tish-lint +0 -0
  36. package/platform/linux-x64/tish-lint +0 -0
  37. package/platform/win32-x64/tish-lint.exe +0 -0
@@ -22,6 +22,7 @@ const RUNTIME_CARGO_FEATURES: &[&str] = &[
22
22
  "encoding",
23
23
  "crypto",
24
24
  "zip",
25
+ "ffi",
25
26
  ];
26
27
 
27
28
  /// Map CLI/compile features to flags passed to `tishlang_runtime` in the temp crate's Cargo.toml.
@@ -228,7 +229,18 @@ pub fn build_via_cargo_with_config(
228
229
  // statement; semantically transparent. `TISH_NATIVE_FAST_ALLOC=0` opts out.
229
230
  let use_fast_alloc = fast_alloc_enabled()
230
231
  && build_config.artifact != NativeArtifact::StaticLib
232
+ && build_config.artifact != NativeArtifact::FfiModule
231
233
  && build_config.cargo_target.is_none();
234
+ if build_config.artifact == NativeArtifact::FfiModule {
235
+ let guest_path = Path::new(&runtime_path)
236
+ .parent()
237
+ .map(|crates| crates.join("tish_ffi_guest"))
238
+ .ok_or_else(|| format!("Cannot locate tish_ffi_guest next to {}", runtime_path))?;
239
+ more_deps.push_str(&format!(
240
+ "\ntishlang_ffi_guest = {{ path = {:?} }}\n",
241
+ guest_path.display().to_string().replace('\\', "/")
242
+ ));
243
+ }
232
244
  if use_fast_alloc {
233
245
  more_deps.push_str("\nmimalloc = \"0.1\"\n");
234
246
  }
@@ -241,20 +253,25 @@ pub fn build_via_cargo_with_config(
241
253
  let ui_dep = ui_dep_toml(&runtime_path, rust_code);
242
254
 
243
255
  let profile = nested_release_profile_toml();
244
- let src_file = if build_config.artifact == NativeArtifact::StaticLib {
245
- "lib.rs"
246
- } else {
247
- "main.rs"
248
- };
249
- let crate_section = if build_config.artifact == NativeArtifact::StaticLib {
256
+ let is_lib_artifact = matches!(
257
+ build_config.artifact,
258
+ NativeArtifact::StaticLib | NativeArtifact::FfiModule
259
+ );
260
+ let src_file = if is_lib_artifact { "lib.rs" } else { "main.rs" };
261
+ let crate_section = if is_lib_artifact {
262
+ let crate_type = if build_config.artifact == NativeArtifact::FfiModule {
263
+ "cdylib"
264
+ } else {
265
+ "staticlib"
266
+ };
250
267
  format!(
251
268
  r#"[lib]
252
269
  name = "{}"
253
- crate-type = ["staticlib"]
270
+ crate-type = ["{}"]
254
271
  path = "src/lib.rs"
255
272
 
256
273
  "#,
257
- cargo_name
274
+ cargo_name, crate_type
258
275
  )
259
276
  } else {
260
277
  format!(
@@ -317,12 +334,16 @@ edition = "2021"
317
334
 
318
335
  tishlang_build_utils::run_cargo_build(&build_dir, target_dir.as_deref(), cross)?;
319
336
 
320
- let artifact = if build_config.artifact == NativeArtifact::StaticLib {
321
- tishlang_build_utils::find_release_staticlib(&binary_dir, &cargo_name)?
322
- } else {
323
- tishlang_build_utils::find_release_binary(&binary_dir, &cargo_name)?
337
+ let artifact = match build_config.artifact {
338
+ NativeArtifact::StaticLib => {
339
+ tishlang_build_utils::find_release_staticlib(&binary_dir, &cargo_name)?
340
+ }
341
+ NativeArtifact::FfiModule => find_release_cdylib(&binary_dir, &cargo_name)?,
342
+ _ => tishlang_build_utils::find_release_binary(&binary_dir, &cargo_name)?,
324
343
  };
325
- let target = if build_config.artifact == NativeArtifact::StaticLib {
344
+ let target = if build_config.artifact == NativeArtifact::FfiModule {
345
+ output_path.to_path_buf()
346
+ } else if build_config.artifact == NativeArtifact::StaticLib {
326
347
  if output_path.extension().is_some_and(|e| e == "a") {
327
348
  output_path.to_path_buf()
328
349
  } else if output_path.to_string_lossy().ends_with('/') || output_path.is_dir() {
@@ -339,6 +360,20 @@ edition = "2021"
339
360
  Ok(())
340
361
  }
341
362
 
363
+ /// The built `cdylib` for `cargo_name` under `dir` (`lib<name>.dylib` / `lib<name>.so` / `<name>.dll`).
364
+ fn find_release_cdylib(dir: &Path, cargo_name: &str) -> Result<PathBuf, String> {
365
+ let candidates = [
366
+ format!("lib{cargo_name}.dylib"),
367
+ format!("lib{cargo_name}.so"),
368
+ format!("{cargo_name}.dll"),
369
+ ];
370
+ candidates
371
+ .iter()
372
+ .map(|c| dir.join(c))
373
+ .find(|p| p.is_file())
374
+ .ok_or_else(|| format!("Built cdylib not found in {} (looked for {:?})", dir.display(), candidates))
375
+ }
376
+
342
377
  /// Read the agb version the facade pins, from `<facade>/Cargo.toml`. This keeps the generated ROM
343
378
  /// crate's agb in lockstep with the facade's (the single source of truth) — see [`build_gba_rom`].
344
379
  /// Matches an `agb = "X"` or `agb = { version = "X", … }` line; `None` if absent (caller falls back).
@@ -15,6 +15,8 @@ pub enum NativeArtifact {
15
15
  /// module's `export fn`s. Unlike every other artifact, cargo is **not** run — the emitted
16
16
  /// crate directory is itself the output, for the consumer to add as a path/registry dependency.
17
17
  RustLib,
18
+ /// A dynamically loadable tish native module (`crate-type = ["cdylib"]`, FFI ABI v2).
19
+ FfiModule,
18
20
  }
19
21
 
20
22
  /// Options passed from the CLI into nested `cargo build`.
@@ -59,6 +61,14 @@ impl NativeBuildConfig {
59
61
  }
60
62
  }
61
63
 
64
+ pub fn ffi_module() -> Self {
65
+ Self {
66
+ artifact: NativeArtifact::FfiModule,
67
+ cargo_target: None,
68
+ emit_mode: NativeEmitMode::FfiModule,
69
+ }
70
+ }
71
+
62
72
  pub fn is_cross_compile(&self) -> bool {
63
73
  self.cargo_target.is_some()
64
74
  }
@@ -65,6 +65,9 @@ encoding = ["dep:base64"]
65
65
  crypto = ["dep:sha2", "dep:rand"]
66
66
  # Zip extraction (tish:zip) — VSIX / archive extract with `..`-traversal guard + unix-mode preserved.
67
67
  zip = ["dep:zip"]
68
+ # Runtime loading of tish FFI cdylibs (tish:ffi `loadModule`), e.g. plugins built with
69
+ # `tish build --crate-type cdylib`. Hosts need no linker flags: ABI v2 passes the accessor table.
70
+ ffi = ["dep:tishlang_ffi"]
68
71
  # Interactive terminal I/O (issue #101): raw mode, key/resize events, size, alt screen.
69
72
  tty = ["dep:crossterm"]
70
73
  # Pseudoterminal hosting (issue #493): spawn a shell/program on a real PTY, read/write/resize/kill —
@@ -97,6 +100,7 @@ futures = { version = "0.3.32", optional = true }
97
100
  base64 = { version = "0.22", optional = true }
98
101
  sha2 = { version = "0.10", optional = true }
99
102
  zip = { version = "2", default-features = false, features = ["deflate"], optional = true }
103
+ tishlang_ffi = { path = "../tish_ffi", version = ">=0.1", optional = true }
100
104
  bytes = { version = "1", optional = true }
101
105
  tiny_http = { version = "0.12.0", optional = true }
102
106
  # fs feature only: random suffix for `mkdtemp`.
@@ -83,6 +83,37 @@ pub(crate) fn subscriber_runtime() -> &'static Runtime {
83
83
  })
84
84
  }
85
85
 
86
+ /// Process-global runtime that drives every outbound `fetch` (send, body reads, text/json).
87
+ ///
88
+ /// Outbound fetch used the thread-local [`RUNTIME`] of whichever thread issued the call. The
89
+ /// reqwest client — and its keep-alive connection POOL — is process-global, but each pooled
90
+ /// connection's driver task lives on the runtime that opened it. In a multi-threaded host
91
+ /// (tish-desktop dispatches each invoke on a pool thread; `block_on_http` even runs on a scoped
92
+ /// thread that dies with the call) the NEXT request could pick a pooled connection whose runtime
93
+ /// was idle or gone: the body then failed mid-stream as "error decoding response body". It hit
94
+ /// exactly the request after a completed stream, intermittently, and never in a single-threaded
95
+ /// program. One immortal runtime for all fetch I/O makes pooled connections always driven.
96
+ pub(crate) fn fetch_runtime() -> &'static Runtime {
97
+ static RT: OnceLock<Runtime> = OnceLock::new();
98
+ RT.get_or_init(|| {
99
+ tokio::runtime::Builder::new_multi_thread()
100
+ .worker_threads(4)
101
+ .thread_name("tish-fetch")
102
+ .enable_all()
103
+ .build()
104
+ .expect("Failed to create fetch runtime")
105
+ })
106
+ }
107
+
108
+ /// `RUNTIME.with(|rt| …)`-shaped handle onto [`fetch_runtime`], so call sites keep their shape.
109
+ pub(crate) struct FetchRt;
110
+ impl FetchRt {
111
+ pub(crate) fn with<R>(&self, f: impl FnOnce(&Runtime) -> R) -> R {
112
+ f(fetch_runtime())
113
+ }
114
+ }
115
+ pub(crate) static FETCH_RT: FetchRt = FetchRt;
116
+
86
117
  pub fn await_fetch(args: Vec<Value>) -> Value {
87
118
  crate::promise::await_promise(crate::native_promise::fetch_promise(args))
88
119
  }
@@ -130,6 +161,19 @@ pub(crate) fn extract_body(options: Option<&Value>) -> Option<String> {
130
161
  })
131
162
  }
132
163
 
164
+ /// `fetch(url, { timeout: ms })` — per-request TOTAL timeout in milliseconds. `None` when absent
165
+ /// (the sender applies the default), `Some(0)` disables the total timeout for that request (a
166
+ /// long-lived stream such as an SSE chat completion); the client's idle read timeout still bounds it.
167
+ pub(crate) fn extract_timeout_ms(options: Option<&Value>) -> Option<u64> {
168
+ options.and_then(|v| match v {
169
+ Value::Object(obj) => obj.borrow().strings.get("timeout").and_then(|t| match t {
170
+ Value::Number(n) if n.is_finite() && *n >= 0.0 => Some(*n as u64),
171
+ _ => None,
172
+ }),
173
+ _ => None,
174
+ })
175
+ }
176
+
133
177
  /// A single `multipart/form-data` part extracted from a `fetch` options object's `multipart` array.
134
178
  pub(crate) enum MultipartPart {
135
179
  /// A text field: `{ name, value }`.
@@ -12,7 +12,7 @@ use tishlang_core::{NativeFn, ObjectMap, TishOpaque, TishPromise, Value};
12
12
 
13
13
  use crate::http::{
14
14
  build_error_response, extract_body, extract_headers, extract_method, extract_multipart,
15
- MultipartPart,
15
+ extract_timeout_ms, MultipartPart,
16
16
  };
17
17
 
18
18
  // --- Promises (Send payloads only; Value built on awaiting/settling thread) ---
@@ -373,7 +373,7 @@ impl TishOpaque for HttpStreamReader {
373
373
  let inner = Arc::clone(&inner);
374
374
  let body = Arc::clone(&body);
375
375
  let (tx, rx) = tokio::sync::oneshot::channel();
376
- crate::http::RUNTIME.with(|rt| {
376
+ crate::http::FETCH_RT.with(|rt| {
377
377
  rt.spawn(async move {
378
378
  let mut slot = inner.lock().await;
379
379
  match slot.stream.next().await {
@@ -421,7 +421,7 @@ pub fn response_value_from_reqwest(response: reqwest::Response) -> Value {
421
421
  let text_fn: NativeFn = tishlang_core::native_fn(move |_args: &[Value]| {
422
422
  let bh = Arc::clone(&bh_text);
423
423
  let (tx, rx) = tokio::sync::oneshot::channel();
424
- crate::http::RUNTIME.with(|rt| {
424
+ crate::http::FETCH_RT.with(|rt| {
425
425
  rt.spawn(async move {
426
426
  let r = bh.take_text_async().await;
427
427
  let _ = tx.send(r);
@@ -432,7 +432,7 @@ pub fn response_value_from_reqwest(response: reqwest::Response) -> Value {
432
432
  let json_fn: NativeFn = tishlang_core::native_fn(move |_args: &[Value]| {
433
433
  let bh = Arc::clone(&bh_json);
434
434
  let (tx, rx) = tokio::sync::oneshot::channel();
435
- crate::http::RUNTIME.with(|rt| {
435
+ crate::http::FETCH_RT.with(|rt| {
436
436
  rt.spawn(async move {
437
437
  let r = bh.take_text_async().await;
438
438
  let _ = tx.send(r);
@@ -452,21 +452,56 @@ pub fn response_value_from_reqwest(response: reqwest::Response) -> Value {
452
452
  Value::object(obj)
453
453
  }
454
454
 
455
- /// Shared, hardened outbound HTTP client: request + connect timeouts (a no-timeout fetch is
455
+ /// Default TOTAL request timeout (headers + whole body), applied per request unless the caller
456
+ /// passes `{ timeout: ms }`. Override the default with `TISH_FETCH_TIMEOUT_MS`.
457
+ const DEFAULT_TOTAL_TIMEOUT_MS: u64 = 30_000;
458
+ /// Idle READ timeout: the longest the body may go without a single byte before the request is
459
+ /// failed. This is what bounds a `{ timeout: 0 }` stream (an SSE chat completion that legitimately
460
+ /// runs for minutes). Override with `TISH_FETCH_READ_TIMEOUT_MS`.
461
+ const DEFAULT_READ_TIMEOUT_MS: u64 = 120_000;
462
+
463
+ /// Pure env parse for a millisecond timeout knob: a valid non-negative integer, else the default.
464
+ fn parse_timeout_ms(env: Option<String>, default: u64) -> u64 {
465
+ env.and_then(|v| v.trim().parse::<u64>().ok()).unwrap_or(default)
466
+ }
467
+
468
+ fn default_total_timeout_ms() -> u64 {
469
+ use std::sync::OnceLock;
470
+ static MS: OnceLock<u64> = OnceLock::new();
471
+ *MS.get_or_init(|| {
472
+ parse_timeout_ms(
473
+ std::env::var("TISH_FETCH_TIMEOUT_MS").ok(),
474
+ DEFAULT_TOTAL_TIMEOUT_MS,
475
+ )
476
+ })
477
+ }
478
+
479
+ /// Shared, hardened outbound HTTP client: connect + idle-read timeouts (a no-timeout fetch is
456
480
  /// an outbound resource-pinning / slowloris vector) and a bounded redirect chain. Cached so
457
481
  /// the connection pool is reused across requests.
482
+ ///
483
+ /// The TOTAL timeout is deliberately NOT set on the client: `ClientBuilder::timeout` covers the
484
+ /// entire body, so a streaming response that ran past it died as "error decoding response body"
485
+ /// no matter how healthily bytes were flowing (an LLM tool-call stream at 30s+ was cut every
486
+ /// time). It is applied per request in `send_request_parts` instead, where `{ timeout: ms }` can
487
+ /// override it and `{ timeout: 0 }` can turn it off, leaving the idle read timeout as the guard.
458
488
  /// NOTE: this does NOT yet block internal/metadata IPs — full SSRF defense (deny loopback /
459
489
  /// link-local / RFC1918 after DNS resolution) is a follow-up that needs a policy decision.
460
490
  fn fetch_client() -> &'static reqwest::Client {
461
491
  use std::sync::OnceLock;
462
492
  static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
463
493
  CLIENT.get_or_init(|| {
464
- reqwest::Client::builder()
465
- .timeout(std::time::Duration::from_secs(30))
494
+ let read_ms = parse_timeout_ms(
495
+ std::env::var("TISH_FETCH_READ_TIMEOUT_MS").ok(),
496
+ DEFAULT_READ_TIMEOUT_MS,
497
+ );
498
+ let mut b = reqwest::Client::builder()
466
499
  .connect_timeout(std::time::Duration::from_secs(10))
467
- .redirect(reqwest::redirect::Policy::limited(5))
468
- .build()
469
- .unwrap_or_else(|_| reqwest::Client::new())
500
+ .redirect(reqwest::redirect::Policy::limited(5));
501
+ if read_ms > 0 {
502
+ b = b.read_timeout(std::time::Duration::from_millis(read_ms));
503
+ }
504
+ b.build().unwrap_or_else(|_| reqwest::Client::new())
470
505
  })
471
506
  }
472
507
 
@@ -577,6 +612,7 @@ async fn send_request_parts(
577
612
  headers: Vec<(String, String)>,
578
613
  body: Option<String>,
579
614
  multipart: Option<Vec<MultipartPart>>,
615
+ timeout_ms: Option<u64>,
580
616
  ) -> Result<reqwest::Response, String> {
581
617
  if fetch_block_private() {
582
618
  ssrf_preflight(&url).await?;
@@ -591,6 +627,11 @@ async fn send_request_parts(
591
627
  "HEAD" => client.head(&url),
592
628
  _ => client.get(&url),
593
629
  };
630
+ // Total timeout: the request's own `{ timeout }` (0 = none) else the process default.
631
+ let total_ms = timeout_ms.unwrap_or_else(default_total_timeout_ms);
632
+ if total_ms > 0 {
633
+ req = req.timeout(std::time::Duration::from_millis(total_ms));
634
+ }
594
635
  for (key, value) in headers {
595
636
  req = req.header(key, value);
596
637
  }
@@ -648,10 +689,11 @@ pub fn fetch_promise_from_args(args: Vec<Value>) -> Value {
648
689
  let headers = extract_headers(args.get(1));
649
690
  let body = extract_body(args.get(1));
650
691
  let multipart = extract_multipart(args.get(1));
692
+ let timeout_ms = extract_timeout_ms(args.get(1));
651
693
  let (tx, rx) = tokio::sync::oneshot::channel();
652
- crate::http::RUNTIME.with(|rt| {
694
+ crate::http::FETCH_RT.with(|rt| {
653
695
  rt.spawn(async move {
654
- let r = send_request_parts(url, method, headers, body, multipart).await;
696
+ let r = send_request_parts(url, method, headers, body, multipart, timeout_ms).await;
655
697
  let _ = tx.send(r);
656
698
  });
657
699
  });
@@ -678,6 +720,7 @@ pub fn fetch_all_promise_from_args(args: Vec<Value>) -> Value {
678
720
  Vec<(String, String)>,
679
721
  Option<String>,
680
722
  Option<Vec<MultipartPart>>,
723
+ Option<u64>,
681
724
  )> = Vec::new();
682
725
  for req in requests {
683
726
  let (url, opt) = match &req {
@@ -714,14 +757,15 @@ pub fn fetch_all_promise_from_args(args: Vec<Value>) -> Value {
714
757
  let headers = extract_headers(opt.as_ref());
715
758
  let body = extract_body(opt.as_ref());
716
759
  let multipart = extract_multipart(opt.as_ref());
717
- parts.push((url, method, headers, body, multipart));
760
+ let timeout_ms = extract_timeout_ms(opt.as_ref());
761
+ parts.push((url, method, headers, body, multipart, timeout_ms));
718
762
  }
719
763
  let (tx, rx) = tokio::sync::oneshot::channel();
720
- crate::http::RUNTIME.with(|rt| {
764
+ crate::http::FETCH_RT.with(|rt| {
721
765
  rt.spawn(async move {
722
766
  let futs: Vec<_> = parts
723
767
  .into_iter()
724
- .map(|(url, m, h, b, mp)| send_request_parts(url, m, h, b, mp))
768
+ .map(|(url, m, h, b, mp, t)| send_request_parts(url, m, h, b, mp, t))
725
769
  .collect();
726
770
  let results = futures::future::join_all(futs).await;
727
771
  let mapped: Vec<Result<reqwest::Response, String>> = results.into_iter().collect();
@@ -735,7 +779,36 @@ pub fn fetch_all_promise_from_args(args: Vec<Value>) -> Value {
735
779
 
736
780
  #[cfg(test)]
737
781
  mod fetch_cap_tests_383 {
738
- use super::{is_blocked_ip, parse_max_body};
782
+ use super::{is_blocked_ip, parse_max_body, parse_timeout_ms, DEFAULT_READ_TIMEOUT_MS, DEFAULT_TOTAL_TIMEOUT_MS};
783
+
784
+ #[test]
785
+ fn timeout_env_defaults_when_unset_or_invalid() {
786
+ assert_eq!(parse_timeout_ms(None, DEFAULT_TOTAL_TIMEOUT_MS), 30_000);
787
+ assert_eq!(parse_timeout_ms(Some("nope".into()), DEFAULT_READ_TIMEOUT_MS), 120_000);
788
+ assert_eq!(parse_timeout_ms(Some("-5".into()), DEFAULT_TOTAL_TIMEOUT_MS), 30_000);
789
+ }
790
+
791
+ #[test]
792
+ fn timeout_env_parses_including_zero_meaning_off() {
793
+ assert_eq!(parse_timeout_ms(Some(" 45000 ".into()), DEFAULT_TOTAL_TIMEOUT_MS), 45_000);
794
+ assert_eq!(parse_timeout_ms(Some("0".into()), DEFAULT_TOTAL_TIMEOUT_MS), 0);
795
+ }
796
+
797
+ #[test]
798
+ fn per_request_timeout_option_extraction() {
799
+ use crate::http::extract_timeout_ms;
800
+ use tishlang_core::{ObjectMap, Value};
801
+ let mut m = ObjectMap::default();
802
+ m.insert(std::sync::Arc::from("timeout"), Value::Number(0.0));
803
+ assert_eq!(extract_timeout_ms(Some(&Value::object(m))), Some(0));
804
+ let mut m2 = ObjectMap::default();
805
+ m2.insert(std::sync::Arc::from("timeout"), Value::Number(90_000.0));
806
+ assert_eq!(extract_timeout_ms(Some(&Value::object(m2))), Some(90_000));
807
+ let mut m3 = ObjectMap::default();
808
+ m3.insert(std::sync::Arc::from("timeout"), Value::String("soon".into()));
809
+ assert_eq!(extract_timeout_ms(Some(&Value::object(m3))), None);
810
+ assert_eq!(extract_timeout_ms(None), None);
811
+ }
739
812
  use std::net::IpAddr;
740
813
 
741
814
  #[test]
@@ -1828,6 +1828,27 @@ pub mod zip;
1828
1828
  #[cfg(feature = "zip")]
1829
1829
  pub use zip::{zip_entries, zip_extract};
1830
1830
 
1831
+ /// `tish:ffi` `loadModule(path)`: load a tish FFI cdylib (ABI v2, falling back to v1) and return
1832
+ /// its exports as an object. Loading the same path again returns the cached module. Throws a
1833
+ /// `TypeError` if the path is not a string or the library cannot be loaded.
1834
+ #[cfg(all(feature = "ffi", not(target_arch = "wasm32")))]
1835
+ pub fn ffi_load_module(args: &[Value]) -> Value {
1836
+ let path = match args.first() {
1837
+ Some(Value::String(s)) => s.to_string(),
1838
+ _ => {
1839
+ set_pending_throw(tishlang_core::type_error("loadModule: expected a library path string"));
1840
+ return Value::Null;
1841
+ }
1842
+ };
1843
+ match tishlang_ffi::load_module(&path) {
1844
+ Ok(exports) => Value::object(exports),
1845
+ Err(e) => {
1846
+ set_pending_throw(tishlang_core::type_error(format!("loadModule: {e}")));
1847
+ Value::Null
1848
+ }
1849
+ }
1850
+ }
1851
+
1831
1852
  #[cfg(feature = "ws")]
1832
1853
  pub use ws::{
1833
1854
  web_socket_client, web_socket_server_accept, web_socket_server_construct,
@@ -32,6 +32,8 @@ http = ["dep:tishlang_runtime", "tishlang_runtime/http", "send-values", "timers"
32
32
  # Promise + `tish:http.await` / `Promise` export without the full HTTP client stack (e.g. wasm32-wasip1).
33
33
  promise = ["dep:tishlang_runtime", "tishlang_runtime/promise", "send-values"]
34
34
  ws = ["dep:tishlang_runtime", "tishlang_runtime/ws"]
35
+ # `tish:ffi` loadModule: runtime loading of tish FFI cdylibs.
36
+ ffi = ["dep:tishlang_runtime", "tishlang_runtime/ffi"]
35
37
 
36
38
  [dependencies]
37
39
  tishlang_ast = { path = "../tish_ast", version = ">=0.1" }
@@ -134,7 +134,9 @@ fn value_object_from_map(m: ObjectMap) -> Value {
134
134
  feature = "promise",
135
135
  feature = "timers",
136
136
  feature = "process",
137
- feature = "ws"
137
+ feature = "ws",
138
+ feature = "tty",
139
+ feature = "ffi"
138
140
  ))]
139
141
  #[inline]
140
142
  fn cap_allows(enabled: &HashSet<String>, name: &str) -> bool {
@@ -161,6 +163,8 @@ pub fn all_compiled_capabilities() -> HashSet<String> {
161
163
  s.insert("ws".to_string());
162
164
  #[cfg(feature = "tty")]
163
165
  s.insert("tty".to_string());
166
+ #[cfg(feature = "ffi")]
167
+ s.insert("ffi".to_string());
164
168
  s
165
169
  }
166
170
 
@@ -173,7 +177,8 @@ pub fn all_compiled_capabilities() -> HashSet<String> {
173
177
  feature = "timers",
174
178
  feature = "process",
175
179
  feature = "ws",
176
- feature = "tty"
180
+ feature = "tty",
181
+ feature = "ffi"
177
182
  )),
178
183
  allow(unused_variables)
179
184
  )]
@@ -455,6 +460,15 @@ fn get_builtin_export(enabled: &HashSet<String>, spec: &str, export_name: &str)
455
460
  _ => None,
456
461
  };
457
462
  }
463
+ #[cfg(feature = "ffi")]
464
+ if spec == "tish:ffi" && cap_allows(enabled, "ffi") {
465
+ return match export_name {
466
+ "loadModule" => Some(Value::native(|args: &[Value]| {
467
+ tishlang_runtime::ffi_load_module(args)
468
+ })),
469
+ _ => None,
470
+ };
471
+ }
458
472
  None
459
473
  }
460
474
 
@@ -1169,6 +1183,13 @@ pub struct Vm {
1169
1183
  /// [`register_native_module`]). Phase-2 item 11: unblocks `cargo:`
1170
1184
  /// imports on the cranelift and llvm backends which run this VM.
1171
1185
  native_modules: VmRef<HashMap<String, VmRef<ObjectMap>>>,
1186
+ /// Numeric JIT + OSR for code this VM runs (default on). The JIT's code caches and callee
1187
+ /// registry are process-global and the registry is keyed by bare global name, so two VMs in one
1188
+ /// process that both JIT can resolve each other's same-named functions. An embedder hosting
1189
+ /// mutually untrusted programs turns this off ([`Vm::set_jit_enabled`]): such a VM never
1190
+ /// compiles, never OSRs (so every loop polls the execution deadline), and never touches the
1191
+ /// shared registry. Inherited by every closure the VM creates.
1192
+ jit: bool,
1172
1193
  }
1173
1194
 
1174
1195
  /// A bytecode-VM closure: a compiled chunk plus its captured lexical chain and shared VM state.
@@ -1584,6 +1605,8 @@ struct VmClosure {
1584
1605
  globals: VmRef<ObjectMap>,
1585
1606
  capabilities: Arc<HashSet<String>>,
1586
1607
  native_modules: VmRef<HashMap<String, VmRef<ObjectMap>>>,
1608
+ /// The creating VM's [`Vm::jit`] setting, carried into every `Vm` built to run this closure.
1609
+ jit: bool,
1587
1610
  }
1588
1611
 
1589
1612
  impl tishlang_core::Callable for VmClosure {
@@ -1681,6 +1704,11 @@ impl tishlang_core::Callable for VmClosure {
1681
1704
  // than overflowing the native stack (this recursive re-entry is the DEFAULT `tish run` path).
1682
1705
  // The counter is thread-local because each closure call builds a fresh `Vm`; the parked throw
1683
1706
  // is picked up by the caller's `take_pending_throw()` check (Call/SelfCall post-call).
1707
+ // Loop-free recursion (e.g. naive `fib`) never reaches a back-edge, so calls poll too.
1708
+ if tishlang_core::execution_deadline_exceeded() {
1709
+ set_pending_throw(construct_builtin::error_object("Error", tishlang_core::DEADLINE_ERROR));
1710
+ return Value::Null;
1711
+ }
1684
1712
  let depth = tishlang_core::inc_call_depth();
1685
1713
  if depth > max_call_depth() {
1686
1714
  tishlang_core::dec_call_depth();
@@ -1694,6 +1722,7 @@ impl tishlang_core::Callable for VmClosure {
1694
1722
  globals: self.globals.clone(),
1695
1723
  capabilities: Arc::clone(&self.capabilities),
1696
1724
  native_modules: self.native_modules.clone(),
1725
+ jit: self.jit,
1697
1726
  };
1698
1727
  #[cfg(not(target_arch = "wasm32"))]
1699
1728
  let run = stacker::maybe_grow(128 * 1024, 2 * 1024 * 1024, || {
@@ -1742,9 +1771,21 @@ impl Vm {
1742
1771
  globals: VmRef::new(init_globals(capabilities.as_ref())),
1743
1772
  capabilities,
1744
1773
  native_modules: VmRef::new(HashMap::new()),
1774
+ jit: true,
1745
1775
  }
1746
1776
  }
1747
1777
 
1778
+ /// Turn the numeric JIT and OSR on or off for this VM and every closure it creates from now on
1779
+ /// (see [`Vm::jit`]). Set before [`Vm::run`]. Off isolates a VM from other VMs' JIT state in the
1780
+ /// same process and guarantees every loop polls the execution deadline; it never changes results.
1781
+ pub fn set_jit_enabled(&mut self, on: bool) {
1782
+ self.jit = on;
1783
+ }
1784
+
1785
+ pub fn jit_enabled(&self) -> bool {
1786
+ self.jit
1787
+ }
1788
+
1748
1789
  /// Register an externally-supplied native module under a `cargo:`-style
1749
1790
  /// spec (e.g. `"cargo:tish_pg"`). The `exports` map is what
1750
1791
  /// `LoadNativeExport` will index into when user code imports from this
@@ -1811,8 +1852,11 @@ impl Vm {
1811
1852
  // here (the outermost entry — closures run via `run_chunk`, not this) so a long-lived process
1812
1853
  // (REPL / embedder) running a NEW program never resolves a stale callee registered by a prior
1813
1854
  // one. A cross-caller and its callee always compile within the same run, so this is sufficient.
1855
+ // A JIT-off VM registers no callees, so it must not clear other VMs' registrations either.
1814
1856
  #[cfg(not(target_arch = "wasm32"))]
1815
- crate::jit::reset_callees();
1857
+ if self.jit {
1858
+ crate::jit::reset_callees();
1859
+ }
1816
1860
  let result = self.run_chunk(chunk, &chunk.nested, &[], repl_mode);
1817
1861
  // A throw that escaped every `catch` reaches here as the pending-throw sentinel; turn the
1818
1862
  // parked value into the conventional uncaught-error message (issue #60).
@@ -2188,7 +2232,7 @@ impl Vm {
2188
2232
  // `region_end - dist`. Once a loop is hot, try to run its remaining iterations in
2189
2233
  // native code (numeric slot loops only; anything else falls straight through).
2190
2234
  #[cfg(not(target_arch = "wasm32"))]
2191
- if Self::osr_enabled() {
2235
+ if self.jit && Self::osr_enabled() {
2192
2236
  let region_end = ip;
2193
2237
  let header_ip = ip.saturating_sub(dist);
2194
2238
  let key = (Arc::as_ptr(&cur) as usize, header_ip);
@@ -2237,6 +2281,9 @@ impl Vm {
2237
2281
  set_pending_throw(stack_overflow_error());
2238
2282
  return Some(Err(PENDING_THROW_SENTINEL.to_string()));
2239
2283
  }
2284
+ if tishlang_core::execution_deadline_exceeded() {
2285
+ return Some(Err(tishlang_core::DEADLINE_ERROR.to_string()));
2286
+ }
2240
2287
  frames.push((cur.clone(), ip, slot_base, stack_base, enclosing.clone()));
2241
2288
  let new_base = slots.len();
2242
2289
  slots.resize(new_base + cur.num_slots as usize, Value::Null);
@@ -2276,6 +2323,9 @@ impl Vm {
2276
2323
  set_pending_throw(stack_overflow_error());
2277
2324
  return Some(Err(PENDING_THROW_SENTINEL.to_string()));
2278
2325
  }
2326
+ if tishlang_core::execution_deadline_exceeded() {
2327
+ return Some(Err(tishlang_core::DEADLINE_ERROR.to_string()));
2328
+ }
2279
2329
  frames.push((cur, ip, slot_base, stack_base, enclosing));
2280
2330
  cur = next_chunk;
2281
2331
  enclosing = next_enc;
@@ -2617,7 +2667,7 @@ impl Vm {
2617
2667
  // and call native code when all args are numbers; else fall back to
2618
2668
  // the interpreter below. Purely additive — can't change behaviour.
2619
2669
  #[cfg(not(target_arch = "wasm32"))]
2620
- let jit_fn = crate::jit::try_compile_numeric(inner);
2670
+ let jit_fn = if self.jit { crate::jit::try_compile_numeric(inner) } else { None };
2621
2671
  let inner_clone = inner.clone();
2622
2672
  let globals = self.globals.clone();
2623
2673
  // The closure captures its defining frame's scope PLUS that frame's own
@@ -2674,6 +2724,7 @@ impl Vm {
2674
2724
  globals,
2675
2725
  capabilities,
2676
2726
  native_modules,
2727
+ jit: self.jit,
2677
2728
  };
2678
2729
  #[cfg(feature = "send-values")]
2679
2730
  {
@@ -2957,6 +3008,9 @@ impl Vm {
2957
3008
  // #381: SelfCall is a second native recursive re-entry (a self-recursive function
2958
3009
  // re-enters run_chunk directly). Bound it with the same shared counter; `raise!` is
2959
3010
  // in scope here, so throw the catchable RangeError directly.
3011
+ if tishlang_core::execution_deadline_exceeded() {
3012
+ return Err(tishlang_core::DEADLINE_ERROR.to_string());
3013
+ }
2960
3014
  let depth = tishlang_core::inc_call_depth();
2961
3015
  if depth > max_call_depth() {
2962
3016
  tishlang_core::dec_call_depth();
@@ -2969,6 +3023,7 @@ impl Vm {
2969
3023
  globals: self.globals.clone(),
2970
3024
  capabilities: Arc::clone(&self.capabilities),
2971
3025
  native_modules: self.native_modules.clone(),
3026
+ jit: self.jit,
2972
3027
  };
2973
3028
  #[cfg(not(target_arch = "wasm32"))]
2974
3029
  let result = stacker::maybe_grow(128 * 1024, 2 * 1024 * 1024, || {
@@ -3109,7 +3164,7 @@ impl Vm {
3109
3164
  // only (`slot_locals` is the live frame); non-slot / non-numeric loops fail the
3110
3165
  // whitelist or the live-in check and fall straight through to the interpreter.
3111
3166
  #[cfg(not(target_arch = "wasm32"))]
3112
- if chunk.slot_based && Self::osr_enabled() {
3167
+ if self.jit && chunk.slot_based && Self::osr_enabled() {
3113
3168
  let region_end = ip;
3114
3169
  let header_ip = ip.saturating_sub(dist);
3115
3170
  // Count on the RAW header via the single-entry fast slot — byte-identical to the
@@ -0,0 +1,32 @@
1
+ //! `tish:ffi` `loadModule` on the bytecode VM. Loading real modules is covered by
2
+ //! `crates/tish_ffi/tests/loader_v2.rs`; this pins the builtin wiring and its error contract.
3
+ #![cfg(feature = "ffi")]
4
+
5
+ use tishlang_bytecode::compile;
6
+ use tishlang_vm::run;
7
+
8
+ fn run_src(src: &str) {
9
+ let cwd = std::env::current_dir().expect("cwd");
10
+ let modules = tishlang_compile::resolve_project_from_stdin(src, &cwd).expect("resolve");
11
+ let program = tishlang_compile::merge_modules(modules).expect("merge").program;
12
+ let chunk = compile(&program).expect("compile");
13
+ run(&chunk).expect("run");
14
+ }
15
+
16
+ #[test]
17
+ fn load_failures_throw_catchable_type_errors() {
18
+ run_src(
19
+ r#"
20
+ import { loadModule } from "tish:ffi"
21
+ if (typeof loadModule !== "function") { throw "loadModule is not wired" }
22
+
23
+ let missing = ""
24
+ try { loadModule("/nonexistent/nimble-test.lib") } catch (e) { missing = e.name + " " + e.message }
25
+ if (missing.indexOf("TypeError loadModule:") !== 0) { throw "missing library: " + missing }
26
+
27
+ let badArg = ""
28
+ try { loadModule(42) } catch (e) { badArg = e.name }
29
+ if (badArg !== "TypeError") { throw "non-string path: " + badArg }
30
+ "#,
31
+ );
32
+ }