Skip to content

rwc + runtime: netpoller + 最小 TCP API (echo server)#97

Merged
ryuichi1208 merged 8 commits into
mainfrom
feat/netpoller-tcp
May 23, 2026
Merged

rwc + runtime: netpoller + 最小 TCP API (echo server)#97
ryuichi1208 merged 8 commits into
mainfrom
feat/netpoller-tcp

Conversation

@ryuichi1208

Copy link
Copy Markdown
Member

Summary

  • ランタイムに netpoller スレッド を追加 (kqueue on macOS / epoll on Linux、専用 pthread 1 本)
  • fiber を fd readiness で park / wake する内部 API (rw_net_park_read/write)
  • rw 言語に 5 つの TCP 組込み:
    • tcp_listen(port: int) -> int
    • tcp_accept(listen_fd: int) -> int
    • tcp_read(fd: int, max: int) -> Bytes
    • tcp_write(fd: int, b: Bytes) -> int
    • tcp_close(fd: int) -> int
  • examples/tcp_echo.rw (10 行) で TCP echo server が動作
  • e2e: 1 接続 + 10 並列接続を Python socket で検証

Why

ロードマップの 最終目標 (docs/specs/12-netpoller-tcp.md)。これまでの言語拡張 (string / Bytes / List / Option / Result / match) と M:N スケジューラ (#90) を組み合わせ、「fiber が recv でブロックしているように見えて裏で kqueue/epoll が回る」 という Go 風の書き味を実現する。

ロードマップ:

  1. 文字列 len / == / + (rwc: 文字列の最小拡張 (len, ==, +) #91)
  2. Bytes 型 (rwc: Bytes 型 (immutable, echo 最小セット) #92)
  3. List[int] (rwc: List[int] 型 (immutable, モノモーフ, echo 最小セット) #93)
    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. (将来) 真のジェネリクス化
  4. このサブプロジェクト: netpoller + TCP API → echo server

アーキテクチャ

                                 +---------------------+
                                 |  netpoller thread   |
                                 |  kevent/epoll_wait  |
                                 +----------+----------+
                                            | enqueue_ready
                                            v
+--------+   spawn    +------------------------------------+
|  main  | ---------> |   ready queue (per-P + globq)      |
+--------+            +-------+----------+-------+---------+
  blocking                    |          |       |
  accept()                    v          v       v
                          +--------+ +--------+ +--------+
                          | wkr M1 | | wkr M2 | | wkr Mn |
                          +--------+ +--------+ +--------+
                              |          |          |
                              v          v          v
                          fiber 実行 → EAGAIN → rw_net_park_read

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)

commit 内容
spec docs/specs/12-netpoller-tcp.md
sched export rw_sched_enqueue_ready / rw_sched_current_fiber / rw_sched_park_current を netpoller 向けに公開
netpoller skeleton runtime/net/{netpoller, netpoller_kqueue, netpoller_epoll, tcp}.{c,h} の枠組み。rw_init / rw_shutdown で netpoller pthread を起動・停止
pipe テスト pipe(2) で park/wake の動作を C テスト (test_netpoller_pipe) で確認
tcp 本実装 tcp_listen/tcp_accept/tcp_read/tcp_write/tcp_close を実装し、localhost loopback の C テストで検証
rwc 5 つの組込みを Sema + irgen に追加、positive 5 + negative 5
examples + e2e examples/tcp_echo.rw + Python e2e (1 接続 / 10 並列接続)
plan docs/plans/2026-05-23-netpoller-tcp.md

設計の要点

専用 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_acceptrw_sched_current_fiber() で呼び出し元を判別:

  • fiber 内: nonblocking accept + EAGAIN park
  • main thread (worker でも fiber でもない): blocking accept で kernel sleep — worker M / netpoller は別 thread なので並行進行は維持

Shutdown 同期

netpoller スレッドは kevent/epoll_wait で sleep している。shutdown 時に起こすため:

  • kqueue: EVFILT_USER + NOTE_TRIGGER
  • epoll: eventfd に書き込み

Test plan

  • `uv run pytest -q` で 143 件 緑 (元 131 + sema 新規 10 + e2e_tcp 新規 2)
  • C 単体テスト test_netpoller_pipe: pipe で fiber が park → 別 fiber が write → 起きる
  • C 単体テスト test_tcp_loopback: localhost で listen → connect → recv/send round-trip
  • 既存 example (option_basic / result_basic / spawn_many) 全部回帰なし
  • `examples/tcp_echo.rw` が手動 smoke で hello を echo
  • e2e で 10 並列接続を捌ける

ネガティブテスト (Sema)

  • tcp_listen("8080") → 引数型エラー
  • tcp_read(3, "big") / tcp_write(3, "hi") → 引数型エラー
  • tcp_listen() → arity エラー
  • spawn tcp_accept(3) → 組込みは spawn 不可

Non-Goals (spec で明示)

  • IPv6 / UDP / TLS / Unix domain socket
  • tcp_listen のホスト指定 (0.0.0.0 固定 IPv4)
  • 詳細エラー (errno 取得 API)
  • graceful shutdown / SIGINT ハンドラ
  • partial write の自動 retry
  • Result[Bytes, int] でのエラー表現 (4c のジェネリクス待ち)
  • C10k ベンチ
  • ファイル I/O / pipe / TTY の rw 言語 API

🤖 Generated with Claude Code

ryuichi1208 and others added 8 commits May 23, 2026 17:22
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>
@ryuichi1208 ryuichi1208 merged commit 6bd73f3 into main May 23, 2026
2 checks passed
@github-actions github-actions Bot mentioned this pull request May 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant