@tishlang/tish-format 2.16.13 → 2.35.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 (50) hide show
  1. package/bin/tish-format +0 -0
  2. package/crates/js_to_tish/src/transform/expr.rs +12 -2
  3. package/crates/tish/tests/fixtures/fs_parity_callbacks.tish +22 -0
  4. package/crates/tish/tests/fixtures/fs_parity_promises.tish +21 -0
  5. package/crates/tish/tests/fixtures/fs_parity_sync.tish +20 -0
  6. package/crates/tish/tests/fs_parity.rs +103 -0
  7. package/crates/tish/tests/shortcircuit.rs +3 -3
  8. package/crates/tish_ast/src/ast.rs +77 -3
  9. package/crates/tish_builtins/Cargo.toml +1 -0
  10. package/crates/tish_builtins/src/array.rs +195 -16
  11. package/crates/tish_builtins/src/collections.rs +37 -2
  12. package/crates/tish_builtins/src/globals.rs +215 -25
  13. package/crates/tish_builtins/src/math.rs +65 -7
  14. package/crates/tish_builtins/src/number.rs +173 -0
  15. package/crates/tish_builtins/src/string.rs +168 -15
  16. package/crates/tish_bytecode/src/compiler.rs +56 -4
  17. package/crates/tish_bytecode/src/opcode.rs +4 -2
  18. package/crates/tish_compile/src/codegen.rs +513 -44
  19. package/crates/tish_compile/src/infer.rs +26 -11
  20. package/crates/tish_compile/src/resolve.rs +87 -10
  21. package/crates/tish_compile_js/src/codegen.rs +82 -24
  22. package/crates/tish_compile_js/src/tests_jsx.rs +27 -0
  23. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +17 -2
  24. package/crates/tish_core/src/json.rs +5 -1
  25. package/crates/tish_core/src/lib.rs +48 -2
  26. package/crates/tish_core/src/uri.rs +45 -0
  27. package/crates/tish_core/src/value.rs +6 -0
  28. package/crates/tish_eval/Cargo.toml +2 -1
  29. package/crates/tish_eval/src/eval.rs +655 -27
  30. package/crates/tish_eval/src/natives.rs +264 -18
  31. package/crates/tish_eval/src/regex.rs +47 -1
  32. package/crates/tish_fmt/Cargo.toml +1 -1
  33. package/crates/tish_fmt/src/lib.rs +23 -4
  34. package/crates/tish_opt/src/lib.rs +20 -1
  35. package/crates/tish_parser/src/lib.rs +41 -0
  36. package/crates/tish_parser/src/parser.rs +180 -11
  37. package/crates/tish_resolve/src/lib.rs +66 -5
  38. package/crates/tish_runtime/Cargo.toml +5 -1
  39. package/crates/tish_runtime/src/fs_ext.rs +359 -0
  40. package/crates/tish_runtime/src/http_hyper.rs +41 -18
  41. package/crates/tish_runtime/src/lib.rs +128 -9
  42. package/crates/tish_ui/src/jsx.rs +18 -3
  43. package/crates/tish_vm/src/jit.rs +1 -1
  44. package/crates/tish_vm/src/vm.rs +270 -60
  45. package/package.json +1 -1
  46. package/platform/darwin-arm64/tish-fmt +0 -0
  47. package/platform/darwin-x64/tish-fmt +0 -0
  48. package/platform/linux-arm64/tish-fmt +0 -0
  49. package/platform/linux-x64/tish-fmt +0 -0
  50. package/platform/win32-x64/tish-fmt.exe +0 -0
