add http/httphi (#171)

* add http/httphi

* begin adding httphi tests

* claude found neat bugs

* add low level Handle function and more tests

* more tests, run go generate

* add Hijacker-like functionality

* improve locking and acquisition of Exchanges in reconfiguring

* several bugfixes, add internal.IntLen, round up http-linux example with new router API

* small nit

* add benchmarks

* add query handling

* remove ForEach pattern, allocates in TinyGo

* massive documentation push and code reordering in files

* Router.Handle returns error after being torn down

* run go fix

* rework Mux interface to receive a string request path

* add MethodFrom

* minor doc nit

* fail on incomplete staging

* add raw buffer access

* add streaming API distinct from Exchange

* begin adding multipart form logic

* finish rounding up multipart form parsing

* remove status type

* first Multipart approach

* begin adding readMultiPart

* add Exchange.ReadMultiparts reimagining of clanker slop

* ai insists with backoffs

* simplify clanker slop

* apply go fix

* add a pattern argument to Mux

* explicit header key/value alloc and add ExchangeConfig

* fix tests after excplicit header alloc change

* fix examples

* run go fix

* expose rawsock as experimental package (will use for external benchmarks)

* remove backoff from form parsing

* @MDr164 suggestions get potential fixes

* apply go fix

* add examples

* add README.md

* fix rawsock tinygo implementation

* apply @MDr164 various fixes

* update documentation on ContentLength methods and fix bug in Form reset on empty body

* fix tests

* add fuzz tests

* run go fix

* io.ErrNoProgress on parsing form spin

* run go fix

* remove backoff assumption from Router

* httphi.Handle rejects unsupported protocols

* go format router.go

* add kvbuffer

* rewrite Cookie with KVBuffer

* mid refactor of KVBuffer into Header

* work on KVBuffer exhausted semantics

* add Go's ServeMux Request.PathValue access semantics to Exchange, Mux and MuxSlice

* add PathValue example

* document all the things; improve req Query semantics; add Form.EnableBufferGrowth

* unexport kvBuffer

* add Exchange.PathValueAppend

* use stdlib in example instead of rawsock

* remove rawsock from http example

* add darwin arch rawsock

* fix example

* rename Router.TeardownGoroutines to Shutdown matching http.Server.Shutdown

* rename types and identifiers

* @MDr164 Content-Type and Transfer-Encoding bug catches
This commit is contained in:
Pat Whittingslow
2026-07-29 20:14:46 -03:00
committed by Patricio Whittingslow
parent a3f2742abf
commit 5c54030f19
47 changed files with 7309 additions and 804 deletions
-96
View File
@@ -1,96 +0,0 @@
//go:build !tinygo && linux
package main
import (
"net/netip"
"syscall"
)
// Conn wraps an accepted TCP connection from a raw Linux socket file descriptor.
// It implements io.Reader/io.Writer/io.Closer over syscall.Read/Write/Close.
type Conn struct {
fd int
remote netip.AddrPort
}
// Read reads bytes from the connection into b.
func (c *Conn) Read(b []byte) (int, error) {
if len(b) == 0 {
return 0, nil
}
n, err := syscall.Read(c.fd, b)
if err != nil {
return 0, err
}
if n == 0 {
return 0, syscall.ECONNRESET // Peer closed.
}
return n, nil
}
// Write writes b to the connection, looping until all bytes are sent.
func (c *Conn) Write(b []byte) (int, error) {
total := 0
for total < len(b) {
n, err := syscall.Write(c.fd, b[total:])
if err != nil {
return total, err
}
total += n
}
return total, nil
}
// Close closes the underlying file descriptor.
func (c *Conn) Close() error {
return syscall.Close(c.fd)
}
// RemoteAddr returns the peer address of the connection.
func (c *Conn) RemoteAddr() netip.AddrPort { return c.remote }
// Listener wraps a listening TCP socket bound to a local port.
type Listener struct {
fd int
}
// Listen creates a listening TCP socket bound to port on all interfaces.
func Listen(port uint16) (*Listener, error) {
fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, syscall.IPPROTO_TCP)
if err != nil {
return nil, err
}
// Allow quick rebind after restart.
if err = syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1); err != nil {
syscall.Close(fd)
return nil, err
}
addr := &syscall.SockaddrInet4{Port: int(port)}
if err = syscall.Bind(fd, addr); err != nil {
syscall.Close(fd)
return nil, err
}
if err = syscall.Listen(fd, syscall.SOMAXCONN); err != nil {
syscall.Close(fd)
return nil, err
}
return &Listener{fd: fd}, nil
}
// Accept blocks until an incoming connection arrives and returns it as a Conn.
func (l *Listener) Accept(conn *Conn) error {
nfd, sa, err := syscall.Accept(l.fd)
if err != nil {
return err
}
conn.fd = nfd
if sa4, ok := sa.(*syscall.SockaddrInet4); ok {
conn.remote = netip.AddrPortFrom(netip.AddrFrom4(sa4.Addr), uint16(sa4.Port))
}
return nil
}
// Close closes the listening socket.
func (l *Listener) Close() error { return syscall.Close(l.fd) }
+60 -61
View File
@@ -3,42 +3,77 @@
package main
import (
"log/slog"
"net"
"os"
"strconv"
"sync/atomic"
"time"
"github.com/soypat/lneto/http/httpraw"
"github.com/soypat/lneto/http/httphi"
)
const listenPort = 8080
const (
kB = 1 << 10
listenPort = 8080
bufferSizes = 2 * kB
// A browser sends around twenty header fields; a request carrying more
// than this is answered 431 rather than parsed into memory it was not
// given. Each field costs 8 bytes of table.
numHeaderFields = 32
numGoroutines = 4
readTimeout = 2 * time.Second
)
func main() {
if err := run(); err != nil {
println("Error: ", err)
println("Error:", err.Error())
os.Exit(1)
}
println("DONE")
}
func run() error {
ln, err := Listen(listenPort)
ln, err := net.Listen("tcp", ":"+strconv.Itoa(listenPort))
if err != nil {
return err
}
defer ln.Close()
println("listening on port", listenPort)
conn := new(Conn)
print("listening on http://localhost:", listenPort, "\n")
var mux httphi.MuxSlice
mux.Handle("GET /", homepage)
var router httphi.Router
err = router.Configure(httphi.RouterConfig{
FixedNumGoroutines: numGoroutines,
RequestHeaderBufferSize: bufferSizes,
RequestNumHeaderKVCap: numHeaderFields,
ResponseHeaderMinBufferSize: bufferSizes,
MaxAwaitingConns: 256,
Mux: &mux,
Logger: slog.Default(),
})
if err != nil {
return err
}
defer router.Shutdown()
for {
err := ln.Accept(conn)
conn, err := ln.Accept()
if err != nil {
return err
}
visits.Add(1)
if err := handle(conn); err != nil {
println("handle:", conn.RemoteAddr().String(), err.Error())
// The connection owns the idle policy: a peer that opens a socket and
// then stalls fails its read instead of holding a router goroutine.
conn.SetReadDeadline(time.Now().Add(readTimeout))
err = router.Handle(conn)
if err != nil {
// Every goroutine is busy and the queue is full. Dropping the
// connection is the backpressure: memory stays bounded.
slog.Warn("dropped connection", slog.String("remote", conn.RemoteAddr().String()), slog.String("err", err.Error()))
conn.Close()
}
conn.Close()
}
}
@@ -50,58 +85,22 @@ const (
htmlTail = `!</blink></h1>` +
`<font color="#FF00FF">Sign my guestbook!</font>` +
`<br><hr>Best viewed in Netscape Navigator</center></body></html>`
// maxPage bounds the rendered page: both halves plus the visitor number.
maxPage = len(htmlHead) + 20 + len(htmlTail)
)
const maxHTTPHeader = 1024
// visits counts served requests. Handlers run on the router's goroutines, so
// every visitor gets their own number.
var visits atomic.Uint64
var (
hdr httpraw.Header
httpbuf [maxHTTPHeader]byte
htmlbuf [512]byte
visits atomic.Uint64
)
func homepage(exch *httphi.Exchange) {
var page [maxPage]byte
n := copy(page[:], htmlHead)
n += len(strconv.AppendUint(page[n:n], visits.Add(1), 10))
n += copy(page[n:], htmlTail)
func handle(conn *Conn) error {
hdr.Reset(httpbuf[:0])
hdr.EnableBufferGrowth(false) // Limit memory to buffer capacity.
const incomingIsResponse = false // We get HTTP requests from clients.
deadline := time.Now().Add(50 * time.Millisecond)
for time.Until(deadline) > 0 {
if _, err := hdr.ReadFromLimited(conn, maxHTTPHeader); err != nil {
return err
}
needmoredata, err := hdr.TryParse(incomingIsResponse)
if err != nil {
return err
}
if needmoredata {
continue
}
break
}
println("\n\n================\n\n", hdr.String())
if time.Since(deadline) > 0 {
print("DEADLINE EXCEED: ", hdr.BufferParsed(), "/", hdr.BufferReceived(), " bytes parsed/read\n")
return nil
}
// Prepare tacky HTML response.
n := copy(htmlbuf[:], htmlHead)
n += len(strconv.AppendUint(htmlbuf[n:n], visits.Load(), 10))
n += copy(htmlbuf[n:], htmlTail)
contentLen := n
hdr.Reset(httpbuf[:0])
hdr.SetProtocol("HTTP/1.1")
hdr.SetStatus("200", "OK")
hdr.Set("Content-Type", "text/html")
hdr.SetInt("Content-Length", int64(contentLen), 10)
// Here we do some buffer juggling. We use remaining space
// of HTTP Header buffer to write the response that will be written over the wire.
respbuf := httpbuf[hdr.BufferUsed():]
header, err := hdr.AppendResponse(respbuf[:0])
if err != nil {
return err
}
conn.Write(header)
_, err = conn.Write(htmlbuf[:contentLen])
return err
exch.StageHeader("Content-Type", "text/html")
exch.StageHeaderInt("Content-Length", int64(n), 10)
exch.WriteHeader(int(httphi.StatusOK))
exch.WriteBody(page[:n])
}