@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 +0 -0
- package/crates/tish_fmt/Cargo.toml +1 -1
- package/crates/tish_runtime/src/http.rs +44 -0
- package/crates/tish_runtime/src/http_fetch.rs +89 -16
- package/package.json +1 -1
- package/platform/darwin-arm64/tish-fmt +0 -0
- package/platform/darwin-x64/tish-fmt +0 -0
- package/platform/linux-arm64/tish-fmt +0 -0
- package/platform/linux-x64/tish-fmt +0 -0
- package/platform/win32-x64/tish-fmt.exe +0 -0
package/bin/tish-format
CHANGED
|
Binary file
|
|
@@ -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::
|
|
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::
|
|
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::
|
|
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
|
-
///
|
|
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
|
-
|
|
465
|
-
|
|
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
|
-
|
|
469
|
-
.
|
|
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::
|
|
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
|
-
|
|
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::
|
|
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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|