Phase 3: Async resolver with parallel I/O and graceful degradation

Tree walker collects I/O nodes (frag, query, action, current-user,
htmx-request?), dispatches them via asyncio.gather(), substitutes results,
and renders to HTML. Failed I/O degrades gracefully to empty string.

27 new tests (199 total), all mocked at execute_io boundary — no
infrastructure dependencies needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 14:22:28 +00:00
parent 09010db70e
commit fbb7a1422c
4 changed files with 702 additions and 0 deletions

View File

@@ -654,6 +654,39 @@ Each phase is independently deployable. The end state: a platform where the appl
- 63 tests: escaping (4), atoms (8), elements (6), attributes (8), boolean attrs (4), void elements (7), fragments (3), raw! (3), components (4), expressions with control flow (8), full pages (3), edge cases (5)
- **172 total tests across all 3 files, all passing**
### Phase 3: Async Resolver — COMPLETE
**Branch:** `sexpression`
**Delivered** (`shared/sexp/`):
- `resolver.py` — Async tree walker: collects I/O nodes from parsed tree, executes them in parallel via `asyncio.gather()`, substitutes results back, renders to HTML. Multi-pass resolution (up to 5 depth) for cases where resolved values contain further I/O. Graceful degradation: failed I/O nodes substitute empty string instead of crashing.
- `primitives_io.py` — I/O primitive registry and handlers:
- `(frag "service" "type" :key val ...)` → wraps `fetch_fragment`
- `(query "service" "query-name" :key val ...)` → wraps `fetch_data`
- `(action "service" "action-name" :key val ...)` → wraps `call_action`
- `(current-user)` → user dict from `RequestContext`
- `(htmx-request?)` → boolean from `RequestContext`
- `RequestContext` — per-request state (user, is_htmx, extras) passed to I/O handlers
**Resolution strategy:**
1. Parse s-expression tree
2. Walk tree, collect all I/O nodes (frag, query, action, current-user, htmx-request?)
3. Parse each node's positional args + keyword kwargs, evaluating expressions
4. Dispatch all I/O in parallel via `asyncio.gather(return_exceptions=True)`
5. Substitute results back into tree (fragments wrapped as `_RawHTML` to prevent escaping)
6. Repeat up to 5 passes if resolved values introduce new I/O nodes
7. Render fully-resolved tree to HTML via Phase 2 renderer
**Design decisions:**
- I/O handlers use deferred imports (inside functions) so `shared.sexp` doesn't depend on infrastructure at import time — only when actually executing I/O
- Tests mock at the `execute_io` boundary (patching `shared.sexp.resolver.execute_io`) rather than patching infrastructure imports, keeping tests self-contained with no external dependencies
- Fragment results wrapped as `_RawHTML` since they're already-rendered HTML
- Identity-based substitution (`id(expr)`) maps I/O nodes back to their tree position
**Tests** (`shared/sexp/tests/test_resolver.py`):
- 27 tests: passthrough rendering (4), I/O collection (8), fragment resolution (3), query resolution (2), parallel I/O (1), request context (4), error handling (2), mixed content (3)
- **199 total tests across all 4 files, all passing**
### Test Infrastructure — COMPLETE
**Delivered:**