@@ -0,0 +1,359 @@
1
+ //! Node-compatible `fs` surface for `tish:fs` and the async `tish:fs/promises` module
2
+ //! (issue #122). Each operation has a single `*_core` returning `Result<Value, Value>`
3
+ //! (Ok = result, Err = an error value); a macro derives the synchronous export (returns the
4
+ //! value, or the error object on failure — tish's sync convention) and the promise export
5
+ //! (a fulfilled/rejected Promise — Node's `fs/promises` convention).
6
+ //!
7
+ //! Node names are primary (`readFileSync`, `statSync`, …); the existing tish names
8
+ //! (`readFile`, `readDir`, `fileExists`, `isDir`) are kept as aliases in the backends.
9
+ #![cfg(feature = "fs")]
10
+
11
+ use crate::promise::{promise_reject, promise_resolve};
12
+ use std::time::{SystemTime, UNIX_EPOCH};
13
+ use tishlang_builtins::helpers::make_error_value;
14
+ use tishlang_core::{ObjectMap, Value, VmRef};
15
+
16
+ fn path_arg(args: &[Value], i: usize) -> String {
17
+ args.get(i).map(|v| v.to_display_string()).unwrap_or_default()
18
+ }
19
+
20
+ /// Map an io::Error to the same error value `read_file` already produces.
21
+ fn io_err(e: std::io::Error) -> Value {
22
+ make_error_value(e)
23
+ }
24
+
25
+ fn unwrap(r: Result<Value, Value>) -> Value {
26
+ match r {
27
+ Ok(v) => v,
28
+ Err(e) => e,
29
+ }
30
+ }
31
+
32
+ fn settle(r: Result<Value, Value>) -> Value {
33
+ match r {
34
+ Ok(v) => promise_resolve(&[v]),
35
+ Err(e) => promise_reject(&[e]),
36
+ }
37
+ }
38
+
39
+ /// Node callback form: the trailing arg is `(err, result) => …`. Run the op on the remaining
40
+ /// args, invoke the callback with `(null, value)` on success or `(err, null)` on failure, and
41
+ /// return null. The op is synchronous under the hood, so the callback fires synchronously.
42
+ fn run_callback(core: fn(&[Value]) -> Result<Value, Value>, args: &[Value]) -> Value {
43
+ let cb = args.last().cloned().unwrap_or(Value::Null);
44
+ let op_args: &[Value] = if args.len() > 1 { &args[..args.len() - 1] } else { &[] };
45
+ let (err, data) = match core(op_args) {
46
+ Ok(v) => (Value::Null, v),
47
+ Err(e) => (e, Value::Null),
48
+ };
49
+ tishlang_core::value_call(&cb, &[err, data]);
50
+ Value::Null
51
+ }
52
+
53
+ /// Generate the sync export (dual: a trailing function arg switches to the Node callback form)
54
+ /// and the promise export over a `*_core`.
55
+ macro_rules! fs_method {
56
+ ($sync:ident, $promise:ident, $core:ident) => {
57
+ pub fn $sync(args: &[Value]) -> Value {
58
+ if let Some(Value::Function(_)) = args.last() {
59
+ return run_callback($core, args);
60
+ }
61
+ unwrap($core(args))
62
+ }
63
+ pub fn $promise(args: &[Value]) -> Value {
64
+ settle($core(args))
65
+ }
66
+ };
67
+ }
68
+
69
+ fn ms_since_epoch(t: std::io::Result<SystemTime>) -> f64 {
70
+ t.ok()
71
+ .and_then(|st| st.duration_since(UNIX_EPOCH).ok())
72
+ .map(|d| d.as_secs_f64() * 1000.0)
73
+ .unwrap_or(0.0)
74
+ }
75
+
76
+ /// Build a Node-like `Stats` object from metadata (predicate methods + size + times + mode).
77
+ fn stats_object(md: &std::fs::Metadata) -> Value {
78
+ let mut m = ObjectMap::default();
79
+ let is_file = md.is_file();
80
+ let is_dir = md.is_dir();
81
+ let is_symlink = md.file_type().is_symlink();
82
+ m.insert("isFile".into(), Value::native(move |_| Value::Bool(is_file)));
83
+ m.insert("isDirectory".into(), Value::native(move |_| Value::Bool(is_dir)));
84
+ m.insert("isSymbolicLink".into(), Value::native(move |_| Value::Bool(is_symlink)));
85
+ m.insert("isBlockDevice".into(), Value::native(|_| Value::Bool(false)));
86
+ m.insert("isCharacterDevice".into(), Value::native(|_| Value::Bool(false)));
87
+ m.insert("isFIFO".into(), Value::native(|_| Value::Bool(false)));
88
+ m.insert("isSocket".into(), Value::native(|_| Value::Bool(false)));
89
+ m.insert("size".into(), Value::Number(md.len() as f64));
90
+ m.insert("mtimeMs".into(), Value::Number(ms_since_epoch(md.modified())));
91
+ m.insert("atimeMs".into(), Value::Number(ms_since_epoch(md.accessed())));
92
+ m.insert("birthtimeMs".into(), Value::Number(ms_since_epoch(md.created())));
93
+ #[cfg(unix)]
94
+ {
95
+ use std::os::unix::fs::MetadataExt;
96
+ m.insert("mode".into(), Value::Number(md.mode() as f64));
97
+ m.insert("uid".into(), Value::Number(md.uid() as f64));
98
+ m.insert("gid".into(), Value::Number(md.gid() as f64));
99
+ m.insert("ino".into(), Value::Number(md.ino() as f64));
100
+ }
101
+ Value::object(m)
102
+ }
103
+
104
+ // ── cores ─────────────────────────────────────────────────────────────────────────────────
105
+
106
+ pub fn read_file_core(args: &[Value]) -> Result<Value, Value> {
107
+ std::fs::read_to_string(path_arg(args, 0))
108
+ .map(|s| Value::String(s.into()))
109
+ .map_err(io_err)
110
+ }
111
+
112
+ pub fn read_file_bytes_core(args: &[Value]) -> Result<Value, Value> {
113
+ std::fs::read(path_arg(args, 0))
114
+ .map(|b| Value::Array(VmRef::new(b.into_iter().map(|x| Value::Number(x as f64)).collect())))
115
+ .map_err(io_err)
116
+ }
117
+
118
+ /// Write a string, or a byte array (numbers 0–255), to a file.
119
+ pub fn write_file_core(args: &[Value]) -> Result<Value, Value> {
120
+ let path = path_arg(args, 0);
121
+ let res = match args.get(1) {
122
+ Some(Value::Array(a)) => {
123
+ let bytes: Vec<u8> = a
124
+ .borrow()
125
+ .iter()
126
+ .map(|v| if let Value::Number(n) = v { *n as u8 } else { 0 })
127
+ .collect();
128
+ std::fs::write(&path, bytes)
129
+ }
130
+ Some(v) => std::fs::write(&path, v.to_display_string()),
131
+ None => std::fs::write(&path, ""),
132
+ };
133
+ res.map(|_| Value::Null).map_err(io_err)
134
+ }
135
+
136
+ pub fn append_file_core(args: &[Value]) -> Result<Value, Value> {
137
+ use std::io::Write;
138
+ let path = path_arg(args, 0);
139
+ let data = args.get(1).map(|v| v.to_display_string()).unwrap_or_default();
140
+ std::fs::OpenOptions::new()
141
+ .create(true)
142
+ .append(true)
143
+ .open(&path)
144
+ .and_then(|mut f| f.write_all(data.as_bytes()))
145
+ .map(|_| Value::Null)
146
+ .map_err(io_err)
147
+ }
148
+
149
+ pub fn stat_core(args: &[Value]) -> Result<Value, Value> {
150
+ std::fs::metadata(path_arg(args, 0)).map(|m| stats_object(&m)).map_err(io_err)
151
+ }
152
+ pub fn lstat_core(args: &[Value]) -> Result<Value, Value> {
153
+ std::fs::symlink_metadata(path_arg(args, 0)).map(|m| stats_object(&m)).map_err(io_err)
154
+ }
155
+
156
+ pub fn readdir_core(args: &[Value]) -> Result<Value, Value> {
157
+ std::fs::read_dir(path_arg(args, 0))
158
+ .map(|entries| {
159
+ let names: Vec<Value> = entries
160
+ .filter_map(|e| e.ok())
161
+ .map(|e| Value::String(e.file_name().to_string_lossy().into()))
162
+ .collect();
163
+ Value::Array(VmRef::new(names))
164
+ })
165
+ .map_err(io_err)
166
+ }
167
+
168
+ /// `mkdir(path[, { recursive }])` — recursive creates parents.
169
+ pub fn mkdir_core(args: &[Value]) -> Result<Value, Value> {
170
+ let path = path_arg(args, 0);
171
+ let recursive = opt_bool(args.get(1), "recursive");
172
+ let res = if recursive {
173
+ std::fs::create_dir_all(&path)
174
+ } else {
175
+ std::fs::create_dir(&path)
176
+ };
177
+ res.map(|_| Value::Null).map_err(io_err)
178
+ }
179
+
180
+ /// `rm(path[, { recursive }])` — file, or whole tree when recursive.
181
+ pub fn rm_core(args: &[Value]) -> Result<Value, Value> {
182
+ let path = path_arg(args, 0);
183
+ let recursive = opt_bool(args.get(1), "recursive");
184
+ let md = std::fs::symlink_metadata(&path);
185
+ let res = match md {
186
+ Ok(m) if m.is_dir() => {
187
+ if recursive {
188
+ std::fs::remove_dir_all(&path)
189
+ } else {
190
+ std::fs::remove_dir(&path)
191
+ }
192
+ }
193
+ _ => std::fs::remove_file(&path),
194
+ };
195
+ res.map(|_| Value::Null).map_err(io_err)
196
+ }
197
+
198
+ pub fn rmdir_core(args: &[Value]) -> Result<Value, Value> {
199
+ std::fs::remove_dir(path_arg(args, 0)).map(|_| Value::Null).map_err(io_err)
200
+ }
201
+ pub fn unlink_core(args: &[Value]) -> Result<Value, Value> {
202
+ std::fs::remove_file(path_arg(args, 0)).map(|_| Value::Null).map_err(io_err)
203
+ }
204
+ pub fn rename_core(args: &[Value]) -> Result<Value, Value> {
205
+ std::fs::rename(path_arg(args, 0), path_arg(args, 1)).map(|_| Value::Null).map_err(io_err)
206
+ }
207
+ pub fn copy_file_core(args: &[Value]) -> Result<Value, Value> {
208
+ std::fs::copy(path_arg(args, 0), path_arg(args, 1)).map(|_| Value::Null).map_err(io_err)
209
+ }
210
+ pub fn realpath_core(args: &[Value]) -> Result<Value, Value> {
211
+ std::fs::canonicalize(path_arg(args, 0))
212
+ .map(|p| Value::String(p.to_string_lossy().into()))
213
+ .map_err(io_err)
214
+ }
215
+ pub fn readlink_core(args: &[Value]) -> Result<Value, Value> {
216
+ std::fs::read_link(path_arg(args, 0))
217
+ .map(|p| Value::String(p.to_string_lossy().into()))
218
+ .map_err(io_err)
219
+ }
220
+ pub fn truncate_core(args: &[Value]) -> Result<Value, Value> {
221
+ let len = match args.get(1) {
222
+ Some(Value::Number(n)) => *n as u64,
223
+ _ => 0,
224
+ };
225
+ std::fs::OpenOptions::new()
226
+ .write(true)
227
+ .open(path_arg(args, 0))
228
+ .and_then(|f| f.set_len(len))
229
+ .map(|_| Value::Null)
230
+ .map_err(io_err)
231
+ }
232
+
233
+ /// `mkdtemp(prefix)` — create a uniquely-named temp dir and return its path. Node appends 6 RANDOM
234
+ /// characters; a timestamp suffix is both predictable (a temp-dir security smell) and collision-prone
235
+ /// under rapid calls. Use a random suffix and rely on `create_dir`'s exclusive semantics, retrying on
236
+ /// the astronomically-rare collision.
237
+ pub fn mkdtemp_core(args: &[Value]) -> Result<Value, Value> {
238
+ let prefix = path_arg(args, 0);
239
+ for _ in 0..16 {
240
+ // 10 base-36 chars derived from a random u64 (~51 bits of entropy).
241
+ let mut n: u64 = rand::random();
242
+ let mut suffix = String::with_capacity(10);
243
+ for _ in 0..10 {
244
+ suffix.push(char::from_digit((n % 36) as u32, 36).unwrap());
245
+ n /= 36;
246
+ }
247
+ let path = format!("{}{}", prefix, suffix);
248
+ match std::fs::create_dir(&path) {
249
+ Ok(()) => return Ok(Value::String(path.into())),
250
+ Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
251
+ Err(e) => return Err(io_err(e)),
252
+ }
253
+ }
254
+ Err(io_err(std::io::Error::new(
255
+ std::io::ErrorKind::AlreadyExists,
256
+ "mkdtemp: could not create a unique temporary directory",
257
+ )))
258
+ }
259
+
260
+ /// `cp(src, dest[, { recursive }])` — copy a file, or a directory tree when recursive.
261
+ pub fn cp_core(args: &[Value]) -> Result<Value, Value> {
262
+ let src = path_arg(args, 0);
263
+ let dest = path_arg(args, 1);
264
+ let recursive = opt_bool(args.get(2), "recursive");
265
+ copy_recursive(std::path::Path::new(&src), std::path::Path::new(&dest), recursive)
266
+ .map(|_| Value::Null)
267
+ .map_err(io_err)
268
+ }
269
+
270
+ fn copy_recursive(src: &std::path::Path, dest: &std::path::Path, recursive: bool) -> std::io::Result<()> {
271
+ if src.is_dir() {
272
+ if !recursive {
273
+ return Err(std::io::Error::new(
274
+ std::io::ErrorKind::InvalidInput,
275
+ "cp on a directory requires { recursive: true }",
276
+ ));
277
+ }
278
+ std::fs::create_dir_all(dest)?;
279
+ for entry in std::fs::read_dir(src)? {
280
+ let entry = entry?;
281
+ copy_recursive(&entry.path(), &dest.join(entry.file_name()), true)?;
282
+ }
283
+ Ok(())
284
+ } else {
285
+ if let Some(parent) = dest.parent() {
286
+ std::fs::create_dir_all(parent)?;
287
+ }
288
+ std::fs::copy(src, dest).map(|_| ())
289
+ }
290
+ }
291
+
292
+ fn opt_bool(v: Option<&Value>, key: &str) -> bool {
293
+ match v {
294
+ Some(Value::Object(o)) => o
295
+ .borrow()
296
+ .strings
297
+ .get(key)
298
+ .map(|b| b.is_truthy())
299
+ .unwrap_or(false),
300
+ _ => false,
301
+ }
302
+ }
303
+
304
+ // ── sync + promise exports ──────────────────────────────────────────────────────────────────
305
+
306
+ fs_method!(read_file, read_file_promise, read_file_core);
307
+ fs_method!(read_file_bytes, read_file_bytes_promise, read_file_bytes_core);
308
+ fs_method!(write_file, write_file_promise, write_file_core);
309
+ fs_method!(append_file, append_file_promise, append_file_core);
310
+ fs_method!(stat, stat_promise, stat_core);
311
+ fs_method!(lstat, lstat_promise, lstat_core);
312
+ fs_method!(readdir, readdir_promise, readdir_core);
313
+ fs_method!(mkdir, mkdir_promise, mkdir_core);
314
+ fs_method!(rm, rm_promise, rm_core);
315
+ fs_method!(rmdir, rmdir_promise, rmdir_core);
316
+ fs_method!(unlink, unlink_promise, unlink_core);
317
+ fs_method!(rename, rename_promise, rename_core);
318
+ fs_method!(copy_file, copy_file_promise, copy_file_core);
319
+ fs_method!(realpath, realpath_promise, realpath_core);
320
+ fs_method!(readlink, readlink_promise, readlink_core);
321
+ fs_method!(truncate, truncate_promise, truncate_core);
322
+ fs_method!(mkdtemp, mkdtemp_promise, mkdtemp_core);
323
+ fs_method!(cp, cp_promise, cp_core);
324
+
325
+ // `exists` / `access` never error — they answer a boolean.
326
+ pub fn exists(args: &[Value]) -> Value {
327
+ Value::Bool(std::path::Path::new(&path_arg(args, 0)).exists())
328
+ }
329
+ pub fn exists_promise(args: &[Value]) -> Value {
330
+ promise_resolve(&[exists(args)])
331
+ }
332
+ /// `accessSync(path)` → true if the path exists (tish-friendly); the promise form resolves
333
+ /// `true` / rejects with an error, matching `fs/promises.access`.
334
+ pub fn access(args: &[Value]) -> Value {
335
+ exists(args)
336
+ }
337
+ pub fn access_promise(args: &[Value]) -> Value {
338
+ let path = path_arg(args, 0);
339
+ if std::path::Path::new(&path).exists() {
340
+ promise_resolve(&[Value::Null])
341
+ } else {
342
+ promise_reject(&[Value::String(format!("ENOENT: no such file '{}'", path).into())])
343
+ }
344
+ }
345
+
346
+ /// `isDir(path)` — tish convenience kept for back-compat (≈ `statSync().isDirectory()`).
347
+ pub fn is_dir(args: &[Value]) -> Value {
348
+ Value::Bool(std::path::Path::new(&path_arg(args, 0)).is_dir())
349
+ }
350
+
351
+ /// `fs.constants` — the access-mode flags.
352
+ pub fn constants() -> Value {
353
+ let mut m = ObjectMap::default();
354
+ m.insert("F_OK".into(), Value::Number(0.0));
355
+ m.insert("R_OK".into(), Value::Number(4.0));
356
+ m.insert("W_OK".into(), Value::Number(2.0));
357
+ m.insert("X_OK".into(), Value::Number(1.0));
358
+ Value::object(m)
359
+ }
@@ -10,7 +10,7 @@
10
10
  //! N OS threads (one per CPU, pinned via core_affinity)
