This page documents the implementation: what happens between the moment an HTTP connection lands on Chappe and the moment a Servlet writes its response. It complements Concepts (vocabulary) and Reference (API).

Big picture: three layers, two Java modules

Diagram

Request sequence

Diagram

Dispatch pipeline

For one request, ChappeServletBridge runs these steps in order (Servlet 6.1 §3.5.2, §10.10, §12.2):

  1. Canonical path. The context path is stripped on a segment boundary (404 outside the context), then RequestPaths.canonicalize removes 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; getRequestURI stays raw.

  2. Mapping. ServletDispatcher.find picks the servlet: exact, then longest prefix, then extension, then the application / servlet, then the container default servlet foy.default, which the deployer adds (as a non-application mapping) only when / is free.

  3. Welcome files. Only when the container default servlet matched: a directory without trailing slash is redirected (302, Location from 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.

  4. Security. WebSecurity.admit applies the constraints of the original canonical path and, when a welcome file replaced it, of the welcome target too; see Security enforcement.

  5. 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.

  6. Servlet. The servlet (or the default servlet) runs; a forward, include, async or error dispatch goes through RequestDispatcherImpl and DispatchResolver, which produce a DispatchTarget (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.

  7. Error. An exception or sendError goes to ErrorPageRegistry (class hierarchy, then ServletException root 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.

Security enforcement

Package io.vidocq.foy.internal.security.

At deploy. WebAppDeployer compiles a ConstraintTable once: the descriptor constraints first, then each servlet’s ServletSecurityElement (annotation or setServletSecurity) on its url-patterns, except the patterns a descriptor constraint names exactly. Without <deny-uncovered-http-methods/>, the uncovered methods are reported then (WARNING). WebSecurity.authenticatorFor(login-config, store) picks the mechanism: BasicAuthenticator, FormAuthenticator, or a DenyingAuthenticator for any other auth-method. A RoleMapping holds the declared roles and each servlet’s security-role-ref`s. The three, with the embedder’s `SecuritySettings (identity store, HTTPS port, rotation), form the context’s WebSecurity.

At request time. WebSecurity.admit(req, res, canonicalPath) runs for the original REQUEST dispatch only; forward, include, error and async dispatches are not checked again (section 13.8). In order:

  1. restore the identity cached in the session (FORM);

  2. decide the constraint of the path and method; when it requires a confidential transport and the request is not secure, answer 403, or 302 to https://<Host>:<confidentialPort><uri>; when a port is configured and the Host header is a plain host name and the URI and query are printable ASCII;

  3. handle a FORM login action (POST …​/j_security_check): it runs after the transport check, so a confidential application never accepts a password over plain HTTP;

  4. an excluded method answers 403;

  5. an unchecked method proceeds;

  6. without an identity, the mechanism authenticates the request (the BASIC header), else challenges (401, or the forward to the login page);

  7. an identity without an allowed role gets 403.

ChappeServletBridge calls admit on its three branches: the normal one (on the original path, then on the welcome target it resolved to), the directory redirect of the default servlet, and the unmapped path. A refusal goes through the <error-page> handling, as sendError does.

Session notes. The FORM identity and saved target are kept as HttpSessionImpl notes, not attributes: no listener, no getAttributeNames, out of the application’s reach. Notes survive changeSessionId and are dropped on invalidation.

Executing servlet. HttpServletRequestImpl.enterServlet(name) and leaveServlet(previous) bracket each servlet invocation, so isUserInRole resolves the `security-role-ref`s of the servlet that is executing, through forwards and includes.

Fail closed. A context built outside WebAppDeployer gets WebSecurity.UNCONFIGURED, which refuses every request with 403; permit-all is only ever chosen explicitly (tests). An exception thrown while checking a request fails closed (the target never runs, 500). An identity store that throws or returns null counts as a refusal. An unsupported auth-method and a FORM login-config without a login page answer 403 on protected resources.

Password handling. The char[] handed to the identity store is wiped once it returns. The FORM password, though, also exists as the String j_password in the request parameter map, which Foy cannot wipe.

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 (a ScopedValue) is re-bound on the pipeline thread. Any other ThreadLocal or ScopedValue a Chappe filter set on the connection thread is not visible to servlet code.

  • No platform pool. No ExecutorService to size; every thread Foy starts (pipeline, AsyncContext.start, ReadListener body pump, WriteListener drain, upgraded-connection pumps, session reaper, async timeouts) is virtual.

  • Async. request.startAsync() lets service() 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. setTrailerFields suppliers 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 101 head, Chappe passes the raw connection to Foy; HttpUpgradeHandler.init runs on Chappe’s connection thread (with the application class loader), and the WebConnection listener callbacks run on Foy virtual threads, serialised, never before init returns.

The bridge never pin`s a VT on a Java monitor — every critical section uses `ReentrantLock or lock-free structures. No benchmark numbers have been recorded yet — a BENCH.md will be created with the first measured run (workspace convention).

Container lifecycle

The deployment lifecycle lives in foy-core (io.vidocq.foy.internal.boot), not in the transport or the TCK harness:

  1. WebAppModel describes the web application: servlets, filters, listeners, web.xml data and options such as metadata-complete.

  2. DescriptorMerger merges the parsed web.xml with the annotated components (Servlet 6.1 §8.2.3: the descriptor wins on a same-name conflict, metadata-complete="true" ignores annotations).

  3. WebAppDeployer.deploy(model, DeployOptions) runs the lifecycle — listeners, ServletContainerInitializer`s, dynamic registrations, `load-on-startup initialisation in order — and returns a Deployment. A servlet whose init() throws (a ServletException or a runtime exception) answers 500 and the rest of the application keeps serving; a filter whose init() throws is left out of the chain. When the deployment itself fails (an initializer, a contextInitialized listener or a component that cannot be instantiated), what was already set up is torn down before the exception propagates.

  4. Deployment exposes handler() (the Chappe Handler), servletContext() and close(), which destroys servlets and filters, fires contextDestroyed and removes the context temp directory (jakarta.servlet.context.tempdir).

Diagram

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:

  1. Discovery: ApplicationSources walks ClassLoader.getResources("META-INF/web-fragment.xml") and the ServletContainerInitializer ServiceLoader. Each fragment, initializer and annotated class is attributed to its root through Fragment.sourceKey (the jar file or the directory), so a jar found twice counts once. The META-INF resources are not encapsulated by Java Modules, which makes the class-loader lookup valid on the module path.

  2. Ordering: FragmentOrderer applies §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.ordering derives from it the roots whose initializers are retained (§8.2.4).

  3. Fragment merge: FragmentMerger folds the ordered fragments into the web.xml data (web.xml wins, conflicts between fragments fail, url-pattern clashes fail).

  4. Annotation merge: DescriptorMerger merges the annotated components (the ones of excluded or metadata-complete roots are dropped) into the result.

  5. Deploy: WebAppDeployer runs listeners, then the retained initializers (each with its @HandlesTypes classes, resolved from the generated class index with a Class-File scan fallback that reads runtime-retained annotations on types and members), dynamic registrations and load-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 ServletContext, HttpServletRequest or HttpSession yet — @Inject HttpServletRequest does not work. Scoped Servlet-object beans are a later milestone.

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

SERVICE_LOADER

META-INF/services/io.vidocq.foy.spi.gen.WebComponent (or provides in module-info.java)

Providers written by foy-processor.

GENERATED_CLASS

X$$FoyComponent next to X

The generated companion, found by name (through publicLookup) when the service entry is missing. In a named module, declare provides io.vidocq.foy.spi.gen.WebComponent with …​ (no export needed) or export the package; otherwise resolution falls to the Class-File tier, which needs opens.

CLASS_FILE

Class bytes of X

Annotations decoded with java.lang.classfile; the factory is a hidden class defined in foy-core’s lookup that carries the constructor `MethodHandle as class data.

REFLECTION

java.lang.reflect

Last resort, only when the hidden class cannot be defined. Like the Class-File tier, it needs opens <package> to io.vidocq.foy.core for a component in a named module.

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 — its Reflective factory, used only on explicit opt-out and in tests;

  • IndexedHandlesTypesResolver — loads the META-INF/foy/class-index.list entries with Class.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 @WebServlet / @WebFilter / @WebListener are known at compile time through Vauban. Startup is O(1) in the number of beans, not O(N) in the classpath.

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 ResponsePipe (a ring buffer of at least 8 KiB): memory per response stays bounded whatever the body size, and a slow client back-pressures the writing servlet. A response that fits in the buffer is never piped.

requires io.vidocq.chappe.api in foy-core

Temporary M1 coupling — to be replaced in M2 by a FoyHttpExchange SPI inside foy-api, opening the door to other transports.

META-INF/services only for generated components

WebComponent providers written by foy-processor are loaded with ServiceLoader; ServletContainerInitializer`s are also loaded with `ServiceLoader at boot (ApplicationSources), restricted to the roots the fragment ordering retains. Components themselves go through the generated index or the Vauban BeanManager.

Known limits

  • WEB-INF/lib nested jars of a WAR are not opened; see TCK: Jakarta Servlet 6.1.

  • HTTP/2 server push is not supported (newPushBuilder() returns null).

  • Response trailers: the supplier runs on Chappe’s thread after service() (no request-scoped state); on HTTP/1.1 a Content-Length set after setTrailerFields, or a Connection: close response, drops the trailers silently; reset() keeps the supplier.

  • Non-blocking I/O: ReadListener.onError is not called on an async timeout; a WriteListener is not resumed after an AsyncContext.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 ReadListener is 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 and getProtocolRequestId() is empty on HTTP/2 (BUG-20261009-09).

  • getRequestDispatcher and cross-context lookup compare the context path with a plain prefix test (no segment boundary), so /app also matches /application/x.

  • Cross-context dispatch (ServletContext.getContext): implemented via CrossContextRegistry (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.

  • Security: CLIENT-CERT is deferred and DIGEST is not supported (both fail closed with 403); security constraints match url-patterns case-sensitively, which a resource served from a case-insensitive file system can bypass (BUG-20261011-05).

No comparative numbers (Tomcat, Jetty) have been recorded yet — a BENCH.md will be created with the first measured run (workspace convention).