wsx

package
v0.2.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 3, 2026 License: Apache-2.0 Imports: 20 Imported by: 0

Documentation

Overview

Package wsx is the WebSocket transport, and the only place it exists.

This package is the sole importer of the WebSocket library in this module. Nothing in the reducer, render, protocol, or provenance path may reference it, directly or transitively; an architecture test asserts that, and the assertion is what a second transport would be built against. The core talks to a connection through channels and a framer function value, not through an interface with one implementation.

Establishment

The order of the handshake is the security property, not an implementation detail: origin allowlist, then authentication against the HTTP request, then the CSRF token, then subprotocol negotiation, and only then the upgrade. No per-session memory is allocated before authentication succeeds, so a rejected origin costs an HTTP response and nothing else.

Lifetime

ServeHTTP RETURNS at the upgrade, and the session runs on a goroutine this package owns. The obvious shape — serving the session inline, so the handler returns when the connection ends — keeps net/http's whole per-request working set alive for the life of the session: a *conn[I] with two 4 KiB bufio buffers, a *response with a third, and the *Request with its header map, none of which return to net/http's pools because hijacking skips the call that returns them. Returning is what lets net/http collect them, and it is what lets this package hand the transport buffers it sized itself (hijack.go). Two consequences: the session runs under context.WithoutCancel, because the request context is cancelled the moment ServeHTTP returns; and net/http's recover is no longer behind the read pump, so the session's teardown carries its own. The goroutine COUNT is unchanged at two per session — this one is the read pump, and net/http's has gone home.

Reading

The inbound size cap is applied to the connection before any payload is allocated, so an oversize frame is refused rather than buffered. The read pump parses, hands events to the single mailbox ingress, and never blocks on a full channel: a flood is dropped with a typed error, because blocking the read pump would stall the connection's own liveness detection.

Liveness and closing

Liveness is the protocol's own heartbeat frame, not an RFC 6455 ping; this package never initiates ping or pong. Every close names a code from the protocol's enumeration, and a close without one is a defect — a test walks every call site to prove it.

Status

Implemented: the ordered handshake, the read pump, the write path, session limits, and draining.

Index

Constants

View Source
const AnyOrigin = "*"

AnyOrigin is the sentinel that disables origin validation. It is a deliberately greppable string rather than a boolean, so an audit of every deployment that turned the check off is one search.

Variables

This section is empty.

Functions

This section is empty.

Types

type Handler

type Handler[I session.IIdentity] struct {
	// contains filtered or unexported fields
}

Handler serves the live connection.

It is the only package in this module that names a WebSocket library, and an architecture test asserts that the session, render and protocol packages never reach it. The core talks to a connection through channels and a framer function value rather than through an interface with one implementation.

func NewHandler

func NewHandler[I session.IIdentity](o Options[I]) (*Handler[I], error)

NewHandler validates the options and returns a handler.

func (*Handler[I]) Close

func (h *Handler[I]) Close(ctx context.Context) error

Close drains every live session, closing each with the going-away code, and waits until each has ended — its read pump, actor and effects joined — or the context's deadline passes. Joining the sessions scope itself is the owner's: Close does not cancel it.

func (*Handler[I]) ServeHTTP

func (h *Handler[I]) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP performs the handshake and then RETURNS, leaving the session running on a goroutine of the sessions scope (Options.Scope).

The order is the security property rather than an implementation detail: origin, then authentication against the HTTP request, then the CSRF token, then subprotocol negotiation, and only then the upgrade. No per-session memory is allocated before authentication succeeds, so a rejected origin costs an HTTP response and nothing else.

Why this returns instead of serving the session inline

The obvious shape — and the one every WebSocket library's example uses — is to serve the connection here, so that ServeHTTP returns when the session ends. It is also, for a session that lives for hours, an expensive shape, and the expense was measured rather than suspected (docs/bench/g2-baseline.md).

