@tishlang/tish-format 3.10.3 → 3.10.9
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/src/main.rs +65 -25
- package/crates/tish_builtins/src/globals.rs +3 -3
- package/crates/tish_builtins/src/symbol.rs +86 -4
- package/crates/tish_bytecode/src/compiler.rs +22 -6
- package/crates/tish_bytecode/tests/fundecl_slot_binding.rs +115 -0
- package/crates/tish_core/src/shape.rs +55 -2
- package/crates/tish_core/src/value.rs +276 -8
- package/crates/tish_eval/src/timers.rs +66 -2
- package/crates/tish_ffi/src/lib.rs +74 -12
- package/crates/tish_ffi/tests/loader.rs +35 -0
- package/crates/tish_fmt/Cargo.toml +1 -1
- package/crates/tish_lsp/src/main.rs +91 -2
- package/crates/tish_runtime/src/fs_ext.rs +28 -19
- package/crates/tish_runtime/src/http_hyper.rs +18 -2
- package/crates/tish_runtime/src/lib.rs +4 -0
- package/crates/tish_runtime/src/net.rs +125 -97
- package/crates/tish_runtime/src/process_spawn.rs +326 -113
- package/crates/tish_runtime/src/pty.rs +161 -107
- package/crates/tish_runtime/src/stream_buf.rs +419 -0
- package/crates/tish_runtime/src/ws.rs +431 -52
- package/crates/tish_vm/src/jit.rs +473 -67
- package/crates/tish_vm/src/lib.rs +6 -0
- package/crates/tish_vm/src/vm.rs +3 -1
- package/crates/tish_vm/tests/fundecl_slot_no_frame_cycle.rs +152 -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
|
@@ -447,7 +447,10 @@ impl LanguageServer for Backend {
|
|
|
447
447
|
// files so an externally edited declaration (`.d.tish`) or source file refreshes the
|
|
448
448
|
// workspace-symbol index without a server restart (#161). The client only delivers these
|
|
449
449
|
// events for globs the server registers; a `.tish` glob does NOT cover `.d.tish` (it has a
|
|
450
|
-
// compound extension), so both are registered explicitly.
|
|
450
|
+
// compound extension), so both are registered explicitly. Also watch the manifests that
|
|
451
|
+
// drive `cargo:` resolution — `tish.rustDependencies` lives in package.json, and a path
|
|
452
|
+
// dep resolves through its Cargo.toml/Cargo.lock — so a dependency edit can invalidate
|
|
453
|
+
// `cargo_src_cache` (#714); without these globs no event could ever reach it.
|
|
451
454
|
let watchers = |glob: &str| FileSystemWatcher {
|
|
452
455
|
glob_pattern: GlobPattern::String(glob.to_string()),
|
|
453
456
|
kind: None, // create | change | delete
|
|
@@ -456,7 +459,13 @@ impl LanguageServer for Backend {
|
|
|
456
459
|
id: "tish-watch-d-tish".to_string(),
|
|
457
460
|
method: "workspace/didChangeWatchedFiles".to_string(),
|
|
458
461
|
register_options: serde_json::to_value(DidChangeWatchedFilesRegistrationOptions {
|
|
459
|
-
watchers: vec![
|
|
462
|
+
watchers: vec![
|
|
463
|
+
watchers("**/*.tish"),
|
|
464
|
+
watchers("**/*.d.tish"),
|
|
465
|
+
watchers("**/package.json"),
|
|
466
|
+
watchers("**/Cargo.toml"),
|
|
467
|
+
watchers("**/Cargo.lock"),
|
|
468
|
+
],
|
|
460
469
|
})
|
|
461
470
|
.ok(),
|
|
462
471
|
};
|
|
@@ -485,6 +494,10 @@ impl LanguageServer for Backend {
|
|
|
485
494
|
let mut roots = self.roots.write().unwrap();
|
|
486
495
|
apply_workspace_folder_changes(&mut roots, ¶ms.event);
|
|
487
496
|
}
|
|
497
|
+
// The cargo-crate-root cache goes with the old folder set (#714): `cargo:` resolution is
|
|
498
|
+
// root-relative, entries are cheap to recompute on the next goto-definition/hover, and
|
|
499
|
+
// clearing here mirrors how the folder change already re-scopes the symbol index.
|
|
500
|
+
self.cargo_src_cache.write().unwrap().clear();
|
|
488
501
|
self.client
|
|
489
502
|
.log_message(MessageType::INFO, "tish-lsp: workspace folders updated")
|
|
490
503
|
.await;
|
|
@@ -510,6 +523,25 @@ impl LanguageServer for Backend {
|
|
|
510
523
|
}
|
|
511
524
|
}
|
|
512
525
|
}
|
|
526
|
+
// #714: a manifest change re-routes `cargo:` resolution — `tish.rustDependencies` lives in
|
|
527
|
+
// package.json, and a path dep resolves through its Cargo.toml/Cargo.lock — so drop the
|
|
528
|
+
// whole cargo-crate-root cache when any watched manifest changes. Whole-cache rather than
|
|
529
|
+
// per-root pruning: a path dep's manifest may live outside every project root, entries are
|
|
530
|
+
// a few hundred bytes, and they are recomputed per (human-rate) goto-definition/hover.
|
|
531
|
+
let manifest_changed = params.changes.iter().any(|change| {
|
|
532
|
+
change
|
|
533
|
+
.uri
|
|
534
|
+
.to_file_path()
|
|
535
|
+
.ok()
|
|
536
|
+
.and_then(|p| {
|
|
537
|
+
p.file_name()
|
|
538
|
+
.map(|f| f == "package.json" || f == "Cargo.toml" || f == "Cargo.lock")
|
|
539
|
+
})
|
|
540
|
+
.unwrap_or(false)
|
|
541
|
+
});
|
|
542
|
+
if manifest_changed {
|
|
543
|
+
self.cargo_src_cache.write().unwrap().clear();
|
|
544
|
+
}
|
|
513
545
|
// Re-lint any OPEN buffer whose declarations may now have changed. A `.d.tish` supplies
|
|
514
546
|
// ambient `declare` bindings other buffers depend on, so a change there can flip
|
|
515
547
|
// unresolved-name diagnostics; recompute open docs against the new on-disk state.
|
|
@@ -2558,6 +2590,63 @@ mod jsonrpc_integration_tests {
|
|
|
2558
2590
|
std::fs::remove_dir_all(&dir).ok();
|
|
2559
2591
|
}
|
|
2560
2592
|
|
|
2593
|
+
/// Seed `cargo_src_cache` the way a successful goto-definition would.
|
|
2594
|
+
fn seed_cargo_src_cache(service: &LspService<Backend>) {
|
|
2595
|
+
service.inner().cargo_src_cache.write().unwrap().insert(
|
|
2596
|
+
(PathBuf::from("/proj"), "cargo:serde".to_string()),
|
|
2597
|
+
PathBuf::from("/old/crate/root"),
|
|
2598
|
+
);
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
fn cargo_src_cache_len(service: &LspService<Backend>) -> usize {
|
|
2602
|
+
service.inner().cargo_src_cache.read().unwrap().len()
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
fn did_change_watched_files_request(uri: &str) -> Request {
|
|
2606
|
+
Request::build("workspace/didChangeWatchedFiles")
|
|
2607
|
+
.params(serde_json::json!({ "changes": [ { "uri": uri, "type": 2 } ] }))
|
|
2608
|
+
.finish()
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
// #714: a workspace-folder change must drop the cargo-crate-root cache along with the roots
|
|
2612
|
+
// rewrite — previously nothing ever invalidated it, so a stale crate root was served for the
|
|
2613
|
+
// rest of the session.
|
|
2614
|
+
#[tokio::test]
|
|
2615
|
+
async fn workspace_folder_change_clears_cargo_src_cache() {
|
|
2616
|
+
let mut service = new_service();
|
|
2617
|
+
initialize(&mut service).await;
|
|
2618
|
+
seed_cargo_src_cache(&service);
|
|
2619
|
+
|
|
2620
|
+
let dir = crate::test_fs::unique_temp_dir("cargocache");
|
|
2621
|
+
let uri = Url::from_file_path(&dir).unwrap().to_string();
|
|
2622
|
+
let _ = call(&mut service, did_change_workspace_folders(&[&uri], &[])).await;
|
|
2623
|
+
assert_eq!(cargo_src_cache_len(&service), 0, "folder change must clear the cargo src cache");
|
|
2624
|
+
|
|
2625
|
+
std::fs::remove_dir_all(&dir).ok();
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
// #714: a watched manifest change (package.json / Cargo.toml / Cargo.lock — the files that
|
|
2629
|
+
// drive `cargo:` resolution) must drop the cargo-crate-root cache; a plain `.tish` change
|
|
2630
|
+
// must leave it alone.
|
|
2631
|
+
#[tokio::test]
|
|
2632
|
+
async fn manifest_change_clears_cargo_src_cache() {
|
|
2633
|
+
let mut service = new_service();
|
|
2634
|
+
initialize(&mut service).await;
|
|
2635
|
+
|
|
2636
|
+
// A `.tish` event leaves the cargo cache untouched…
|
|
2637
|
+
seed_cargo_src_cache(&service);
|
|
2638
|
+
let _ = call(&mut service, did_change_watched_files_request("file:///proj/a.tish")).await;
|
|
2639
|
+
assert_eq!(cargo_src_cache_len(&service), 1, "a .tish change must not clear the cargo src cache");
|
|
2640
|
+
|
|
2641
|
+
// …while any manifest event drops it.
|
|
2642
|
+
for name in ["package.json", "Cargo.toml", "Cargo.lock"] {
|
|
2643
|
+
seed_cargo_src_cache(&service);
|
|
2644
|
+
let req = did_change_watched_files_request(&format!("file:///proj/{name}"));
|
|
2645
|
+
let _ = call(&mut service, req).await;
|
|
2646
|
+
assert_eq!(cargo_src_cache_len(&service), 0, "a {name} change must clear the cargo src cache");
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2561
2650
|
// #162 protocol edge cases the client is allowed to send: adding the same folder twice must not
|
|
2562
2651
|
// change the result, and removing a folder that was never added must not evict a tracked root.
|
|
2563
2652
|
#[tokio::test]
|
|
@@ -74,31 +74,40 @@ fn ms_since_epoch(t: std::io::Result<SystemTime>) -> f64 {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
/// Build a Node-like `Stats` object from metadata (predicate methods + size + times + mode).
|
|
77
|
+
///
|
|
78
|
+
/// FIXED key order via `object_from_pairs`: building through an `ObjectMap` (AHashMap) iterated
|
|
79
|
+
/// in random per-instance order made every call mint a fresh permanent chain in the global shape
|
|
80
|
+
/// registry (~2.3KB leaked per `stat()` call, unbounded — a file watcher stat-walking a workspace
|
|
81
|
+
/// leaked multi-GB/hour). Fixed-order construction dedupes to one cached shape chain, and skips
|
|
82
|
+
/// the intermediate hashmap entirely.
|
|
77
83
|
fn stats_object(md: &std::fs::Metadata) -> Value {
|
|
78
|
-
let mut m = ObjectMap::default();
|
|
79
84
|
let is_file = md.is_file();
|
|
80
85
|
let is_dir = md.is_dir();
|
|
81
86
|
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
87
|
#[cfg(unix)]
|
|
94
|
-
{
|
|
88
|
+
let (mode, uid, gid, ino) = {
|
|
95
89
|
use std::os::unix::fs::MetadataExt;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
90
|
+
(md.mode() as f64, md.uid() as f64, md.gid() as f64, md.ino() as f64)
|
|
91
|
+
};
|
|
92
|
+
#[cfg(not(unix))]
|
|
93
|
+
let (mode, uid, gid, ino) = (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64);
|
|
94
|
+
Value::object_from_pairs([
|
|
95
|
+
("isFile".into(), Value::native(move |_| Value::Bool(is_file))),
|
|
96
|
+
("isDirectory".into(), Value::native(move |_| Value::Bool(is_dir))),
|
|
97
|
+
("isSymbolicLink".into(), Value::native(move |_| Value::Bool(is_symlink))),
|
|
98
|
+
("isBlockDevice".into(), Value::native(|_| Value::Bool(false))),
|
|
99
|
+
("isCharacterDevice".into(), Value::native(|_| Value::Bool(false))),
|
|
100
|
+
("isFIFO".into(), Value::native(|_| Value::Bool(false))),
|
|
101
|
+
("isSocket".into(), Value::native(|_| Value::Bool(false))),
|
|
102
|
+
("size".into(), Value::Number(md.len() as f64)),
|
|
103
|
+
("mtimeMs".into(), Value::Number(ms_since_epoch(md.modified()))),
|
|
104
|
+
("atimeMs".into(), Value::Number(ms_since_epoch(md.accessed()))),
|
|
105
|
+
("birthtimeMs".into(), Value::Number(ms_since_epoch(md.created()))),
|
|
106
|
+
("mode".into(), Value::Number(mode)),
|
|
107
|
+
("uid".into(), Value::Number(uid)),
|
|
108
|
+
("gid".into(), Value::Number(gid)),
|
|
109
|
+
("ino".into(), Value::Number(ino)),
|
|
110
|
+
])
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
// ── cores ─────────────────────────────────────────────────────────────────────────────────
|
|
@@ -491,6 +491,17 @@ fn handle_ws_upgrade(mut req: Request<hyper::body::Incoming>) -> Response<Full<B
|
|
|
491
491
|
use tokio_tungstenite::tungstenite::handshake::derive_accept_key;
|
|
492
492
|
use tokio_tungstenite::tungstenite::protocol::Role;
|
|
493
493
|
|
|
494
|
+
// Enforce the same connection cap as the tish:ws listener BEFORE the 101. This path runs
|
|
495
|
+
// pre-auth — the VM handler never sees the request — so without the check an unauthenticated
|
|
496
|
+
// client could register unbounded permanent connections (tasks + fd + buffers each) on any
|
|
497
|
+
// serve() app built with http-hyper + ws (#707).
|
|
498
|
+
if !crate::ws::has_ws_capacity() {
|
|
499
|
+
return simple_error_response(
|
|
500
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
501
|
+
"websocket connection limit reached",
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
494
505
|
let accept = match req.headers().get(hyper::header::SEC_WEBSOCKET_KEY) {
|
|
495
506
|
Some(k) => derive_accept_key(k.as_bytes()),
|
|
496
507
|
None => return simple_error_response(StatusCode::BAD_REQUEST, "missing Sec-WebSocket-Key"),
|
|
@@ -501,8 +512,13 @@ fn handle_ws_upgrade(mut req: Request<hyper::body::Incoming>) -> Response<Full<B
|
|
|
501
512
|
match on_upgrade.await {
|
|
502
513
|
Ok(upgraded) => {
|
|
503
514
|
let io = TokioIo::new(upgraded);
|
|
504
|
-
|
|
505
|
-
|
|
515
|
+
// Shared config: 16 KiB buffers instead of tungstenite's eager 128 KiB default.
|
|
516
|
+
let ws = tokio_tungstenite::WebSocketStream::from_raw_socket(
|
|
517
|
+
io,
|
|
518
|
+
Role::Server,
|
|
519
|
+
Some(crate::ws::ws_config()),
|
|
520
|
+
)
|
|
521
|
+
.await;
|
|
506
522
|
let id = crate::ws::register_ws_stream(ws);
|
|
507
523
|
crate::ws::enqueue_upgraded_conn(id);
|
|
508
524
|
}
|
|
@@ -1773,6 +1773,10 @@ pub use tty::{
|
|
|
1773
1773
|
tty_enter_alt_screen, tty_is_tty, tty_leave_alt_screen, tty_read, tty_read_line, tty_set_raw_mode, tty_size,
|
|
1774
1774
|
};
|
|
1775
1775
|
|
|
1776
|
+
/// Shared reader-thread buffer + UTF-8 drain for the byte-stream modules (pty / process / net).
|
|
1777
|
+
#[cfg(any(feature = "pty", feature = "process", feature = "net"))]
|
|
1778
|
+
mod stream_buf;
|
|
1779
|
+
|
|
1776
1780
|
#[cfg(feature = "pty")]
|
|
1777
1781
|
pub mod pty;
|
|
1778
1782
|
#[cfg(feature = "pty")]
|
|
@@ -18,24 +18,28 @@
|
|
|
18
18
|
//! - `probe(host, port, timeoutMs?) -> bool` (can we connect?)
|
|
19
19
|
//! - `sleep(ms) -> null` (blocking backoff for the poll loops above)
|
|
20
20
|
//!
|
|
21
|
-
//! Each connection has a reader thread filling a UTF-8-boundary-drained buffer
|
|
22
|
-
//! pty.rs / process_spawn.rs
|
|
21
|
+
//! Each connection has a reader thread filling a bounded, UTF-8-boundary-drained buffer shared
|
|
22
|
+
//! with pty.rs / process_spawn.rs (see `stream_buf.rs` for the cap/backpressure and
|
|
23
|
+
//! invalid-byte semantics). Global OnceLock<Mutex<HashMap>> registries; errors surface as
|
|
23
24
|
//! null/false.
|
|
25
|
+
//!
|
|
26
|
+
//! Connection lifecycle: `close(id)` shuts the socket down (`Shutdown::Both`) so the reader
|
|
27
|
+
//! thread — which holds a dup of the fd — unblocks and exits, freeing thread + fd + buffer
|
|
28
|
+
//! together, and the peer sees a FIN. Entries whose peer disconnected and whose buffer was
|
|
29
|
+
//! read to EOF (`read` returned `null`) are swept when the next connection registers, so
|
|
30
|
+
//! read-to-EOF-and-forget cannot strand CLOSE_WAIT fds; a swept id keeps returning `null`
|
|
31
|
+
//! from `read`, exactly as before.
|
|
24
32
|
|
|
25
33
|
use std::collections::HashMap;
|
|
26
|
-
use std::io::{ErrorKind,
|
|
34
|
+
use std::io::{ErrorKind, Write};
|
|
27
35
|
use std::net::{TcpListener, TcpStream, ToSocketAddrs};
|
|
28
36
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
29
|
-
use std::sync::{
|
|
37
|
+
use std::sync::{Mutex, OnceLock};
|
|
30
38
|
use std::time::{Duration, Instant};
|
|
31
39
|
|
|
32
40
|
use tishlang_core::{ObjectMap, Value};
|
|
33
41
|
|
|
34
|
-
|
|
35
|
-
data: Vec<u8>,
|
|
36
|
-
eof: bool,
|
|
37
|
-
}
|
|
38
|
-
type SharedBuf = Arc<(Mutex<StreamBuf>, Condvar)>;
|
|
42
|
+
use crate::stream_buf::{drain_stream, is_drained, new_buf, retire_buf, spawn_reader, SharedBuf};
|
|
39
43
|
|
|
40
44
|
struct Conn {
|
|
41
45
|
writer: Mutex<TcpStream>,
|
|
@@ -82,95 +86,20 @@ fn arg_timeout(args: &[Value], i: usize) -> u64 {
|
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
88
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}),
|
|
91
|
-
Condvar::new(),
|
|
92
|
-
))
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Reader thread: block on the socket, append bytes, wake waiters; mark EOF on 0/err.
|
|
96
|
-
fn spawn_reader(mut stream: TcpStream, buf: SharedBuf) {
|
|
97
|
-
std::thread::spawn(move || {
|
|
98
|
-
let mut tmp = [0u8; 8192];
|
|
99
|
-
loop {
|
|
100
|
-
match stream.read(&mut tmp) {
|
|
101
|
-
Ok(0) => {
|
|
102
|
-
let (lock, cv) = &*buf;
|
|
103
|
-
if let Ok(mut b) = lock.lock() {
|
|
104
|
-
b.eof = true;
|
|
105
|
-
}
|
|
106
|
-
cv.notify_all();
|
|
107
|
-
break;
|
|
108
|
-
}
|
|
109
|
-
Ok(n) => {
|
|
110
|
-
let (lock, cv) = &*buf;
|
|
111
|
-
if let Ok(mut b) = lock.lock() {
|
|
112
|
-
b.data.extend_from_slice(&tmp[..n]);
|
|
113
|
-
}
|
|
114
|
-
cv.notify_all();
|
|
115
|
-
}
|
|
116
|
-
Err(_) => {
|
|
117
|
-
let (lock, cv) = &*buf;
|
|
118
|
-
if let Ok(mut b) = lock.lock() {
|
|
119
|
-
b.eof = true;
|
|
120
|
-
}
|
|
121
|
-
cv.notify_all();
|
|
122
|
-
break;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
fn drain_stream(buf: &SharedBuf, timeout_ms: u64) -> Value {
|
|
130
|
-
let (lock, cv) = &**buf;
|
|
131
|
-
let mut b = match lock.lock() {
|
|
132
|
-
Ok(b) => b,
|
|
133
|
-
Err(_) => return Value::Null,
|
|
134
|
-
};
|
|
135
|
-
if b.data.is_empty() && !b.eof && timeout_ms > 0 {
|
|
136
|
-
let res = cv.wait_timeout_while(b, Duration::from_millis(timeout_ms), |b| {
|
|
137
|
-
b.data.is_empty() && !b.eof
|
|
138
|
-
});
|
|
139
|
-
b = match res {
|
|
140
|
-
Ok((g, _)) => g,
|
|
141
|
-
Err(e) => e.into_inner().0,
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
if b.data.is_empty() {
|
|
145
|
-
if b.eof {
|
|
146
|
-
return Value::Null;
|
|
147
|
-
}
|
|
148
|
-
return Value::String("".into());
|
|
149
|
-
}
|
|
150
|
-
let valid = match std::str::from_utf8(&b.data) {
|
|
151
|
-
Ok(_) => b.data.len(),
|
|
152
|
-
Err(e) => e.valid_up_to(),
|
|
153
|
-
};
|
|
154
|
-
if valid == 0 {
|
|
155
|
-
if b.eof {
|
|
156
|
-
let s = String::from_utf8_lossy(&b.data).into_owned();
|
|
157
|
-
b.data.clear();
|
|
158
|
-
return Value::String(s.into());
|
|
159
|
-
}
|
|
160
|
-
return Value::String("".into());
|
|
161
|
-
}
|
|
162
|
-
let out: Vec<u8> = b.data.drain(..valid).collect();
|
|
163
|
-
Value::String(String::from_utf8(out).unwrap_or_default().into())
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// Register a connected stream (reader thread + writer clone) and return its id.
|
|
89
|
+
// Register a connected stream (reader thread + writer clone) and return its id. Also sweeps
|
|
90
|
+
// out entries whose peer disconnected and whose buffer was fully read (`read` already
|
|
91
|
+
// returned `null` for them), so read-to-EOF-and-forget cannot strand CLOSE_WAIT fds — the
|
|
92
|
+
// sweep never touches a conn with undrained data or a live reader, so an id that has not yet
|
|
93
|
+
// returned `null` keeps its full read contract.
|
|
167
94
|
fn register_conn(stream: TcpStream) -> Option<u64> {
|
|
168
95
|
let reader = stream.try_clone().ok()?;
|
|
169
96
|
let _ = reader.set_nonblocking(false);
|
|
170
97
|
let buf = new_buf();
|
|
171
98
|
spawn_reader(reader, buf.clone());
|
|
172
99
|
let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
|
|
173
|
-
conns().lock().ok()
|
|
100
|
+
let mut g = conns().lock().ok()?;
|
|
101
|
+
g.retain(|_, c| !is_drained(&c.buf));
|
|
102
|
+
g.insert(
|
|
174
103
|
id,
|
|
175
104
|
Conn {
|
|
176
105
|
writer: Mutex::new(stream),
|
|
@@ -262,9 +191,12 @@ pub fn net_write(args: &[Value]) -> Value {
|
|
|
262
191
|
}
|
|
263
192
|
}
|
|
264
193
|
|
|
265
|
-
/// `close(id)` →
|
|
266
|
-
///
|
|
267
|
-
///
|
|
194
|
+
/// `close(id)` → close a connection or a listener. For a connection this actually shuts the
|
|
195
|
+
/// socket down (`Shutdown::Both`, the `http.rs` idiom): the reader thread — which holds a dup
|
|
196
|
+
/// of the fd — unblocks out of its `read()`, exits, and frees thread + fd + buffer together,
|
|
197
|
+
/// and the peer sees a FIN instead of a silently half-alive socket. Conn and listener ids
|
|
198
|
+
/// share one counter, so an id names at most one of the two; closing a listener frees its
|
|
199
|
+
/// port — the reserve-ephemeral-then-release pattern a TCP DAP `spawn` uses so the child can
|
|
268
200
|
/// bind it. Returns whether anything live was removed.
|
|
269
201
|
pub fn net_close(args: &[Value]) -> Value {
|
|
270
202
|
let id = match arg_u64(args, 0) {
|
|
@@ -272,10 +204,15 @@ pub fn net_close(args: &[Value]) -> Value {
|
|
|
272
204
|
None => return Value::Bool(false),
|
|
273
205
|
};
|
|
274
206
|
let removed_conn = match conns().lock() {
|
|
275
|
-
Ok(mut g) => g.remove(&id)
|
|
207
|
+
Ok(mut g) => g.remove(&id),
|
|
276
208
|
Err(_) => return Value::Bool(false),
|
|
277
209
|
};
|
|
278
|
-
if removed_conn {
|
|
210
|
+
if let Some(c) = removed_conn {
|
|
211
|
+
if let Ok(w) = c.writer.lock() {
|
|
212
|
+
let _ = w.shutdown(std::net::Shutdown::Both);
|
|
213
|
+
}
|
|
214
|
+
// Wake a reader parked at the buffer cap (shutdown only unblocks a read-blocked one).
|
|
215
|
+
retire_buf(&c.buf);
|
|
279
216
|
return Value::Bool(true);
|
|
280
217
|
}
|
|
281
218
|
let removed_listener = match listeners().lock() {
|
|
@@ -468,6 +405,97 @@ mod tests {
|
|
|
468
405
|
assert!(matches!(net_close(&[Value::Number(server)]), Value::Bool(true)));
|
|
469
406
|
}
|
|
470
407
|
|
|
408
|
+
fn listen_pair() -> (f64, f64, f64) {
|
|
409
|
+
let l = net_listen(&[Value::Number(0.0)]);
|
|
410
|
+
let (lid, port) = match &l {
|
|
411
|
+
Value::Object(m) => {
|
|
412
|
+
let b = m.borrow();
|
|
413
|
+
let id = match b.strings.get("id") {
|
|
414
|
+
Some(Value::Number(i)) => *i,
|
|
415
|
+
_ => panic!("no id"),
|
|
416
|
+
};
|
|
417
|
+
let port = match b.strings.get("port") {
|
|
418
|
+
Some(Value::Number(p)) => *p,
|
|
419
|
+
_ => panic!("no port"),
|
|
420
|
+
};
|
|
421
|
+
(id, port)
|
|
422
|
+
}
|
|
423
|
+
other => panic!("listen failed: {:?}", other),
|
|
424
|
+
};
|
|
425
|
+
let client = match net_connect(&[Value::String("127.0.0.1".into()), Value::Number(port)]) {
|
|
426
|
+
Value::Number(n) => n,
|
|
427
|
+
other => panic!("connect failed: {:?}", other),
|
|
428
|
+
};
|
|
429
|
+
let server = match net_accept(&[Value::Number(lid), Value::Number(1000.0)]) {
|
|
430
|
+
Value::Number(n) => n,
|
|
431
|
+
other => panic!("accept failed: {:?}", other),
|
|
432
|
+
};
|
|
433
|
+
(lid, client, server)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
#[test]
|
|
437
|
+
fn close_shuts_down_the_socket_so_the_peer_sees_eof() {
|
|
438
|
+
// Pre-fix, net_close only dropped the writer fd: the reader thread's dup kept the
|
|
439
|
+
// socket alive, no FIN was ever sent, and the peer's reads blocked forever.
|
|
440
|
+
let (lid, client, server) = listen_pair();
|
|
441
|
+
assert!(matches!(
|
|
442
|
+
net_close(&[Value::Number(server)]),
|
|
443
|
+
Value::Bool(true)
|
|
444
|
+
));
|
|
445
|
+
let mut saw_eof = false;
|
|
446
|
+
for _ in 0..100 {
|
|
447
|
+
if let Value::Null = net_read(&[Value::Number(client), Value::Number(100.0)]) {
|
|
448
|
+
saw_eof = true;
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
assert!(saw_eof, "peer never saw EOF after close — no FIN was sent");
|
|
453
|
+
let _ = net_close(&[Value::Number(client)]);
|
|
454
|
+
let _ = net_close(&[Value::Number(lid)]);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
#[test]
|
|
458
|
+
fn conn_read_to_eof_is_swept_on_next_register() {
|
|
459
|
+
// The natural "read until null, forget the id" pattern: pre-fix the Conn entry (and
|
|
460
|
+
// its CLOSE_WAIT fd) survived forever.
|
|
461
|
+
let (lid, client, server) = listen_pair();
|
|
462
|
+
assert!(matches!(
|
|
463
|
+
net_write(&[Value::Number(server), Value::String("bye".into())]),
|
|
464
|
+
Value::Bool(true)
|
|
465
|
+
));
|
|
466
|
+
assert!(matches!(
|
|
467
|
+
net_close(&[Value::Number(server)]),
|
|
468
|
+
Value::Bool(true)
|
|
469
|
+
));
|
|
470
|
+
// Drain the client to EOF (null) — and never close it.
|
|
471
|
+
let mut got = String::new();
|
|
472
|
+
let mut saw_eof = false;
|
|
473
|
+
for _ in 0..100 {
|
|
474
|
+
match net_read(&[Value::Number(client), Value::Number(100.0)]) {
|
|
475
|
+
Value::String(s) => got.push_str(&s),
|
|
476
|
+
Value::Null => {
|
|
477
|
+
saw_eof = true;
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
_ => {}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
assert!(saw_eof, "client never reached EOF");
|
|
484
|
+
assert!(got.contains("bye"), "payload lost before EOF: {:?}", got);
|
|
485
|
+
// Registering the next connection sweeps the drained entry.
|
|
486
|
+
let (lid2, client2, server2) = listen_pair();
|
|
487
|
+
assert!(
|
|
488
|
+
!conns().lock().unwrap().contains_key(&(client as u64)),
|
|
489
|
+
"drained-at-EOF conn was not swept"
|
|
490
|
+
);
|
|
491
|
+
// The read-to-EOF-then-read-again contract: the swept id still reads as null.
|
|
492
|
+
assert!(matches!(net_read(&[Value::Number(client)]), Value::Null));
|
|
493
|
+
let _ = net_close(&[Value::Number(client2)]);
|
|
494
|
+
let _ = net_close(&[Value::Number(server2)]);
|
|
495
|
+
let _ = net_close(&[Value::Number(lid)]);
|
|
496
|
+
let _ = net_close(&[Value::Number(lid2)]);
|
|
497
|
+
}
|
|
498
|
+
|
|
471
499
|
#[test]
|
|
472
500
|
fn probe_and_unknown() {
|
|
473
501
|
// Nothing listening on this port -> probe false.
|