Friday, August 21, 2026

Next.js Dynamic Routes now also available in Faces!

OmniFaces 5.5 has been released! It's a relatively small release but with one big addition in FacesViews.

New: dynamic route segments

How do you serve a URL like /organizations/123/members in Faces without complex URL-rewriting rules? Until now the only answer was PHP-inspired MultiViews configured as follows in web.xml:

<context-param>
    <param-name>org.omnifaces.FACES_VIEWS_SCAN_PATHS</param-name>
    <param-value>/*.xhtml/*</param-value>
</context-param>

<welcome-file-list>
    <welcome-file>index</welcome-file>
</welcome-file-list>

And this in the bean associated with /organizations/index.xhtml:

@Inject
@Param(pathIndex = 0)
private Long id; // So we can preload in @PostConstruct.

@Inject
@Param(pathIndex = 1)
private String action; // Can be e.g. a path for ui:include (use enum/validator for robustness).

Whereafter the view has to branch on that action to decide whether it shows the members, the settings, or something else. E.g. a dynamic include path <ui:include src="/WEB-INF/includes/organization/#{bean.action}.xhtml" /> or even conditional rendering. Every subpage you add is another branch, and a typo in the URL possibly arrives as a perfectly valid action which nothing renders. In other words, the URL structure ends up living in your bean instead of in your file structure.

As of OmniFaces 5.5 you can put the variable part in a directory name instead, wrapped in square brackets, the way you may already know it from Next.js and friends in case you're also familiar with them. Given the following file structure:

src
└── main
    └── webapp
        └── organizations
            ├── [id]
            │   ├── index.xhtml
            │   └── members.xhtml
            └── settings
                └── members.xhtml

Yes, the directory on disk is literally named [id]. Square brackets and all, and that's exactly what marks it as a dynamic route segment. The above file structure makes the Facelets available via the following URLs:

example.com/organizations/123                (forwards to /organizations/[id]/index.xhtml with segment "id" being "123")
example.com/organizations/123/members        (forwards to /organizations/[id]/members.xhtml with segment "id" being "123")
example.com/organizations/settings/members   (forwards to /organizations/settings/members.xhtml without any segment)

This needs no additional configuration beyond the earlier shown web.xml. The segment value is injectable by name via the new pathName attribute of @Param which must match exactly the directory name without square brackets:

@Inject
@Param(pathName = "id")
private Long id;

A link to such a view spells out the route in the outcome and supplies each segment with the also new name attribute of <o:pathParam>:

<h:link value="Members" outcome="/organizations/[id]/members">
    <o:pathParam name="id" value="#{organization.id}" />
</h:link>

which renders as <a href="/context-path/organizations/123/members">Members</a>. Without a name the <o:pathParam> tag keeps behaving as before and appends the value as the next positional path parameter of a MultiViews view.

Of course, just inlining continues to work totally fine:

<h:link value="Members" outcome="/organizations/#{organization.id}/members" />

Rules of the road

A request path is resolved by first looking for an exact match among the scanned views, then walking the dynamic route segments, and only then falling back to MultiViews. A literal directory always wins from a dynamic one at the same level, which means a literal sibling is a value the segment can never take; in the above example an organization whose id is settings is thus unreachable. An application without any bracketed directory never reaches the dynamic route resolution at all and therefore behaves exactly as before.

The segments nest and combine flawlessly with MultiViews, so a view of /[locale]/products/[sku]/reviews.xhtml answers to /nl/products/12345/reviews/2 with nl and 12345 available as @Param(pathName = "locale") and @Param(pathName = "sku"), and 2 available as @Param(pathIndex = 0) in the bean associated with reviews.xhtml.

Only a directory name is interpreted this way. A bracketed file name such as /organizations/[oid]/members/[mid].xhtml with the intent to capture /organizations/123/members/456 is ignored and logged as a warning, especially because some containers reject those files with a 400 during RequestDispatcher.forward before the request even reaches the FacesServlet. The correct solution therefor is to continue using the MultiViews @Param(pathIndex = 0) in the bean associated with /organizations/[id]/members.xhtml. The more future-proof way, though, is to make use of the welcome file facility /organizations/[oid]/members/[mid]/index.xhtml and a @Param(pathName = "mid") in the bean associated with index.xhtml. This way we can easily extend to e.g. /organizations/[oid]/members/[mid]/settings.xhtml.

All in all, your URL structure is now simply your file structure again, also when it has variable parts in it. See also the FacesViews documentation for the complete story.

Fixes

A handful small fixes next to the one big ticket feature:

<o:massAttribute> failed to apply the attribute to components conditionally created by any nested JSTL tags or dynamic includes when the view is built for the second time under different conditions during render response of a postback (#990). This gap was discovered while reviewing and improving the view build time of Mojarra.

@ViewScoped unload confirmation can now finally also be registered via the canonical and modern JS way window.addEventListener("beforeunload", handler) instead of only with window.onbeforeunload = handler (#986).

@ViewScoped answers the unload beacon with a 204 instead of a bodyless 200, so that an in-flight navigation (e.g. redirect from view scoped bean action) cannot anymore potentially collide with the unload when you have a window.beforeunload handler registered, which would listen on pagehide event instead (#989).

PWAResourceHandler service worker cache is now not anymore stale when resource contents change rather than resource set itself (#987).

You can find the complete list of additions, changes and fixes at What's new in OmniFaces 5.5? in the showcase.

Installation

Non-Maven users: download OmniFaces 5.5.2 JAR and drop it in /WEB-INF/lib the usual way, replacing the older version if any.

Maven users:

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omnifaces</artifactId>
    <version>5.5.2</version>
</dependency>

How about OmniFaces 4.x and 3.x?

OmniFaces 4.7.13 and 3.14.24 have been released as well. They contain the <o:massAttribute>, @ViewScoped unload 204 and PWAResourceHandler fixes, but not the dynamic route segments, nor the @ViewScoped addEventListener which are 5.x only.

Thursday, August 20, 2026

OmniHai gets motion

OmniHai 1.7 has been released! In 1.1 OmniHai grew ears and in 1.6 it got hands. In 1.7 it gets motion, in both directions: hand it a recording and ask what happens in it, or describe a scene and get one back. Relatively a lot of smaller things have been added around that, such as classification against your own labels, audio generation through OpenRouter, and a modality check which looks up what a model can do instead of guessing it from the name.

New: analyzeVideo()

How do you ask an AI when the goal happens in a match recording? Until now you had to cut the video into frames yourself and send those as images, which loses the timing and makes you pay per frame. OmniHai 1.7 sends the video itself:

String answer = service.analyzeVideo(Path.of("match.mp4"), "When does the goal happen?");

The video may also be passed as a byte[], and there is an async variant analyzeVideoAsync(), as with every other operation.

An AI does not watch a video the way you do. It samples frames from it, by default one per second, and reads those. On a long recording that is a lot of frames for a question about one minute of it. You can therefore state which part to sample and how densely, with AnalyzeVideoOptions:

String answer = service.analyzeVideo(Path.of("match.mp4"), "When does the goal happen?",
    AnalyzeVideoOptions.newBuilder()
        .fps(2)
        .startOffset(Duration.ofMinutes(1))
        .endOffset(Duration.ofMinutes(2))
        .build());

Video input is currently only accepted by Gemini (Google AI), by Muse Spark (Meta AI; note that Meta AI is now finally available for public whereas it was previously preview only) and by the video capable models which OpenRouter routes. The sampling options of AnalyzeVideoOptions are honored by Gemini only.

The other providers do not accept a video at all: OpenAI, Azure, Anthropic, xAI, Mistral and Ollama. This is not OmniHai holding something back. Their APIs simply do not support video analysis, so supportsModality() continues to answer false for VIDEO_ANALYSIS on those providers, so you can pick a service which can do it before you call.

Gemini does not take a video in the request itself. It must first be uploaded, and the AI provider then needs a moment to process it before it can be referred to, which on a larger file is longer than one round trip. OmniHai waits for that state to become active and only then asks the question, so you do not have to poll it yourself. A video which the provider rejects fails with an AIException stating why. OpenRouter takes the video as a plain data URI only and has nowhere to put them, so be careful with large videos.

New: generateVideo()

Making a video does not fit in one call. The AI provider answers within seconds with a job id and then takes minutes to actually produce the video, and it never calls you back. A method which just returns the video would sit and wait for those minutes, which you cannot do in a request. So generateVideo() hands you a handle on the job as soon as it is accepted:

VideoGeneration video = service.generateVideo("Sunrise over the colorful houses of Willemstad");
String jobId = video.jobId();

The handle is serializable and carries that job id, so you can submit in one request and look at it again in a later one, after a restart or on another node. Keep the id, and pick the job up again with findVideoGeneration():

VideoGeneration video = service.findVideoGeneration(jobId);

if (video.refresh().status() == VideoGeneration.Status.COMPLETED) {
    video.writeTo(Path.of("curacao.mp4"));
}

status() is a plain getter which does no I/O at all, so it costs you nothing to call it from a render pass. refresh() is the one which asks the AI provider, exactly one request per call, on your own schedule.

When you do want to wait, generateVideoAsync() does the polling for you and completes when the video is there:

CompletableFuture<VideoGeneration> pending = service.generateVideoAsync("Sunrise over the colorful houses of Willemstad");

pending.thenAccept(video -> video.writeTo(Path.of("curacao.mp4")));

Asking for a path writes the video there for you, asynchronously or not:

CompletableFuture<Void> written = service.generateVideoAsync("Sunrise over the colorful houses of Willemstad", Path.of("curacao.mp4"));

service.generateVideo("Sunrise over the colorful houses of Willemstad", Path.of("curacao.mp4"));

That last one blocks for as long as it takes, so it is for a batch job rather than for a request. The polling stops as soon as you stop watching, so a handle nobody looks at costs nothing. A job which never finishes fails after five minutes rather than keeping you there, which you can raise with GenerateVideoOptions, together with the aspect ratio, the resolution and the duration.

Video generation is offered by Google with Veo, by xAI with Grok Imagine, and by OpenRouter, which routes the generators of several labs. OpenAI and Azure both retire Sora without a successor to route to, so supportsModality() continues to answer false for VIDEO_GENERATION there, as it does on Anthropic, Mistral, Meta, Hugging Face and Ollama.

The generated video is hosted by the AI provider for about a day and is then deleted, upon which the status becomes EXPIRED. Some of them host it on a separate host and hand out a pre-signed URL for it; OmniHai downloads such a URL without your API key, which has no business leaving the endpoint it belongs to.

New: classify() and classifyAll()

Which queue does this support ticket belong in? You could ask that in a chat prompt, but then you have to parse whatever came back, and "This looks like a billing issue." is not a queue name. AIService now has a method for it:

ClassificationResult result = service.classify(ticket, "billing", "shipping", "technical");
route(result.label());

The labels you offer are the only values the AI may answer with. This uses structured outputs under the covers: a schema, which is a description of the exact shape the answer must have, is sent along with the question, and the AI provider holds the model to it. So label() is never anything else than one of your labels, and you can switch on it without a default branch for surprises. You do not write that schema yourself here; classify() derives it from the labels you pass in.

The AI must pick one even when nothing fits well, which is what confidence() is for. It runs from 0.0 to 1.0 and tells a certainty apart from a guess:

if (result.confidence() < 0.7) {
    queueForReview(ticket);
}

A ticket can of course be about two things at once. classifyAll() scores every label on its own merit and returns them with the best fitting one first:

List<ClassificationResult> results = service.classifyAll(ticket, "billing", "shipping", "technical");
List<String> tags = results.stream().filter(r -> r.confidence() > 0.5).map(ClassificationResult::label).toList();

The scores are not divided among the labels here, so several of them may score high, or none of them. A text which belongs to none of your labels scores low on all of them, which is the answer you want and which classify() cannot give you.

New: audio generation through OpenRouter

generateAudio() has been in OmniHai since 1.2, on OpenAI and Google. OpenRouter now joins them, by a different route. It does have an audio/speech endpoint, but that one serves a model catalog of its own which the model listing does not enumerate, so there is no telling which model to ask. Audio comes out of an audio capable chat model instead, which streams it back in chunks. OmniHai collects those and assembles a playable WAV file from it, so the call is the same one you already know:

service.generateAudio("Hello world", Path.of("hello.wav"));

The audio is written straight to the given path while it streams in, so a long text does not have to fit in memory first. Hugging Face still cannot do this; the text-to-speech models there are served by third parties with their own API rather than via Hugging Face's own API.

Modality lookup instead of name matching

A modality is a kind of input or output: text, image, audio, video. supportsModality() tells you whether a service can handle one, so you can check up front rather than by catching:

if (service.supportsModality(AIModality.VIDEO_ANALYSIS)) {
    return service.analyzeVideo(video, "Summarize this");
}

Most providers publish one model family per endpoint, so the answer follows from the predefined model names and versions. Aggregators do not; OpenRouter and Hugging Face route hundreds of models from many labs, and a name says nothing about them. Of the OpenRouter models which accept video, not one carries "video" in its name. Both of them do publish the input and output modalities per model, which OmniHai now looks up instead of guessing or even hardcoding.

That listing is fetched at most once a day per endpoint and shared by every service instance on it, so the first call blocks on one HTTP request and the rest are answered from memory. When it cannot be obtained, matching the model name is the fallback, and the last known listing is kept rather than cleared.

It stays a hint, not a guarantee. A published listing goes stale, a name matched guess is a guess, and a provider may still refuse a call it advertises, for this key, this region or this moment. The only guarantee is to make the call and handle its failure. The actual calls do namely not precheck the supportsModality().

Smaller things

analyzeImage() and generateAltText() now also accept a Path, as the video and audio methods already did.

ModerationOptions now also accepts a List of categories instead of only String... varargs.

Installation

Non-Maven users: download OmniHai 1.7 JAR and drop it in /WEB-INF/lib the usual way, replacing the older version if any.

Maven users: use the following coordinates.

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omnihai</artifactId>
    <version>1.7</version>
</dependency>

Tuesday, August 11, 2026

OmniHai gets hands

OmniHai 1.6 is out!

Version 1.0 gave it structure, version 1.1 ears, 1.2 a voice, 1.3 let it browse, 1.4 taught it to count the cost, and 1.5 gave it a backbone. Version 1.6 gives it hands! The AI can now act as an agent and reach out to call your own methods before it answers, and it works on every supported provider.

What an agent is

Half the industry is currently selling you "agents". Strip the marketing and an agent is an AI with access to tools. That is all it is.

A "tool" is basically one of your own methods, plus a sentence in natural language which clearly says what it does. That sentence is all the AI gets. It never sees the method body, and it cannot run anything at all, because a model produces text and nothing else. A tool call is therefore just the AI writing down a name and some arguments, and your own code deciding what to do with that.

This makes the tool description the actual API. The AI picks a tool by reading the description, not by matching a type signature, so a vague description is a bug like any other. "Looks up a single order by id" gets picked when someone asks about order 42. "Handle orders" does not. So describe carefully in natural language.

Add a list of tools to the system prompt and ask a question. The AI either answers or it asks for a tool to be called. When it asks for a tool to be called, you call it and you hand back what it returned in natural language, and you repeat that until it answers. The AI never runs anything itself. It only asks, and your own code decides.

So there is no autonomy in it, and no intelligence beyond the model you already had. What you add is a list of methods and a loop. Once your AI can call your methods, you have an agent, and you can stop wondering whether you need a whole framework for it.

Tools

Annotate a method with @AITool, describe its parameters with @AIToolParam, and hand the object over. It's nicer if you have a dedicated CDI bean for this:

@ApplicationScoped
public class OrderTools {

    @Inject
    private OrderService orders;

    @ReadOnly
    @AITool("Looks up a single order by id")
    public String findOrder(@AIToolParam("The order id") long orderId) {
        return orders.findById(orderId).map(Order::toSummary).orElse("No order found with that id.");
    }

    @ReadOnly
    @AITool("Lists the orders placed by a customer")
    public String listOrders(@AIToolParam("The customer email") String email) {
        var summaries = orders.listByEmail(email).stream().map(Order::toSummary).collect(joining("\n"));
        return summaries.isEmpty() ? "No orders found for that email." : summaries;
    }

    @AITool("Refunds an order and pays the money back to the customer")
    public Refund refundOrder(@AIToolParam("The order id") long orderId) {
        return orders.refund(orderId);
    }

}
@Inject
@AI(apiKey = "#{keys.openai}", tools = OrderTools.class)
private AIService agent;

public String handle(String question) {
    return agent.chat(question);
}

Each turn the AI either names a tool or answers. A named tool is invoked with its arguments converted to the declared parameter types, the return value is fed back, and the next turn begins. A tool may return anything; the AI is handed its toString(), so you need to ensure that your tool returns something which reads as an answer in natural language or even JSON-y. A String or Record, or a properly implemented toString() does nicely, a bare com.example.Bean@hashcode does not.

There is no classpath scanning. Only the classes you explicitly pass to tools are scanned for methods annotated @AITool (and cached for performance). Exposing your own code to model output must to be a deliberate act.

@ReadOnly is explained later in "Grouping tools".

How it works

OmniHai does not use the provider's native function calling to register tools. It uses the same provider-enforced structured outputs which chat(message, DesiredOutput.class) already supported since 1.0. The response schema constrains the AI to either name one of your tools or answer, and the tool name is an enumeration of exactly the tools you registered.

Two things travel along with every request. The first one is the manifest: a plain text list of your tools, appended to the system prompt. The system prompt is the block of instructions which is sent ahead of your question on every call. For the OrderTools above it looks exactly like this:

Available tools:
- OrderTools_findOrder(orderId: The order id) -> Looks up a single order by id
- OrderTools_listOrders(email: The customer email) -> Lists the orders placed by a customer
- OrderTools_refundOrder(orderId: The order id) -> Refunds an order and pays the money back to the customer

Call exactly one tool per turn, or answer directly once you have enough information. Never invent data a tool did not return. There is nobody to ask for more information, so when a tool needs a value you do not have, obtain it from another tool first.

This is the entire documentation the AI gets. The tool name is derived from the class and the method, so findOrder() of OrderTools becomes OrderTools_findOrder, which keeps two classes with a findOrder() of their own apart. Every parameter shows up as its name plus your @AIToolParam description. Your @AITool description follows after the arrow. Nothing else of your class is exposed; not the return type, not the other methods, and certainly not the bodies.

The second one is the structured output. This is a JSON schema which the provider enforces on its own output, generated from the registry:

{
    "type": "object",
    "properties": {
        "tool": {
            "type": "string",
            "enum": ["OrderTools_findOrder", "OrderTools_listOrders", "OrderTools_refundOrder", "ANSWER"]
        },
        "arguments": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "name": { "type": "string" },
                    "value": { "type": "string" }
                },
                "required": ["name", "value"]
            }
        },
        "answer": { "type": "string" }
    },
    "required": ["tool", "arguments", "answer"]
}

The enum is the interesting part. It holds your three tools plus ANSWER, and nothing else. The AI cannot call a tool which is not in the registry, simply because there is no way for it to say so. That is a different thing than asking it nicely in the prompt: the name is not representable at all.

The arguments arrive as name/value pairs instead of as a free-form object. That is on purpose. The strict schema modes of most providers require every property of an object to be declared up front, and those properties differ per tool. A list of pairs is one single shape which fits every tool. OmniHai converts each value to the declared Java type of the parameter, so orderId arrives in your method as a long.

A turn is one request plus one response. Ask "Where is order 42?" and the first turn comes back like this:

{
    "tool": "OrderTools_findOrder",
    "arguments": [{ "name": "orderId", "value": "42" }],
    "answer": ""
}

OmniHai invokes the method and feeds the returned value back as the message of the next turn:

You called OrderTools_findOrder(orderId=42) and it returned: Order 42, in transit, shipped on 2026-08-03, tracking NL1234567890
Do not call it again with the same arguments. Answer the question now if this is enough, otherwise call another tool.

The second turn then comes back with ANSWER as tool and the prose in answer, and the loop ends.

{
    "tool": "ANSWER",
    "arguments": [],
    "answer": "Order 42 is still in transit. It shipped on 2026-08-03 and you can follow it with tracking code NL1234567890."
}

Two turns, two provider calls, one method invocation on your own thread.

This has one big advantage: there is no per-provider wire format to maintain. Tools behave identically on OpenAI, Anthropic, Google, xAI, Mistral, Meta, Azure, OpenRouter, Hugging Face and Ollama. The provider's own function calling API is never touched, so a provider which adds one tomorrow needs no work here.

Declaring tools programmatically

Outside CDI, or when the tools depend on the request rather than on the injection point, you can compose everything yourself and pass a concrete instance of the tools:

AIService ai = AIConfig.of(AIProvider.OPENAI, System.getenv("OPENAI_API_KEY")).createService();
AIService agent = ai.withTools(orderTools);

String answer = agent.chat(question);

Not every tool wants an annotation either. ToolRegistry is the programmatic counterpart, in the same way AIConfig is the programmatic counterpart of the @AI qualifier. A method reference cannot carry a name, a description or its parameter names, because those are erased, so you state them:

ToolRegistry tools = ToolRegistry.newBuilder()
    .add("FIND_ORDER_BY_ID", "Looks up a single order by id", orders::findById, ToolParam.of(long.class, "orderId", "The order id"))
    .add("LIST_OPEN_ORDERS", "Lists all open orders", orders::listOpen)
    .add(shippingTools)
    .build();

AIService agent = ai.withTools(tools);

A tool taking no arguments needs nothing beyond its description, so a plain method reference is enough. Annotated objects and lambdas mix freely in one registry.

Grouping tools

The object you hand over is already a group, and AIService#withTools() accepts varargs, so a class is a toolset. For subsets which cut across classes, such as read-only versus mutating, you declare your own tag with the @AIToolGroup meta-annotation:

@AIToolGroup
@Retention(RUNTIME)
@Target(METHOD)
public @interface ReadOnly {}
@Inject
@AI(apiKey = "#{keys.openai}", tools = OrderTools.class, toolGroup = ReadOnly.class)
private AIService tier1;

@Inject
@AI(apiKey = "#{keys.openai}", tools = { OrderTools.class, ShippingTools.class })
private AIService supervisor;

Programmatically that is ai.withTools(ReadOnly.class, orderTools) respectively ai.withTools(orderTools, shippingTools).

Narrowing applies to the generated schema, not to a check afterwards. The tier-1 agent cannot name refundOrder at all, because that token is not in the grammar the model decodes against. That is a stronger guarantee than a permission check which runs after the AI already asked.

Who is asking

Grouping decides which tools exist. It does not decide which rows they may hand back. The AI picks the arguments, and it picks them from everything it has read: your question, and every tool result before it. So orderId is not a value you chose, it is a value which arrived from outside, exactly like a request parameter. A lookup by id or by email which does not check who is asking lets any customer read any customer's orders, and the OrderTools above only reads well because OrderService is assumed to do that check.

Keep the check there and not in the tool. The tool is an adapter: it turns a name/value pair into a method call and a result into a sentence. The rule about who may see an order belongs to the thing which owns orders, or you write it again in every backing bean and every REST resource, and the tool is the one which gets forgotten.

@ApplicationScoped
public class OrderService {

    @Inject
    private SecurityContext security;

    @Inject
    private OrderRepository repository;

    public Optional<Order> findById(long orderId) {
        return repository.findById(orderId).filter(order -> mayRead(order.getCustomer().getEmail()));
    }

    public List<Order> listByEmail(String email) {
        return mayRead(email) ? repository.listByEmail(email) : emptyList();
    }

    @Transactional
    public Refund refund(long orderId) {
        if (!security.isCallerInRole("SUPPORT")) {
            throw new SecurityException("Refunding requires the SUPPORT role.");
        }
        return repository.refund(orderId);
    }

    private boolean mayRead(String email) {
        var caller = security.getCallerPrincipal();
        return security.isCallerInRole("SUPPORT") || (caller != null && (caller.getName().equalsIgnoreCase(email));
    }

}

Return nothing rather than throw. The tool then answers "No order found with that id.", which is true for this caller and does not confirm that the order exists for somebody else. The security context is in scope because the synchronous chat invokes the tool on your own thread, which is covered further below.

Dropping a parameter the caller has no business choosing is stronger still. A listMyOrders() which takes the email from the principal leaves nothing in the schema to aim at, in the same way that a tool outside the group leaves nothing in the enumeration to name. The email-taking variant then belongs in a support-only tool group.

Scopes and composition

@Inject
@AI(apiKey = "#{keys.openai}", tools = OrderTools.class, maxToolCalls = 4, maxAttempts = 3)
private AIService agent;

The tool classes named on the qualifier are resolved as CDI beans, which must be normal-scoped so that they observe their own scope and their own interceptors on every call. A @Dependent one is rejected at injection time instead of being silently pinned to the lifecycle of the injection point. Handing withTools an object yourself as in AIService agent = ai.withTools(orderTools) resolves nothing; that object is used as it is, and whether it is a bean at all is your own business.

The maxAttempts attribute is new too. It composes the in 1.5 introduced RetryingAIService around the produced service, and tool calling is composed around that, so a retry re-attempts a single provider call rather than replaying the whole loop and every side effect it already caused.

Bounding the loop

The tool call cap bounds latency and spend. It defaults to five, after which the AI must answer, so a conversation takes at most six provider calls.

The turn after the last permitted call is offered no tool at all. Its schema enumerates only the answer, so a model which keeps reaching for tools is denied the tokens to name one. Asking it politely to stop is not enough, as a weaker model simply ignores prose. AIToolIterationException is what is left as the backstop for a provider which does not enforce the schema. Asking for a type forces the typed answer at the cap instead of throwing, as that call carries your own schema and offers no tool to begin with.

An observer receives every tool call after it ran, which is your audit point for logging and metrics:

AIService agent = new ToolCallingAIService(ai, ToolRegistry.of(orderTools), 4, invocation -> {
    if (invocation.hasFailed()) {
        logger.log(WARNING, () -> "Tool " + invocation.toolName() + " failed");
    }
});

A tool which throws, or whose arguments cannot be converted, is reported back to the AI rather than aborting the call, so it can correct itself and try again. This self-correction is the main thing the loop buys you over a single call. What the AI is told is deliberately bounded: an argument it got wrong is quoted back verbatim, while an exception thrown by the tool itself is reduced to a generic line and logged. A stack trace, a SQL error or a constraint message must never reach the AI and be repeated to a user. Your observer still receives the real exception. An Error is not reported back at all but rethrown, because it is not something the AI can work around.

The synchronous chat methods invoke the tools on the calling thread, so they observe its transaction, scope and security context. The asynchronous ones invoke them on whichever thread completes the provider call, where none of that applies. Use the synchronous ones for tools which touch a database or a scoped bean.

Limits

Tool use and a typed result each want the one response schema a provider call carries, so they take a turn each. The loop runs on its own schema, and the turn which would have answered is asked again for your type instead, with every tool result still in front of it:

public record Delivery(String carrier, LocalDate estimated) {}

Delivery delivery = agent.chat("Where is order 42?", Delivery.class);

That costs one extra call, and only when you ask for a type: five tool calls take six provider calls, and seven when the conversation has memory, as the answer it records is then a call of its own. Streaming cannot be combined with tools at all, because the tool which the AI picks is only known once its reply is complete, so chatStream on a tool calling service throws UnsupportedOperationException rather than quietly answering without the tools.

Further, the AI calls one tool per turn rather than several at once, there is no way to force a specific tool, and arguments are converted from strings rather than typed per tool on the wire. Those three need the provider's native function calling, which stays out of scope for now.

Installation

Non-Maven users: download OmniHai 1.6.1 JAR and drop it in /WEB-INF/lib the usual way, replacing the older version if any.

Maven users:

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omnihai</artifactId>
    <version>1.6.1</version>
</dependency>

@AITool was introduced in 1.6 and it works fine, but 1.6.1 has further improved the tool naming so that it has better support for duplicate/overloaded methods.

Minimum requirements are unchanged: Java 17 and Jakarta EE 11 or MicroProfile 7. JSON-P is the only required dependency, and CDI, EL and MP Config remain optional.

Wednesday, July 15, 2026

OmniHai grows a backbone

OmniHai 1.5 is out! After 1.1 gave the library ears, 1.2 a voice, 1.3 the ability to step outside and browse the web, and 1.4 taught it to count the cost, 1.5 gives it a backbone.

Talking to a remote AI provider means talking to something that occasionally says no. Rate limits, a provider that is briefly down, a connection that drops halfway. None of that is your fault, and none of it should reach your users as a stack trace. Until now you had to manually wrap your own retry loop around every call. OmniHai 1.5 ships two ready-to-use resilience decorators so you never have to write that loop anymore.

Retry

Wrap any service in RetryingAIService and transient failures are retried for you. It triggers on an HTTP 429 rate limit, an HTTP 503 unavailable, and transient I/O, with exponential backoff and full jitter between attempts.

AIService resilient = new RetryingAIService(service); // 3 attempts, sensible defaults

That is the whole change. The wrapped service keeps its exact API, so every caller downstream stays the same. Need other numbers? Reach for the builder.

AIService tuned = RetryingAIService.newBuilder(service)
    .maxAttempts(5)
    .initialBackoff(Duration.ofSeconds(1))
    .maxBackoff(Duration.ofSeconds(20))
    .maxDuration(Duration.ofMinutes(1))
    .build();

It never retries a deterministic error. A bad request or an authentication failure would fail the same way on the second attempt, so retrying it only wastes time and tokens. You can override the condition with retryOn(...) when your case is different.

Failover

Retrying the same provider helps when the provider is merely busy. It does not help when the provider is down. For that there is FailoverAIService, which tries a primary service and then falls back to alternates in order, on those same transient failures.

@Inject @AI(apiKey = "#{keys.openai}")
private AIService gpt;

@Inject @AI(provider = ANTHROPIC, apiKey = "#{keys.anthropic}")
private AIService claude;

AIService resilient = new FailoverAIService(gpt, claude);
String response = resilient.chat("Explain the Jakarta EE security model.");

When OpenAI is rate limiting you, the call quietly lands on Anthropic instead. Your code asked one question and got one answer; which provider answered it is an operational detail, not an application concern.

Composing them

Both are pure decorators built on InterceptingAIServiceWrapper, so they wrap the entire service surface: chat, image, audio, moderation, synchronous and asynchronous alike. And because they are decorators, they compose. Retry each provider a few times before giving up on it and failing over to the next.

AIService resilient = new FailoverAIService(
    new RetryingAIService(gpt),
    new RetryingAIService(claude));

No CDI magic, no configuration file, no framework to buy into. Just constructors that take an AIService and return an AIService. Stack them in whatever order your situation asks for.

Streaming and partial results

Retrying a plain chat call is easy; you just call it again. Retrying a streaming call is not, because the first attempt may already have handed a dozen tokens to your consumer. A blind second attempt would replay the stream from the start and leave the consumer with a duplicated prefix. Rather than corrupt your output in silence, the decorator throws an AIStreamAbortedException, with the original failure as its cause. This exception is terminal and is never retried nor failed over.

If you do want a partially consumed stream re-attempted, hand in a ResettableConsumer as your token consumer. It carries a second handler next to the token handler, invoked right before each new attempt, so it can discard what it accumulated and let the retry start from a clean slate.

var response = new StringBuilder();
AIService resilient = new RetryingAIService(service);

resilient.chatStream("Explain the Jakarta EE security model.", ResettableConsumer.of(
    token -> response.append(token),           // append every token as it streams in
    (cause, attempt) -> response.setLength(0)  // a retry is starting; drop the partial output
));

The token handler is your normal streaming consumer. The reset handler receives the failure that triggered the re-attempt and the number of the attempt about to start, and clears the buffer so the fresh stream does not stack on top of the old one.

Refreshed default models

As always the per-provider default models have moved forward to the current generation.

ProviderDefault model
OpenAIgpt-5.6-terra
Anthropicclaude-sonnet-5
Google AIgemini-3.5-flash
xAIgrok-4.5
Mistral AImistral-medium-3-5
Meta AImuse-spark-1.1
Azure OpenAIgpt-5.5

Meta moved the most. It retired the old Llama endpoint and now serves Muse Spark through the OpenAI-compatible Meta Model API at https://api.meta.ai/v1. OmniHai follows: the default is now muse-spark-1.1, and the provider rides the shared OpenAI text handler instead of its own. If you pinned the old Llama-4-Maverick-17B-128E-Instruct-FP8 model or the api.llama.com endpoint, update those.

This generation also changed the rules. The newest Claude and Fable models (Opus 4.7 and up, Sonnet 5 and up, Fable 5 and up) dropped the classic sampling knobs; send them a temperature or a legacy thinking budget and they answer with an HTTP 400. OmniHai now knows this through supportsSamplingParameters() and simply omits those fields for such models, steering them with the ReasoningEffort from 1.4 instead. Older models keep the legacy behavior. You set your options the same way you always did; OmniHai sends whatever the target model still accepts.

Getting 1.5

Non-Maven users: download the OmniHai 1.5 JAR and drop it in /WEB-INF/lib the usual way, replacing the older version if any. Maven users: update the version.

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omnihai</artifactId>
    <version>1.5</version>
</dependency>

OmniHai still needs only Java 17 and Jakarta EE 11 or MicroProfile 7, with JSON-P required and CDI, EL and MP Config optional. No new dependencies; the resilience decorators are plain Java.

Give it a try

Wrap your existing service in a RetryingAIService, or chain a couple of providers behind a FailoverAIService, and watch the transient failures stop reaching your users. As always, feedback and contributions are welcome on GitHub. If you run into anything, open an issue. Pull requests are welcome too. :)

Tuesday, July 14, 2026

Making Mojarra fast: a per-phase performance review

Mojarra 4.1.9 was measurably slower than MyFaces 4.1.3. That was the whole of issue #5753: perform a performance review of the Jakarta Faces reference implementation and close or even surpass the gap where possible. This post walks through how the review was done and what came out of it, broken down per lifecycle phase. The story throughout is 4.1.10 against the 4.1.9 release; MyFaces is the yardstick it chases. Each phase where MyFaces ran faster marked a lever to find, and a few times the fix was simply to adopt what MyFaces already did, and for the rest AI figured out it by itself. The short version: Mojarra 4.1.10 is 64% (almost 3 times) faster than the 4.1.9 release, and along the way overtook the current MyFaces 4.1.4-SNAPSHOT development build (a72f081e2) by 18%, faster on every one of six tested servers.

None of this is one clever trick. It is a few dozen small, individually-boring levers, each measured on its own, spread across the six phases of the request lifecycle. So let's first look at how they were found.

The benchmark: a WAR that touches every phase

You cannot optimize what you cannot measure, so an early deliverable of the review was a benchmark WAR under test/perf. The first cut was incomplete: it did not exercise the whole pipeline, and PROCESS_VALIDATIONS in particular was barely touched, so the real conversion and validation cost stayed invisible. So it was reworked round after round until every phase and every component was covered under a realistic load. Components like h:selectOneRadio, h:outputScript and h:message were only added in later rounds, which exposed yet more performance levers.

It is a component-family matrix. Four iterating families, h:dataTable, ui:repeat, composite components and build-time unrolled c:forEach, each spanning up to six variants: a read-only GET, a full postback with per-row inputs, a two-level nested variant, a state-restore-only "build" postback, plus ajax twins. On top of that sit flat multi-section forms (text, textarea, select, checkbox, radio over managed converters, validators and Jakarta Validation), a happy and an unhappy validation path, two dynamic-component scenarios and a couple of trivial baselines. 32 scenarios in total, each firing exactly the phases you would expect: GET-only scenarios fire Restore View and Render Response, postbacks fire all six.

A Faces PhaseListener registered in the WAR times every phase of every request and accumulates the result per scenario into a shared PerfStats. A companion servlet exposes those accumulators at /perf-stats as a fixed-width text table parseable by AI. A single integration test drives the whole thing: it resets the stats, loops thousands of GETs and postbacks against a managed app server, then reads /perf-stats at the end to dump the per-scenario, per-phase count/total/avg/min/max table. The same WAR runs against GlassFish, WildFly, TomEE, Payara, OpenLiberty and Tomcat. Every server has a -myfaces twin that swaps in MyFaces instead of Mojarra, so the exact same request stream is measured on both implementations. It lives in the test/perf module of the Mojarra source tree on the 4.1 branch. Run after cloning https://github.com/eclipse-ee4j/mojarra in the repo folder:

git switch 4.1
mvn clean install -pl impl,test/base -DskipTests
cd test/perf

Then run the perf bench using one of the following commands:

mvn clean verify -Dperf=true -Pglassfish           # Mojarra on GlassFish (default)
mvn clean verify -Dperf=true -Pwildfly             # Mojarra on WildFly
mvn clean verify -Dperf=true -Ptomee               # Mojarra on TomEE
mvn clean verify -Dperf=true -Ppayara              # Mojarra on Payara
mvn clean verify -Dperf=true -Pliberty             # Mojarra on OpenLiberty
mvn clean verify -Dperf=true -Ptomcat              # Mojarra on Tomcat
mvn clean verify -Dperf=true -Pglassfish-myfaces   # MyFaces on GlassFish
mvn clean verify -Dperf=true -Pwildfly-myfaces     # MyFaces on WildFly
mvn clean verify -Dperf=true -Ptomee-myfaces       # MyFaces on TomEE
mvn clean verify -Dperf=true -Ppayara-myfaces      # MyFaces on Payara
mvn clean verify -Dperf=true -Pliberty-myfaces     # MyFaces on OpenLiberty
mvn clean verify -Dperf=true -Ptomcat-myfaces      # MyFaces on Tomcat

Each run will take a little over a minute before it prints a fixed-width text table with the numbers. Note that an explicit -Dperf=true is mandatory to activate the PerfBenchIT, otherwise a regular mvn clean install in Mojarra repo root will unintentionally launch it as well.

The versions are not fixed either. -Dmojarra.version picks the Mojarra jar on any profile and -Dmyfaces.version picks the MyFaces jar on the -myfaces twins, so a released version can be benched against the current build without a rebuild. For example, this explicitly runs the 4.1.9 release on Tomcat instead of the current build:

mvn clean verify -Dperf=true -Ptomcat -Dmojarra.version=4.1.9

The server versions themselves are knobs too. Each profile takes a matching -Dglassfish.version, -Dwildfly.version, -Dtomee.version, -Dpayara.version, -Dliberty.version or -Dtomcat.version, and the Tomcat profile additionally takes -Dweld.version and -DhibernateValidator.version for the Weld and Hibernate Validator jars it bundles into the WAR. For example, this pins the GlassFish container version explicitly instead of using the default version hardcoded in test/pom.xml:

mvn clean verify -Dperf=true -Pglassfish -Dglassfish.version=8.0.2

The state-saving knobs, the heap size and the iteration counts are all filtered into the WAR at package time, so some predefined state-saving configurations can be applied without editing anything:

mvn clean verify -Dperf=true -Pwildfly -Dwebapp.stateSavingMethod=client

Or even custom context parameters, which you'll have to spell out fully:

mvn clean verify -Dperf=true -Ptomcat -Dwebapp.additionalContextParams='<context-param><param-name>com.sun.faces.disableIdUniquenessCheck</param-name><param-value>true</param-value></context-param>'

Every server launches with the same -Xmx1g so the cross-server comparison stays apples-to-apples.

The workflow: measure, profile, prototype, discard

The bench tells you which phase is slow. It does not tell you why. For that the same WAR doubles as a JFR driver: append the flight-recorder flags to the forked server VM, run at a tighter iteration count, and you get a per-method CPU profile and per-call-site allocation profile of the entire request stack, Mojarra, EL, CDI, Servlet, the server and the JDK all together. The jfr tool ranks the hot methods and the top allocators; a small script attributes each execution sample to a lifecycle phase by scanning its stack for the phase class.

This is where AI-assisted development earned its keep. The loop, run the A/B bench back-to-back on a quiesced machine (performance governor, no background load, no temperature throttles), profile the slow phase, read the JFR hot-method and allocation rankings, form a hypothesis, prototype a lever, rebuild both arms, remeasure, was run hundreds of times. Claude Code drove the harness, ran the mvn commands, injected the JFR flight-recorder flags into the forked server VM, generated the scripts, aggregated the raw per-phase dumps into readable delta tables, cross-checked JFR self-time against actual wall time, and kept the bookkeeping honest across dozens of candidate changes. That last part matters more than it sounds: a handful of candidate levers do not survive measurement. For example, caching the composite ValueExpression in TagAttributeImpl during Restore View looked obvious, because MyFaces caches it too, and was prototyped and reverted three separate times, each time about 2% slower, and VE creation is only ~1.7% of the phase to begin with. An attributesThatAreSet bitmask regressed buildView by ~20%. A whole class of "obvious" micro-optimizations measured at exactly zero. Only the levers that held up under a clean back-to-back A/B shipped.

A recurring trap worth calling out: JFR self-time percentage is not wall-clock cost. A phase that is thin in wall time starves for samples, and a subtree that shows 9% self-time can move the needle 0% when you actually fix it. The bench wall time, not the profiler, was always the final word.

The machine itself is the other thing that lies to you. A performance run is only as honest as the box it runs on, and a box that is busy, or hot, or on a scaling governor hands you swings the size of a real lever. The early rounds showed it plainly. WildFly came out surprisingly fast and OpenLiberty surprisingly slow, and neither had anything to do with the code: the six-server run was sequential, and a long sequential run heats the CPU until it throttles, so whichever server ran late looked slow. The fix is boring. Pin the governor to performance, drop the background load, let the machine cool between servers, and run each arm back-to-back so the two builds you compare meet the same conditions. A change that only shows up on a loaded or throttling box is not a lever; it is weather. Every number in this post was taken on a quiesced machine for that reason. In the end, all servers were within a few % from each other.

The container can lie to you too, and profiling the whole stack is what caught it. Because the JFR run captures CDI and the app server alongside Mojarra, an early GlassFish profile showed almost half of the HTTP-listener CPU going into GlassFish's own InjectionServicesImpl, not into Faces at all. Every CDI injection was re-walking the superclass and annotation hierarchy, which a view full of initially unoptimized @FacesConverter and @FacesValidator injections hits hard. That was a GlassFish bug, not a Mojarra one, and it made the initial GlassFish bench run about twice as slow. It was reported and fixed in GlassFish 8.0.3 (glassfish#26046); if you reproduce the bench on 8.0.2 you will see that inflation, so use 8.0.3 or later. It hit both implementations equally, so it never tilted the Mojarra-versus-MyFaces comparison, but it is exactly the kind of container cost a Faces-only view would have hidden.

Now the levers, phase by phase.

Restore View

Restore View rebuilds the component tree on every postback and restores its partial state. Mojarra was allocating heavily here. The biggest single lever was #5761: the descendant-id lookup used a per-tree HashMap cache that was rebuilt eagerly; it was replaced with a refresh-gated direct scan, and UIComponentBase.findComponent stopped allocating a fresh collection at every node it walked. #5819 cut further allocation on the unrolled c:forEach restore path, and #5828 memoizes the c:forEach items expression so ForEachHandler evaluates it once per phase instead of on every access. #5822 skips the duplicate-id uniqueness check on postbacks that reuse the already-restored tree, the way MyFaces does, plus two smaller lifecycle levers.

A subtle one came in via #5853: an h:selectOneRadio's @ListenerFor listener was being persisted as a needless per-component partial-state delta on every request. A single non-empty component delta is enough to force the full O(N) clientId-keyed restore walk instead of the cheap view-root-only fast path. Persisting the system-event listeners in the delta only when they actually changed brought the flat-form Restore View back to parity in isolation.

Net Restore View dropped about 65% versus 4.1.9, landing within a few percent of MyFaces; what's left is the shared buildView Facelet re-apply that MyFaces pays too. Notably the unrolled views, which carry the most components and the most per-component state of anything in the suite, are already faster than MyFaces here.

Several smaller levers round out the phase: #5764 (a dynamic-add gate, field-backed Facelets markers and ancestor memoization on the restore walk), #5778 (partial-state-saving and tree-walk trims), #5800 (cheaper per-component createComponent in buildView), #5805 (field-backed component value expressions), #5836 (an indexed Facelets tag-id lookup on refresh) and #5813 (dropping a per-component reflective composite-probe from the restore walk).

Apply Request Values

Apply Request Values decodes the submitted values onto the components. This is per-component overhead territory, and the fix is boring: #5777 reduced the fixed cost paid at every component in the pipeline. There is no single hotspot here; it is a broad, flat cost that only comes down by shaving every node a little. The phase came down about 54% versus 4.1.9, and now runs 18% faster than MyFaces across the suite; the win is dead consistent across all six servers, which is the tell that it is structural and not host-specific.

#5798 reads disabled and readonly through typed getters on decode instead of the reflective attribute map. Two broader request-pipeline levers land hardest in this phase, decisively faster on all six servers, though they touch every phase: #5757 (a FacetsMap empty short-circuit, a descriptor-map computeIfAbsent and hoisted UIData/UIRepeat restore state) and #5759 (per-component state access, a NamingContainer-ancestor cache, per-row state, indexed traversal, event publishing and render-output coalescing).

Process Validations

Of every phase this one fell the furthest against 4.1.9, by nearly 79%. It was also the closer: after the other phases had landed, Process Validations was the one still trailing MyFaces. Once the bench exposed the real conversion and validation load, it measured 44% slower than MyFaces; it is now 32% faster, and Mojarra leads in every component family. It came mainly from three PRs.

#5838 moved the java.time formatters used by f:convertDateTime to application scope instead of rebuilding them per conversion. #5840 shared a single application-scoped BeanValidator, cached the NumberConverter parser, and made UIInput's per-request validation flags (valid, localValueSet, submittedValue) cheaper: they live in transient state, and a default value now costs no map lookup or allocation, so an input whose flags are all default holds no transient map at all, saving a per-input map hash across a table's rows. An intermediate experiment had field-backed those flags as plain fields instead; a MyFaces maintainer rightly flagged that as fishy in the review thread, since per-request lifecycle state does not belong in persisted fields, so it was reverted to transient state and the transient get/put path was optimized instead.

The big one was #5847. Mojarra re-resolved the EL root once per input per validation, and each re-resolution allocated a fresh CDI CreationalContext. On a form with dozens of inputs that is dozens of pointless CDI allocations per request. After the fix, UIInput.validate also skips reading the previous value when nothing observes a value change, and the bean property type is taken from the already-resolved ValueReference instead of a second BeanValidator.getType walk. The form-* family, which carries all the new conversion work the bench added, dropped from behind MyFaces to parity, and the composite family, which had been the single biggest deficit anywhere in the suite at +18%, inverted to 28% faster once the composite cc.attrs EL depth was addressed with #5843 and #5847 (composite component stacks backed by ArrayList, non-contributing ELResolvers skipped, the postback check gated behind the composite check).

Four more levers finish the phase: #5767 (eliminating nested UIData/UIRepeat per-row state cost), #5782 (a guard plus per-attribute memoization in place of the composite-expression cache), #5831 (converter formatter caching and reuse of the built-in by-type converters) and #5834 (a cached parse formatter in DateTimeConverter).

Update Model Values

Update Model Values pushes the validated values into the backing beans, so it walks the same per-component and EL resolution paths as Apply Request Values and Process Validations. It did not get its own dedicated PR; it rode along on the per-component overhead work behind Apply Request Values and the validation EL work behind Process Validations, coming down about 63% versus 4.1.9 and landing ~9% faster than MyFaces. Worth naming as its own phase because that shared-cost effect is the point: a per-component or EL-resolution lever fixed once pays off in every phase that walks the tree.

Invoke Application

Invoke Application is sub-100µs per request in every scenario except the dynamic-component ones which programmatically modify the component tree during an action, so in absolute terms it is too small to matter; the aggregate percentage is a rough indicator only. But the dynamic add/remove machinery the dynamic-component scenarios drive was genuinely broken and got a cluster of fixes. #5783 made dynamic-component restore O(N) instead of O(N²). #5785 fixed a regression where a full-tree clientId index was being built on every postback even when there were zero dynamic actions, and trimmed CDI and event-dispatch overhead on the same path. #5853 landed two more: skipping the redundant dynamic-child reorder in buildView when the children are already in order, and trimming the add/remove path further while removing the dead TREE_HAS_DYNAMIC_COMPONENTS view-root flag that had been write-only since #5761 (for Restore View) dropped its only reader. Finally #5791 trims further per-component overhead on that same add/remove path.

One dynamic case is deliberately left alone, and it comes down to how each impl carries dynamic structure across a postback. Rather than persist an added subtree as full component state the way MyFaces does, Mojarra records each add or remove as a compact action (a ComponentStruct) and replays that action list when the view is rebuilt. It defers that replay to Render Response, because an Invoke Application action may still navigate away; deferring means Mojarra only ever re-applies the actions to the view it actually renders, never one it discards. The subtree itself is fully in the tree from Restore View onward, so nothing is withheld from decode, validate or update; only the render-time re-apply is deferred. That is why the dynamic-toggle-ajax scenario, which toggles on the same view, renders slower than MyFaces while its Restore View is faster; net it is a wash. A sound state-model trade, not a defect, and "fixing" it would break the navigation case.

Render Response

Render Response is the heaviest phase in absolute terms, so even modest percentages here move the suite total more than anything else. It came down about 58% versus 4.1.9 and is now 20% faster than MyFaces on all six servers. The largest lever was #5812: for static views Mojarra was re-applying the Facelet at render time even though nothing had changed since the build, a redundant tree walk that was skipped. #5796 attacked a subtler, per-component cost: the renderers were reading standard attributes like styleClass through getAttributes().get(...), which routes through the reflective AttributesMap, on every component they encoded. Casting to the concrete component subclass and calling its typed getter (getStyleClass() and friends) skips the reflection entirely, and the read is skipped altogether when the attribute is unset. #5822 does the same for the pass-through attribute sweep, which is skipped when a component declares none. Finally #5785 also lives partly here: a @FacesConverter(managed=true) was being re-resolved through CDI for every single table cell, which a small Bean cache eliminated, and the per-event listener dispatch got a fast path that took it off the top of the allocation profile.

The remaining Render Response levers are a spread of per-component and output trims: #5752 (caching CDI bean resolution to remove per-render BeanManager lookups), #5755 (response-writer range-emit and buffer elimination), #5770 (a lazy state map, UIOutput converter, rendererType and AttributesMap getter), #5793 (per-component encode and view-build overhead), #5825 (skipping the render re-apply of static c:forEach and deduping ViewScope FacesContext lookups) and #5839 (emitting an event-handler attribute only once when set via expression).

The numbers

Six servers, default settings (partial state saving, server state saving, Production stage), 1000-run suite, JDK 21, all three implementations built back-to-back on a quiesced machine. Values below are aggregated across servers; negative means Mojarra 4.1.10 is faster.

phasevs Mojarra 4.1.9vs MyFaces
Restore View−65%−4%
Apply Request Values−54%−18%
Process Validations−79%−32%
Update Model Values−63%−9%
Invoke Application−56%+22% †
Render Response−58%−20%
whole suite−64%−18%

† Invoke Application is sub-100µs per request outside the dynamic-toggle-ajax scenario, so its aggregate percentage is a rough indicator, not a real cost.

The vs-4.1.9 column is the cumulative gain since the last release: 64% faster, dead consistent across every clean server at −64 to −65%. The vs-MyFaces column is measured against the current MyFaces 4.1.4-SNAPSHOT development build (a72f081e2), itself 22% faster than its own 4.1.3 release, so this is Mojarra ahead of MyFaces at its best, not against a stale yardstick. The wins are structural: the tight cross-server spread on Apply Request Values and Render Response is what tells you these are real code changes and not one lucky host.

The same levers ship across all three active release lines: authored on the 4.1 branch or backported from master, they are all present in 4.1.10, backported to 4.0.19, and forward-ported to 5.0.0-M3.

What made the iteration fast

A performance review is only as fast as your validation loop, because every lever has to pass the full Jakarta Faces TCK before it can ship. A lever that shaves 3% is worthless if it quietly breaks a spec-mandated edge case, and the only way to know is to run the TCK, all of it.

Not long ago that meant hours. The Faces TCK ran the old JavaTest harness through repeated GlassFish cold starts and took over three hours end to end, which is fatal to an iterate-measure-validate loop; you get one or two validated levers per day and you lose your train of thought between them. The GlassFish pool work described in From hours to minutes: GlassFish pool for Jakarta EE TCKs brought that same TCK down to under four minutes with a reusable, leased server pool and parallel execution.

Four minutes changes the character of the work. A candidate lever could be prototyped, benched, and TCK-validated within an hour, so it was cheap to try a change, measure it, and throw it away when it did not pan out; and, as noted, a handful did not. The speed of the TCK is what let the review be exhaustive rather than conservative. 64% faster than the previous release is the combined result of being able to afford that many attempts in a relatively short time.

The fast TCK was only half of the loop; the other half was that same Claude Code workflow on the authoring side. Drafting a candidate lever, then forward- and backporting it across the release lines once it held up, is mechanical work that scales badly by hand. The judgement stayed human: which lever is worth shipping, whether a change is spec-legal, and reading the TCK output when a change was not. What came off your hands was the bulk, and for a review this wide that bulk is most of the hours.

The traffic went both ways. Digging into why Restore View was slow surfaced a corner case worth guarding: a UICommand bound into an h:dataTable via binding must fire its action exactly once per click instead of multiple times, and an earlier Restore View fix (#4128) was the thing keeping it that way. Analyzing that fix, because it badly impacted performance, turned up that its regression test, which had guarded exactly this behaviour, was nowhere in the TCK: it had been dropped from Mojarra's own test tree in the 3.0-to-4.0 migration and never migrated across. So it was ported back into the TCK as jakartaee/faces#2179, so that no Restore View optimization could quietly reintroduce the double-fire.

That one test exposed a bigger gap: Mojarra's old suite of 577 integration tests had been dropped wholesale in that migration, and only a part was ever migrated into the TCK, because porting each one by hand was too much work to justify. With AI assistance it no longer was. jakartaee/faces#2181 resurrected 138 of the spec-relevant ones with help of Claude Code, filtering out the ones not yet covered by the existing tests, each ported from the old HtmlUnit harness to Selenium, modernised to Jakarta namespaces and CDI, and named after the issue it covers. All within hours instead of weeks. Those resurrected tests promptly caught three real Mojarra regressions, unrelated to the performance work, fixed in #5774. And the pool swallowed all 138 new tests without complaint: the full 6026-test 5.0 TCK still finishes in less than four minutes at my machine with -T8.

The bottom line

"Mojarra is slow" was a fair thing to say against 4.0.18, 4.1.9 and 5.0.0-M2 or earlier. Now it does not hold anymore. Since 4.0.19, 4.1.10 and 5.0.0-M3, Mojarra is not just almost 3 times faster than its own previous release, it runs ahead of MyFaces at its best, on every one of six tested servers. If you last benchmarked Mojarra a release or two ago and wrote it off, the number you remember is stale; the reputation outlived the reality, and the numbers have caught up.

UPDATE: a few corner-case regressions have been reported and fixed, with no performance loss. Upgrade further to at least 4.0.21, 4.1.12 or 5.0.0-M5 to get them.

Appendix: all levers at a glance

Every lever from the sections above, collected in one place and grouped by the phase it moves most. All are present in 4.0.19, 4.1.10 and 5.0.0-M3: those authored on the 4.1 branch landed directly; those authored on master reached the release lines as a backport. A handful of small regression-guard fixes that accompanied the perf work are omitted.

phasePRwhat it does
Restore View#5761Descendant-id HashMap cache replaced with a refresh-gated direct scan; findComponent stops allocating per node
#5764Dynamic-add gate, field-backed Facelets markers, ancestor memoization on the restore walk
#5778Partial-state-saving and component-tree-walk improvements
#5800Cut per-component createComponent overhead in buildView
#5805Field-back component value expressions to cut restore cost
#5813Drop the per-component composite-probe reflective lookup from the restore walk
#5818Reduce restore allocation on JSTL c:forEach views
#5821Skip the id-uniqueness check on tree-reusing postbacks, plus two more view-lifecycle levers
#5828Memoize the c:forEach items expression per phase
#5835Index the Facelets tag-id lookup on refresh
#5853Persist system-event listeners in the partial-state delta only when changed
Apply Request Values#5757FacetsMap empty short-circuit, descriptor-map computeIfAbsent, UIData/UIRepeat restore-state hoist, indexed child traversal
#5759Per-component state/property access, NamingContainer-ancestor cache, per-row state, indexed traversal, event publishing, render-output coalescing
#5777Reduce per-component request-pipeline overhead
#5798Read disabled/readonly via typed getters on decode; less StateHelper and reflective overhead
Process Validations#5767Eliminate nested UIData/UIRepeat per-row state cost
#5782Replace the composite-expression cache with a guard plus per-attribute memoization (cc.attrs)
#5829Reuse the built-in by-type converters per target class
#5831Cache converter formatters/parsers
#5838Move the java.time f:convertDateTime formatters to application scope
#5840Shared BeanValidator, cached NumberConverter parser, lazy transient UIInput state
#5843EL resolution, composite component (ArrayList-backed stacks, non-contributing ELResolver skip) and component-id
#5847No per-input EL-root re-resolution; skip the previous-value read when unobserved; bean type from the ValueReference
Invoke Application#5783Dynamic component add/remove restore made O(N) instead of O(N²)
#5785Dynamic-action zero-actions regression fix, plus CDI and event-dispatch overhead
#5791Trim per-component overhead on the dynamic add/remove path
#5848Skip the redundant dynamic-child reorder; trim the add/remove path
#5851Remove the dead TREE_HAS_DYNAMIC_COMPONENTS flag, collapse the DYNAMIC_CHILD_COUNT counter
Render Response#5752Cache CDI bean resolution to eliminate per-render BeanManager lookups
#5755Response-writer range-emit and buffer elimination
#5770Lazy state map, UIOutput converter, rendererType, AttributesMap getter
#5793Reduce per-component overhead in encode and view build
#5796Read renderer attributes (e.g. styleClass) via typed getters instead of the reflective AttributesMap; skip when unset
#5811Skip the render-time Facelet re-apply for static views
#5824Skip the render re-apply of static c:forEach; dedup ViewScope FacesContext lookups
#5839Render an event-handler attribute only once when set via expression

All of it was validated on GlassFish, WildFly, TomEE, Payara, OpenLiberty and Tomcat. The full breakdown, round by round, is on issue #5753.

OmniFaces 5.4 released, now compatible with Faces 5.0

OmniFaces 5.4.1 has been released! This is the first OmniFaces version which is compatible with Jakarta Faces 5.0, while still keeping the Faces 4.1 minimum of the whole 5.x line. In other words, one and the same JAR runs on Jakarta EE 11 (Faces 4.1) as well as on the upcoming Faces 5.0. Next to that there are a few new features, two deprecations and two notable fixes.

New: Jakarta Faces 5.0 compatibility

Until now the whole OmniFaces 5.x line required Jakarta Faces 4.1 as minimum and was not verified against Faces 5.0. As of 5.4 the integration test suite also runs against both Mojarra 5.0.0-SNAPSHOT and MyFaces 5.0.0-SNAPSHOT on Tomcat, next to the existing Faces 4.1 runs. Note that these are still snapshots; Jakarta Faces 5.0 has not been finalized yet, so consider this a first compatibility milestone rather than a guarantee against the final release. The changes needed to span both generations turned out to be small; OmniFaces only had to catch up with the in Faces 5.0 renamed implementation packages and with a changed rendering of the on* attributes. The minimum stays at Faces 4.1, so upgrading to 5.4 is safe on Jakarta EE 11 while you have the opportunity to move to Faces 5.0 whenever you want.

New: OmniFaces.Ajax.validationFailed

Ever needed to know at the client side whether a Faces ajax request failed on validation? Until now you had to inspect the returned partial response or add a hidden component whose changed value acted as a flag. As of 5.4, each OmniFaces ajax response exposes FacesContext#isValidationFailed() to the client side as a boolean OmniFaces.Ajax.validationFailed. So your JavaScript can react to a validation failure without any server or DOM round-trip.

if (OmniFaces.Ajax.validationFailed) {
    // Do your thing.
}

This works out of the box; there is nothing to configure. See also Ajax in the showcase. (#955)

New: org.omnifaces.CDN_RESOURCE_HANDLER_EXCLUDED_RESOURCES

The CDNResourceHandler rewrites resource URLs to a CDN host. When you use a wildcard mapping for a whole library, it may happen that a specific resource of that library is not actually hosted on the CDN (a typical example is PrimeFaces dynamiccontent.properties). As of 5.4 you can exclude such resources from rewriting via the new context parameter org.omnifaces.CDN_RESOURCE_HANDLER_EXCLUDED_RESOURCES. It takes a comma separated list of libraryName:resourceName identifiers which are then served as-is by the default Faces resource handler. The match is exact; wildcards are not supported here.

<context-param>
    <param-name>org.omnifaces.CDN_RESOURCE_HANDLER_EXCLUDED_RESOURCES</param-name>
    <param-value>primefaces:dynamiccontent.properties</param-value>
</context-param>

See also CDNResourceHandler in the showcase. (#954)

New: query params in FullAjaxExceptionHandler error pages

The FullAjaxExceptionHandler forwards to the error page declared in web.xml. Until now the declared <location> could only be a plain path. As of 5.4 you can add query params to it and they are honored on the forward.

<error-page>
    <exception-type>java.lang.NullPointerException</exception-type>
    <location>/WEB-INF/errorpages/general.xhtml?type=NPE</location>
</error-page>

See also FullAjaxExceptionHandler in the showcase. (#962)

Improved: OnDemandResponseBufferFilter

The OnDemandResponseBufferFilter, which backs <o:cache>, previously buffered only responses written via the Writer. It now also buffers responses written via the OutputStream. This was never a problem in practice; it was a long-standing TODO, addressed so the filter is reusable for binary or streamed responses too. (#959)

Deprecated: <o:selectItemGroups>

The <o:selectItemGroups> component has been deprecated. Faces 4.0 introduced a standard <f:selectItemGroups> which is functionally equivalent, so the OmniFaces one is not needed anymore. As of 5.4 a warning is logged once at runtime, and the component is marked for removal in a future version. Replace it with the standard <f:selectItemGroups>. (#958)

Deprecated: @Param without @Inject

Using @Param without @Inject on the same field has been deprecated. As of 5.4 a warning is logged at deployment time for each affected field. Add @Inject next to @Param to get rid of the warning. (#960)

Fixes

<o:inputFile> would duplicate the client side validation script in its onchange when the component was re-rendered. This has been fixed; the script is now added only once. (#963)

@ViewScoped could lose an active bean under concurrent requests. When the LRU eviction of the view scope bean storage kicked in, it could destroy a bean storage that was still actively used by a concurrent request. This has been fixed; a storage in active use is never anymore destroyed. (#966)

Installation

Non-Maven users: download OmniFaces 5.4.1 JAR and drop it in /WEB-INF/lib the usual way, replacing the older version if any.

Maven users: use the following coordinates.

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omnifaces</artifactId>
    <version>5.4.1</version>
</dependency>

The <o:inputFile> and @ViewScoped fixes (#963 and #966) have also been backported to 4.x and 3.x, so OmniFaces 4.7.11 and OmniFaces 3.14.22 have been released as well. The Jakarta Faces 5.0 compatibility, the new features and the deprecations are exclusive to 5.4.1.

For the complete list of additions, changes and fixes, see What's new in OmniFaces 5.4.1? in the showcase.

Thursday, June 4, 2026

From hours to minutes: GlassFish pool for Jakarta EE TCKs

Jakarta EE TCKs are notoriously slow. The bulk of the wall clock is not test execution but GlassFish cold-start: every test module unpacks a dist, boots a domain, deploys, undeploys, and stops the domain. With one hundred-ish test modules and several seconds of start/stop per module, a full TCK run easily takes hours. The arquillian-glassfish-server-pool module and the glassfish-pool-maven-plugin, both released as part of OmniFish arquillian-container-glassfish 2.2.0, eliminate that overhead by sharing a pool of pre-started GlassFish instances across the entire reactor.

The proof of concept: Faces TCK

The Jakarta Faces TCK historically consisted of two parts. The "old TCK" was the original Oracle suite: an Ant-driven JavaTest harness inherited from the JSF 1.x days, with around 5000 tests. The "new TCK" was the body of contributed and later-added tests built on JUnit + Arquillian, optionally with HtmlUnit or Selenium for browser interaction. Running both ends to end easily took over 3 hours on Jenkins CI, dominated by the old TCK.

Folding the old TCK into the new-TCK style was always the goal, but per-test manual conversion was prohibitively cumbersome; AI-assisted development is what finally made it feasible. Pull requests #2145, #2146, #2147, #2149, and #2150 mechanically migrated the entire old TCK, with Claude Code doing the bulk of the rewriting and consolidating the remaining HtmlUnit assertions onto Selenium along the way. WAR consolidation (one WAR per feature group instead of one per test) brought wall clock down to ~1h.

The second step was an in-house gf-pool prototype (#2156) that pre-started a pool of GlassFish instances and leased one slot per failsafe-forked JVM. With mvn clean verify -T8 (8 threads), the full Faces TCK reactor now finishes in under 4 minutes. The prototype proved the model works; the natural next step was extracting it into a reusable Maven plugin so other TCKs do not have to copy-paste the wiring.

PhaseLinuxMacBookJenkins
Pre-migration02:57 h02:53 h03:19 h
Post-migration01:05 h40:52 m01:18 h
With gf-pool3:46 m (-T8)4:47 m (-T5)13:06 m (-T2)

Linux: Intel Core i9-10900X with 32GB
MacBook: M1 Pro 10 Core with 16GB
Jenkins: Eclipse Jiro with "2 CPU" and "8 GB"

What gf-pool is

The pool ships in two artifacts:

  • arquillian-glassfish-server-pool: a runtime Arquillian DeployableContainer that leases a slot for the duration of a test JVM and deploys against the leased slot's DAS through the standard CommonGlassFishManager.
  • glassfish-pool-maven-plugin: lifecycle goals (up, down, provision, status, nuke) that provision and start slots before integration-test and stop them after.

Provisioning clones a single source GlassFish install into slot-1/, slot-2/, …, rewrites each slot's domain.xml so its ports land in a non-overlapping window (adminBase + (slot - 1) * portStride), and starts every slot in parallel. Each test JVM acquires an exclusive FileChannel.tryLock() on slot-N/lock, reads slot-N/ports.properties, and holds the lock for the JVM's lifetime. The lease protocol is pure Java; there's no -javaagent, no surefire argLine plumbing, and no shell scripts.

The pool grows on demand. A sequential build uses one slot; mvn clean verify -T4 grows to four; -T8 grows to eight. A JVM shutdown hook installed on Maven's own JVM stops every slot at session end (or on Ctrl+C), so no orphaned processes survive a hard build failure.

The optimal -TN for your machine is bounded by available RAM, not by core count. Each slot is a full GlassFish JVM plus a failsafe-forked test JVM, so the dominant cost is heap and resident memory, not CPU. Moreover, if your TCK drives a browser (as Faces does), each slot also spawns its own Chrome plus chromedriver, which pushes the total to ~1.5GB per slot. A 16-core box with 16GB will usually thrash at -T8 while a 8-core box with 32GB happily handles -T8; pick N by watching resident memory and swap, not number of processors.

Maven setup

Two plugin blocks: run the pool plugin (which resolves and unpacks GlassFish itself), and point failsafe at the same <poolDir> and <poolSource>.

<build>
    <plugins>
        <plugin>
            <groupId>ee.omnifish.arquillian</groupId>
            <artifactId>glassfish-pool-maven-plugin</artifactId>
            <version>2.2.0</version>
            <configuration>
                <poolDir>${project.build.directory}/pool</poolDir>
                <poolSource>${project.build.directory}/dist/glassfish9</poolSource>
                <distribution>
                    <groupId>org.glassfish.main.distributions</groupId>
                    <artifactId>glassfish</artifactId>
                    <version>9.0.0-M2</version>
                    <type>zip</type>
                </distribution>
            </configuration>
            <executions>
                <execution><id>pool-up</id><goals><goal>up</goal></goals></execution>
                <execution><id>pool-down</id><goals><goal>down</goal></goals></execution>
            </executions>
        </plugin>

        <plugin>
            <artifactId>maven-failsafe-plugin</artifactId>
            <configuration>
                <systemPropertyVariables>
                    <gf.pool.dir>${project.build.directory}/pool</gf.pool.dir>
                    <gf.pool.source>${project.build.directory}/dist/glassfish9</gf.pool.source>
                </systemPropertyVariables>
            </configuration>
        </plugin>
    </plugins>
</build>

The <distribution> block tells the plugin to resolve the named artifact through your usual Maven repositories and unpack it under ${project.build.directory}/dist before provisioning runs. Staging is idempotent: re-runs fast-exit when the marker file written after a successful unpack is still present.

Add the runtime as a test-scope dependency:

<dependency>
    <groupId>ee.omnifish.arquillian</groupId>
    <artifactId>arquillian-glassfish-server-pool</artifactId>
    <version>2.2.0</version>
    <scope>test</scope>
</dependency>

No arquillian.xml is needed. The failsafe <systemPropertyVariables> above forward gf.pool.dir and gf.pool.source to the test JVM, and the container adapter reads them at start() when it leases a slot. Drop in an arquillian.xml with <container qualifier="glassfish-pool"> only if you need to override inherited fields like adminPassword, or if you're running against a hand-staged pool without the plugin.

That's it. mvn clean verify works sequentially; mvn clean verify -T8 fans out across eight slots.

Need to peek at the pool? mvn glassfish-pool:status in a separate terminal redraws a top-style table once per second:

mvn glassfish-pool:status command output

Overlays

For TCK-style builds that test a SNAPSHOT impl jar against a released distribution (or vice versa), the plugin can copy overlay jars into glassfish/modules/ after unpack and before slot cloning. Declare zero or more <overlay> blocks:

<configuration>
    <overlays>
        <overlay>
            <groupId>org.glassfish</groupId>
            <artifactId>jakarta.faces</artifactId>
            <version>5.0.0-SNAPSHOT</version>
            <destFileName>mojarra.jar</destFileName>
        </overlay>
    </overlays>
</configuration>

Each overlay accepts a <skip> child that wires to your existing per-profile property switches, so you can hold back individual jars per release line without forking the pom.

Bring your own unpack

If you'd rather use maven-dependency-plugin for the unpack (e.g. because your build already has one for unrelated reasons), drop the <distribution> block from the pool plugin and add a regular unpack execution that lands in the same directory <poolSource> points at:

<plugin>
    <artifactId>maven-dependency-plugin</artifactId>
    <executions>
        <execution>
            <id>unpack-glassfish</id>
            <phase>process-test-classes</phase>
            <goals><goal>unpack</goal></goals>
            <configuration>
                <artifactItems>
                    <artifactItem>
                        <groupId>org.glassfish.main.distributions</groupId>
                        <artifactId>glassfish</artifactId>
                        <version>9.0.0-M2</version>
                        <type>zip</type>
                        <outputDirectory>${project.build.directory}/dist</outputDirectory>
                    </artifactItem>
                </artifactItems>
            </configuration>
        </execution>
    </executions>
</plugin>

The pool plugin will then skip staging and clone slots directly from ${project.build.directory}/dist/glassfish9.

JVM system properties at slot boot

Some properties have to be on the GF JVM at startup. The canonical example is javax.net.ssl.trustStorePassword: a PKCS12 truststore needs the password before SSL is used for the first time, because Java caches the default SSLContext after first use and never reloads from disk. The plugin's <systemProperties> hook bakes each key=value into every <java-config> of each slot's domain.xml at provisioning time:

<configuration>
    <systemProperties>
        javax.net.ssl.trustStorePassword=changeit
        java.awt.headless=true
    </systemProperties>
</configuration>

Adoption: Security TCK

The Jakarta Security TCK followed the same old-TCK / new-TCK split as Faces: a JavaTest "old TCK" from Oracle plus a JUnit + Arquillian "new TCK" of later contributions. The combined suite ran in just under 13 minutes. Unlike Faces, the old-TCK side wasn't the bottleneck: it deployed its apps onto a single long-running GlassFish domain and replayed all 83 of its JavaTest clients against them, so it was already efficient on its own. Migrating it was therefore not about runtime; it was about consolidating on a single test harness. Pull request #365 did exactly that, mechanically rewriting the old TCK into the new-TCK style with Claude Code doing the bulk of the assertion work. The unavoidable trade-off is that each migrated test now spins up its own Arquillian-managed GlassFish instead of sharing one domain, so single-threaded runtime nearly doubled to ~24 minutes. That is the cost of trading a shared harness for per-test isolation. Pull request #368 recovers that cost (and then some) by swapping the JVM-scoped arquillian-glassfish-server-managed container for arquillian-glassfish-server-pool. With mvn clean verify -T8, the Security TCK now finishes in under 2 minutes.

Per-test isolation is a much bigger deal for Faces, whose old TCK has ~5000 tests and takes 2+ hours on a single shared GlassFish; there the post-migration single-threaded runtime would be prohibitive without the pool. Security is the small-scale case where you can see the trade clearly; Faces is where parallelism stops being optional.

PhaseLinuxMacBookJenkins
Pre-migration12:50 m9:38 m19:05 m
Post-migration23:42 m16:34 m
With gf-pool1:49 m (-T8)1:30 m (-T5)

Linux: Intel Core i9-10900X with 32GB
MacBook: M1 Pro 10 Core with 16GB
Jenkins: Eclipse Jiro with "2 CPU" and "8 GB"

Parallelism: what you'll discover

Moving to a shared pool surfaces parallelism issues that a sequential build hides. The Security TCK migration is a good cross-section. None of these are pool bugs; they're latent contracts that finally see daylight when two slots run side by side.

Hardcoded ports. Tests that embed an LDAP server, a Tomcat instance, or any other side-process on a fixed port collide as soon as two slots co-run. Pick distinct ports per module, or derive them from the slot index (gf.pool.slot is published as a system property by the leaser). The Security TCK's embedded LDAP modules split 33389 onto 33390 and 33391 for its two extra app-ldap variants.

Hardcoded URLs. Tests that publish http://localhost:8080/... URLs to an external party (OAuth callback URIs, OIDC issuer metadata) break the moment the slot's HTTP port is anything but 8080. Replace literals with UriInfo-derived or request-derived URLs at runtime, so the URL tracks the slot's actual HTTP port.

Pre-registered redirect URIs for every slot. External identity providers that need redirect URIs pre-registered (Mitre OIDC in the Security TCK case) have to be told about every slot the pool may grow to. Maven exposes ${session.request.degreeOfConcurrency} as the -TN value; bsh-property can promote it to a regular property if your plugin only consumes typed properties.

Cross-app singletons. A java:global/ DataSource shared across apps, or any other JNDI/CDI/resource a previous app deploy leaves behind, can leak into the next app's lookup on the same slot. The Security TCK adoption uncovered a related upstream GlassFish bug where ComponentEnvManagerImpl.getResourceId returned an empty string for ScopeType.GLOBAL, fixed in eclipse-ee4j/glassfish#26029. Worth re-running your TCK against this fix if you exercise cross-app global resources.

Persisted state across re-runs. Anything written to work/, sessions/, or other on-disk caches survives a slot's lease release and can resurrect into the next consumer. If your test relies on a known starting state or an existing HTTP session variable, wipe the relevant directories on container start.

Aggregator goals on the reactor root. Goals like failsafe-report-only, cyclonedx:makeAggregateBom, or install-file bound to a per-module phase stall the -T reactor because Maven serialises them across modules. Move these to inherited=false on the reactor root only.

For Jakarta EE TCK maintainers

If your Jakarta EE TCK still drags a JavaTest "old TCK" alongside its JUnit + Arquillian "new TCK", or runs sequentially against a freshly-unpacked GlassFish per module, the migration path is the one Faces and Security walked. First, fold any remaining old TCK into the new-TCK style (JUnit + Arquillian, plus Selenium if your tests drive a browser, as Faces does); AI-assisted development handles the mechanical rewriting well enough that the bulk of the work is reviewing diffs, not writing them. Then wire arquillian-glassfish-server-pool and glassfish-pool-maven-plugin in. The result is a TCK that finishes in minutes instead of hours, and a build that still runs sequentially under mvn clean verify for vendors who prefer that.

The README at glassfish-pool-maven-plugin/README.md documents the full configuration surface; the working example at integration-tests/src/it/pool is ~150 lines of pom and runs as a smoke test in the project's own CI. Both Faces TCK (faces#2165) and Security TCK (security#368) are open and worth studying as real-world consumers.