Skip to content

Commit 3dbbf14

Browse files
feat(net): runtime:net TCP sockets (WinterTC connect + listen)
Add runtime:net, the fourth runtime: standard module. - connect(address, options?) follows the WinterTC Sockets API: returns a Socket synchronously with .opened/.closed promises and .readable/.writable web streams; closing the writable half-closes (FIN). - listen(options) binds a server and yields inbound Sockets as an async-iterable Listener (addr/accept()/close(); port 0 = ephemeral). - New injectable NetProvider (tokio SystemNet, spawned reader/writer tasks + channels) and net_ops gated on Capability::Net (connect) and the new Capability::NetListen (listen). - Add ReadableStream async iteration (values() / [Symbol.asyncIterator]), required for `for await (const chunk of socket.readable)`. - Driver parks via timers.sleep(1) instead of yield_now so the reactor runs. - TLS (secureTransport/startTls) not supported yet. Docs/types: runtime-net.d.ts (+ wired into index.d.ts, package.json, CLI TYPES), docs/API.md section, /api/net site page, examples/modules/net.mjs, e2e test runtime_net_tcp_echo_roundtrip, CHANGELOG.
1 parent d42a9dc commit 3dbbf14

23 files changed

Lines changed: 1030 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,26 @@ pre-`0.1.0` and the public API is unstable.
88

99
### Added
1010

11+
- **`runtime:net`** — TCP sockets, the fourth `runtime:` standard module.
12+
`connect(address, options?)` follows the WinterTC Sockets API (returns a
13+
`Socket` synchronously; `.opened`/`.closed` promises, `.readable`/`.writable`
14+
web streams, closing the writable half-closes with FIN). `listen(options)`
15+
binds a server and yields inbound `Socket`s as an async-iterable `Listener`
16+
(`addr`/`accept()`/`close()`; `port: 0` picks an ephemeral port). Backed by a
17+
new injectable `NetProvider` (tokio `SystemNet`, spawned reader/writer tasks)
18+
and ops gated on `Capability::Net` (connect) / new `Capability::NetListen`
19+
(listen). TLS (`secureTransport`/`startTls`) is not supported yet. Also added
20+
`ReadableStream` async iteration (`values()` / `[Symbol.asyncIterator]`). New
21+
`examples/modules/net.mjs` and `runtime-net.d.ts`.
22+
1123
- **`esrun upgrade`** — self-update built into the CLI: finds the latest GitHub
1224
release for the platform, downloads + extracts it, and replaces the running
1325
binary in place (rustls TLS, via `self_update`). The Installation page's
1426
Upgrade step now uses it.
1527

1628
- **`@opentf/esrun-types`** — hand-written TypeScript definitions for the
17-
`runtime:` standard modules (`runtime:process`, `runtime:path`, `runtime:fs`),
29+
`runtime:` standard modules (`runtime:process`, `runtime:path`, `runtime:fs`,
30+
`runtime:net`),
1831
in [`types/`](types/), for editor completion and type-checking. Ambient
1932
`declare module` blocks; add via `tsconfig` `types` or a triple-slash
2033
reference. Validated with `tsc --strict`. Also emitted by **`esrun types`**

crates/common/src/capability.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,16 @@ pub enum Capability {
4141
/// (`FileSystem` provider; backs `runtime:fs` mutations — `write`, `mkdir`,
4242
/// `remove`, `rename`).
4343
FileWrite,
44+
/// Bind a listening socket and accept inbound connections (`Net` provider;
45+
/// backs `runtime:net` `listen`). Distinct from [`Net`](Self::Net) outbound
46+
/// connections — a server-side privilege, deny-by-default.
47+
NetListen,
4448
}
4549

