From 520af5d253bef41375d6b6b8721610ad747ddd46 Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Sun, 23 Aug 2026 01:22:59 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20framework=20WS=20upgrade=20=E2=80=94=20?= =?UTF-8?q?ws=5Faccept=20+=20hijack=20sentinel=20(http/ws.wo)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Req grows internal conn field (net.Conn, filled by parse) — handlers touch it only through ws_accept - ws_upgrade_valid: RFC 6455 §4.2.1 (GET, Upgrade token, Connection token list, 24-char key, version 13); ws_accept_key pure (base64(sha1(key+GUID)) — the runtime vector already pins the RFC worked example); ws_accept writes the 101 and returns the fd; hijacked() = the status-101 sentinel - serve.wo: 101 skips serialize AND close — the loop forgets the fd and returns to accept; plain HTTP byte-identical (web-app 26/26) - codec + end-to-end proof land with the chat sample's gate; battery 12/12 (fibers TSan leg flaked empty under load, 10/10 on rerun; web-app restart leg has a pre-existing 0.5s boot race, noted) Co-Authored-By: Claude Fable 5 --- .../writeonce-framework/http/types.wo | 8 +- docs/examples/writeonce-framework/http/ws.wo | 81 +++++++++++++++++++ .../writeonce-framework/internal/parse.wo | 2 +- .../writeonce-framework/internal/serve.wo | 14 +++- 4 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 docs/examples/writeonce-framework/http/ws.wo diff --git a/docs/examples/writeonce-framework/http/types.wo b/docs/examples/writeonce-framework/http/types.wo index 21f79c8..a07d85c 100644 --- a/docs/examples/writeonce-framework/http/types.wo +++ b/docs/examples/writeonce-framework/http/types.wo @@ -9,8 +9,14 @@ pub typedef Req = { query: map, -- decoded query-string pairs headers: map, -- names lowercased on read body: Text, -- exactly Content-Length bytes ("" if none) - principal: Text -- who this is: "" until an auth middleware + principal: Text, -- who this is: "" until an auth middleware -- (http/auth.wo) authenticates the request + conn: net.Conn -- the connection the request arrived on. + -- INTERNAL plumbing for http/ws.wo's + -- upgrade (iteration 24): handlers never + -- read or write it except through + -- ws_accept; everything else treats Req + -- as if this field did not exist } pub typedef Resp = { diff --git a/docs/examples/writeonce-framework/http/ws.wo b/docs/examples/writeonce-framework/http/ws.wo new file mode 100644 index 0000000..13bb277 --- /dev/null +++ b/docs/examples/writeonce-framework/http/ws.wo @@ -0,0 +1,81 @@ +-- http/ws.wo — the WebSocket upgrade (iteration 24, RFC 6455 §4.2). The +-- framework owns exactly the HANDSHAKE: validating the upgrade request, +-- computing Sec-WebSocket-Accept (base64 of SHA-1 of key + GUID — SHA-1 +-- by RFC, not by choice), and writing the 101 on the request's own +-- connection. What happens on the socket AFTERWARDS belongs to the app: +-- `spawn` takes a class literal, so the framework cannot spawn an +-- app-defined connection actor — the app's route handler calls +-- ws_accept, moves the returned fd into ITS actors, and answers the +-- `hijacked()` sentinel so the serve loop leaves the connection alone. +-- +-- Handler shape: +-- if ws_upgrade_valid(req) == false { return bad_request("not a websocket upgrade"); } +-- let fd = ws_accept(req); +-- ... spawn reader/writer actors owning fd ... +-- return hijacked(); +use net + +-- The RFC's fixed GUID, appended to the client's key before hashing. +const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + +-- A comma-separated header value contains a token, case-insensitively — +-- `Connection: keep-alive, Upgrade` is the shape browsers actually send. +fn header_has_token(value: Text, token: Text) -> Bool { + let parts = split(to_lower(value), ","); + let i = 0; + while i < len(parts) { + if trim(parts[i]) == token { return true; } + i = i + 1; + } + return false; +} + +-- RFC 6455 §4.2.1: GET, `Upgrade: websocket`, `Connection` containing +-- `upgrade`, a Sec-WebSocket-Key (16 bytes base64 = exactly 24 chars), +-- and version 13. Anything else is not an upgrade — the handler answers +-- a plain HTTP response instead. +pub fn ws_upgrade_valid(req: Req) -> Bool { + if req.method != "GET" { return false; } + let up = req.headers["upgrade"]; + if up == nil { return false; } + if to_lower(trim(up)) != "websocket" { return false; } + let conn = req.headers["connection"]; + if conn == nil { return false; } + if header_has_token("${conn}", "upgrade") == false { return false; } + let key = req.headers["sec-websocket-key"]; + if key == nil { return false; } + if len(trim(key)) != 24 { return false; } + let ver = req.headers["sec-websocket-version"]; + if ver == nil { return false; } + if trim(ver) != "13" { return false; } + return true; +} + +-- The accept key, pure: base64(SHA-1(key + GUID)). Split out so a probe +-- can pin the RFC's worked example ("dGhlIHNhbXBsZSBub25jZQ==" -> +-- "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=") without a socket. +pub fn ws_accept_key(key: Text) -> Text { + return base64_encode(sha1(bytes_of_text("${key}${WS_GUID}"))); +} + +-- Write the 101 and hand the connection to the caller. The caller MUST +-- have checked ws_upgrade_valid first — this function trusts the headers +-- it reads. After this returns, the serve loop must never touch the fd +-- again: the handler answers hijacked() to make that true. +pub fn ws_accept(req: Req) -> net.Conn { + let key = req.headers["sec-websocket-key"]; + let accept = ws_accept_key(trim("${key}")); + let resp = "HTTP/1.1 101 Switching Protocols\r\n"; + resp = resp .. "Upgrade: websocket\r\n"; + resp = resp .. "Connection: Upgrade\r\n"; + resp = resp .. "Sec-WebSocket-Accept: ${accept}\r\n\r\n"; + net.write(req.conn, resp); + return req.conn; +} + +-- The hijack sentinel: status 101 tells serve.wo the connection left the +-- HTTP world — no serialization, no close, straight back to accept. +pub fn hijacked() -> Resp { + let h: map = {}; + return Resp { status: 101, headers: h, body: "" }; +} diff --git a/docs/examples/writeonce-framework/internal/parse.wo b/docs/examples/writeonce-framework/internal/parse.wo index bb57b5a..97bff46 100644 --- a/docs/examples/writeonce-framework/internal/parse.wo +++ b/docs/examples/writeonce-framework/internal/parse.wo @@ -150,6 +150,6 @@ pub fn parse_request(c: net.Conn, carry: Text) -> Parsed { } let req = Req { method: method, path: path, params: {}, query: query, - headers: headers, body: body, principal: "" }; + headers: headers, body: body, principal: "", conn: c }; return Parsed { closed: false, ok: true, req: req, rest: rest }; } diff --git a/docs/examples/writeonce-framework/internal/serve.wo b/docs/examples/writeonce-framework/internal/serve.wo index a70c12e..38f0079 100644 --- a/docs/examples/writeonce-framework/internal/serve.wo +++ b/docs/examples/writeonce-framework/internal/serve.wo @@ -56,6 +56,7 @@ pub fn serve(host: Text, port: Int, d: Dispatcher) -> Int { let c = net.accept(srv); let carry = ""; let alive = true; + let hijacked = false; while alive { if env.stopping() { alive = false; continue; } let p = try parse_request(c, carry) catch (e) nil; -- an IO trap = gone @@ -85,9 +86,20 @@ pub fn serve(host: Text, port: Int, d: Dispatcher) -> Int { if to_lower(conn) == "close" { keep = false; } } let resp = try d.dispatch(r) catch (e) server_error(); + -- iteration 24: status 101 is the hijack sentinel (http/ws.wo). + -- The handler completed a WebSocket upgrade and now OWNS the fd + -- through its own actors: no serialization, no close — the loop + -- forgets this connection and returns to accept. + if resp.status == 101 { + hijacked = true; + alive = false; + continue; + } try net.write(c, serialize(resp, keep, is_head)) catch (e) { alive = false; } if keep == false { alive = false; } } - net.close(c); + if hijacked == false { + net.close(c); + } } }