@tishlang/tish-format 3.12.1 → 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.1"
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]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tishlang/tish-format",
3
- "version": "3.12.1",
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