11
11
  //! ├─ single-threaded tokio runtime per thread
12
12
  //! ├─ SO_REUSEPORT-bound TcpListener per thread
13
- //! └─ hyper HTTP/1.1 + HTTP/2 server
13
+ //! └─ hyper HTTP/1.1 server (`http1::Builder`)
14
14
  //! │
15
15
  //! ├─ async per-connection state machine (no OS thread)
16
16
  //! ├─ static-route fast path (lock-free ArcSwap<HashMap> from
@@ -23,13 +23,17 @@
23
23
  //!
24
24
  //! * Removes tiny_http's thread-per-connection model (the bottleneck on
25
25
  //! every macOS/Linux Tish HTTP server at >50k concurrent connections).
26
- //! * Gives HTTP/2 + TLS-ready surface for free (hyper handles the state
27
- //! machine; we only have to convert our `RequestPrimitive` to/from
28
- //! `http::Request` / `http::Response`).
29
26
  //! * Shared async context with `reqwest`, `tokio-postgres`, `tokio` in
30
27
  //! general — the tokio runtime is reused for client fetch, db, and
31
28
  //! server accept.
32
29
  //!
30
+ //! ## Protocol support
31
+ //!
32
+ //! HTTP/1.1 only today (the accept loop uses `http1::Builder`). The `hyper` dependency enables the
33
+ //! `http2` feature, so upgrading to an `hyper_util::server::conn::auto` builder (h2c prior-knowledge,
34
+ //! and HTTP/2 over TLS once ALPN is wired) is a contained follow-up — tracked separately. Do NOT
35
+ //! advertise HTTP/2 until that lands.
36
+ //!
33
37
  //! ## Integration with the Tish VM
