@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 +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/crates/tish_runtime/src/http_prefork.rs +1 -0
- package/crates/tish_runtime/src/lib.rs +18 -4
- package/crates/tish_runtime/src/process_spawn.rs +1 -0
- 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]
|
|
@@ -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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|