Repository navigation
Session Framework Design v2 #8
Closed
SeanTAllen
started this conversation in
Research
Replies: 1 comment
|
Superseded by #10. Hobby's actor-per-request redesign (shipped in 0.4.0) eliminates the three gaps that forced pyrois to build its own request pipeline: async handlers, response buffering, and after-middleware header injection. Sessions belong in Hobby now. The design work here transfers to the Hobby implementation, but the architectural premise — pyrois builds directly on stallion, bypassing Hobby — no longer has a technical basis. |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Session Framework Design v2
The first version of this plan was built on a wrong premise: that pyrois had to use hobby's middleware system. It doesn't. Pyrois is the framework. It owns its architecture. The session framework's needs should drive the architectural choices, not the other way around.
This version starts from that corrected framing. Pyrois builds its own request pipeline directly on stallion, giving it full control over request lifecycle, response buffering, and async actor coordination. The pieces that don't belong in a framework (HTTP parsing, cookie parsing, TCP connection management) stay in stallion where they already exist.
Why pyrois needs its own pipeline
The session design needs two things that hobby can't provide:
Async actor queries before the handler runs. Loading session data from an in-memory actor requires sending a message and receiving a callback. Hobby's
_ChainRunnerexecutes the entire before → handler → after pipeline synchronously within a single behavior on the_Connectionactor. There are no yield points. The middleware can't pause execution, wait for the session store to respond, and then resume.Response buffering so middleware can inject headers after the handler. When a handler modifies session data, the updated session needs to be serialized into a
Set-Cookieheader on the response. In hobby, by the timeafterfires,ctx.respond()has already sent bytes to the wire. The response is gone.Pyrois solves both by implementing
HTTPServerActordirectly on stallion. The connection actor controls when the response gets sent. It can receive the session data callback, run the handler, let middleware add headers, and then send the complete response.This doesn't mean pyrois reinvents HTTP handling. Stallion does the parsing, connection management, response queuing, backpressure, idle timeouts, and pipelining. Pyrois adds routing, middleware, handlers, context, and session management on top. Hobby becomes a peer project that shares stallion as a foundation, not a dependency of pyrois.
Architecture
Request pipeline
The request lifecycle inside pyrois's connection actor:
on_request_complete(request, responder).loadmessage to theMemorySessionStoreactor, and waits. The store responds via a callback behavior with the session data. Execution resumes in a new behavior turn.beforeruns in order. Any middleware can short-circuit by callingctx.respond().afterruns in reverse order. Session middleware serializes the session into aSet-Cookieheader on the buffered response.Responder.The response is buffered as a status + headers + body inside the context until step 7. This is the fundamental difference from hobby: the handler calls
ctx.respond()to set the response data, but the bytes don't go to the wire until the full middleware chain completes. This givesaftermiddleware the ability to add or modify headers.Core types
Handler(interface val) — Same shape as hobby's:fun apply(ctx: Context ref) ?. Handlers areval(shareable, stateless between requests). The?allows errors to propagate; unhandled errors result in a 500.Middleware(interface val) — Same shape as hobby's:fun before(ctx: Context ref) ?andfun after(ctx: Context ref) => None. Thebefore/afternaming and the guarantee thatafteralways runs for every middleware whosebeforewas invoked carry over directly. These are good design decisions worth preserving.Context(class ref) — The request context. Provides access to the request, route parameters, request body, and a typed data map for inter-middleware communication. Response methods (respond,respond_with_headers) buffer the response instead of sending it immediately.start_streaming()bypasses the buffer and sends headers directly via the stallionResponder, same as hobby's streaming API (returnsStreamSender tag | ChunkedNotSupported | BodyNotNeeded). The context also holds theSessionEditorfor session access.Application(class iso) — Route registration and server startup, same builder pattern as hobby's. Register routes via.>chaining (get,post, etc.), add middleware, callserve()to freeze and start listening. Consumes the Application onserve(). Has an explicitwith_sessions(config: SessionConfig)method that stores the config and addsSessionMiddlewareto the middleware list in one call. This avoids requiring the user to add the middleware separately and ensures the config is available to the listener and connections._Connection(actor) — Implementsstallion.HTTPServerActorand_SessionRequester. Owns the session-loading async flow, runs the middleware chain, and flushes the buffered response. Each accepted TCP connection gets its own_Connection. Because session loading is async (the connection sendsloadto the store and waits for_session_loadedin a later behavior turn), additional pipelined requests can arrive during the wait. These are buffered in a_pending_requestsqueue, same pattern as hobby uses for requests arriving during streaming responses. After the current request is fully processed (response flushed), the connection drains the queue._Listener(actor) — Implementslori.TCPListenerActor. Accepts connections and spawns_Connectionactors. Created byApplication.serve(). Holds a(SessionConfig | None)that is threaded fromApplication(which receives it viawith_sessions()) through to each_Connectionit spawns._Connectionuses the config to know which cookie to read and which store to query. IfNone(no session middleware), the connection skips session loading entirely and runs the middleware chain immediately._Router— Route lookup by method and path. Same trie-based approach as hobby's, with parameterized routes (:idsegments).Deviation from hobby: what changes, what doesn't
Preserving hobby's patterns where they're good:
HandlerandMiddlewareinterfaces are structurally identical.Applicationbuilder API with.>chaining and.serve()consumption._ChainRunnerforward/after phase logic, including the counter-based guarantee thatafterruns for every invokedbefore.set/getwithAny val) for inter-middleware communication.Changing what needs to change:
ctx.respond()stores status + headers + body.ctx.respond_with_headers()does the same but with explicit headers._add_header(name, value)(package-private) for middleware to inject headers after the handler responds. Session middleware uses this inafterto add theSet-Cookieheader.ctx.start_streaming()sends headers immediately via the responder, same as hobby. After middleware can't modify streaming response headers. For new sessions, the connection actor pre-sets theSet-Cookieheader on the context before the middleware chain starts, so the cookie goes out with the streaming headers. For session modifications during a streaming handler, those changes are saved to the store (the middleware'safterstill callssave) but theSet-Cookieheader can't be injected since the headers are already on the wire. This only matters if the handler changes the session ID (viaregenerate_id()), since the session data itself lives server-side. This is a known limitation for v1. Most session modifications happen in non-streaming request handlers.Session framework
Server-side storage
Session data lives in a
MemorySessionStoreactor. The cookie carries only a signed session ID, not the data. This is what the original build plan called for.The reasons to prefer server-side storage over cookie-based:
The tradeoff: server restart loses all sessions. This is documented and expected for the in-memory store. A persistent store (database-backed) is a future concern. For development and small deployments, session loss on restart is acceptable.
SessionId(primitive)32 bytes from
crypto.RandBytes, hex-encoded to a 64-character string. 256 bits of entropy. At a billion guesses per second, the expected time to find a valid session ID is longer than the heat death of the universe.Why hex instead of Base64-URL: hex is always cookie-safe without padding concerns, unambiguous in logs and debugging output, and trivially validated (64 characters, all
[0-9a-f]). The 21-character savings from Base64-URL is meaningless inside a signed cookie. The existingSignedCookiecode uses Base64-URL for HMAC signatures where compactness matters (fixed 44-char overhead on every cookie). Session IDs have different priorities: one ID per cookie, debuggability matters more than density.If
RandBytesfails,generateerrors. The session middleware propagates this as a 500. A CSPRNG failure means something is fundamentally wrong with the runtime environment. Issuing a session with a weak or empty ID is worse than a 500 because everything downstream (auth, CSRF) would treat it as a valid session.SessionData(class val)Immutable snapshot. The session store sends
SessionData valto the connection actor, which stores it in the context. The handler reads from this snapshot via the context.String keys and string values. No
Any val— stringly-typed session data is the standard approach (Phoenix, Rails, Flask all serialize to strings). It forces explicit serialization at the boundary, which is the right place for it.The
_is_newflag distinguishes sessions that were just created (no matching cookie, or invalid cookie) from sessions that were loaded from the store. Downstream code can use this to decide whether to set the cookie — no point setting a cookie if the session already existed and hasn't been modified.SessionEditor(class ref)Mutable editor wrapping the immutable snapshot. The connection actor creates this from the loaded
SessionData valand stores it in the context. The handler reads and writes through it. After the handler and all middleware complete, the connection actor calls_finish()to produce the newSessionData valfor saving.regenerate_id()generates a fresh session ID, stashes the old ID in_previous_id, and marks the editor as modified. Auth handlers should call this after login to prevent session fixation. It's partial because it callsSessionId.generate()which depends on the CSPRNG. The editor itself doesn't interact with the store — it can't, since it's arefclass and the store is an actor (tag). The actual deletion of the old session is the middleware's responsibility: inafter, if_previous_id()returns aString val, the middleware sendsdelete(old_id)to the store before saving the new session.mark_for_deletion()flags the session for removal. The middleware'safterphase handles the actual work: deleting from the store and sendingMax-Age=0on the cookie. This is the logout path._finish()builds a newSessionData valfrom the current state. It copies the mutable map into arecover valblock. For typical session sizes (under 20 entries), the copy cost is negligible._is_modified()returns whether any mutation happened. The session middleware checks this to decide whether to save — if the handler only read the session, no save is needed, noSet-Cookieheader is needed.Why a mutable editor instead of functional updates on
Session val: handlers read and write session data multiple times during a request. Threading avalthrough every mutation (let s = s.with_value("k", "v")) is tedious and error-prone. Arefeditor stored in the context is natural Pony: the handler hasrefaccess to the context, the editor is areffield on the context, mutations are straightforward. Thevalboundaries are at the actor edges (store → connection and connection → store), which is where they belong.MemorySessionStore(actor)The store holds a
Map[String, SessionData val].loadlooks up the session by ID and sends the result back to the connection actor via the_SessionRequestercallback interface.savereplaces the entry.deleteremoves it.saveis fire-and-forget. The connection actor sends the save message and flushes the response to the client without waiting for confirmation. Causal messaging in Pony guarantees FIFO delivery to a single actor: if the same connection sends a save for session X, then a subsequent request loads session X, the load message arrives at the store after the save. The save is already applied. This ordering guarantee is the key insight that makes fire-and-forget safe.Concurrent requests from different connections can race. Two requests for the same session, both modifying it, both saving — last write wins. This is inherent to server-side sessions without locking, and it's the same behavior as every other in-memory session store (Phoenix ETS, Rails MemoryStore). For the overwhelming majority of web applications, this is fine.
The store doesn't handle expiry for v1. Sessions live until the server restarts or they're explicitly deleted. TTL-based expiry (sweep timer, lazy eviction on load) is a future enhancement. It's worth calling out that this means the store grows without bound if sessions are never deleted. For development and small deployments, this is acceptable. For production, a persistent store with TTL is the right answer anyway.
_SessionRequester(interface tag) — callback interface for the store to deliver session data back to the connection:The connection actor implements this. When the store finishes looking up a session, it calls
_session_loadedon the requesting connection actor. This resumes request processing.SessionConfig(class val)The constructor is partial because it validates the cookie configuration at construction time by building a test
SetCookieBuilder(cookie_name', "x").with_path(cookie_path').build(). The path is included in the test build because__Host-prefix validation requiresPath="/"— without it,SetCookieBuilderreturnsCookiePrefixViolation. If the name isn't a valid RFC 2616 token or the prefix constraints aren't met, construction fails at startup instead of on the first request.The default cookie name uses the
__Host-prefix. This prefix tells the browser to enforce three constraints: the cookie must haveSecure, must havePath=/, and must not haveDomain. NoDomainattribute means the cookie is locked to the exact origin — it won't be sent to subdomains. This prevents an attacker who controlsevil.example.comfrom setting a session cookie thatexample.comwould accept (a session fixation vector via subdomain cookie injection).Security attributes are hardcoded, not configurable:
Secure=true,HttpOnly=true,SameSite=Lax. These are the only correct settings for session cookies. Per "it is easier to give than take away" — if someone has a legitimate need to weaken these (local development without TLS), we can add the knobs later.max_agecontrols cookie expiry.Nonemeans a session cookie (browser deletes it when closed). AnI64value setsMax-Agein seconds. The browser enforces this.SessionMiddleware(class val, is Middleware)Session middleware is unusual: most of its work doesn't happen in
before/after. The actual session loading is handled by the connection actor's async flow before the middleware chain even starts. The middleware'sbeforedoes nothing. The middleware'safterhandles persistence and cookie writing.Why have it as middleware at all? Because the
afterphase is the right place to inject theSet-Cookieheader on buffered responses. The connection actor handles the async loading, but the cookie writing is a synchronous operation that belongs in the middleware pipeline where it can be ordered relative to other middleware. For streaming responses, the connection actor pre-sets the cookie for new sessions before the chain starts (see the streaming deviation above).afterbehavior:SessionEditorfrom context._previous_id()is notNone, senddelete(old_id)to the store (fixation prevention)._finish()to produce the newSessionData val.saveto the store (fire-and-forget).SignedCookie.sign().Set-Cookieheader viaSetCookieBuilder.ctx._add_header().For new sessions (no valid cookie received), the cookie is always set even if the session wasn't modified. The client needs the session ID to maintain continuity on the next request.
For session deletion (logout), the handler calls
editor.mark_for_deletion(). The middleware'safterchecks_is_deleted(): if true, it sendsdeleteto the store and builds aSet-Cookieheader withMax-Age=0to instruct the browser to remove the cookie. The normal save/cookie-set path is skipped.Cookie flow
Inbound (request arrives):
request.cookies.get(config.cookie_name).SessionData, mark as new. Skip store lookup.SignedCookie.verify().load(id, this)to the store._session_loaded(data). If the store has no entry for that ID (expired, deleted, server restarted): create emptySessionDatawith the same ID, mark as new.SessionEditorfrom theSessionData, stores it in context, runs the middleware chain.Outbound (response ready):
afterreads the editor._finish()→saveto store → sign ID →Set-Cookieheader on buffered response.Responder.Session fixation prevention
Invalid or missing cookies always create fresh sessions with new IDs. An attacker who pre-sets a cookie value gets nothing because it fails signature verification.
Auth handlers should also call
editor.regenerate_id()after login. This rotates the session ID while preserving the data. An attacker who observed the pre-login session ID can't use it to access the authenticated session.regenerate_id()generates a new ID, stashes the old ID, and marks the session as modified. The middleware'safterphase handles the actual cleanup: it deletes the old session from the store (so the old ID can't be reused) and writes the new ID into the cookie.Cookie security
Secure=true— cookie only sent over HTTPS.HttpOnly=true— not readable by JavaScript (XSS mitigation).SameSite=Lax— not sent on cross-site POST requests (CSRF baseline).__Host-prefix — locked to exact origin, no subdomain leakage.SignedCookie— tamper-proof, constant-time comparison.Key management
CookieSigningKey.generate()creates a random key at startup, suitable for development. In production, the key must be persisted across restarts or all sessions lose their cookie association (the store still has the data, but clients can't prove their session ID anymore).CookieSigningKey.create(key_from_env)?is the production path.Key rotation is manual for v1: deploy a new key, clients get new session cookies on next request, old sessions are orphaned in the store until restart. The architecture supports adding a
CookieSigningKeyRinglater (primary key for signing, retired keys for verification fallback) without changing the session API.Concurrent requests
Two concurrent requests from the same user both load the same session snapshot from the store. If both modify the session and save, last write wins. This is inherent to server-side sessions without locking. It's the same behavior as Rails MemoryStore, Phoenix ETS sessions, and Flask's server-side session stores. For the vast majority of applications, concurrent session modification is rare enough that last-write-wins is fine.
Dependencies
Update
corral.json:{ "locator": "github.com/ponylang/stallion.git", "version": "0.5.1" }Direct stallion dependency, not through hobby. The existing
ssldependency remains for crypto access. Whether corral handles the diamond cleanly (pyrois → stallion, pyrois → ssl, stallion → ssl) needs to be verified empirically during the first build step.Hobby is no longer a dependency. Pyrois implements the same patterns (same interface shapes, same builder API) but owns the implementation.
File organization
All session code lives in the
pyroispackage. No subpackage. There isn't enough code to justify the split, anduse "pyrois/session"alongsideuse "pyrois"adds friction for the common case where you want both.The request pipeline types (Handler, Middleware, Context, Application, etc.) also live in the
pyroispackage. They're the framework.Build order
Phase 1: Request pipeline foundation
This can be built and tested without sessions. It's the framework skeleton.
Step 1:
_mort.pony,_Router,_RouteDefinition,_RouteMatch. The_Unreachablepanic primitive (used by all subsequent steps), route registration, and lookup. Parameterized routes (:id). Tests: exact match, parameterized match, no match, multiple methods, overlapping paths.Step 2:
Handler,Middleware,_ChainRunner. Interface definitions and the chain execution logic. Port_ChainRunnerfrom hobby, adapting it for the buffered response model. Tests: forward phase ordering, after phase reverse ordering, short-circuit on respond, error handling (500 on unhandled error), after always runs.Step 3:
Context,_ResponseBuffer,_ContextKeys. The request context with response buffering.respond()andrespond_with_headers()store data instead of sending._add_header()for post-handler header injection.start_streaming()for streaming responses.set/getdata map._ContextKeysholds the shared key constants used by session middleware and the context's session accessor. Tests: response buffering, header injection, data map,is_handled()state, streaming pass-through.Step 4:
Application,RouteGroup,_Listener,_Connection(basic, no sessions). The server startup, route groups with prefix/middleware, and request dispatch. At this point, pyrois can serve HTTP requests with routing and middleware, just without session support. This is the "hobby replacement" milestone. Tests: integration tests that start a server, send requests, verify responses.Phase 2: Session framework
Step 5:
SessionId. Depends onssl/crypto(already a dependency). Tests: length (64 characters), character set ([0-9a-f]), uniqueness, hex decodability.Step 6:
SessionData. Immutable session snapshot. Depends oncollections.Map. Tests: apply/contains, get_or, is_new flag, pairs/size, empty session.Step 7:
SessionEditor. Mutable editor wrappingSessionData. Tests: set/remove, regenerate_id, _finish produces correct val, _is_modified tracking, _previous_id tracking, immutability of original snapshot.Step 8:
MemorySessionStore,_SessionRequester. The session store actor. Tests: load returns saved data, load for unknown ID, save overwrites, delete removes, callback delivery. These tests need a test actor that implements_SessionRequester.Step 9:
SessionConfig,SessionMiddleware,_SessionLoader. Configuration with validation, session cookie loading logic (extracted into a testable_SessionLoaderprimitive), and middleware that handles theafterphase. Update_Connectionto implement_SessionRequesterand the async session loading flow. Tests: config defaults, config validation (invalid cookie name,__Host-prefix violation), loader cookie parsing, middleware cookie writing, middleware skip-when-unmodified, middleware old-session deletion on regenerate, middleware deletion path (mark_for_deletion→Max-Age=0), full round-trip (request → session load → handler modify → middleware save → response with Set-Cookie).Step 10: Package docstring, example,
examples/README.md. The example is a pyrois application with a visit counter and logout. ShowsSessionConfig,SessionMiddleware,SessionEditor, session reading in a handler,regenerate_id()on login, session deletion on logout.Phase 3: Follow-up
Step 11: File a tracking issue on hobby noting that pyrois has forked the pipeline design and why. This isn't a bug report — it's a record of the architectural divergence so future work on either project is aware.
Testing strategy
Tests follow existing pyrois patterns:
\nodoc\on all test declarations, private_Test*and_Prop*naming, PonyCheck for property-based tests, single test runner in_test.ponydelegating to per-file registration primitives.Build and run:
make test(requiressslflag for the OpenSSL version on the build machine).Session ID tests (
_test_session_id.pony)Property-based:
_PropSessionIdLength— every generated ID is exactly 64 characters (32 bytes → hex)_PropSessionIdHexChars— every character is in[0-9a-f]_PropSessionIdUnique— generate 50 IDs per sample, assert all distinctUnit:
_TestSessionIdVector— known 32 bytes → expected hex string (isolates encoding from RNG)_TestSessionIdDecodable— generated IDs round-trip through hex decodeSessionData tests (
_test_session_data.pony)Property-based:
_PropSessionDataPairs— for a generatedMap[String, String],SessionData._create(id, map).pairs()yields all entriesUnit:
_TestSessionDataApply— set key, get returns value_TestSessionDataApplyMissing—apply("absent")errors_TestSessionDataGetOr— returns default for missing key_TestSessionDataContains— true for present, false for absent_TestSessionDataSize— reflects entry count_TestSessionDataIsNew— flag reflects construction parameter_TestSessionDataEmpty—_empty(id)has size 0, is_new trueSessionEditor tests (
_test_session_editor.pony)Property-based:
_PropEditorSetGetRoundTrip—editor.set(k, v)theneditor.apply(k)? == vfor arbitrary printable ASCII k/v_PropEditorSetPreservesOther— for distinct k1/k2, setting k2 doesn't affect k1_PropEditorRemoveRemoves—editor.set(k, v)theneditor.remove(k)theneditor.contains(k) == false_PropEditorOriginalUnchanged— after mutations, the originalSessionData valis unmodifiedUnit:
_TestEditorSet— basic set and get_TestEditorOverwrite— set same key twice, second value wins_TestEditorRemove— remove a key that exists_TestEditorRemoveMissing— remove a key that doesn't exist (no error)_TestEditorRegenerateId— new ID, data preserved, marked modified, previous ID stashed_TestEditorFinish— producesSessionData valwith correct state_TestEditorIsModifiedInitially— false when no mutations_TestEditorIsModifiedAfterSet— true after set_TestEditorIsModifiedAfterRemove— true after remove_TestEditorMarkForDeletion—_is_deleted()is true aftermark_for_deletion()_TestEditorNotDeletedInitially—_is_deleted()is false initiallyStore tests (
_test_session_store.pony)These use a test actor that implements
_SessionRequesterto receive callbacks._TestStoreSaveAndLoad— save then load returns same data_TestStoreLoadUnknown— load unknown ID returns empty session_TestStoreOverwrite— save twice, second data wins_TestStoreDelete— delete then load returns empty session_TestStoreDeleteUnknown— delete unknown ID doesn't errorSession middleware tests (
_test_session_middleware.pony)Core session loading logic extracted into a testable function:
Returns either a session ID (needs store lookup) or a
SessionData val(new session, no lookup needed).Property-based:
_PropLoaderValidCookieExtractsId— for any generated session ID, sign it, pass toload(), get the same ID back_PropLoaderTamperDetection— flip any byte in a valid signed cookie,load()returns a new session (not the original ID)Unit:
_TestLoaderNoCookie—load(None, config)returns new session_TestLoaderValidCookie— valid signed cookie returns session ID for store lookup_TestLoaderTamperedCookie— tampered cookie returns new session_TestLoaderWrongKey— signed with different key returns new session_TestLoaderMalformed—load("garbage", config)returns new session_TestLoaderEmptyString—load("", config)returns new sessionContext and response buffer tests (
_test_context.pony)_TestContextRespond—respond()stores status and body_TestContextRespondWithHeaders— stores explicit headers_TestContextAddHeader—_add_header()appends to buffered headers_TestContextFirstResponseWins— secondrespond()is ignored_TestContextDataMap—set/getround-trip_TestContextParam— route parameter accessRouter tests (
_test_router.pony)_TestRouterExactMatch—/foomatches/foo_TestRouterParamMatch—/users/:idmatches/users/42, params hasid=42_TestRouterNoMatch— returns None for unregistered path_TestRouterMethodMatch— GET/foodoesn't match POST/foo_TestRouterMultipleParams—/users/:id/posts/:pidextracts bothTest totals
(Plus the 26 existing signed cookie tests.)
Downstream integration points
LiveView session bridge (build plan item 3). The session ID is available via
session_data.id(). The bridge embeds this in the SSR page as a data attribute. On WebSocket upgrade,mare.UpgradeRequest.header("cookie")provides the session cookie, which is verified the same way the HTTP path does it. TheMemorySessionStorecan be queried from the LiveView connection actor to load the session by ID.CSRF protection (build plan item 4). The CSRF token lives in the session under the key
"_csrf_token". CSRF middleware generates the token if absent, embeds it in forms, validates it on state-changing requests. The session framework doesn't need to know about CSRF — it's just another key-value pair.Auth middleware (build plan item 6). Login stores
"user_id"in the session and rotates the ID viaeditor.regenerate_id(). Logout deletes the session. The require-auth middleware readseditor.apply("user_id")?and redirects if it errors.Open questions
Stallion as a direct dependency. Pyrois has been listing
sslas its only dependency. Adding stallion means pyrois is now coupled to stallion's API (Request, Responder, HTTPServerActor, ServerConfig, etc.). This is the right coupling — stallion is the HTTP layer and pyrois is the framework on top. But it's a meaningful dependency, not a small utility. The public API of pyrois (Application, Handler, Context) will expose stallion types. Worth confirming this is the intended direction before building.Routing implementation. The plan calls for pyrois to have its own router. Hobby's router is not directly reusable (it's package-private). The routing logic needs to be reimplemented. The question is whether to start simple (linear scan) or match hobby's trie-based approach from the start. For v1, linear scan is fine — the number of routes in a typical application is small enough that the difference is unmeasurable.
Streaming response + session ID regeneration. Streaming responses bypass response buffering. For new sessions, the connection actor pre-sets the
Set-Cookieheader before the chain starts. Session data modifications during streaming are saved to the store normally (the middleware'safterstill runs). The one gap: if a streaming handler callsregenerate_id(), the new session ID can't be written to the client because the headers are already on the wire. This is a narrow edge case (streaming handlers that also change auth state), but it should be documented.Contexttestability. Pyrois owns itsContextclass, so unlike the hobby situation, we control the constructor. The constructor can be designed to be testable from the start — taking aRequest val, a_ResponseBuffer, and route params, without requiring a connection actor reference. The response buffer is a separate class that can be inspected in tests. This is a significant improvement over the hobby situation where_Connection tagwas required.All reactions