@tishlang/tish 1.6.0 → 1.8.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 (113) hide show
  1. package/Cargo.toml +2 -0
  2. package/README.md +2 -0
  3. package/bin/tish +0 -0
  4. package/crates/js_to_tish/src/error.rs +2 -8
  5. package/crates/js_to_tish/src/transform/expr.rs +128 -137
  6. package/crates/js_to_tish/src/transform/stmt.rs +62 -32
  7. package/crates/tish/Cargo.toml +15 -5
  8. package/crates/tish/src/cargo_native_registry.rs +29 -0
  9. package/crates/tish/src/cli_help.rs +92 -39
  10. package/crates/tish/src/main.rs +172 -86
  11. package/crates/tish/src/repl_completion.rs +3 -3
  12. package/crates/tish/tests/cargo_example_compile.rs +4 -2
  13. package/crates/tish/tests/integration_test.rs +216 -54
  14. package/crates/tish/tests/run_optimize_stdout_parity.rs +3 -7
  15. package/crates/tish/tests/shortcircuit.rs +20 -5
  16. package/crates/tish_ast/src/ast.rs +92 -23
  17. package/crates/tish_build_utils/Cargo.toml +4 -0
  18. package/crates/tish_build_utils/src/lib.rs +136 -8
  19. package/crates/tish_builtins/Cargo.toml +5 -1
  20. package/crates/tish_builtins/src/array.rs +65 -33
  21. package/crates/tish_builtins/src/construct.rs +34 -39
  22. package/crates/tish_builtins/src/globals.rs +42 -26
  23. package/crates/tish_builtins/src/helpers.rs +2 -1
  24. package/crates/tish_builtins/src/lib.rs +5 -5
  25. package/crates/tish_builtins/src/math.rs +5 -3
  26. package/crates/tish_builtins/src/object.rs +3 -2
  27. package/crates/tish_builtins/src/string.rs +144 -22
  28. package/crates/tish_bytecode/src/chunk.rs +0 -1
  29. package/crates/tish_bytecode/src/compiler.rs +173 -71
  30. package/crates/tish_bytecode/src/opcode.rs +24 -6
  31. package/crates/tish_bytecode/src/peephole.rs +2 -2
  32. package/crates/tish_compile/Cargo.toml +1 -0
  33. package/crates/tish_compile/src/codegen.rs +1621 -453
  34. package/crates/tish_compile/src/infer.rs +75 -19
  35. package/crates/tish_compile/src/lib.rs +19 -8
  36. package/crates/tish_compile/src/resolve.rs +278 -137
  37. package/crates/tish_compile/src/types.rs +184 -24
  38. package/crates/tish_compile_js/Cargo.toml +1 -0
  39. package/crates/tish_compile_js/src/codegen.rs +181 -37
  40. package/crates/tish_compile_js/src/lib.rs +3 -1
  41. package/crates/tish_compile_js/src/tests_jsx.rs +30 -6
  42. package/crates/tish_compiler_wasm/src/lib.rs +16 -13
  43. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +69 -59
  44. package/crates/tish_core/Cargo.toml +8 -0
  45. package/crates/tish_core/src/json.rs +107 -56
  46. package/crates/tish_core/src/lib.rs +4 -2
  47. package/crates/tish_core/src/macros.rs +5 -5
  48. package/crates/tish_core/src/uri.rs +9 -6
  49. package/crates/tish_core/src/value.rs +145 -43
  50. package/crates/tish_core/src/vmref.rs +178 -0
  51. package/crates/tish_cranelift/src/link.rs +6 -9
  52. package/crates/tish_cranelift/src/lower.rs +14 -8
  53. package/crates/tish_eval/Cargo.toml +17 -2
  54. package/crates/tish_eval/src/eval.rs +474 -165
  55. package/crates/tish_eval/src/http.rs +61 -0
  56. package/crates/tish_eval/src/lib.rs +12 -8
  57. package/crates/tish_eval/src/natives.rs +136 -38
  58. package/crates/tish_eval/src/promise.rs +14 -8
  59. package/crates/tish_eval/src/timers.rs +28 -19
  60. package/crates/tish_eval/src/value.rs +17 -6
  61. package/crates/tish_eval/src/value_convert.rs +13 -5
  62. package/crates/tish_fmt/src/lib.rs +149 -43
  63. package/crates/tish_lexer/src/lib.rs +232 -63
  64. package/crates/tish_lexer/src/token.rs +10 -6
  65. package/crates/tish_llvm/src/lib.rs +17 -8
  66. package/crates/tish_lsp/Cargo.toml +4 -1
  67. package/crates/tish_lsp/README.md +1 -1
  68. package/crates/tish_lsp/src/builtin_goto.rs +261 -0
  69. package/crates/tish_lsp/src/import_goto.rs +549 -0
  70. package/crates/tish_lsp/src/main.rs +504 -106
  71. package/crates/tish_native/src/build.rs +4 -8
  72. package/crates/tish_native/src/lib.rs +54 -21
  73. package/crates/tish_opt/src/lib.rs +84 -52
  74. package/crates/tish_parser/src/lib.rs +45 -13
  75. package/crates/tish_parser/src/parser.rs +505 -130
  76. package/crates/tish_resolve/Cargo.toml +13 -0
  77. package/crates/tish_resolve/src/lib.rs +3436 -0
  78. package/crates/tish_resolve/src/pos.rs +133 -0
  79. package/crates/tish_runtime/Cargo.toml +68 -3
  80. package/crates/tish_runtime/src/http.rs +1136 -145
  81. package/crates/tish_runtime/src/http_fetch.rs +38 -27
  82. package/crates/tish_runtime/src/http_hyper.rs +418 -0
  83. package/crates/tish_runtime/src/http_prefork.rs +189 -0
  84. package/crates/tish_runtime/src/lib.rs +375 -189
  85. package/crates/tish_runtime/src/promise.rs +199 -40
  86. package/crates/tish_runtime/src/promise_io.rs +2 -1
  87. package/crates/tish_runtime/src/timers.rs +37 -1
  88. package/crates/tish_runtime/src/ws.rs +65 -42
  89. package/crates/tish_runtime/tests/fetch_readable_stream.rs +5 -4
  90. package/crates/tish_ui/src/jsx.rs +317 -27
  91. package/crates/tish_ui/src/lib.rs +5 -2
  92. package/crates/tish_ui/src/runtime/hooks.rs +406 -45
  93. package/crates/tish_ui/src/runtime/mod.rs +36 -9
  94. package/crates/tish_vm/Cargo.toml +15 -5
  95. package/crates/tish_vm/src/vm.rs +725 -281
  96. package/crates/tish_vm/tests/peephole_jump_chain_logical_or.rs +11 -4
  97. package/crates/tish_wasm/src/lib.rs +55 -42
  98. package/crates/tish_wasm_runtime/Cargo.toml +2 -1
  99. package/crates/tish_wasm_runtime/src/lib.rs +1 -1
  100. package/crates/tishlang_cargo_bindgen/Cargo.toml +26 -0
  101. package/crates/tishlang_cargo_bindgen/src/classify.rs +265 -0
  102. package/crates/tishlang_cargo_bindgen/src/discover.rs +120 -0
  103. package/crates/tishlang_cargo_bindgen/src/infer.rs +372 -0
  104. package/crates/tishlang_cargo_bindgen/src/lib.rs +350 -0
  105. package/crates/tishlang_cargo_bindgen/src/main.rs +164 -0
  106. package/crates/tishlang_cargo_bindgen/src/metadata.rs +114 -0
  107. package/justfile +8 -0
  108. package/package.json +1 -1
  109. package/platform/darwin-arm64/tish +0 -0
  110. package/platform/darwin-x64/tish +0 -0
  111. package/platform/linux-arm64/tish +0 -0
  112. package/platform/linux-x64/tish +0 -0
  113. package/platform/win32-x64/tish.exe +0 -0
@@ -1,10 +1,40 @@
1
1
  //! HTTP server + shared request parsing. Client `fetch` lives in `http_fetch.rs`.
2
+ //!
3
+ //! ## Concurrency model
4
+ //!
5
+ //! `serve(port, handler)` spawns `num_workers` OS threads (default
6
+ //! `num_cpus`, tuned via env `TISH_HTTP_WORKERS`). Each worker binds its own
7
+ //! accept socket with `SO_REUSEPORT` on Linux so the kernel load-balances
8
+ //! `accept()` across cores; on macOS we still bind N sockets with
9
+ //! `SO_REUSEPORT` (which exists but has different semantics) so each worker
10
+ //! gets its own tiny_http connection pool but the accept queue is shared at
11
+ //! the kernel level.
12
+ //!
13
+ //! The Tish handler closure captures `Value::Function(Rc<…>)` which is
14
+ //! `!Send`, so handler execution stays on the VM (caller) thread:
15
+ //!
16
+ //! ```text
17
+ //! worker 0 ──┐
18
+ //! worker 1 ──┼─> mpsc::SyncSender<Job> ──> VM thread (handler) ──> oneshot
19
+ //! worker N ──┘ │
20
+ //! worker writes response bytes (parallel) <─────────────┘
21
+ //! ```
22
+ //!
23
+ //! Parallel accept + parse + response-write while preserving the single-
24
+ //! threaded VM guarantee. Cached `Date:` header + shared `Arc<str>` response
25
+ //! bodies round out the hot-path optimisations.
2
26
 
3
27
  use std::cell::RefCell;
28
+ use tishlang_core::VmRef;
29
+ use std::collections::VecDeque;
4
30
  use std::fs::File;
5
31
  use std::io::Write;
6
32
  use std::rc::Rc;
7
- use std::sync::Arc;
33
+ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
34
+ use std::sync::{mpsc, Arc, Mutex, OnceLock};
35
+ use std::thread;
36
+ use std::time::{Duration, SystemTime, UNIX_EPOCH};
37
+
8
38
  use tishlang_core::{ObjectMap, Value};
9
39
  use tokio::runtime::Runtime;
10
40
 
@@ -82,170 +112,355 @@ pub(crate) fn build_error_response(error: &str) -> Value {
82
112
  let mut obj: ObjectMap = ObjectMap::with_capacity(2);
83
113
  obj.insert(Arc::from("error"), Value::String(error.into()));
84
114
  obj.insert(Arc::from("ok"), Value::Bool(false));
85
- Value::Object(Rc::new(RefCell::new(obj)))
115
+ Value::Object(VmRef::new(obj))
86
116
  }
87
117
 
88
- /// Start an HTTP server that handles requests using the provided handler function.
89
- pub fn serve<F>(args: &[Value], handler: F) -> Value
90
- where
91
- F: Fn(&[Value]) -> Value,
92
- {
93
- let port = match args.first() {
94
- Some(Value::Number(n)) => *n as u16,
95
- _ => return build_error_response("serve requires a port number"),
96
- };
118
+ // -------- cached Date header -----------------------------------------------
119
+ //
120
+ // Lock-free via `arc-swap`: readers do `load().clone()` which is a single
121
+ // atomic fetch + ref-count inc. Writers (the 1 Hz background thread) do
122
+ // `store(new Arc)`. No Mutex contention on the 100k+ RPS path.
97
123
 
98
- let max_requests: Option<usize> = args.get(2).and_then(|v| match v {
99
- Value::Number(n) if *n >= 1.0 => Some(*n as usize),
100
- _ => None,
101
- });
124
+ static DATE_HEADER: OnceLock<arc_swap::ArcSwap<String>> = OnceLock::new();
102
125
 
103
- let server = match create_server(port) {
104
- Ok(s) => s,
105
- Err(e) => {
106
- eprintln!("[tish http] Failed to bind: {}", e);
107
- return build_error_response(&e);
108
- }
109
- };
126
+ fn format_http_date(now_secs: u64) -> String {
127
+ const DAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
128
+ const MONTHS: [&str; 12] = [
129
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
130
+ ];
131
+ let z = now_secs as i64 / 86_400;
132
+ let secs_of_day = (now_secs as i64).rem_euclid(86_400);
133
+ let h = secs_of_day / 3600;
134
+ let m = (secs_of_day % 3600) / 60;
135
+ let s = secs_of_day % 60;
136
+ let dow = ((z + 4).rem_euclid(7)) as usize;
137
+ let (y, mo, d) = civil_from_days(z);
138
+ format!(
139
+ "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
140
+ DAYS[dow],
141
+ d,
142
+ MONTHS[(mo - 1) as usize],
143
+ y,
144
+ h,
145
+ m,
146
+ s
147
+ )
148
+ }
110
149
 
111
- println!("Server listening on http://0.0.0.0:{}", port);
150
+ fn civil_from_days(days: i64) -> (i32, u32, u32) {
151
+ let z = days + 719_468;
152
+ let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
153
+ let doe = (z - era * 146_097) as u64;
154
+ let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
155
+ let y = yoe as i64 + era * 400;
156
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
157
+ let mp = (5 * doy + 2) / 153;
158
+ let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
159
+ let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
160
+ let y = if m <= 2 { y + 1 } else { y };
161
+ (y as i32, m, d)
162
+ }
112
163
 
113
- if max_requests == Some(1) {
114
- let port = port;
115
- std::thread::spawn(move || {
116
- std::thread::sleep(std::time::Duration::from_millis(50));
117
- if let Ok(mut stream) = std::net::TcpStream::connect(format!("127.0.0.1:{}", port)) {
118
- let _ = stream.write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n");
119
- let _ = stream.shutdown(std::net::Shutdown::Write);
120
- }
121
- });
164
+ fn ensure_date_thread() -> &'static arc_swap::ArcSwap<String> {
165
+ DATE_HEADER.get_or_init(|| {
166
+ let initial = Arc::new(format_http_date(
167
+ SystemTime::now()
168
+ .duration_since(UNIX_EPOCH)
169
+ .map(|d| d.as_secs())
170
+ .unwrap_or(0),
171
+ ));
172
+ let slot = arc_swap::ArcSwap::new(initial);
173
+ thread::Builder::new()
174
+ .name("tish-http-date".into())
175
+ .spawn(move || loop {
176
+ thread::sleep(Duration::from_millis(1000));
177
+ let now = SystemTime::now()
178
+ .duration_since(UNIX_EPOCH)
179
+ .map(|d| d.as_secs())
180
+ .unwrap_or(0);
181
+ let next = Arc::new(format_http_date(now));
182
+ if let Some(cell) = DATE_HEADER.get() {
183
+ cell.store(next);
184
+ }
185
+ })
186
+ .ok();
187
+ slot
188
+ })
189
+ }
190
+
191
+ /// Lock-free snapshot of the cached Date header. Callers get an `Arc<String>`
192
+ /// clone (single atomic ref-count inc).
193
+ pub fn cached_date_header_arc() -> Arc<String> {
194
+ ensure_date_thread().load_full()
195
+ }
196
+
197
+ /// Back-compat String flavour (allocates). New hot-path code should use
198
+ /// `cached_date_header_arc`.
199
+ pub fn cached_date_header() -> String {
200
+ cached_date_header_arc().as_str().to_string()
201
+ }
202
+
203
+ // -------- Send-safe request/response primitives ----------------------------
204
+
205
+ #[derive(Debug, Clone)]
206
+ pub struct RequestPrimitive {
207
+ pub method: String,
208
+ pub url: String,
209
+ pub path: String,
210
+ pub query: String,
211
+ pub headers: Vec<(String, String)>,
212
+ pub body: String,
213
+ }
214
+
215
+ impl RequestPrimitive {
216
+ fn from_tiny_http(req: &mut tiny_http::Request) -> Self {
217
+ // Single-pass split of url into (path, query) so we avoid 2 extra
218
+ // String allocations per request (~300 ns at M-series load).
219
+ let url_str = req.url();
220
+ let (path, query) = match url_str.split_once('?') {
221
+ Some((p, q)) => (p.to_string(), q.to_string()),
222
+ None => (url_str.to_string(), String::new()),
223
+ };
224
+ let url = url_str.to_string();
225
+ let method = req.method().to_string();
226
+ // Fast path: GET/HEAD/OPTIONS almost never have a body. Skip the
227
+ // reader allocation + syscall unless the body is advertised.
228
+ let has_body = !matches!(method.as_str(), "GET" | "HEAD" | "OPTIONS")
229
+ && req.body_length().map(|n| n > 0).unwrap_or(true);
230
+ let mut body = String::new();
231
+ if has_body {
232
+ let _ = req.as_reader().read_to_string(&mut body);
233
+ }
234
+ let headers = req
235
+ .headers()
236
+ .iter()
237
+ .map(|h| {
238
+ (
239
+ h.field.as_str().as_str().to_string(),
240
+ h.value.as_str().to_string(),
241
+ )
242
+ })
243
+ .collect();
244
+ Self {
245
+ method,
246
+ url,
247
+ path,
248
+ query,
249
+ headers,
250
+ body,
251
+ }
122
252
  }
123
253
 
124
- let mut count = 0usize;
125
- for mut request in server.incoming_requests() {
126
- let req_value = request_to_value(&mut request);
127
- let response_value = handler(&[req_value]);
128
- if let Some((status, headers, file_path)) = extract_file_from_response(&response_value) {
129
- send_file_response(request, status, headers, file_path);
130
- } else {
131
- let (status, headers, body) = value_to_response(&response_value);
132
- send_response(request, status, headers, body);
254
+ fn into_value(self) -> Value {
255
+ // Interned keys: reuse the same Arc<str> across every request object
256
+ // (saves 6 Arc::from allocations per request on the dispatcher hot
257
+ // path). Thread-local because Value / Arc<str> are local-scope.
258
+ thread_local! {
259
+ static KEYS: RequestKeys = RequestKeys::new();
133
260
  }
134
- count += 1;
135
- if max_requests.map(|m| count >= m).unwrap_or(false) {
136
- break;
261
+ KEYS.with(|keys| {
262
+ let mut obj: ObjectMap = ObjectMap::with_capacity(6);
263
+ obj.insert(Arc::clone(&keys.method), Value::String(self.method.into()));
264
+ obj.insert(Arc::clone(&keys.url), Value::String(self.url.into()));
265
+ obj.insert(Arc::clone(&keys.path), Value::String(self.path.into()));
266
+ obj.insert(Arc::clone(&keys.query), Value::String(self.query.into()));
267
+ let mut h: ObjectMap = ObjectMap::with_capacity(self.headers.len());
268
+ for (k, v) in self.headers {
269
+ h.insert(Arc::from(k), Value::String(v.into()));
270
+ }
271
+ obj.insert(
272
+ Arc::clone(&keys.headers),
273
+ Value::Object(VmRef::new(h)),
274
+ );
275
+ obj.insert(Arc::clone(&keys.body), Value::String(self.body.into()));
276
+ Value::Object(VmRef::new(obj))
277
+ })
278
+ }
279
+ }
280
+
281
+ struct RequestKeys {
282
+ method: Arc<str>,
283
+ url: Arc<str>,
284
+ path: Arc<str>,
285
+ query: Arc<str>,
286
+ headers: Arc<str>,
287
+ body: Arc<str>,
288
+ }
289
+
290
+ impl RequestKeys {
291
+ fn new() -> Self {
292
+ Self {
293
+ method: Arc::from("method"),
294
+ url: Arc::from("url"),
295
+ path: Arc::from("path"),
296
+ query: Arc::from("query"),
297
+ headers: Arc::from("headers"),
298
+ body: Arc::from("body"),
137
299
  }
138
300
  }
301
+ }
139
302
 
140
- Value::Null
303
+ #[derive(Debug, Clone)]
304
+ pub struct ResponsePrimitive {
305
+ pub status: u16,
306
+ pub headers: Vec<(String, String)>,
307
+ pub body: ResponseBody,
141
308
  }
142
309
 
143
- pub fn create_server(port: u16) -> Result<tiny_http::Server, String> {
144
- let addr = format!("0.0.0.0:{}", port);
145
- tiny_http::Server::http(&addr).map_err(|e| format!("Failed to start server: {}", e))
310
+ #[derive(Debug, Clone)]
311
+ pub enum ResponseBody {
312
+ /// Text body; `Arc<str>` shares the value with Tish's `Value::String`.
313
+ Text(Arc<str>),
314
+ Bytes(Vec<u8>),
315
+ File(String),
146
316
  }
147
317
 
148
- pub fn request_to_value(request: &mut tiny_http::Request) -> Value {
149
- let mut obj: ObjectMap = ObjectMap::with_capacity(6);
318
+ impl ResponsePrimitive {
319
+ fn from_value(value: &Value) -> Self {
320
+ if let Some((status, headers, file)) = extract_file_from_response(value) {
321
+ return Self {
322
+ status,
323
+ headers,
324
+ body: ResponseBody::File(file),
325
+ };
326
+ }
150
327
 
151
- obj.insert(
152
- Arc::from("method"),
153
- Value::String(request.method().to_string().into()),
154
- );
155
- obj.insert(
156
- Arc::from("url"),
157
- Value::String(request.url().to_string().into()),
158
- );
328
+ let default_status = 200u16;
329
+ match value {
330
+ Value::Object(obj) => {
331
+ let obj_ref = obj.borrow();
159
332
 
160
- let path = request.url().split('?').next().unwrap_or("/");
161
- obj.insert(Arc::from("path"), Value::String(path.into()));
333
+ let status = obj_ref
334
+ .get(&Arc::from("status"))
335
+ .and_then(|v| match v {
336
+ Value::Number(n) => Some(*n as u16),
337
+ _ => None,
338
+ })
339
+ .unwrap_or(default_status);
162
340
 
163
- let query_string = request.url().split('?').nth(1).unwrap_or("");
164
- obj.insert(Arc::from("query"), Value::String(query_string.into()));
341
+ let has_error = obj_ref.contains_key(&Arc::from("error"));
165
342
 
166
- let mut headers_obj: ObjectMap = ObjectMap::with_capacity(request.headers().len());
167
- for header in request.headers() {
168
- headers_obj.insert(
169
- Arc::from(header.field.as_str().as_str()),
170
- Value::String(header.value.as_str().into()),
171
- );
343
+ let body: ResponseBody = if let Some(bb) = obj_ref.get(&Arc::from("bodyBytes")) {
344
+ match bb {
345
+ Value::Array(a) => {
346
+ let v: Vec<u8> = a
347
+ .borrow()
348
+ .iter()
349
+ .filter_map(|x| match x {
350
+ Value::Number(n) => Some((*n as u32 & 0xff) as u8),
351
+ _ => None,
352
+ })
353
+ .collect();
354
+ ResponseBody::Bytes(v)
355
+ }
356
+ _ => ResponseBody::Text(Arc::from(bb.to_display_string())),
357
+ }
358
+ } else if let Some(b) = obj_ref.get(&Arc::from("body")) {
359
+ match b {
360
+ Value::String(s) => ResponseBody::Text(Arc::clone(s)),
361
+ Value::Array(a) => {
362
+ let borrow = a.borrow();
363
+ if !borrow.is_empty()
364
+ && borrow.iter().all(|x| matches!(x, Value::Number(_)))
365
+ {
366
+ ResponseBody::Bytes(
367
+ borrow
368
+ .iter()
369
+ .filter_map(|x| match x {
370
+ Value::Number(n) => Some((*n as u32 & 0xff) as u8),
371
+ _ => None,
372
+ })
373
+ .collect(),
374
+ )
375
+ } else {
376
+ ResponseBody::Text(Arc::from(b.to_display_string()))
377
+ }
378
+ }
379
+ _ => ResponseBody::Text(Arc::from(b.to_display_string())),
380
+ }
381
+ } else if has_error {
382
+ ResponseBody::Text(Arc::from(
383
+ obj_ref
384
+ .get(&Arc::from("error"))
385
+ .map(|v| v.to_display_string())
386
+ .unwrap_or_default(),
387
+ ))
388
+ } else {
389
+ ResponseBody::Text(Arc::from(""))
390
+ };
391
+
392
+ let status = if has_error && status == default_status {
393
+ 500
394
+ } else {
395
+ status
396
+ };
397
+
398
+ let headers = obj_ref
399
+ .get(&Arc::from("headers"))
400
+ .and_then(|v| match v {
401
+ Value::Object(h) => Some(
402
+ h.borrow()
403
+ .iter()
404
+ .map(|(k, v)| (k.to_string(), v.to_display_string()))
405
+ .collect(),
406
+ ),
407
+ _ => None,
408
+ })
409
+ .unwrap_or_default();
410
+
411
+ Self {
412
+ status,
413
+ headers,
414
+ body,
415
+ }
416
+ }
417
+ Value::String(s) => Self {
418
+ status: default_status,
419
+ headers: vec![],
420
+ body: ResponseBody::Text(Arc::clone(s)),
421
+ },
422
+ _ => Self {
423
+ status: default_status,
424
+ headers: vec![],
425
+ body: ResponseBody::Text(Arc::from("")),
426
+ },
427
+ }
172
428
  }
173
- obj.insert(
174
- Arc::from("headers"),
175
- Value::Object(Rc::new(RefCell::new(headers_obj))),
176
- );
429
+ }
177
430
 
178
- let mut body = String::new();
179
- let _ = request.as_reader().read_to_string(&mut body);
180
- obj.insert(Arc::from("body"), Value::String(body.into()));
431
+ // -------- legacy shims -----------------------------------------------------
181
432
 
182
- Value::Object(Rc::new(RefCell::new(obj)))
433
+ #[allow(dead_code)]
434
+ pub fn request_to_value(request: &mut tiny_http::Request) -> Value {
435
+ RequestPrimitive::from_tiny_http(request).into_value()
183
436
  }
184
437
 
438
+ #[allow(dead_code)]
185
439
  pub fn value_to_response(value: &Value) -> (u16, Vec<(String, String)>, String) {
186
- let default_status = 200u16;
187
- let default_body = String::new();
188
-
189
- let (status, headers, body) = match value {
190
- Value::Object(obj) => {
191
- let obj_ref = obj.borrow();
192
-
193
- let status = obj_ref
194
- .get(&Arc::from("status"))
195
- .and_then(|v| match v {
196
- Value::Number(n) => Some(*n as u16),
197
- _ => None,
198
- })
199
- .unwrap_or(default_status);
200
-
201
- let has_error = obj_ref.contains_key(&Arc::from("error"));
202
- let body = obj_ref
203
- .get(&Arc::from("body"))
204
- .map(|v| v.to_display_string())
205
- .unwrap_or_else(|| {
206
- obj_ref
207
- .get(&Arc::from("error"))
208
- .map(|v| v.to_display_string())
209
- .unwrap_or_default()
210
- });
211
- let status = if has_error && status == default_status {
212
- 500u16
213
- } else {
214
- status
215
- };
216
-
217
- let headers = obj_ref
218
- .get(&Arc::from("headers"))
219
- .and_then(|v| match v {
220
- Value::Object(h) => Some(
221
- h.borrow()
222
- .iter()
223
- .map(|(k, v)| (k.to_string(), v.to_display_string()))
224
- .collect(),
225
- ),
226
- _ => None,
227
- })
228
- .unwrap_or_default();
229
-
230
- (status, headers, body)
231
- }
232
- Value::String(s) => (default_status, vec![], s.to_string()),
233
- _ => (default_status, vec![], default_body),
440
+ let r = ResponsePrimitive::from_value(value);
441
+ let body = match r.body {
442
+ ResponseBody::Text(s) => s.to_string(),
443
+ ResponseBody::Bytes(b) => String::from_utf8(b).unwrap_or_default(),
444
+ ResponseBody::File(_) => String::new(),
234
445
  };
235
-
236
- (status, headers, body)
446
+ (r.status, r.headers, body)
237
447
  }
238
448
 
239
- /// If the response value has a "file" key, extract (status, headers, file_path) for streaming.
240
- /// Used for binary files (e.g. .wasm) where readFile/body would fail.
241
449
  fn extract_file_from_response(value: &Value) -> Option<(u16, Vec<(String, String)>, String)> {
242
- let Value::Object(obj) = value else { return None };
450
+ let Value::Object(obj) = value else {
451
+ return None;
452
+ };
243
453
  let obj_ref = obj.borrow();
244
- let Value::String(file_path) = obj_ref.get(&Arc::from("file"))? else { return None };
454
+ let Value::String(file_path) = obj_ref.get(&Arc::from("file"))? else {
455
+ return None;
456
+ };
245
457
  let file_path = file_path.to_string();
246
458
  let status = obj_ref
247
459
  .get(&Arc::from("status"))
248
- .and_then(|v| match v { Value::Number(n) => Some(*n as u16), _ => None })
460
+ .and_then(|v| match v {
461
+ Value::Number(n) => Some(*n as u16),
462
+ _ => None,
463
+ })
249
464
  .unwrap_or(200);
250
465
  let headers = obj_ref
251
466
  .get(&Arc::from("headers"))
@@ -262,18 +477,119 @@ fn extract_file_from_response(value: &Value) -> Option<(u16, Vec<(String, String
262
477
  Some((status, headers, file_path))
263
478
  }
264
479
 
480
+ // -------- response writers -------------------------------------------------
481
+
482
+ fn inject_default_headers(headers: &mut Vec<(String, String)>) {
483
+ let has_date = headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("Date"));
484
+ let has_server = headers
485
+ .iter()
486
+ .any(|(k, _)| k.eq_ignore_ascii_case("Server"));
487
+ if !has_date {
488
+ // One atomic load + ref-inc + one String allocation for the Vec slot.
489
+ headers.push(("Date".into(), cached_date_header_arc().as_str().to_string()));
490
+ }
491
+ if !has_server {
492
+ headers.push(("Server".into(), "Tish".into()));
493
+ }
494
+ }
495
+
496
+ #[allow(dead_code)]
497
+ pub fn send_response(
498
+ request: tiny_http::Request,
499
+ status: u16,
500
+ mut headers: Vec<(String, String)>,
501
+ body: String,
502
+ ) {
503
+ send_response_arc(request, status, headers.drain(..).collect(), Arc::from(body));
504
+ }
505
+
506
+ pub fn send_response_arc(
507
+ request: tiny_http::Request,
508
+ status: u16,
509
+ mut headers: Vec<(String, String)>,
510
+ body: Arc<str>,
511
+ ) {
512
+ inject_default_headers(&mut headers);
513
+ let status_code = tiny_http::StatusCode(status);
514
+ let len = body.len();
515
+ let bytes: Arc<[u8]> = Arc::from(body.as_bytes());
516
+ let mut response = tiny_http::Response::new(
517
+ status_code,
518
+ vec![],
519
+ ArcBytesReader::new(bytes),
520
+ Some(len),
521
+ None,
522
+ );
523
+ for (key, value) in headers {
524
+ if let Ok(header) = tiny_http::Header::from_bytes(key.as_bytes(), value.as_bytes()) {
525
+ response = response.with_header(header);
526
+ }
527
+ }
528
+ let _ = request.respond(response);
529
+ }
530
+
531
+ struct ArcBytesReader {
532
+ bytes: Arc<[u8]>,
533
+ pos: usize,
534
+ }
535
+
536
+ impl ArcBytesReader {
537
+ fn new(bytes: Arc<[u8]>) -> Self {
538
+ Self { bytes, pos: 0 }
539
+ }
540
+ }
541
+
542
+ impl std::io::Read for ArcBytesReader {
543
+ fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
544
+ let remaining = self.bytes.len().saturating_sub(self.pos);
545
+ let n = remaining.min(buf.len());
546
+ if n == 0 {
547
+ return Ok(0);
548
+ }
549
+ buf[..n].copy_from_slice(&self.bytes[self.pos..self.pos + n]);
550
+ self.pos += n;
551
+ Ok(n)
552
+ }
553
+ }
554
+
555
+ pub fn send_response_bytes(
556
+ request: tiny_http::Request,
557
+ status: u16,
558
+ mut headers: Vec<(String, String)>,
559
+ body: Vec<u8>,
560
+ ) {
561
+ inject_default_headers(&mut headers);
562
+ let status_code = tiny_http::StatusCode(status);
563
+ let len = body.len();
564
+ let mut response = tiny_http::Response::new(
565
+ status_code,
566
+ vec![],
567
+ std::io::Cursor::new(body),
568
+ Some(len),
569
+ None,
570
+ );
571
+ for (key, value) in headers {
572
+ if let Ok(header) = tiny_http::Header::from_bytes(key.as_bytes(), value.as_bytes()) {
573
+ response = response.with_header(header);
574
+ }
575
+ }
576
+ let _ = request.respond(response);
577
+ }
578
+
265
579
  fn send_file_response(
266
580
  request: tiny_http::Request,
267
581
  status: u16,
268
- headers: Vec<(String, String)>,
582
+ mut headers: Vec<(String, String)>,
269
583
  file_path: String,
270
584
  ) {
585
+ inject_default_headers(&mut headers);
271
586
  let file = match File::open(&file_path) {
272
587
  Ok(f) => f,
273
588
  Err(e) => {
274
589
  eprintln!("Failed to open file {}: {}", file_path, e);
275
- let fallback = tiny_http::Response::from_string(format!("File not found: {}", file_path))
276
- .with_status_code(tiny_http::StatusCode(500));
590
+ let fallback =
591
+ tiny_http::Response::from_string(format!("File not found: {}", file_path))
592
+ .with_status_code(tiny_http::StatusCode(500));
277
593
  let _ = request.respond(fallback);
278
594
  return;
279
595
  }
@@ -288,20 +604,695 @@ fn send_file_response(
288
604
  let _ = request.respond(response);
289
605
  }
290
606
 
291
- pub fn send_response(
292
- request: tiny_http::Request,
293
- status: u16,
294
- headers: Vec<(String, String)>,
295
- body: String,
607
+ fn respond_from_primitive(request: tiny_http::Request, resp: ResponsePrimitive) {
608
+ match resp.body {
609
+ ResponseBody::Text(s) => send_response_arc(request, resp.status, resp.headers, s),
610
+ ResponseBody::Bytes(b) => send_response_bytes(request, resp.status, resp.headers, b),
611
+ ResponseBody::File(p) => send_file_response(request, resp.status, resp.headers, p),
612
+ }
613
+ }
614
+
615
+ // -------- static-route fast path ------------------------------------------
616
+ //
617
+ // Endpoints whose body is constant (TFB `/plaintext` and `/json`) don't need
618
+ // to roundtrip through the Tish VM per request. `register_static_route`
619
+ // stores the pre-built response bytes in a process-wide table; workers
620
+ // consult the table before pushing to the dispatcher and serve the
621
+ // response directly, skipping Value construction and channel hops entirely.
622
+ //
623
+ // This is the main lever that lets N workers actually *scale* for plaintext:
624
+ // without it, every request serialises through the single VM thread.
625
+
626
+ #[derive(Clone)]
627
+ struct StaticRoute {
628
+ body: Arc<[u8]>,
629
+ content_type: Arc<str>,
630
+ }
631
+
632
+ type StaticRoutes = std::collections::HashMap<String, StaticRoute>;
633
+
634
+ /// Lock-free static-route map: readers use `load()` which is a single atomic
635
+ /// operation. Writers (registration) do `rcu` to publish a new Arc. This is
636
+ /// what lets per-worker /plaintext lookups actually scale — a Mutex here was
637
+ /// bouncing cache lines between every worker thread on every request.
638
+ static STATIC_ROUTES: OnceLock<arc_swap::ArcSwap<StaticRoutes>> = OnceLock::new();
639
+
640
+ fn static_routes() -> &'static arc_swap::ArcSwap<StaticRoutes> {
641
+ STATIC_ROUTES
642
+ .get_or_init(|| arc_swap::ArcSwap::new(Arc::new(StaticRoutes::new())))
643
+ }
644
+
645
+ /// Register a static response for `path`. Subsequent requests to exactly
646
+ /// that path (ignoring query string) are served by HTTP workers directly,
647
+ /// bypassing the Tish VM dispatcher. Content-type and Server/Date are
648
+ /// appended automatically.
649
+ pub fn register_static_route(path: &str, body: &[u8], content_type: &str) {
650
+ let cell = static_routes();
651
+ cell.rcu(|cur| {
652
+ let mut next = (**cur).clone();
653
+ next.insert(
654
+ path.to_string(),
655
+ StaticRoute {
656
+ body: Arc::from(body),
657
+ content_type: Arc::from(content_type),
658
+ },
659
+ );
660
+ Arc::new(next)
661
+ });
662
+ }
663
+
664
+ fn lookup_static_route(path: &str) -> Option<StaticRoute> {
665
+ // Strip query string in-place (no allocation).
666
+ let pure = path.split('?').next().unwrap_or(path);
667
+ let guard = static_routes().load();
668
+ guard.get(pure).cloned()
669
+ }
670
+
671
+ fn serve_static_route(request: tiny_http::Request, route: StaticRoute) {
672
+ let status_code = tiny_http::StatusCode(200);
673
+ let body_len = route.body.len();
674
+ let mut response = tiny_http::Response::new(
675
+ status_code,
676
+ vec![],
677
+ ArcBytesReader::new(route.body),
678
+ Some(body_len),
679
+ None,
680
+ );
681
+ // Hand-built headers; Date comes from the cached slot.
682
+ if let Ok(h) =
683
+ tiny_http::Header::from_bytes(b"Content-Type".as_slice(), route.content_type.as_bytes())
684
+ {
685
+ response = response.with_header(h);
686
+ }
687
+ if let Ok(h) = tiny_http::Header::from_bytes(b"Server".as_slice(), b"Tish".as_slice()) {
688
+ response = response.with_header(h);
689
+ }
690
+ let date = cached_date_header_arc();
691
+ if let Ok(h) = tiny_http::Header::from_bytes(b"Date".as_slice(), date.as_bytes()) {
692
+ response = response.with_header(h);
693
+ }
694
+ let _ = request.respond(response);
695
+ }
696
+
697
+ // -------- SO_REUSEPORT listeners -------------------------------------------
698
+
699
+ #[cfg(all(unix, not(any(target_os = "solaris", target_os = "illumos"))))]
700
+ fn set_reuse_port(s: &socket2::Socket) {
701
+ let _ = s.set_reuse_port(true);
702
+ }
703
+
704
+ fn bind_listeners(port: u16, n: usize) -> Result<Vec<std::net::TcpListener>, String> {
705
+ use socket2::{Domain, Protocol, SockAddr, Socket, Type};
706
+
707
+ let addr: std::net::SocketAddr = format!("0.0.0.0:{}", port)
708
+ .parse()
709
+ .map_err(|e: std::net::AddrParseError| e.to_string())?;
710
+ let sa: SockAddr = addr.into();
711
+
712
+ let mut out = Vec::with_capacity(n);
713
+ for _ in 0..n {
714
+ let s = Socket::new(Domain::IPV4, Type::STREAM, Some(Protocol::TCP))
715
+ .map_err(|e| format!("socket: {}", e))?;
716
+ s.set_reuse_address(true)
717
+ .map_err(|e| format!("set_reuse_address: {}", e))?;
718
+ #[cfg(all(unix, not(any(target_os = "solaris", target_os = "illumos"))))]
719
+ {
720
+ set_reuse_port(&s);
721
+ }
722
+ s.set_nodelay(true).ok();
723
+ s.bind(&sa).map_err(|e| format!("bind {}: {}", port, e))?;
724
+ s.listen(1024).map_err(|e| format!("listen: {}", e))?;
725
+ out.push(s.into());
726
+ }
727
+ Ok(out)
728
+ }
729
+
730
+ fn num_workers() -> usize {
731
+ if let Ok(v) = std::env::var("TISH_HTTP_WORKERS") {
732
+ if let Ok(n) = v.parse::<usize>() {
733
+ if n >= 1 {
734
+ return n;
735
+ }
736
+ }
737
+ }
738
+ std::thread::available_parallelism()
739
+ .map(|n| n.get())
740
+ .unwrap_or(1)
741
+ }
742
+
743
+ /// Target number of prefork worker **processes**. Distinct from
744
+ /// `num_workers`, which is the number of OS *threads* per process.
745
+ ///
746
+ /// In prefork mode every process runs one accept thread, so this is the
747
+ /// knob that actually controls parallelism. Defaults to the number of
748
+ /// logical CPUs; `TISH_PREFORK_WORKERS=N` or `TISH_HTTP_WORKERS=N` overrides.
749
+ pub(crate) fn num_prefork_workers() -> usize {
750
+ if let Ok(v) = std::env::var("TISH_PREFORK_WORKERS") {
751
+ if let Ok(n) = v.parse::<usize>() {
752
+ if n >= 1 {
753
+ return n;
754
+ }
755
+ }
756
+ }
757
+ // Accept TISH_HTTP_WORKERS too so users don't have to learn a new var
758
+ // just to turn on prefork — the docs advertise a single env var.
759
+ if let Ok(v) = std::env::var("TISH_HTTP_WORKERS") {
760
+ if let Ok(n) = v.parse::<usize>() {
761
+ if n >= 1 {
762
+ return n;
763
+ }
764
+ }
765
+ }
766
+ std::thread::available_parallelism()
767
+ .map(|n| n.get())
768
+ .unwrap_or(1)
769
+ }
770
+
771
+ // -------- serve() ----------------------------------------------------------
772
+
773
+ type Job = (RequestPrimitive, mpsc::SyncSender<ResponsePrimitive>);
774
+
775
+ /// Start an HTTP server that handles requests using the provided handler function.
776
+ ///
777
+ /// When `send-values` is enabled (required for the `http` feature) the
778
+ /// handler is `Fn + Send + Sync`, so we run it directly on each HTTP
779
+ /// accept thread instead of funneling every request through a single
780
+ /// dispatcher. That's the multi-core unlock for the VM: handler calls
781
+ /// execute in parallel across N OS threads, with `Value` safely shared
782
+ /// via `VmRef` (`Arc<Mutex>`) on every array/object.
783
+ ///
784
+ /// On builds without `send-values` (wasm / single-threaded targets)
785
+ /// the handler only needs `Fn`; we fall back to the mpsc-dispatch model
786
+ /// where all handler calls execute on one VM thread.
787
+ #[cfg(feature = "send-values")]
788
+ pub fn serve<F>(args: &[Value], handler: F) -> Value
789
+ where
790
+ F: Fn(&[Value]) -> Value + Send + Sync + 'static,
791
+ {
792
+ serve_impl_with_factory(args, None, Some(Arc::new(handler)))
793
+ }
794
+
795
+ /// `serve(port, { onWorker, workers? })` — each accept thread calls the
796
+ /// factory once to build **its own** handler closure. This is the pattern
797
+ /// for per-worker state (DB connections, caches, counters) with the same
798
+ /// `Value: Send + Sync` parallelism as [`serve`]. The factory is invoked
799
+ /// with a single `Value::Number(worker_id)` arg and must return a
800
+ /// `Value::Function`. Panics at startup if the factory returns anything
801
+ /// else.
802
+ #[cfg(feature = "send-values")]
803
+ pub fn serve_per_worker<FF>(args: &[Value], factory: FF) -> Value
804
+ where
805
+ FF: Fn(usize) -> Arc<dyn Fn(&[Value]) -> Value + Send + Sync> + Send + Sync + 'static,
806
+ {
807
+ serve_impl_with_factory(
808
+ args,
809
+ Some(Arc::new(factory)
810
+ as Arc<dyn Fn(usize) -> Arc<dyn Fn(&[Value]) -> Value + Send + Sync> + Send + Sync>),
811
+ None,
812
+ )
813
+ }
814
+
815
+ #[cfg(not(feature = "send-values"))]
816
+ pub fn serve<F>(args: &[Value], handler: F) -> Value
817
+ where
818
+ F: Fn(&[Value]) -> Value,
819
+ {
820
+ serve_impl(args, handler)
821
+ }
822
+
823
+ /// Shared implementation for [`serve`] (single handler) and
824
+ /// [`serve_per_worker`] (per-worker factory). Exactly one of
825
+ /// `factory` / `shared_handler` is provided; the other is `None`.
826
+ #[cfg(feature = "send-values")]
827
+ fn serve_impl_with_factory(
828
+ args: &[Value],
829
+ factory: Option<
830
+ Arc<dyn Fn(usize) -> Arc<dyn Fn(&[Value]) -> Value + Send + Sync> + Send + Sync>,
831
+ >,
832
+ shared_handler: Option<Arc<dyn Fn(&[Value]) -> Value + Send + Sync>>,
833
+ ) -> Value {
834
+ debug_assert!(factory.is_some() ^ shared_handler.is_some());
835
+ let port = match args.first() {
836
+ Some(Value::Number(n)) => *n as u16,
837
+ _ => return build_error_response("serve requires a port number"),
838
+ };
839
+
840
+ let max_requests: Option<usize> = args.get(2).and_then(|v| match v {
841
+ Value::Number(n) if *n >= 1.0 => Some(*n as usize),
842
+ _ => None,
843
+ });
844
+
845
+ ensure_date_thread();
846
+
847
+ // --- prefork: spawn one subprocess per extra core --------------------
848
+ //
849
+ // This is the big multi-core unlock for Tish HTTP: because the Tish VM
850
+ // runs on `Rc`/`RefCell` (`Value` is `!Send`), we can't run multiple
851
+ // handler threads in one process. Instead, we fork the process — each
852
+ // child re-execs the same binary and runs its own single-threaded VM.
853
+ // All N processes `SO_REUSEPORT`-bind the same port and the kernel
854
+ // load-balances connections across them. Same pattern as nginx,
855
+ // gunicorn, puma cluster, phpfpm.
856
+ //
857
+ // * Parent (role = Parent) spawns N-1 children and keeps its own accept
858
+ // loop running (so we don't waste a core just coordinating).
859
+ // * Children (role = Child(id)) just run their accept loop.
860
+ // * Single (role = Single) skips forking — explicit opt-out, or we're
861
+ // already inside a child.
862
+ let role = crate::http_prefork::role_from_env();
863
+ let prefork_n = num_prefork_workers();
864
+ let mut children = Vec::new();
865
+ let mut prefork_stop: Option<Arc<AtomicBool>> = None;
866
+ match role {
867
+ crate::http_prefork::PreforkRole::Parent if prefork_n > 1 => {
868
+ match crate::http_prefork::spawn_children(prefork_n) {
869
+ Ok(c) => {
870
+ let handles = crate::http_prefork::install_parent_signal_handler(c);
871
+ prefork_stop = Some(handles);
872
+ // Children are spawned; fall through and run our own
873
+ // accept loop as worker 0.
874
+ children.push(()); // sentinel: we're in a prefork group
875
+ }
876
+ Err(e) => {
877
+ eprintln!(
878
+ "[tish http] prefork spawn failed, continuing as single process: {}",
879
+ e
880
+ );
881
+ }
882
+ }
883
+ }
884
+ _ => {}
885
+ }
886
+
887
+ // Per-process accept threads. In prefork mode this is 1; SO_REUSEPORT
888
+ // between processes provides kernel-level load balancing. Without
889
+ // prefork we fall back to the old multi-thread-in-one-process layout.
890
+ let workers = if children.is_empty() {
891
+ num_workers()
892
+ } else {
893
+ 1
894
+ };
895
+ let listeners = match bind_listeners(port, workers) {
896
+ Ok(l) => l,
897
+ Err(e) => {
898
+ eprintln!("[tish http] failed to bind: {}", e);
899
+ return build_error_response(&e);
900
+ }
901
+ };
902
+
903
+ let worker_id = match role {
904
+ crate::http_prefork::PreforkRole::Child(id) => id,
905
+ _ => 0,
906
+ };
907
+ if matches!(role, crate::http_prefork::PreforkRole::Parent) && !children.is_empty() {
908
+ println!(
909
+ "tish http: listening on http://0.0.0.0:{} ({} process{} x {} accept thread{})",
910
+ port,
911
+ prefork_n,
912
+ if prefork_n == 1 { "" } else { "es" },
913
+ workers,
914
+ if workers == 1 { "" } else { "s" }
915
+ );
916
+ } else if worker_id == 0 {
917
+ println!(
918
+ "tish http: listening on http://0.0.0.0:{} ({} worker{})",
919
+ port,
920
+ workers,
921
+ if workers == 1 { "" } else { "s" }
922
+ );
923
+ }
924
+
925
+ let stop = prefork_stop.clone().unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
926
+
927
+ if max_requests == Some(1) {
928
+ let p = port;
929
+ thread::spawn(move || {
930
+ thread::sleep(Duration::from_millis(50));
931
+ if let Ok(mut s) = std::net::TcpStream::connect(format!("127.0.0.1:{}", p)) {
932
+ let _ =
933
+ s.write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n");
934
+ let _ = s.shutdown(std::net::Shutdown::Write);
935
+ }
936
+ });
937
+ }
938
+
939
+ // Per-thread handler: either a single shared `Arc<dyn Fn>` or one
940
+ // built per worker via the user-supplied factory. Because
941
+ // `Value: Send + Sync` under `send-values`, both variants run in
942
+ // parallel on N accept threads without a dispatcher queue.
943
+ //
944
+ // The worker index passed to the factory is *global across prefork
945
+ // processes*: parent is 0..N-1, each child is its own id, so a 14-
946
+ // core + 14-process layout produces worker ids 0..13 total, never
947
+ // duplicated.
948
+ let global_worker_base = match role {
949
+ crate::http_prefork::PreforkRole::Child(id) => id * workers,
950
+ _ => 0,
951
+ };
952
+
953
+ let processed = Arc::new(AtomicUsize::new(0));
954
+ let mut worker_handles = Vec::with_capacity(workers);
955
+ for (idx, listener) in listeners.into_iter().enumerate() {
956
+ let stop = Arc::clone(&stop);
957
+ let processed = Arc::clone(&processed);
958
+ let max = max_requests;
959
+ let worker_handler: Arc<dyn Fn(&[Value]) -> Value + Send + Sync> =
960
+ if let Some(f) = &factory {
961
+ f(global_worker_base + idx)
962
+ } else {
963
+ Arc::clone(shared_handler.as_ref().unwrap())
964
+ };
965
+ let handle = thread::Builder::new()
966
+ .name(format!("tish-http-w{}", idx))
967
+ .spawn(move || worker_loop_direct(listener, worker_handler, stop, processed, max))
968
+ .expect("spawn tish-http worker");
969
+ worker_handles.push(handle);
970
+ }
971
+
972
+ // Wait until one of: stop flag set, a worker bumps `processed >= max`, or
973
+ // we're shutting down (parent received SIGINT).
974
+ loop {
975
+ if stop.load(Ordering::Relaxed) {
976
+ break;
977
+ }
978
+ if let Some(m) = max_requests {
979
+ if processed.load(Ordering::Relaxed) >= m {
980
+ stop.store(true, Ordering::Relaxed);
981
+ break;
982
+ }
983
+ }
984
+ thread::sleep(Duration::from_millis(50));
985
+ }
986
+
987
+ // Kick each accept so its blocking `recv_timeout` wakes up promptly.
988
+ for _ in 0..worker_handles.len() {
989
+ let _ = std::net::TcpStream::connect(format!("127.0.0.1:{}", port));
990
+ }
991
+ for h in worker_handles {
992
+ let _ = h.join();
993
+ }
994
+
995
+ Value::Null
996
+ }
997
+
998
+ #[cfg(not(feature = "send-values"))]
999
+ fn serve_impl<F>(args: &[Value], handler: F) -> Value
1000
+ where
1001
+ F: Fn(&[Value]) -> Value,
1002
+ {
1003
+ // Single-threaded dispatch path: multiple accept threads push onto a
1004
+ // shared `mpsc::sync_channel`, one VM thread (this one) drains and runs
1005
+ // the handler. Used by wasm / single-threaded Rust builds where
1006
+ // `Value` is `Rc`-backed and not `Send`.
1007
+ let port = match args.first() {
1008
+ Some(Value::Number(n)) => *n as u16,
1009
+ _ => return build_error_response("serve requires a port number"),
1010
+ };
1011
+ let max_requests: Option<usize> = args.get(2).and_then(|v| match v {
1012
+ Value::Number(n) if *n >= 1.0 => Some(*n as usize),
1013
+ _ => None,
1014
+ });
1015
+ ensure_date_thread();
1016
+ let workers = num_workers();
1017
+ let listeners = match bind_listeners(port, workers) {
1018
+ Ok(l) => l,
1019
+ Err(e) => {
1020
+ eprintln!("[tish http] failed to bind: {}", e);
1021
+ return build_error_response(&e);
1022
+ }
1023
+ };
1024
+ println!(
1025
+ "tish http: listening on http://0.0.0.0:{} ({} worker{}, single-vm)",
1026
+ port,
1027
+ workers,
1028
+ if workers == 1 { "" } else { "s" }
1029
+ );
1030
+
1031
+ let stop = Arc::new(AtomicBool::new(false));
1032
+ let (tx, rx) = mpsc::sync_channel::<Job>(workers * 256);
1033
+
1034
+ if max_requests == Some(1) {
1035
+ let p = port;
1036
+ thread::spawn(move || {
1037
+ thread::sleep(Duration::from_millis(50));
1038
+ if let Ok(mut s) = std::net::TcpStream::connect(format!("127.0.0.1:{}", p)) {
1039
+ let _ =
1040
+ s.write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n");
1041
+ let _ = s.shutdown(std::net::Shutdown::Write);
1042
+ }
1043
+ });
1044
+ }
1045
+
1046
+ let mut worker_handles = Vec::with_capacity(workers);
1047
+ for (idx, listener) in listeners.into_iter().enumerate() {
1048
+ let tx = tx.clone();
1049
+ let stop = Arc::clone(&stop);
1050
+ let handle = thread::Builder::new()
1051
+ .name(format!("tish-http-w{}", idx))
1052
+ .spawn(move || worker_loop(listener, tx, stop))
1053
+ .expect("spawn tish-http worker");
1054
+ worker_handles.push(handle);
1055
+ }
1056
+ drop(tx);
1057
+
1058
+ let mut count = 0usize;
1059
+ while let Ok((req_prim, resp_tx)) = rx.recv() {
1060
+ let req_value = req_prim.into_value();
1061
+ let response_value = handler(&[req_value]);
1062
+ let resp_prim = ResponsePrimitive::from_value(&response_value);
1063
+ let _ = resp_tx.send(resp_prim);
1064
+
1065
+ count += 1;
1066
+ if max_requests.map(|m| count >= m).unwrap_or(false) {
1067
+ stop.store(true, Ordering::Relaxed);
1068
+ break;
1069
+ }
1070
+ }
1071
+
1072
+ stop.store(true, Ordering::Relaxed);
1073
+ for _ in 0..worker_handles.len() {
1074
+ let _ = std::net::TcpStream::connect(format!("127.0.0.1:{}", port));
1075
+ }
1076
+ for h in worker_handles {
1077
+ let _ = h.join();
1078
+ }
1079
+
1080
+ Value::Null
1081
+ }
1082
+
1083
+ /// Parallel accept + dispatch loop used when `send-values` is on. The
1084
+ /// handler runs on the same OS thread that accepted the connection, so
1085
+ /// there is no cross-thread queue on the hot path. Static-route fast path
1086
+ /// is unchanged.
1087
+ #[cfg(feature = "send-values")]
1088
+ fn worker_loop_direct(
1089
+ listener: std::net::TcpListener,
1090
+ handler: Arc<dyn Fn(&[Value]) -> Value + Send + Sync>,
1091
+ stop: Arc<AtomicBool>,
1092
+ processed: Arc<AtomicUsize>,
1093
+ max_requests: Option<usize>,
296
1094
  ) {
297
- let status_code = tiny_http::StatusCode(status);
298
- let mut response = tiny_http::Response::from_string(body).with_status_code(status_code);
1095
+ let server = match tiny_http::Server::from_listener(listener, None) {
1096
+ Ok(s) => s,
1097
+ Err(e) => {
1098
+ eprintln!("[tish http] worker failed to adopt listener: {}", e);
1099
+ return;
1100
+ }
1101
+ };
1102
+ loop {
1103
+ if stop.load(Ordering::Relaxed) {
1104
+ break;
1105
+ }
1106
+ match server.recv_timeout(Duration::from_millis(250)) {
1107
+ Ok(Some(mut request)) => {
1108
+ // Static-route fast path: serve pre-baked bytes without
1109
+ // touching the Tish VM at all.
1110
+ if let Some(route) = lookup_static_route(request.url()) {
1111
+ serve_static_route(request, route);
1112
+ } else {
1113
+ let req_prim = RequestPrimitive::from_tiny_http(&mut request);
1114
+ let req_value = req_prim.into_value();
1115
+ let response_value = handler(&[req_value]);
1116
+ let resp_prim = ResponsePrimitive::from_value(&response_value);
1117
+ respond_from_primitive(request, resp_prim);
1118
+ }
1119
+ if let Some(m) = max_requests {
1120
+ let p = processed.fetch_add(1, Ordering::Relaxed) + 1;
1121
+ if p >= m {
1122
+ stop.store(true, Ordering::Relaxed);
1123
+ break;
1124
+ }
1125
+ }
1126
+ }
1127
+ Ok(None) => {} // timeout; re-check stop flag
1128
+ Err(_) => break,
1129
+ }
1130
+ }
1131
+ }
299
1132
 
300
- for (key, value) in headers {
301
- if let Ok(header) = tiny_http::Header::from_bytes(key.as_bytes(), value.as_bytes()) {
302
- response = response.with_header(header);
1133
+ fn worker_loop(
1134
+ listener: std::net::TcpListener,
1135
+ dispatch: mpsc::SyncSender<Job>,
1136
+ stop: Arc<AtomicBool>,
1137
+ ) {
1138
+ let server = match tiny_http::Server::from_listener(listener, None) {
1139
+ Ok(s) => s,
1140
+ Err(e) => {
1141
+ eprintln!("[tish http] worker failed to adopt listener: {}", e);
1142
+ return;
1143
+ }
1144
+ };
1145
+
1146
+ // Per-worker buffer of in-flight requests. We interleave accept with
1147
+ // response drain so a slow VM-thread handler doesn't block new accepts
1148
+ // (up to the pending capacity) and so single-request responses still
1149
+ // flush immediately when accept() blocks on the next connection.
1150
+ let mut pending: VecDeque<(tiny_http::Request, mpsc::Receiver<ResponsePrimitive>)> =
1151
+ VecDeque::with_capacity(256);
1152
+
1153
+ loop {
1154
+ if stop.load(Ordering::Relaxed) {
1155
+ break;
1156
+ }
1157
+
1158
+ // Short-poll accept so we can drain pending responses even when
1159
+ // there's no new request arriving.
1160
+ match server.recv_timeout(Duration::from_millis(1)) {
1161
+ Ok(Some(mut request)) => {
1162
+ // Static-route fast path: serve pre-baked bytes without a
1163
+ // round trip through the VM dispatcher. This is what lets
1164
+ // per-worker accept actually scale for /plaintext + /json.
1165
+ if let Some(route) = lookup_static_route(request.url()) {
1166
+ serve_static_route(request, route);
1167
+ continue;
1168
+ }
1169
+ let req_prim = RequestPrimitive::from_tiny_http(&mut request);
1170
+ let (resp_tx, resp_rx) = mpsc::sync_channel::<ResponsePrimitive>(1);
1171
+ if dispatch.send((req_prim, resp_tx)).is_err() {
1172
+ break;
1173
+ }
1174
+ pending.push_back((request, resp_rx));
1175
+ }
1176
+ Ok(None) => {
1177
+ // timed out; fall through to drain
1178
+ }
1179
+ Err(_) => break,
1180
+ }
1181
+
1182
+ // Drain all ready responses (FIFO preserves order).
1183
+ while let Some((_, rx)) = pending.front() {
1184
+ match rx.try_recv() {
1185
+ Ok(resp) => {
1186
+ let (req, _rx) = pending.pop_front().unwrap();
1187
+ respond_from_primitive(req, resp);
1188
+ }
1189
+ Err(mpsc::TryRecvError::Empty) => break,
1190
+ Err(mpsc::TryRecvError::Disconnected) => {
1191
+ pending.pop_front();
1192
+ }
1193
+ }
303
1194
  }
304
1195
  }
305
1196
 
306
- let _ = request.respond(response);
1197
+ while let Some((req, rx)) = pending.pop_front() {
1198
+ match rx.recv_timeout(Duration::from_millis(500)) {
1199
+ Ok(resp) => respond_from_primitive(req, resp),
1200
+ Err(_) => drop(req),
1201
+ }
1202
+ }
307
1203
  }
1204
+
1205
+ #[allow(dead_code)]
1206
+ pub fn create_server(port: u16) -> Result<tiny_http::Server, String> {
1207
+ let addr = format!("0.0.0.0:{}", port);
1208
+ tiny_http::Server::http(&addr).map_err(|e| format!("Failed to start server: {}", e))
1209
+ }
1210
+
1211
+ // -------- public shims for alternate HTTP backends ------------------------
1212
+ //
1213
+ // Exposed so `http_hyper.rs` (and any future backend) can reuse the same
1214
+ // request / response primitives, static-route table, cached Date header,
1215
+ // SO_REUSEPORT binder, and worker count. Keeping these in one place
1216
+ // guarantees parity between tiny_http and hyper code paths — cached Date,
1217
+ // Server header, static-route table, key interning all work identically.
1218
+
1219
+ #[cfg(feature = "http-hyper")]
1220
+ impl RequestPrimitive {
1221
+ /// Build a `RequestPrimitive` from already-parsed parts. Used by
1222
+ /// backends that do their own HTTP parsing (e.g. hyper).
1223
+ pub fn new_pub(
1224
+ method: String,
1225
+ url: String,
1226
+ path: String,
1227
+ query: String,
1228
+ headers: Vec<(String, String)>,
1229
+ body: String,
1230
+ ) -> Self {
1231
+ Self {
1232
+ method,
1233
+ url,
1234
+ path,
1235
+ query,
1236
+ headers,
1237
+ body,
1238
+ }
1239
+ }
1240
+
1241
+ /// Same as the crate-private `into_value` but re-exported for
1242
+ /// alternate HTTP backends.
1243
+ pub fn into_value_pub(self) -> Value {
1244
+ self.into_value()
1245
+ }
1246
+ }
1247
+
1248
+ #[cfg(feature = "http-hyper")]
1249
+ impl ResponsePrimitive {
1250
+ /// Public alias of the crate-private `from_value`. Used by alternate
1251
+ /// HTTP backends (hyper) so Tish handlers return the same response
1252
+ /// shape regardless of which server is underneath.
1253
+ pub fn from_value_pub(value: &Value) -> Self {
1254
+ Self::from_value(value)
1255
+ }
1256
+ }
1257
+
1258
+ /// Public snapshot of a static route (body + content-type), returned to
1259
+ /// alternate HTTP backends so they can serve pre-baked responses without
1260
+ /// reaching into our private types.
1261
+ #[cfg(feature = "http-hyper")]
1262
+ #[derive(Clone)]
1263
+ pub struct StaticRouteSnapshot {
1264
+ pub body: Arc<[u8]>,
1265
+ pub content_type: Arc<str>,
1266
+ }
1267
+
1268
+ #[cfg(feature = "http-hyper")]
1269
+ pub fn lookup_static_route_pub(path: &str) -> Option<StaticRouteSnapshot> {
1270
+ lookup_static_route(path).map(|r| StaticRouteSnapshot {
1271
+ body: r.body,
1272
+ content_type: r.content_type,
1273
+ })
1274
+ }
1275
+
1276
+ #[cfg(feature = "http-hyper")]
1277
+ pub fn num_workers_pub() -> usize {
1278
+ num_workers()
1279
+ }
1280
+
1281
+ #[cfg(feature = "http-hyper")]
1282
+ pub fn num_prefork_workers_pub() -> usize {
1283
+ num_prefork_workers()
1284
+ }
1285
+
1286
+ #[cfg(feature = "http-hyper")]
1287
+ pub fn bind_listeners_reuseport(
1288
+ port: u16,
1289
+ n: usize,
1290
+ ) -> Result<Vec<std::net::TcpListener>, String> {
1291
+ bind_listeners(port, n)
1292
+ }
1293
+
1294
+ #[cfg(feature = "http-hyper")]
1295
+ pub fn build_error_response_pub(msg: &str) -> Value {
1296
+ build_error_response(msg)
1297
+ }
1298
+