@tishlang/tish-format 3.12.0 → 3.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/tish-format CHANGED
Binary file
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang_fmt"
3
- version = "3.12.0"
3
+ version = "3.12.2"
4
4
  edition = "2021"
5
5
  description = "Opinionated formatter for Tish source (parse → pretty-print)"
6
6
  license-file = { workspace = true }
@@ -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]
@@ -108,6 +108,7 @@ pub fn spawn_children(n: usize) -> io::Result<Vec<Child>> {
108
108
  let mut out = Vec::with_capacity(n - 1);
109
109
  for i in 1..n {
110
110
  let mut cmd = Command::new(&exe);
111
+ crate::no_console_window(&mut cmd);
111
112
  cmd.args(&args);
112
113
  cmd.env("TISH_WORKER_ID", i.to_string());
113
114
  cmd.env("TISH_HTTP_PREFORK", "child");
@@ -1013,6 +1013,20 @@ pub fn process_cwd(_args: &[Value]) -> Value {
1013
1013
  }
1014
1014
 
1015
1015
  #[cfg(feature = "process")]
1016
+ /// Windows: a child spawned from a GUI-subsystem parent (a desktop app) gets its own console
1017
+ /// window unless created with CREATE_NO_WINDOW — a packaged app spawning git / node / shell
1018
+ /// helpers at boot flashed a terminal window per child and stole focus. No-op elsewhere.
1019
+ #[cfg(windows)]
1020
+ pub fn no_console_window(cmd: &mut std::process::Command) -> &mut std::process::Command {
1021
+ use std::os::windows::process::CommandExt;
1022
+ const CREATE_NO_WINDOW: u32 = 0x0800_0000;
1023
+ cmd.creation_flags(CREATE_NO_WINDOW)
1024
+ }
1025
+ #[cfg(not(windows))]
1026
+ pub fn no_console_window(cmd: &mut std::process::Command) -> &mut std::process::Command {
1027
+ cmd
1028
+ }
1029
+
1016
1030
  pub fn process_exec(args: &[Value]) -> Value {
1017
1031
  use std::process::Command;
1018
1032
  let cmd = args
@@ -1022,7 +1036,7 @@ pub fn process_exec(args: &[Value]) -> Value {
1022
1036
  if cmd.is_empty() {
1023
1037
  return Value::Number(0.0);
1024
1038
  }
1025
- match Command::new("sh").arg("-c").arg(&cmd).status() {
1039
+ match no_console_window(Command::new("sh").arg("-c").arg(&cmd)).status() {
1026
1040
  Ok(status) => Value::Number(status.code().unwrap_or(1) as f64),
1027
1041
  Err(_) => Value::Number(1.0),
1028
1042
  }
@@ -1046,7 +1060,7 @@ pub fn process_exec_file(args: &[Value]) -> Value {
1046
1060
  Some(Value::Array(a)) => a.borrow().iter().map(|v| v.to_display_string()).collect(),
1047
1061
  _ => Vec::new(),
1048
1062
  };
1049
- match Command::new(&program).args(&argv).status() {
1063
+ match no_console_window(Command::new(&program).args(&argv)).status() {
1050
1064
  Ok(status) => Value::Number(status.code().unwrap_or(1) as f64),
1051
1065
  Err(_) => Value::Number(1.0),
1052
1066
  }
@@ -1093,7 +1107,7 @@ pub fn process_exec_capture(args: &[Value]) -> Value {
1093
1107
  if cmd.is_empty() {
1094
1108
  return process_capture_obj(0, String::new(), String::new());
1095
1109
  }
1096
- process_capture_result(Command::new("sh").arg("-c").arg(&cmd).output())
1110
+ process_capture_result(no_console_window(Command::new("sh").arg("-c").arg(&cmd)).output())
1097
1111
  }
1098
1112
 
1099
1113
  /// `process.execFileCapture(program, [args])` — run a program directly, WITHOUT a shell, capturing
@@ -1115,7 +1129,7 @@ pub fn process_exec_file_capture(args: &[Value]) -> Value {
1115
1129
  Some(Value::Array(a)) => a.borrow().iter().map(|v| v.to_display_string()).collect(),
1116
1130
  _ => Vec::new(),
1117
1131
  };
1118
- process_capture_result(Command::new(&program).args(&argv).output())
1132
+ process_capture_result(no_console_window(Command::new(&program).args(&argv)).output())
1119
1133
  }
1120
1134
 
1121
1135
  #[cfg(all(test, feature = "process", unix))]
@@ -198,6 +198,7 @@ pub fn process_spawn(args: &[Value]) -> Value {
198
198
  };
199
199
 
200
200
  let mut cmd = Command::new(program);
201
+ crate::no_console_window(&mut cmd);
201
202
  if let Some(Value::Array(a)) = obj_field(opts, "args") {
202
203
  for v in a.borrow().iter() {
203
204
  cmd.arg(v.to_display_string());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tishlang/tish-format",
3
- "version": "3.12.0",
3
+ "version": "3.12.2",
4
4
  "description": "Tish code formatter",
5
5
  "license": "PIF",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
Binary file