# Prototype decisions

Skirr is an ASGI transport, not an application framework. Keep a single native
owner for each connection and run the application on the same thread and loop.

The prototype tests Peregrine's native-poller integration: epoll itself is
registered with asyncio, and the callback processes bounded native work. This
keeps uvloop usable and avoids another runtime and cross-thread request queues.
The alternative Weft design drives Tokio through a replacement asyncio selector.
Both deserve measurement; neither language nor reactor choice guarantees a win.

Sources reviewed:

- [Weft ASGI](https://github.com/grepjava/weft/blob/cec2030ed2a2f6e3fcb54df1270cc654cc9247e3/src/asgi.rs)
- [Weft callable types](https://github.com/grepjava/weft/blob/cec2030ed2a2f6e3fcb54df1270cc654cc9247e3/src/types.rs)
- [Peregrine ASGI](https://github.com/grepjava/peregrine/blob/3f8cac19b60ce6a151a07a5535a7e4ee8e3bbccf/Sources/PeregrineServer/ASGIDispatch.swift)
- [Peregrine connections](https://github.com/grepjava/peregrine/blob/3f8cac19b60ce6a151a07a5535a7e4ee8e3bbccf/Sources/PeregrineServer/Connection.swift)
- [Peregrine fuzzing](https://github.com/grepjava/peregrine/blob/3f8cac19b60ce6a151a07a5535a7e4ee8e3bbccf/Sources/PeregrineFuzzTargets/FuzzTargets.swift)

Native send and receive return a completed awaitable for ready operations and
Futures for waits. A shared completed-None awaitable avoids allocating a Future for every send.
A bounded operation budget forces cooperative yields even on fast sockets.

Connections have monotonically assigned identities; request callables refer to
their own request state and hold weak connection/worker references. Connections
are not recycled into the same identity. This is simpler than a slab for the
prototype and preserves the stale-callback safety property of generation tokens.
A slab is a later profiling decision.

Worker fields and connection state use thread-local ownership (Rc/RefCell),
not locks. Mutable state borrows must end before potentially reentrant Python
calls. CPython object ownership uses PyO3 rather than a parallel handwritten
reference-counting abstraction. Free-threaded Python is intentionally unqualified.

## Profile-driven optimization

Native cycle sampling found a large read-buffer clearing cost (about 16% of
baseline raw-ASGI user-space cycles), while wall-time stack sampling also exposed
repeated epoll changes. The worker now reuses initialized scratch space and leaves
an empty socket readable across requests. Buffered pipelined bytes still pause
reads; a held application cannot make the reactor spin on its next request.

Response serialization writes into the connection's reusable buffer. A later
large-response profile still attributed about 42% of user-space cycles to copying.
Vectored writes now pass large immutable Python bodies directly to the socket;
partial writes immediately copy the unsent suffix into owned native memory.
The retained implementation's bulk profile reduced copying to about 2.5% of
user-space cycles. This avoids an intermediate copy, not the kernel's copy.

CPython Task eager startup (available in the supported Python 3.12+) avoids a
scheduler turn when the application does not need to wait. Each request still
has its own Task and context. Custom task factories use the normal loop path.
Already-finished tasks are cleaned up synchronously; pending tasks retain a done
callback. Pipeline progress belongs to the outer bounded pump, with explicit
deferred resumption when buffered work remains. It must never recurse once per
pipelined request. A request may disappear during eager dispatch on write failure,
so pump re-checks its active request rather than assuming one still exists.

The first optimization round shared an already-completed asyncio Future for sends. Real waits still get
their own Future. The operation budget still forces cooperative yields. Empty
request events are constructed lazily without allocating an otherwise unused
body queue. Parser header scratch uses httparse's safe uninitialized-header API.

Two experiments were discarded: borrowed native header views did not show a
clear benefit, and a PyCFunction vectorcall wrapper's modest improvements did not
justify extra unsafe code and two additional context/function objects per request.
That optimization round added neither dependencies nor unsafe blocks. A separate eager-work-budget/scope-completion experiment also failed to improve tail latency and was reverted; its patch and diagnostic runs are retained.

The next bottlenecks include request dispatch and the Python application itself.
FastAPI's baseline native CPU profile attributed roughly 45% of user-space cycles
to CPython's evaluator alone. The server does not change application GC policy,
skip response validation, disable fairness, or specialize for benchmark routes.

Cycle percentages are samples of user-space execution, not end-to-end wall time.
They must not be interpreted as predicted throughput gains. Raw perf reports,
commands and sampled build metadata live in `bench/profiles`; the authoritative
performance comparison is the repeated unprofiled run in `bench/results`.

Remaining roadmap: complete HTTP conformance evidence, aggregate byte budgets,
worker supervision and listener handoff, WebSockets via a maintained codec, then
optional TLS/HTTP2/HTTP3 dependencies with independent protocol qualification.

## Exact-release Peregrine comparison

The second round reviewed the source of the installed Peregrine 1.1.7 release,
including its native ready awaitables, header spans and reusable buffers. The
[study](bench/profiles/peregrine-study.md) records profiles, source provenance,
syscall counts, tests and rejected experiments. Request headers now use one
owned block with safe ranges, response headers serialize once into bounded
reusable storage, and decoded uploads copy straight into owned Python bytes.
A level-triggered short read ends the current read batch instead of probing
again for EAGAIN. Another event services later data.

Completed sends now share a native stateless awaitable. Ready receives and
fairness yields use the same native slots with per-operation state; waits still
use Futures. CPython ownership for this small type is isolated in
`src/completed.rs`, with no thread-bound Rust pointers or global interpreter
references. Unlike the discarded vectorcall channel experiment, this is not a
parallel callable/connection ownership framework. The operation quota, Task
isolation, validation and scope isolation remain in force.

## FastAPI fairness and allocation study

The third round profiles the exact release binaries and the actual FastAPI worker.
A worker-global send quota made some short requests yield behind an unrelated batch.
The quota now belongs to each request and includes ready receives. A 16-event
reactor batch and bounded eager starts preserve loop opportunities without making
every response asynchronous. Custom task factories and request contexts remain intact.

Scope construction uses startup-interned fixed keys, separate shallow copies of
mutable dictionaries, borrowed common method names and a pre-sized header list.
Completed Tasks are inspected once through result(), preserving application errors
and cancellation. Server-generated disconnect errors have an OSError subclass so
expected disconnects are quiet while application OSError failures remain reported.

The retained changes add 48 native lines and no dependencies or unsafe code.
Write-batching and alternative task schedulers were rejected after throughput and
disconnect checks. Detailed profiles, rejected patches and comparisons are in
[the FastAPI study](bench/profiles/fastapi-study.md). Native library sample shares
are small; CPython execution includes both application code and server object/API
work, so it is not valid to treat that entire share as unavoidable application cost.

## Bounded final writes and FastAPI overhead

The fourth round replaces the adaptive-batch checkpoint with sixteen-event
cheap-work batches and at most 32 events while final flushes are queued. Small
final responses can copy into the kernel using MSG_MORE, then push at the native poll boundary or in a bounded
loop callback for independently resumed apps. Streaming responses retain immediate writes. A queued callback
tries the write before arming EPOLLOUT, avoiding redundant interest changes.

Native request-head scratch storage is recycled after Python owns its scope.
Short identical header blocks share immutable pairs behind fresh mutable lists;
the worker's cross-connection hint is weak and each connection retains only one
bounded snapshot. Optional state is omitted only for explicit lifespan off.
Loop methods are cached without caching the result of get_task_factory.

A guarded vectorcall entry removes generic call packaging for Sender while
keeping its PyO3 allocation, thread checks, errors and ownership. Layout checks
fall back to PyO3's ordinary call path; type slots are initialized under the GIL.
This adds unsafe CPython integration, so it needs independent qualification for
each Python version. The repeated profiles, rejected experiments and precise
limits of the results are in [the fourth-round study](bench/profiles/latency-round4-study.md).
