Repository navigation
fix(ppr): consumption-lane rule — handler ctx.use(loader) bakes during shell capture - #674
Conversation
commit: |
8d91a34 to
12b6ce6
Compare
|
Amended with a fix for the cloudflare-basic ppr-shell regression CI caught: loaders carrying their own cache() config are now EXEMPT from capture priming — a cache-keyed value is shared by definition, so masking buys no identity safety, and masking it starved the capture's ring-3 cacheRoute write into an infinite Flight-serialization hang (empty snapshot -> generation drift -> React #418 on HIT). Also hardened: cacheRoute skips its capture-time write when a masked handler's segment is in the set, so no shape can hang the worker. New unit case pins the exemption (red without the fix); cloudflare-basic FULL e2e green post-fix (production 340, dev 279), router shell-cache 22/22 both projects, full unit suite 4938. The CI dev :145 failure did not reproduce locally (0/4) and is tracked as a suspected order-dependent flake in the ppr-shell describe (no isolatedServer + shared ring-3 keys). |
12b6ce6 to
a9551a2
Compare
…g shell capture, identity reads permitted (#672) A slot handler awaiting ctx.use(SlotLoader) during PPR shell capture executed the loader and tripped the cookies() identity guard, refusing the capture forever (x-rango-shell: MISS permanently) with a warning blaming a bake-lane loader that did not exist. This affected EVERY route (the issue's working "/" was a stale-shell artifact). Resolution: align PPR capture with the cache()/"use cache" precedent instead of adding masking machinery. The CONSUMPTION-LANE RULE, now doctrine: for any shared-artifact capture, server-side handler consumption via ctx.use(loader) yields a BAKED copy with cookies()/headers() reads permitted — the same documented footgun cache() already carries ("Cache purity & tainted objects"); client-side useLoader is the live lane. DSL segment lanes are untouched: a live-lane (loading()) registration still masks its segment — so for the parallel-slot shape the slot stays a LIVE hole (the segment mask pins the boundary; the handler's baked copy is discarded with the postponed subtree), and the baked copy materializes only where the value renders as unshielded shell material. Mechanism: the loader-body ALS scope (which already carries loaderId for guard attribution) gains handlerInvoked, set when the invoking ctx.use ran outside DSL loader scope; the shell-capture identity guard in cookie-store exempts handler-invoked loader bodies. Nothing else: no priming, no release machinery, no new request-context fields. refuseOnGuardTrip now names the actual tripping loader; the misleading bake-lane attribution is gone. Locked in as doctrine: semantic-matrix row [PPR3] (dev+prod) + docs/internal/execution-model.md; e2e pins for BOTH artifact types (shell-cache slot-use cases both modes; new cache() handler-consumed baked- copy cases both modes); design docs (ppr-shell-resume.md, incl. the masking history as scar tissue; loader-container-bake.md); skills (/rango invariant, /ppr, /parallel, /caching, /use-cache, /loader). Tests: guard exemption proven red (2 failed with the exemption disabled); full router suite 4937 passed; shell-cache e2e 22/22 both projects; semantic matrix 21 dev / 24 production; cloudflare-basic ppr-shell 19/19 both projects (confirms the slot settles and ring-3 writes serialize — no hang); typecheck 15/15; lint; skills/doc-links/docs-api guards green; gen files regenerated.
a9551a2 to
1315b06
Compare
…ites (closes #725 gap) The cached-scope header guard (#725) exempted every loader body under a cache() boundary via isInsideAnyLoaderScope(). That let a handler-invoked loader — await ctx.use(Loader) from a handler, never registered with loader() — write a cookie/header on the MISS. On a HIT the handler is skipped and only DSL-registered loaders re-run, so that loader never executes and its write silently vanishes — the exact MISS/HIT divergence #725 was built to eliminate. Narrow the exemption to isInsideLoaderScope() (DSL scope only): registered loaders re-run on every HIT and merge into every response, so their writes stay allowed; a handler-invoked loader now throws deterministically like a handler write. The DSL scope ALS survives nested ctx.use bodies, so a handler-invoked loader nested under a DSL loader stays exempt (its parent re-runs it every HIT). The broad predicate is deferred to the throw path for the error label so the PPR handler-invoked-loader message stays "loader", and the throw reason keys on scope.kind so the cache() case explains the handler-skip cause. Re-ground the neighbouring read-purity guard comment, which this change makes contradictory: reads stay on the broad predicate under the consumption-lane baked-copy tradeoff (#672/#674), only writes narrow to DSL scope. Unit (cached-header-guard.test.ts): DSL-allowed pin, handler-invoked-throws pin (red before this change, green after), nested-under-DSL-allowed pin. E2e (cache-scope-guard, dev + production): /handler-loader-cookie-blocked asserts the error boundary renders and the cookie never reaches the wire; the DSL /loader-cookie-allowed control stays green in both modes.
…ites (#725 gap) + drop removed __debug_manifest docs (#730) * fix(router): handler-invoked loaders under cache() throw on header writes (closes #725 gap) The cached-scope header guard (#725) exempted every loader body under a cache() boundary via isInsideAnyLoaderScope(). That let a handler-invoked loader — await ctx.use(Loader) from a handler, never registered with loader() — write a cookie/header on the MISS. On a HIT the handler is skipped and only DSL-registered loaders re-run, so that loader never executes and its write silently vanishes — the exact MISS/HIT divergence #725 was built to eliminate. Narrow the exemption to isInsideLoaderScope() (DSL scope only): registered loaders re-run on every HIT and merge into every response, so their writes stay allowed; a handler-invoked loader now throws deterministically like a handler write. The DSL scope ALS survives nested ctx.use bodies, so a handler-invoked loader nested under a DSL loader stays exempt (its parent re-runs it every HIT). The broad predicate is deferred to the throw path for the error label so the PPR handler-invoked-loader message stays "loader", and the throw reason keys on scope.kind so the cache() case explains the handler-skip cause. Re-ground the neighbouring read-purity guard comment, which this change makes contradictory: reads stay on the broad predicate under the consumption-lane baked-copy tradeoff (#672/#674), only writes narrow to DSL scope. Unit (cached-header-guard.test.ts): DSL-allowed pin, handler-invoked-throws pin (red before this change, green after), nested-under-DSL-allowed pin. E2e (cache-scope-guard, dev + production): /handler-loader-cookie-blocked asserts the error boundary renders and the cookie never reaches the wire; the DSL /loader-cookie-allowed control stays green in both modes. * docs: drop the removed __debug_manifest browser endpoint from the skill and guide #728 deleted the ?__debug_manifest HTTP endpoint and its routeAncestry data, but the debug-manifest skill and docs guide still told users to open http://localhost:PORT/?__debug_manifest and documented the deleted { routerId, routeManifest, routeAncestry, routeTrie, precomputedEntries } shape. Remove both browser-endpoint sections; both files already carry the correct router.debugManifest() programmatic path ({ routes, layouts, totalRoutes, totalLayouts }), now the primary documented route.
Closes #672.
Problem statement
A slot handler awaiting
ctx.use(SlotLoader)during PPR shell capture executed the loader and tripped the cookies() identity guard, refusing the capture forever (x-rango-shell: MISSpermanently) with a warning blaming a bake-lane loader that did not exist. Affects EVERY route (the issue's working/was a stale-shell artifact — reproduced at full router.match fidelity).Resolution: the consumption-lane rule (maintainer decision)
Instead of masking machinery, PPR capture now follows the cache()/"use cache" precedent, locked in as doctrine: for any shared-artifact capture, server-side handler consumption via
ctx.use(loader)yields a BAKED copy with cookies()/headers() reads permitted — the same documented footgun cache() carries ("Cache purity & tainted objects"); client-sideuseLoaderis the live lane. DSL segment lanes untouched: aloading()registration still masks its segment, so in the parallel-slot shape the slot remains a LIVE hole (the segment mask pins the boundary; the handler's baked copy is discarded with the postponed subtree) — the baked copy materializes only where the value renders as unshielded shell material.Mechanism (net ~40 lines of runtime change)
The loader-body ALS scope (which now carries
loaderIdfor guard attribution) gainshandlerInvoked, set when the invokingctx.useran outside DSL loader scope; the shell-capture identity guard in cookie-store exempts handler-invoked loader bodies. No priming, no release machinery, no new request-context fields.refuseOnGuardTripnow names the actual tripping loader id.Semantics (during capture)
ctx.use(loader), any laneLocked in
Semantic-matrix row [PPR3] (dev+prod) +
docs/internal/execution-model.md; e2e pins for BOTH artifact types (shell-cache slot-use cases, new cache() handler-consumed baked-copy cases — all dev+prod); design docs (ppr-shell-resume.mdwith the masking-approach history as scar tissue,loader-container-bake.md); skills (/rango invariant, /ppr, /parallel, /caching, /use-cache, /loader).Test plan
Accepted trade (stated plainly)
The capture request's identity value can freeze into the shared shell where rendered as shell material — deliberate, e2e-pinned (a user-A HIT still sees the anon value), same class as cache()'s existing allowance; escape hatch is client-side
useLoader. Prior masking approach (priming + settled-release) was reviewed, built, and rejected as over-complex for this edge; history preserved in ppr-shell-resume.md.