net/http holds a `*conn[I]` for as long as its handler has not returned, and that `*conn[I]` holds a 4,096 B `bufio.Reader`, a 4,096 B `bufio.Writer`, the `*response` with its own 2,048 B `bufio.Writer` and header map, and the `*Request` with its header map and URL. For an ordinary HTTP request all of that is scratch that lives for a millisecond and returns to net/http's pools. For a hijacked WebSocket held open by a blocking handler it is per-SESSION memory — retained for the life of the connection and never returned to those pools, because `finishRequest` is the thing that returns them and hijacking skips it.

Returning at the upgrade is what lets net/http collect all of it: the hijack already untracked the connection from `Server.activeConn` (`setState(StateHijacked)`), the transport already reset both buffers off net/http's own reader and writer, and once this frame is gone nothing references the `*conn[I]` at all.

Two consequences, both deliberate and neither free:

  • The request context is cancelled the moment this returns, so the session runs under `context.WithoutCancel`. Request VALUES — an application's tracing or auth context — still resolve; request CANCELLATION no longer ends the session, which it never did anyway: it fired only when this function returned, which was the end of the session.
  • net/http's `recover` is no longer behind the read pump. §9 of RFC-0001 makes recovery mandatory rather than a nicety, so the session goroutine installs its own; see the guard in serve.

Middleware that wraps this handler now completes at the upgrade rather than at the end of the session. That is the honest boundary for a request that became a connection, and it is the one that lets a request-scoped logger or timeout mean what it says.

func (*Handler[I]) SessionGoroutines added in v0.2.0

func (h *Handler[I]) SessionGoroutines() int64

SessionGoroutines reports how many goroutines ended sessions started in their own scopes — read pump, actor, effects — which the sessions scope's join has collected or is collecting. It is what the live UI service logs as joined at shutdown.

func (*Handler[I]) Sessions

func (h *Handler[I]) Sessions() int

Sessions reports how many sessions are live. It exists for tests and for the leak check, and reads the registry rather than a counter that could drift from it.

type Options

type Options[I session.IIdentity] struct {
	// Origins is the allowlist checked on the upgrade request. Deny by
	// default: no wildcard, no reflection of the request's own Origin, and no
	// pass for a request that sends none.
	Origins []string

	// Authenticate derives the session identity from the upgrade request. It
	// runs before any per-session memory is allocated.
	Authenticate func(request *http.Request) (I, error)

	// CSRF validates a token bound to the authenticated application session.
	CSRF func(request *http.Request) error

	// NewApp returns the application behaviour for one connection.
	NewApp func(request *http.Request) session.IApp[I]

	// Limits are the per-session resource bounds, handed to every session this
	// handler starts. Zero fields take their documented defaults, which
	// session.Limits.withDefaults applies once per session rather than here.
	Limits session.Limits

	// Metrics, Tracer and Logger are the instrumentation triple, and each may
	// be nil: nil is the disabled configuration, not a missing dependency.
	// obs.NewMetrics, NewTracer and NewLogger return nil for a nil provider,
	// and every method on all three is nil-receiver safe, so no caller on the
	// connection path branches on presence.
	Metrics *obs.Metrics

	// Tracer starts the connection and session spans. Nil disables tracing;
	// see Metrics.
	Tracer *obs.Tracer

	// Logger writes the structured connection records. Nil disables logging;
	// see Metrics.
	Logger *obs.Logger

	// Dev is developer mode, carried through to every session this handler
	// starts. It widens the message on the Error frame a contained panic
	// produces and nothing else.
	Dev bool

	// MaxSessions bounds the whole process; zero means unbounded, which the
	// documentation tells operators to change.
	MaxSessions int
	// MaxSessionsPerIdentity bounds one subject's concurrent connections.
	MaxSessionsPerIdentity int

	// Scope is the runtime scope every session goroutine starts in: the
	// connection's read pump, its actor and its effects. The handler borrows
	// it; the live UI service that owns it joins it after Close has drained
	// every session. It is required.
	Scope *runtime.Scope
}

Options[I] configure the transport.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL