Architecture
Build stages, the request path, and the C boundary.
The build
scan walk app/routes, read each module, build a route manifest
assets copy public/, hash each file, concatenate the island bundle
codegen emit CataractAssets, GeneratedRoutes and CataractMain
compile invoke chpl over the runtime, the generated modules and app/Route and library modules are passed to chpl as source files rather than through -M, because their module names deliberately do not have to match their filenames. The runtime is passed as module search paths.
The request path
accept -> gate -> begin task -> parse -> middleware -> route -> handlerOne task per connection. The accept loop takes a permit from a bounded gate before spawning a task, so a connection flood becomes backpressure rather than unbounded task creation — Chapel's begin has no limit of its own.
Each connection is an owned Connection moved into its task, which makes the descriptor's lifetime exactly the task's lifetime, including on an error unwind. The accept loop runs inside a sync block, so no connection task can outlive the scope holding the gate it borrows.
Middleware is two-phase
Middleware is two-phase rather than nested continuations. before may short-circuit by filling the response and returning true; after always runs for the stages actually entered, in reverse order. A short-circuiting stage therefore cannot strand logging or header hardening.
app.addMiddleware(new shared AccessLogger());
app.addMiddleware(guard);
app.addMiddleware(files);Entry order is unwind order reversed: the logger wraps everything.
Why every socket is non-blocking
This constraint shapes the whole I/O design: a Chapel task blocked inside a foreign call does not release its scheduler worker. Under the qthreads tasking layer, a task spawned by a task sitting in a blocking accept() does not run until that call returns. A server written the obvious way accepts one connection and then serves nothing.
So every descriptor is O_NONBLOCK and every wait happens in Chapel: the accept loop sleeps when nothing is pending, and the connection's fill and flush retry across EAGAIN with exponential backoff against a deadline. sleep yields to the scheduler, so the worker is free while a connection waits. Timeouts are tracked against a monotonic clock rather than delegated to SO_RCVTIMEO.
The buffer is a sliding window
A connection owns one read buffer used as a sliding window: [start, stop) is unconsumed, and bytes left over after a request are the head of the next pipelined request, so the window compacts rather than resets. The header scan resumes where the previous partial read stopped, so a slow drip costs O(n) overall rather than O(n²).
The C boundary
All of it is src/runtime/net/cataract_net.[ch] plus one extern block. Struct layout — sockaddr_in, timeval — is platform-dependent and padding-sensitive, so it is never mirrored into Chapel records. Chapel sees file descriptors and flat byte buffers, and nothing else.
SIGPIPE is masked at start-up, so a peer disappearing mid-write surfaces as EPIPE on one task instead of killing the process. strerror is called through a thread-local buffer, because the standard one is shared and two concurrent failures would overwrite each other.
Nothing above net/ imports CTypes for socket work, and no application route can reach a descriptor at all.