rwc + runtime: netpoller + 最小 TCP API (echo server)#97
Merged
Conversation
Sixth step in the language-extensions track — the final pillar of the original roadmap that lets us write a TCP echo server in rw. Adds a dedicated netpoller pthread (kqueue on macOS, epoll on Linux) that observes fd readiness and wakes fibers parked on those fds. The rw language gets five tcp_* builtins (listen / accept / read / write / close) that look synchronous but transparently park the calling fiber on EAGAIN and resume when the netpoller signals readiness. Total OS threads: 1 main + nproc worker M + 1 netpoller = nproc+2, independent of connection count. Per-connection state lives in fibers (64KB each), not OS threads. Errors and EOF are intentionally coarse — tcp_read returns Bytes with len==0 for both cases — to avoid pulling in true generics (Result[Bytes, int]) which is a separate sub-project (4c). main-thread tcp_accept uses blocking accept (kernel sleep); fiber-thread tcp_accept uses nonblocking + netpoller park, with tls_m branching distinguishing the two paths. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The netpoller thread (next commits) needs to:
- enqueue a fiber back onto the ready queue when its fd becomes
ready (rw_sched_enqueue_ready)
- know which fiber is currently running on the calling thread so
rw_net_park_read can record the handle before swapping out
(rw_sched_current_fiber)
- park the current fiber WITHOUT having the scheduler re-enqueue
it; only the netpoller's ready notification should resume it
(rw_sched_park_current)
All three are thin wrappers around already-existing internals (the
private enqueue_ready helper, the _Thread_local tls_m pointer, and
the existing swap-to-sched_ctx path). No behavior change for
existing callers.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the dedicated netpoller pthread and the platform-specific event-loop bodies (kqueue on macOS/FreeBSD, epoll on Linux). The loop runs and idles on kevent/epoll_wait; fibers don't park on it yet (rw_net_park_read does call rw_sched_park_current, but no rw code triggers that path until Task 4 fills in tcp_*). Wake-up mechanism for shutdown: - kqueue: EVFILT_USER with NOTE_TRIGGER - epoll: eventfd written from rw_netpoller_shutdown rw_init / rw_shutdown now bracket the netpoller lifecycle around the existing scheduler. The TCP API prototypes go in runtime.h ahead of their real implementation; net/tcp.c ships as stubs that return -1 so the build is green from this commit forward. Makefile gains a UNAME_S branch to pick netpoller_kqueue.o vs netpoller_epoll.o and includes net/*.o in OBJS. No behavior change for non-network code: the netpoller pthread sits in epoll_wait / kevent and consumes no CPU while idle. Existing 131 pytest tests pass unchanged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fiber/test_netpoller_pipe.c verifies the full park/wake loop:
- Open pipe(2), spawn reader and writer fibers.
- Reader sets the read end nonblocking, hits EAGAIN, calls
rw_net_park_read on the read end.
- Writer sleeps 50ms (ensuring the reader has parked) then writes
"hello\n" to the write end.
- The netpoller pthread observes the read end becoming readable
(via kqueue/epoll), calls rw_sched_enqueue_ready on the reader.
- Reader resumes, reads 6 bytes, returns.
Both join return 6; the test then exits cleanly through rw_shutdown,
exercising the netpoller pthread join path on both backends.
rw_sched_park_current itself was added in the previous "export
sched API" commit; this is the first test that actually drives it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the Task-2 stubs in net/tcp.c with the real implementations:
rw_tcp_listen(port) — bind 0.0.0.0:port, listen(128), return fd
rw_tcp_accept(lfd) — branches on rw_sched_current_fiber():
main thread = blocking accept,
fiber thread = nonblocking + park
rw_tcp_read(out, fd, max) — recv loop with EAGAIN park on fibers,
malloc'd Bytes (len==0 for EOF/error)
rw_tcp_write(fd, b) — send loop with EAGAIN park
rw_tcp_close(fd) — straight close()
C-level smoke test (fiber/test_tcp_loopback.c):
- server fiber listens on 127.0.0.1:<kernel-chosen port>, accepts
one client, echoes "ping\n" once, closes.
- client runs in a regular pthread, connect/send/recv "ping\n".
- assert both round-trip is byte-identical.
The server fiber exercises the full "park on accept", "park on read"
chain via the netpoller; the kernel-chosen port avoids CI clashes.
test_netpoller_pipe still passes alongside.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sema:
- tcp_listen(int) -> int
- tcp_accept(int) -> int
- tcp_read(int, int) -> Bytes
- tcp_write(int, Bytes) -> int
- tcp_close(int) -> int
Each enforces arg types; spawn of any of these is forbidden.
irgen:
- All five extern declarations land in _declare_runtime alongside
the list_int helpers.
- tcp_read uses the pointer-out shim (alloca RW_STR_TY, call with
out-pointer, load the result back) to match the rest of the
netpoller code's calling convention.
- The rest are scalar passes (i64 fd, i64 result) or value-passed
Bytes (rw_str fits in 16 bytes so no ABI quirks).
Tests (10 new in test_sema.py): 5 positive (one per builtin), 5
negative (wrong-type, wrong-arity, spawn-of-builtin).
End-to-end smoke: a minimal `tcp_listen(8081); tcp_close(fd)` rw
program builds and runs cleanly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
examples/tcp_echo.rw is the canonical TCP echo server demo. main()
calls tcp_listen(8080) — the only thing that runs on the main
OS thread, which blocks in accept(). Each accepted client is
handed to a fresh fiber via spawn handle_client(client), so 10000
clients map to 10000 fibers, not 10000 threads.
A trailing `return 0` is added after the infinite `while true:`
loop in handle_client to satisfy sema's return-coverage check
(the loop body's return is inside an `if`, so sema doesn't see it
as unconditional).
tests/test_e2e_tcp.py rewrites the port literal to a kernel-picked
free port at test time (avoids CI collisions), builds the example,
spawns the server, and exercises it from regular Python sockets:
- test_echo_single_connection: one connect/send/recv round-trip.
- test_echo_ten_concurrent_connections: 10 parallel clients send
distinct payloads and assert each gets its own payload back.
If the server dies before the test connects, its stderr is
surfaced as an assertion message rather than masking the failure
as a misleading ConnectionRefusedError.
RW_WORKERS=2 is set in the env so the server runs with at least
two worker M's even on single-core CI runners; 0.5s startup wait
covers slow CI shells without making the tests flaky.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Plan file authored during the planning phase and referenced by the spec at docs/specs/12-netpoller-tcp.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
rw_net_park_read/write)tcp_listen(port: int) -> inttcp_accept(listen_fd: int) -> inttcp_read(fd: int, max: int) -> Bytestcp_write(fd: int, b: Bytes) -> inttcp_close(fd: int) -> intexamples/tcp_echo.rw(10 行) で TCP echo server が動作Why
ロードマップの 最終目標 (docs/specs/12-netpoller-tcp.md)。これまでの言語拡張 (string / Bytes / List / Option / Result / match) と M:N スケジューラ (#90) を組み合わせ、「fiber が
recvでブロックしているように見えて裏で kqueue/epoll が回る」 という Go 風の書き味を実現する。ロードマップ:
len/==/+(rwc: 文字列の最小拡張 (len, ==, +) #91)4a. Option[int] + match (rwc: Option[int] 型と最小 match 構文 (Python 3.10 風) #94)
4b. Result[int, int] (rwc: Result[int, int] 型と match の Ok/Err アーム拡張 #95)
4c. (将来) 真のジェネリクス化
アーキテクチャ
OS thread:
1 main + nproc worker M + 1 netpoller = nproc + 2(接続数に依存しない)。fiber: 接続ごとに 1 つ、上限は実質的に
ulimit -nと RAM。できるようになったこと
```rw
def handle_client(fd: int) -> int:
while true:
b: Bytes = tcp_read(fd, 4096)
if len(b) == 0:
tcp_close(fd)
return 0
tcp_write(fd, b)
return 0
def main() -> int:
listen_fd: int = tcp_listen(8080)
while true:
client: int = tcp_accept(listen_fd)
spawn handle_client(client)
return 0
```
```sh
$ uv run rwc run examples/tcp_echo.rw &
$ python3 -c "import socket; s=socket.create_connection(('127.0.0.1',8080)); s.sendall(b'hello'); print(s.recv(64))"
b'hello'
```
コミット構成 (8 commits)
rw_sched_enqueue_ready/rw_sched_current_fiber/rw_sched_park_currentを netpoller 向けに公開rw_init/rw_shutdownで netpoller pthread を起動・停止test_netpoller_pipe) で確認tcp_listen/tcp_accept/tcp_read/tcp_write/tcp_closeを実装し、localhost loopback の C テストで検証examples/tcp_echo.rw+ Python e2e (1 接続 / 10 並列接続)設計の要点
専用 netpoller スレッド
1 つの pthread が
kevent/epoll_waitを ループ。fd ready 通知を受けたらrw_sched_enqueue_ready(fiber)で fiber を起こす。worker M のスケジュール責務とは分離。ONESHOT 監視
kqueue は
EV_ONESHOT、epoll はEPOLLONESHOTを使用。同じ fd への複数 fiber 同時 park は protocol 規約で排除し (Non-Goals)、kernel 側の自動 deregister でクリーンアップ。main thread と fiber で分岐
rw_tcp_acceptはrw_sched_current_fiber()で呼び出し元を判別:Shutdown 同期
netpoller スレッドは
kevent/epoll_waitで sleep している。shutdown 時に起こすため:EVFILT_USER+NOTE_TRIGGEReventfdに書き込みTest plan
test_netpoller_pipe: pipe で fiber が park → 別 fiber が write → 起きるtest_tcp_loopback: localhost で listen → connect → recv/send round-tripoption_basic/result_basic/spawn_many) 全部回帰なしhelloを echoネガティブテスト (Sema)
tcp_listen("8080")→ 引数型エラーtcp_read(3, "big")/tcp_write(3, "hi")→ 引数型エラーtcp_listen()→ arity エラーspawn tcp_accept(3)→ 組込みは spawn 不可Non-Goals (spec で明示)
tcp_listenのホスト指定 (0.0.0.0固定 IPv4)Result[Bytes, int]でのエラー表現 (4c のジェネリクス待ち)🤖 Generated with Claude Code