Dispatch pipeline
For one request, ChappeServletBridge runs these steps in order (Servlet 6.1 §3.5.2, §10.10, §12.2):
-
Canonical path. The context path is stripped on a segment boundary (404 outside the context), then
RequestPaths.canonicalizeremoves path parameters, decodes once and normalises; a refused path is a 400 before anything else. The;jsessionid=parameter is extracted first for the session lookup. The result drives every decision below;getRequestURIstays raw. -
Mapping.
ServletDispatcher.findpicks the servlet: exact, then longest prefix, then extension, then the application/servlet, then the container default servletfoy.default, which the deployer adds (as a non-application mapping) only when/is free. -
Welcome files. Only when the container default servlet matched: a directory without trailing slash is redirected (302,
Locationfrom the re-encoded canonical path), a directory path ending with/is replaced by the first welcome file that a static resource or a servlet serves, and the mapping is looked up again. -
Filter chain.
FilterRegistry.chainFor(path, dispatcherType, servletName)collects the filters whose URL pattern matches, then those mapped by servlet name (a filter matching both runs once), each in registration order, filtered by the dispatcher type. -
Servlet. The servlet (or the default servlet) runs; a forward, include, async or error dispatch goes through
RequestDispatcherImplandDispatchResolver, which produce aDispatchTarget(servlet, mapping, paths) and rebuild a chain for that dispatcher type with the same URL-then-name rule. Named dispatch looks the servlet up in an index of every registration, mapped or not. -
Error. An exception or
sendErrorgoes toErrorPageRegistry(class hierarchy, thenServletExceptionroot cause, then status code); a committed response is never replaced, the exception is logged instead.
The session is touched in step 1 (accessRequestedSession) for every request carrying a session id, not only when the servlet calls getSession, so a busy session does not expire and getLastAccessedTime() stays correct.
Body path: ServletOutputStreamImpl buffers up to getBufferSize() bytes; the commit hands Chappe a Body whose reader is a bounded ResponsePipe, which the servlet then fills (coalesced writes pushed when the buffer fills, on a flush, at the end). The ServletInputStream reads from the Chappe body directly in blocking mode; with a ReadListener, a body pump (one virtual thread) reads ahead into a small buffer and drives the callbacks.
Threading model
-
Two virtual threads per request. Chappe runs each connection (or HTTP/2 stream) on its own VT and calls the bridge there. The bridge starts the servlet pipeline on a second VT,
foy-request-<n>, and parks Chappe’s thread until the response head is known (the first commit, or the end of a request that never committed). Chappe then writes the head and reads the live body while the servlet keeps writing. -
Context crossing. Chappe’s
RequestContext.CURRENT(aScopedValue) is re-bound on the pipeline thread. Any otherThreadLocalorScopedValuea Chappe filter set on the connection thread is not visible to servlet code. -
No platform pool. No
ExecutorServiceto size; every thread Foy starts (pipeline,AsyncContext.start,ReadListenerbody pump,WriteListenerdrain, upgraded-connection pumps, session reaper, async timeouts) is virtual. -
Async.
request.startAsync()letsservice()return; the pipeline thread waits for the end of the cycle (complete, dispatch, timeout, error), runs dispatches and listeners, then ends the body. The container reserves no platform thread. -
Trailer supplier.
setTrailerFieldssuppliers are called by Chappe, on its connection thread, after the last byte of the body: request-scoped state is not available there. -
HTTP Upgrade. After the
101head, Chappe passes the raw connection to Foy;HttpUpgradeHandler.initruns on Chappe’s connection thread (with the application class loader), and theWebConnectionlistener callbacks run on Foy virtual threads, serialised, never beforeinitreturns.
|
The bridge never |
Container lifecycle
The deployment lifecycle lives in foy-core (io.vidocq.foy.internal.boot), not in the transport or the TCK harness:
-
WebAppModeldescribes the web application: servlets, filters, listeners,web.xmldata and options such asmetadata-complete. -
DescriptorMergermerges the parsedweb.xmlwith the annotated components (Servlet 6.1 §8.2.3: the descriptor wins on a same-name conflict,metadata-complete="true"ignores annotations). -
WebAppDeployer.deploy(model, DeployOptions)runs the lifecycle — listeners,ServletContainerInitializer`s, dynamic registrations, `load-on-startupinitialisation in order — and returns aDeployment. A servlet whoseinit()throws (aServletExceptionor a runtime exception) answers 500 and the rest of the application keeps serving; a filter whoseinit()throws is left out of the chain. When the deployment itself fails (an initializer, acontextInitializedlistener or a component that cannot be instantiated), what was already set up is torn down before the exception propagates. -
Deploymentexposeshandler()(the ChappeHandler),servletContext()andclose(), which destroys servlets and filters, firescontextDestroyedand removes the context temp directory (jakarta.servlet.context.tempdir).
FoyChappeBoot.builder()…build() performs discovery, modelling and deployment in one call and returns an Optional<FoyChappeBoot.Mounted>. Mounted is AutoCloseable; the caller closes it when the application stops (typically after the server). The TCK harness uses the same WebAppDeployer and only adapts Arquillian archives to a WebAppModel.
Discovery and deployment pipeline
FoyChappeBoot.Builder.build() runs these stages in order:
-
Discovery:
ApplicationSourceswalksClassLoader.getResources("META-INF/web-fragment.xml")and theServletContainerInitializerServiceLoader. Each fragment, initializer and annotated class is attributed to its root throughFragment.sourceKey(the jar file or the directory), so a jar found twice counts once. TheMETA-INFresources are not encapsulated by Java Modules, which makes the class-loader lookup valid on the module path. -
Ordering:
FragmentOrdererapplies §8.2.2, absolute or relative, with<others/>meaning the fragments not named by the declaring fragment; cycles and duplicate fragment names fail the deployment.ApplicationSources.orderingderives from it the roots whose initializers are retained (§8.2.4). -
Fragment merge:
FragmentMergerfolds the ordered fragments into theweb.xmldata (web.xmlwins, conflicts between fragments fail,url-patternclashes fail). -
Annotation merge:
DescriptorMergermerges the annotated components (the ones of excluded ormetadata-completeroots are dropped) into the result. -
Deploy:
WebAppDeployerruns listeners, then the retained initializers (each with its@HandlesTypesclasses, resolved from the generated class index with a Class-File scan fallback that reads runtime-retained annotations on types and members), dynamic registrations andload-on-startup.
The async-supported flag of the chain is recomputed on forward, include, async and error dispatch. The TCK harness takes the same route: it builds fragments from WEB-INF/lib and deploys through the product merge.
CDI integration through Vauban
foy-cdi-vauban is deliberately minimal: its single class, FoyVaubanBootstrap, returns the current VaubanContainer’s `BeanManager, which the caller passes to FoyChappeBoot. WebAppDiscovery then resolves the Servlet/Filter/Listener instances through that BeanManager, so they can @Inject application beans (build-time resolution through Vauban). The component classes come first from the CdiWebComponents index beans that FoyWebExtension registers, one per archive built with foy-cdi-vauban on the processor path; the Servlet, Filter and EventListener beans are walked as well, and a web-annotated bean missing from every index (an archive built without foy-cdi-vauban) is still discovered, after the indexed classes, with one WARNING naming the class. Without any index (a CDI container other than Vauban), the walk is the only source and one INFO line says so.
|
There are no CDI producers for |
Component resolution tiers
At deployment, WebComponentRegistry (io.vidocq.foy.internal.gen) resolves every Servlet, Filter and listener class to a WebComponent (descriptor plus factory) through four tiers, tried in order:
| Tier | Source | Behaviour |
|---|---|---|
|
|
Providers written by |
|
|
The generated companion, found by name (through |
|
Class bytes of |
Annotations decoded with |
|
|
Last resort, only when the hidden class cannot be defined. Like the Class-File tier, it needs |
The guard test NoProductReflectionTest scans the main sources of foy-core for three patterns, getAnnotation(, getDeclaredConstructor( and Class.forName(, and fails on any match outside an allow-list of three files:
-
WebComponentRegistry— the tiers themselves (loading a class by name, the generated companion, the reflective tier); -
ComponentFactory— itsReflectivefactory, used only on explicit opt-out and in tests; -
IndexedHandlesTypesResolver— loads theMETA-INF/foy/class-index.listentries withClass.forName(name, false, loader), without initialising them.
The guard covers foy-core only: foy-cdi-vauban’s `CdiWebComponentsCreator also loads the class names recorded at build time with Class.forName(name, false, loader) (no initialisation, no reflective instantiation).
Logging policy. One INFO line per class resolved through the Class-File tier, one WARNING per class on the reflective tier (naming the reason), and nothing per request.
Class index. foy-processor writes META-INF/foy/class-index.list (binary class names). IndexedHandlesTypesResolver uses it to answer @HandlesTypes of a ServletContainerInitializer without scanning; it yields nothing when no indexed class matches.
When opens is needed. The Class-File tier defines its hidden class through privateLookupIn, so a component of a named application module needs opens <package> to io.vidocq.foy.core for the Class-File and the reflective tiers. Companions generated by foy-processor (the SERVICE_LOADER and GENERATED_CLASS tiers) do not need it, and code on the class path (unnamed module) needs nothing.
Implementation choices
| Choice | Justification |
|---|---|
No runtime classpath scan |
All |
No runtime reflection on the nominal path |
Aligns with the Vidocq ecosystem’s principles. AOT-friendly (GraalVM, Leyden CDS). Reflection remains only as the last, logged tier of the registry. |
Bounded pipe instead of a whole-body buffer |
A committed body goes through |
|
Temporary M1 coupling — to be replaced in M2 by a |
|
|
Known limits
-
Security constraints are parsed, not enforced;
WEB-INF/libnested jars of a WAR are not opened; see TCK: Jakarta Servlet 6.1. -
HTTP/2 server push is not supported (
newPushBuilder()returnsnull). -
Response trailers: the supplier runs on Chappe’s thread after
service()(no request-scoped state); on HTTP/1.1 aContent-Lengthset aftersetTrailerFields, or aConnection: closeresponse, drops the trailers silently;reset()keeps the supplier. -
Non-blocking I/O:
ReadListener.onErroris not called on an async timeout; aWriteListeneris not resumed after anAsyncContext.dispatch; bytes still waiting at the end of a cycle are sent in blocking mode, bounded only by Chappe’s write timeout for a client that never reads (BUG-20261010-03). -
HTTP Upgrade: no idle timeout once upgraded — a handler that never reads nor closes keeps the connection until a write fails or the application is undeployed. HTTP/1.1 only.
-
A request whose
ReadListeneris still waiting for upload bytes when the request ends (a client gone silent mid-body) is answered first, then its connection is closed instead of being kept alive. -
Chappe exposes no connection identity and no HTTP/2 stream id:
getServletConnection().getConnectionId()can repeat andgetProtocolRequestId()is empty on HTTP/2 (BUG-20261009-09). -
getRequestDispatcherand cross-context lookup compare the context path with a plain prefix test (no segment boundary), so/appalso matches/application/x. -
Cross-context dispatch (
ServletContext.getContext): implemented viaCrossContextRegistry(strict contextPath match on the contexts deployed in the same JVM). -
Multipart: fully buffered in memory, no streaming parse.
-
WebSocket Servlet 6.1: not implemented (planned, no date).
-
JSP: not supported by Foy itself; planned through a JSP-engine SPI that Ibarra (Jakarta Pages 4.0) plugs into.
-
Form-based and Digest authentication: not implemented.
No comparative numbers (Tomcat, Jetty) have been recorded yet — a BENCH.md will be created with the first measured run (workspace convention).