34
38
  //!
35
39
  //! The Tish handler returns a synchronous `Value`. We call it from inside
@@ -182,20 +186,37 @@ where
182
186
  }
183
187
  drop(tx);
184
188
 
185
- // VM-thread dispatcher loop: identical to the tiny_http path.
189
+ // VM-thread dispatcher loop. Mirrors the tiny_http path (#384): `recv_timeout` rather than a
190
+ // blocking `recv` so a handler's scheduled timers still FIRE when the server is idle, and
191
+ // `drain_timers()` runs after every handler call AND on each idle tick. Handler calls all run on
192
+ // this single VM thread, so the timer registry lives here — without the drain a `setTimeout`
193
+ // scheduled from a hyper handler would never fire and never be freed.
186
194
  let mut count = 0usize;
187
- while let Ok((req_prim, resp_tx)) = rx.recv() {
188
- let req_value = req_prim.into_value_pub();
189
- // `handler` is the generic `F: Fn(&[Value]) -> Value` — call it directly. (`.call(..)` here
190
- // would bind the *unstable* `Fn::call` trait method, which takes a tuple, not `&[Value]`.)
191
- let response_value = handler(&[req_value]);
192
- let resp_prim = ResponsePrimitive::from_value_pub(&response_value);
193
- let _ = resp_tx.send(resp_prim);
194
-
195
- count += 1;
196
- if max_requests.map(|m| count >= m).unwrap_or(false) {
197
- stop.store(true, Ordering::Relaxed);
198
- break;
195
+ loop {
196
+ match rx.recv_timeout(std::time::Duration::from_millis(250)) {
197
+ Ok((req_prim, resp_tx)) => {
198
+ let req_value = req_prim.into_value_pub();
199
+ // `handler` is the generic `F: Fn(&[Value]) -> Value` — call it directly. (`.call(..)`
200
+ // here would bind the *unstable* `Fn::call` trait method, which takes a tuple, not
201
+ // `&[Value]`.)
202
+ let response_value = handler(&[req_value]);
203
+ crate::timers::drain_timers();
204
+ let resp_prim = ResponsePrimitive::from_value_pub(&response_value);
205
+ let _ = resp_tx.send(resp_prim);
206
+
207
+ count += 1;
208
+ if max_requests.map(|m| count >= m).unwrap_or(false) {
209
+ stop.store(true, Ordering::Relaxed);
210
+ break;
211
+ }
212
+ }
213
+ Err(std_mpsc::RecvTimeoutError::Timeout) => {
214
+ crate::timers::drain_timers();
215
+ if stop.load(Ordering::Relaxed) {
216
+ break;
217
+ }
218
+ }
219
+ Err(std_mpsc::RecvTimeoutError::Disconnected) => break,
199
220
  }
200
221
  }
201
222
 
@@ -295,7 +316,9 @@ async fn handle_request(
295
316
 
296
317
  // Slow path: cross the mpsc to the VM thread.
297
318
  let (method, url, path, query, headers, body_bytes) = extract_request(req).await;
298
- let body = String::from_utf8(body_bytes).unwrap_or_default();
319
+ // Lossy UTF-8 decode to match the tiny_http path (`http.rs`): a body with invalid UTF-8 becomes
320
+ // replacement chars, NOT an empty string — `req.body` is a String and both backends must agree.
321
+ let body = String::from_utf8_lossy(&body_bytes).into_owned();
299
322
  let prim = RequestPrimitive::new_pub(method, url, path, query, headers, body);
300
323
 
301
324
  let (resp_tx, resp_rx) = std_mpsc::sync_channel::<ResponsePrimitive>(1);