4650
impl Capability {
4751
/// All capabilities, in a fixed order. Used to build [`CapabilitySet::all`]
4852
/// and to keep the bit assignment in [`bit`](Self::bit) exhaustive.
49-
const ALL: [Capability; 9] = [
53+
const ALL: [Capability; 10] = [
5054
Capability::Clock,
5155
Capability::Entropy,
5256
Capability::Timers,
@@ -56,6 +60,7 @@ impl Capability {
5660
Capability::Env,
5761
Capability::FileRead,
5862
Capability::FileWrite,
63+
Capability::NetListen,
5964
];
6065

6166
/// This capability's single-bit mask within a [`CapabilitySet`].
@@ -71,6 +76,7 @@ impl Capability {
7176
Capability::Env => 1 << 6,
7277
Capability::FileRead => 1 << 7,
7378
Capability::FileWrite => 1 << 8,
79+
Capability::NetListen => 1 << 9,
7480
}
7581
}
7682
}

crates/default-providers/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ globset.workspace = true
2020
walkdir.workspace = true
2121
reqwest.workspace = true
2222
serde_json.workspace = true
23-
tokio = { workspace = true, features = ["rt", "time", "fs"] }
23+
tokio = { workspace = true, features = ["rt", "time", "fs", "net", "io-util", "sync"] }
2424
tracing.workspace = true
2525
url.workspace = true
2626

crates/default-providers/src/driver.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,13 @@ impl Driver {
5858
self.timers.sleep(delay).await;
5959
}
6060
None => {
61-
// Async work pending but no timer: let other tasks (e.g.
62-
// offloaded blocking work) progress, then re-poll next tick.
63-
tokio::task::yield_now().await;
61+
// Async work pending but no timer due (e.g. an open socket
62+
// awaiting bytes). Park briefly so the runtime's I/O reactor
63+
// can deliver network readiness before we re-poll — a busy
64+
// `yield_now` starves the reactor on a current-thread runtime
65+
// (sockets stall) and pegs a core. 1ms keeps latency low
66+
// while leaving the CPU near idle.
67+
self.timers.sleep(1).await;
6468
}
6569
}
6670
}

crates/default-providers/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ mod net;
2626
mod node_modules;
2727
mod process;
2828
mod system_fs;
29+
mod system_net;
2930
mod task;
3031
mod timers;
3132

@@ -41,5 +42,6 @@ pub use net::ReqwestTransport;
4142
pub use node_modules::NodeModuleLoader;
4243
pub use process::SystemProcess;
4344
pub use system_fs::SystemFileSystem;
45+
pub use system_net::SystemNet;
4446
pub use task::TokioTaskSpawner;
4547
pub use timers::TokioTimers;
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
//! OS-backed [`NetProvider`] — tokio TCP sockets for `runtime:net` (SPEC §12).
2+
//!
3+
//! Each socket's I/O runs in **spawned runtime tasks** (a reader and a writer)
4+
//! that move bytes over channels; the ops just send/recv on those channels.
5+
//! This is the same shape the HTTP client uses: the actual I/O is driven by the
6+
//! runtime's reactor (via spawned tasks), so reads that must wait for bytes make
7+
//! progress — polling the raw socket future inline from the op loop would not.
8+
//! TLS is a follow-up; `connect(tls = true)` errors rather than downgrading.
9+
10+
use std::collections::HashMap;
11+
use std::net::SocketAddr;
12+
use std::sync::atomic::{AtomicU64, Ordering};
13+
use std::sync::{Arc, Mutex};
14+
15+
use es_runtime_providers::{BoxFuture, NetProvider, ProviderError, SocketInfo};
16+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
17+
use tokio::net::{TcpListener, TcpStream};
18+
use tokio::sync::mpsc;
19+
20+
type ReadRx = mpsc::Receiver<Result<Vec<u8>, String>>;
21+
type WriteTx = mpsc::Sender<Vec<u8>>;
22+
type AcceptRx = mpsc::Receiver<(TcpStream, SocketAddr)>;
23+
24+
/// A connection's channel ends. `read_rx` is taken out during a read; `write_tx`
25+
/// is cloned to send and dropped (set to `None`) to half-close.
26+
struct Slot {
27+
read_rx: Option<ReadRx>,
28+
write_tx: Option<WriteTx>,
29+
}
30+
31+
/// A [`NetProvider`] over real tokio TCP sockets. The `Arc`s are cloned into each
32+
/// returned future so the futures stay `'static`.
33+
#[derive(Clone, Default)]
34+
pub struct SystemNet {
35+
sockets: Arc<Mutex<HashMap<u64, Slot>>>,
36+
listeners: Arc<Mutex<HashMap<u64, AcceptRx>>>,
37+
next_id: Arc<AtomicU64>,
38+
}
39+
40+
impl SystemNet {
41+
/// Builds an empty socket registry.
42+
pub fn new() -> Self {
43+
Self::default()
44+
}
45+
46+
fn id(&self) -> u64 {
47+
self.next_id.fetch_add(1, Ordering::Relaxed) + 1
48+
}
49+
50+
/// Splits `stream` and spawns its reader + writer tasks, returning the
51+
/// channel ends to register.
52+
fn spawn_socket(stream: TcpStream) -> Slot {
53+
let (mut r, mut w) = stream.into_split();
54+
let (read_tx, read_rx) = mpsc::channel::<Result<Vec<u8>, String>>(8);
55+
let (write_tx, mut write_rx) = mpsc::channel::<Vec<u8>>(8);
56+
57+
tokio::spawn(async move {
58+
let mut buf = vec![0u8; 64 * 1024];
59+
loop {
60+
match r.read(&mut buf).await {
61+
Ok(0) => break, // EOF — dropping read_tx signals it
62+
Ok(n) => {
63+
if read_tx.send(Ok(buf[..n].to_vec())).await.is_err() {
64+
break; // consumer gone
65+
}
66+
}
67+
Err(e) => {
68+
let _ = read_tx.send(Err(e.to_string())).await;
69+
break;
70+
}
71+
}
72+
}
73+
});
74+
75+
tokio::spawn(async move {
76+
while let Some(data) = write_rx.recv().await {
77+
if w.write_all(&data).await.is_err() {
78+
break;
79+
}
80+
}
81+
let _ = w.shutdown().await; // write_tx dropped (half-close / close)
82+
});
83+
84+
Slot {
85+
read_rx: Some(read_rx),
86+
write_tx: Some(write_tx),
87+
}
88+
}
89+
}
90+
91+
fn err(e: impl ToString) -> ProviderError {
92+
ProviderError::Other(e.to_string())
93+
}
94+
95+
fn info_of(local: Option<SocketAddr>, remote: Option<SocketAddr>) -> SocketInfo {
96+
SocketInfo {
97+
remote_address: remote.map(|a| a.ip().to_string()).unwrap_or_default(),
98+
remote_port: remote.map(|a| a.port()).unwrap_or(0),
99+
local_address: local.map(|a| a.ip().to_string()).unwrap_or_default(),
100+
local_port: local.map(|a| a.port()).unwrap_or(0),
101+
}
102+
}
103+
104+
impl NetProvider for SystemNet {
105+
fn connect(
106+
&self,
107+
host: String,
108+
port: u16,
109+
tls: bool,
110+
) -> BoxFuture<Result<(u64, SocketInfo), ProviderError>> {
111+
let this = self.clone();
112+
Box::pin(async move {
113+
if tls {
114+
return Err(err(
115+
"runtime:net TLS is not supported yet (plaintext TCP only)",
116+
));
117+
}
118+
let stream = TcpStream::connect((host.as_str(), port))
119+
.await
120+
.map_err(err)?;
121+
let _ = stream.set_nodelay(true);
122+
let info = info_of(stream.local_addr().ok(), stream.peer_addr().ok());
123+
let id = this.id();
124+
this.sockets
125+
.lock()
126+
.unwrap()
127+
.insert(id, SystemNet::spawn_socket(stream));
128+
Ok((id, info))
129+
})
130+
}
131+
132+
fn read(&self, id: u64) -> BoxFuture<Result<Option<Vec<u8>>, ProviderError>> {
133+
let sockets = self.sockets.clone();
134+
Box::pin(async move {
135+
let mut rx = match sockets
136+
.lock()
137+
.unwrap()
138+
.get_mut(&id)
139+
.and_then(|s| s.read_rx.take())
140+
{
141+
Some(rx) => rx,
142+
None => return Ok(None), // closed or already at EOF
143+
};
144+
match rx.recv().await {
145+
Some(Ok(buf)) => {
146+
if let Some(slot) = sockets.lock().unwrap().get_mut(&id) {
147+
slot.read_rx = Some(rx);
148+
}
149+
Ok(Some(buf))
150+
}
151+
Some(Err(e)) => Err(err(e)),
152+
None => Ok(None), // reader task ended (EOF) — leave it taken
153+
}
154+
})
155+
}
156+
157+
fn write(&self, id: u64, data: Vec<u8>) -> BoxFuture<Result<(), ProviderError>> {
158+
let sockets = self.sockets.clone();
159+
Box::pin(async move {
160+
let tx = sockets
161+
.lock()
162+
.unwrap()
163+
.get(&id)
164+
.and_then(|s| s.write_tx.clone());
165+
match tx {
166+
Some(tx) => tx.send(data).await.map_err(|_| err("socket is closed")),
167+
None => Err(err("socket is closed")),
168+
}
169+
})
170+
}
171+
172+
fn shutdown(&self, id: u64) -> BoxFuture<Result<(), ProviderError>> {
173+
let sockets = self.sockets.clone();
174+
Box::pin(async move {
175+
// Drop the sender: the writer task's recv() ends and it shuts down
176+
// the write half (FIN). The read half keeps working.
177+
if let Some(slot) = sockets.lock().unwrap().get_mut(&id) {
178+
slot.write_tx = None;
179+
}
180+
Ok(())
181+
})
182+
}
183+
184+
fn close(&self, id: u64) -> BoxFuture<Result<(), ProviderError>> {
185+
let sockets = self.sockets.clone();
186+
Box::pin(async move {
187+
// Dropping the slot drops both channel ends, ending both tasks.
188+
sockets.lock().unwrap().remove(&id);
189+
Ok(())
190+
})
191+
}
192+
193+
fn listen(
194+
&self,
195+
host: String,
196+
port: u16,
197+
) -> BoxFuture<Result<(u64, SocketInfo), ProviderError>> {
198+
let this = self.clone();
199+
Box::pin(async move {
200+
let listener = TcpListener::bind((host.as_str(), port))
201+
.await
202+
.map_err(err)?;
203+
let local = listener.local_addr().ok();
204+
let (tx, rx) = mpsc::channel::<(TcpStream, SocketAddr)>(8);
205+
tokio::spawn(async move {
206+
while let Ok(conn) = listener.accept().await {
207+
if tx.send(conn).await.is_err() {
208+
break; // listener closed (rx dropped)
209+
}
210+
}
211+
});
212+
let id = this.id();
213+
this.listeners.lock().unwrap().insert(id, rx);
214+
Ok((id, info_of(local, None)))
215+
})
216+
}
217+
218+
fn accept(&self, id: u64) -> BoxFuture<Result<Option<(u64, SocketInfo)>, ProviderError>> {
219+
let this = self.clone();
220+
Box::pin(async move {
221+
let mut rx = match this.listeners.lock().unwrap().remove(&id) {
222+
Some(rx) => rx,
223+
None => return Ok(None), // listener closed
224+
};
225+
let conn = rx.recv().await;
226+
this.listeners.lock().unwrap().insert(id, rx); // keep accepting
227+
match conn {
228+
Some((stream, remote)) => {
229+
let _ = stream.set_nodelay(true);
230+
let info = info_of(stream.local_addr().ok(), Some(remote));
231+
let sid = this.id();
232+
this.sockets
233+
.lock()
234+
.unwrap()
235+
.insert(sid, SystemNet::spawn_socket(stream));
236+
Ok(Some((sid, info)))
237+
}
238+
None => Ok(None),
239+
}
240+
})
241+
}
242+
243+
fn close_listener(&self, id: u64) -> BoxFuture<Result<(), ProviderError>> {
244+
let listeners = self.listeners.clone();
245+
Box::pin(async move {
246+
listeners.lock().unwrap().remove(&id);
247+
Ok(())
248+
})
249+
}
250+
}

0 commit comments

Comments
 (0)