A bookmark outlives the thing it points at

uPortal pull request 2924 wraps one call in a try/catch so a stale URL stops returning a 500. It also deletes an @Ignore that had been skipping 30 tests, which reaches further than the fix that prompted it.

uPortal-Project/uPortal PR 2924 at dc19c65, explained 2026-09-01

Background

New to uPortal or to portlets? Expand for the deeper background.

uPortal is a campus portal. A signed-in student sees one page assembled from many small independent applications: a course list, a calendar, an announcements feed. Each of those is a portlet, and the portal composes them into a layout that each user can rearrange.

A portlet definition is the installed application, the thing an administrator adds or removes for the whole institution. A portlet entity is one user's placed instance of it, at one spot in that user's layout. So one definition has many entities.

A portlet window is the third of these, and the one this change returns. It is an entity as it exists during a single request, carrying the state that belongs to this render: which mode the portlet is in, whether it is maximized, and the parameters aimed at it. Turning an id from the URL into a window is the lookup that fails here.

One request can involve several windows. The one the user is acting on is the targeted window, and the others are delegates. That distinction matters twice later, so it is worth holding onto.

An entity is named by a string built from three parts joined with underscores:

portletDefinitionId + "_" + layoutNodeId + "_" + userId

Which makes 27_ctf9_142032 readable: portlet definition 27, at layout node ctf9, for user 142032.

Those entity ids travel in the URL. When a portlet needs its own parameters in a shared page, the portal prefixes them so they cannot collide with anything else:

?pCa=27_ctf9_142032&pP_27_ctf9_142032_implicitModel=...

pCa lists the entity ids present in this request, and each pP_ parameter carries an id followed by the real parameter name. Here that real name is implicitModel, a parameter one portlet reads; everything before it is addressing. Turning these strings back into objects is URL parsing, and it happens on the way in, before anything renders.

The method that does that parsing is parsePortletParameterName. It takes a raw parameter name, and the set of entity ids the request declared, and returns a pair: the real parameter name, and the window the parameter belongs to. uPortal calls that pair a Tuple, whose fields are named first and second.

uPortal-rendering/.../url/UrlSyntaxProviderImpl.java:894

protected Tuple<String, IPortletWindowId> parsePortletParameterName(
        HttpServletRequest request, String name, Set<String> additionalPortletIds) {

Resolving the second value means turning 27_ctf9_142032 into a live window. That runs through two registries: a window registry, which asks an entity registry to resolve the id first. The entity registry splits the string, looks up the definition, and if the definition is not there, it throws.

uPortal-rendering/.../portlet/registry/PortletEntityRegistryImpl.java:757

if (portletDefinition == null) {
    throw new IllegalArgumentException(
            "No parent IPortletDefinition found for "
                    + portletDefinitionIdString
                    + " from entity id string: "
                    + consistentEntityIdString);
}

IllegalArgumentException is unchecked, so Java does not require any caller to handle it, and nothing did. It traveled up through URL parsing, out of the rendering pipeline, and into the filter that sits across every request.

Key concept A URL is user-supplied input that outlives the data it names. A bookmark, a browser's restored session, a link in an old email, or a search engine's index can all send you an entity id for a portlet an administrator removed last term. Parsing has to survive that, because the person holding the stale link did nothing wrong.

Intuition

Before the change, one missing portlet definition cost the whole page. The user asked for their layout, and got an error page, because a parameter belonging to one removed portlet could not be resolved.

Before: a bookmarked URL after portlet 27 is removed
HTTP 500 uPortal: unhandled exception 'No parent IPortletDefinition found for 27 from entity id string: 27_ctf9_142032'

The whole layout is gone, not just the missing portlet. Nothing on the page told the user their bookmark was the problem.

After: the same URL
HTTP 200 [ Courses ] [ Calendar ] [ Announcements ] portal log: WARN Failed to resolve portlet window id for parameter 'pP_27_ctf9_142032_implicitModel': No parent IPortletDefinition found for 27. The parameter will be ignored.

The layout renders without the removed portlet, and the operator gets a warning naming the parameter. The user sees a working page.

The shape of the fix

The method already had an answer for "this parameter does not belong to any known window": return the parameter name with a null window id. That path existed for the case where none of the declared ids appear in the parameter name at all.

So the fix does not invent a new outcome. It catches the exception, logs it, and lets control reach that existing fallback.

The diagram below shows the simple case, where one id fails and the method falls through. The catch actually sits inside a loop over every declared id, so a failure is not always the end of the attempt. Step 3 of the walkthrough follows that, and it is where the detail lives.

What the caller receives

stale id in URL
27_ctf9_142032
registry lookup
throws
caught. warn logged
falls through
Tuple with null second

The same story with the timing made explicit, and both outcomes side by side.

sequenceDiagram
    autonumber
    actor U as Browser
    participant G as getPortalRequestInfo
    participant S as parsePortletParameterName
    participant R as portletWindowRegistry
    participant E as PortletEntityRegistryImpl
    participant F as ExceptionLoggingFilter

    U->>G: GET with pCa=27_ctf9_142032
    G->>S: name = pP_27_ctf9_142032_implicitModel
    S->>R: getPortletWindowId(request. "27_ctf9_142032")
    R->>E: getPortletEntity for definition 27
    E--)R: IllegalArgumentException. no definition 27
    R--)S: propagates unchanged

    rect rgb(251, 231, 233)
    Note over S,F: before. nothing catches it
    S--)G: propagates
    G--)F: propagates
    F->>F: logger.error. then rethrow
    F->>U: HTTP 500 via /500.html
    end

    rect rgb(228, 243, 234)
    Note over S,G: after. the catch logs and the loop moves on
    S->>S: logger.warn at line 913
    S->>S: no more ids match. fall to line 922
    S-->>G: Tuple("27_ctf9_142032_implicitModel". null)
    G->>G: null window id. drop or reattribute the parameter
    G->>U: HTTP 200. layout without the deleted portlet
    end
Edge case Look at the string in that second-to-last arrow. The success path computes the parameter name at line 903, stripping the prefix and the entity id, which yields implicitModel. The fallback at line 922 strips only the pP_ prefix, so it yields 27_ctf9_142032_implicitModel, entity id still attached. The fallback only handles parameters with no id in them, so it has no id to strip. Reaching it from the catch means the parameter survives under a name nothing downstream reads. No portlet acts on it. The two paths still compute the name differently, and step 3 shows where that surfaces.

Code walkthrough

The flow, end to end. A browser sends a stale entity id. getPortalRequestInfo, the method that turns a request into parsed information, hands each prefixed parameter to parsePortletParameterName. That method asks the window registry, which asks the entity registry, which throws. What happens next is the change.

Four files: one fix, one test file, and two log lines in other modules.

1. Where the exception is born

The entity id has three parts, and the throw quoted in Background is what happens when the first of them, the definition id, does not resolve. That is the failure this change is about. Two details matter for reading the log.

First, a deleted portlet is not the only cause. The same lookup returns null, and so raises the same exception with the same message, when the definition exists but the user is not permitted to render it. A warning naming definition 27 does not by itself tell an operator which of the two happened.

Second, successful parses are cached, and failures are not. So a stale id pays the full lookup on every request that carries it, and logs a warning every time.

2. The 500 path, before

uPortal-web/.../web/ExceptionLoggingFilter.java:46

try {
    chain.doFilter(request, response);
} catch (Throwable t) {
    // ... builds a message with the URL, query string, user and IP
    this.logger.error(
            "uPortal: unhandled exception '" + t.getMessage() + "' " + msg.toString(), t);
    // ...
    if (t instanceof RuntimeException) {
        throw (RuntimeException) t;
    }

This filter is mapped to /*, so it saw the exception, and its name says what it does with it: it logs. Then it rethrows unchanged, the container turns an uncaught exception into a 500, and web.xml maps 500 to /500.html. Nothing in that chain was ever going to produce a partial page, because nothing in it knows that one parameter out of many is the problem. The only place with enough context to make that call is the parser.

3. The fix, and where control actually goes

uPortal-rendering/.../url/UrlSyntaxProviderImpl.java:908

+            try {
                 final IPortletWindowId portletWindowId =
                         this.portletWindowRegistry.getPortletWindowId(request, additionalPortletId);
                 return new Tuple<String, IPortletWindowId>(paramName, portletWindowId);
+            } catch (IllegalArgumentException e) {
+                this.logger.warn(
+                        "Failed to resolve portlet window id for parameter '{}': {}."
+                                + " This may be caused by a stale URL referencing a"
+                                + " removed portlet. The parameter will be ignored.",
+                        name,
+                        e.getMessage());
             }

Read what the catch block does not contain. No return, no break, no continue, no rethrow. It logs, and then control reaches the end of the loop body.

That matters, because this code sits inside a for loop over every entity id the request declared. So a failure is not the end of the attempt. The loop advances to the next id, and if a later one both matches the parameter name and resolves, line 911 returns a real window after all. Only when no iteration returns does control leave the loop and reach the fallback.

Situation Returns from Tuple first Tuple second
An id matches and resolves line 911 implicitModel the window id
An id matches, resolution throws, no other id resolves line 923 27_ctf9_142032_implicitModel null
No declared id appears in the name line 923 implicitModel null

Row three is the case the fallback handles on its own, and there the two strings agree, because a parameter with no id in it loses nothing when only the prefix is stripped. Row two reaches the same line with a name that does contain an id. That is the edge case from Intuition, seen from the code side.

4. The pattern it says it matches

The pull request describes the new catch as matching an existing one in the same class. That existing catch is real, and it is worth comparing precisely.

uPortal-rendering/.../url/UrlSyntaxProviderImpl.java:945

            try {
                return this.portletWindowRegistry.getPortletWindowId(request, portletWindowIdStr);
            } catch (IllegalArgumentException e) {
                this.logger.warn(
                        "Failed to parse portlet window id: "
                                + portletWindowIdStr
                                + " null will be returned",
                        e);
            }
        }

        return null;

Same shape: try the registry call, catch the same exception type, log at warn, fall through to a no-window result. Three differences a reader should see.

The older one is single-shot, guarded by a contains check rather than sitting in a loop, so it can log at most once. The newer one is inside the loop, so one parameter with several declared ids can log several times.

The older one returns a bare null. The newer one returns a non-null Tuple whose second is null, which is why the caller has a null check rather than a null result to handle.

The older one passes the exception object e to the logger, so the stack trace is recorded. The newer one passes e.getMessage(), so the log carries the message without the trace. That is the difference between knowing what failed and knowing where.

5. What the caller does with a null window

Grepping the repository for parsePortletParameterName finds one production caller, getPortalRequestInfo in the same class, plus the new test. When second comes back null the caller takes one of two paths, and neither one throws.

If no portlet is targeted by the request, it logs its own warning, saying the parameter will be ignored, removes the parameter, and breaks. If a portlet is targeted, the parameter is attributed to that portlet under the name in first. For the stale case that name still carries the entity id, so the targeted portlet is handed a parameter it does not read.

Either way, parsing completes, rendering proceeds, and the user gets their layout minus the removed portlet.

6. The tests, and what they cover

Two tests are added. Both stub the registry to throw the real message, and both assert that second is null rather than that an exception escapes.

uPortal-webapp/.../url/UrlSyntaxProviderImplTest.java:1059

    public void testParsePortletParameterNameWithInvalidPortletEntity() {

That one covers the new catch. The second, at line 1088, exercises parsePortletWindowIdSuffix, the sibling method whose catch already existed and which this diff does not touch. It changes no behavior; it records the reference the new code was written to match.

Note what the first test asserts and what it does not. It asserts that second is null. It does not assert the value of first, so the difference between implicitModel and 27_ctf9_142032_implicitModel from step 3 is not pinned by a test.

7. Thirty tests that were not running

The pull request describes itself in three bullets: the try/catch, the two log lines, and "Existing UrlSyntaxProviderImplTest updated with test for stale portlet entity IDs". The words @Ignore, MockitoJUnitRunner, and Silent do not appear in it. So the two lines below are in the diff without being in the description.

uPortal-webapp/.../url/UrlSyntaxProviderImplTest.java:53

-@Ignore // Breaks on move to Gradle
-@RunWith(MockitoJUnitRunner.class)
+@RunWith(MockitoJUnitRunner.Silent.class)
 public class UrlSyntaxProviderImplTest {

@Ignore on a JUnit 4 class skips the entire class. Not one method ran, and the setup methods did not run either. The class holds 32 @Test methods at this commit, two of them new, so 30 tests had been reporting as skipped rather than passing. A green build said nothing about this file.

The comment gives the reason as // Breaks on move to Gradle, which names a build migration and not a defect in the code under test. When the annotation landed is not something this page can tell you, because the checkout it was written from holds a single commit.

The runner change is what switching them back on required. Plain MockitoJUnitRunner is an alias for MockitoJUnitRunner.Strict: after the class runs, Mockito collects any when(...) stubbing that was never actually called and fails the class with UnnecessaryStubbingException. Silent runs with lenient strictness, which switches off that detection and leaves verify behavior untouched. This project pins Mockito 4.11.0 and JUnit 4.13.2.

The file shows what that detection would have caught. In one URL-generation test, three stubbings sit together on portletWindow2, which stands in for a delegate window:

uPortal-webapp/.../url/UrlSyntaxProviderImplTest.java:421-423

        when(portletWindow2.getPortletEntity()).thenReturn(portletEntity2);
        when(portletWindow2.getDelegationParentId()).thenReturn(portletWindowId1);
        when(portletEntity2.getLayoutNodeId()).thenReturn(subscribeId2);

The middle one is used: generateUrl reads getDelegationParentId() for a non-targeted window. The other two are not. generateUrl reads getPortletEntity() and getLayoutNodeId() only inside its targetedPortletWindowId != null branch, and portletWindow2 is not the targeted window here. So two of the three stubbings are never realized, and strict mode fails the whole class on them, not the one test. The same two appear again in another generation test at line 546.

What the runner line does Silent is what lets the 30 tests run at all, and it switches off unused-stub detection for every test in this class, including ones added later whose stubs stop matching. Both follow from the one line. Which of the two weighs more is a question for this project's maintainers; what the page can do is make sure you saw the line.

8. Two log lines in other modules

The remaining two files are unrelated to stale URLs. Each lowers one log level. The frequencies below say how often each line fires, which is the fact you need to judge the edits.

uPortal-security/.../url/ClassicMaxInactiveStrategy.java:47

-            log.info(
+            log.debug(
                     "No {} permissions apply to user '{}'",

This runs in a filter mapped to the portal's main servlets, and the filter re-checks at most once every five minutes per session. The branch it sits in is the state of any deployment that never grants the permission. At INFO, such a deployment logs this line once per authenticated session per five minutes.

uPortal-utils/.../personalize/PersonalizerImpl.java:110

-                log.warn("Person attribute value is not a string!! : [{}]", key);
+                log.debug("Person attribute value is not a string : [{}]", key);

This one sits in the else branch of a loop over every attribute of a person, so it repeats per attribute, not per event. One call site passes no session, which skips the cache and re-runs the whole loop, re-logging it, for every personalized string. It was at WARN, so it appeared among genuine faults.

Two smaller things in the same hunk. The message text changed: the doubled exclamation marks are gone. And the pull request describes both log edits as going "from info to debug", which is right for ClassicMaxInactiveStrategy and not for this one, which was at WARN. Worth knowing if you are reading the description rather than the diff.

One piece of context a reader of this repository should have. uPortal keeps a file called AGENTS.md at its root, holding the conventions it expects contributors and coding agents to follow. Its surgical-changes section says "Touch only what the task requires. Do not 'improve' adjacent code, comments, or formatting" and "Every changed line must trace directly back to the task at hand". Its boundaries section lists "A change would affect more than one module" under when to always stop and ask. These two edits are in two modules that have nothing to do with stale URLs. Whether that reads as welcome cleanup or as unrelated scope is a call for this project's reviewers; the page's job is to make sure you noticed the edits are there.

The transferable part When you make a fix by falling through to an existing path, check what that path computes, not only what it returns. Here the fallback produces a correct null window and a differently-derived parameter name, because it only ever had to handle names with no id in them. And when you read a diff, read the annotations as carefully as the statements: a deleted @Ignore and a changed runner moved 30 tests from skipped to running, which changes what a green build tells you about this file.

Quiz

Five questions about why the change is shaped the way it is. Click an option to see the answer.

1. After the new catch fires for pP_27_ctf9_142032_implicitModel, and no other declared id resolves, what Tuple does the method return?

The fallback at line 922 strips only the pP_ prefix, which is all a parameter with no entity id in it needs. The success path at line 903 strips the prefix, the id, and the separator. Each is correct for the case that reaches it directly; the catch routes one case into the other's code.

2. The diff deletes @Ignore // Breaks on move to Gradle from the test class. How many tests were running in that file before this pull request?

A skipped test is not a failing test, so a green build said nothing about this file either way. The pull request description does not mention this part of the change.

3. Why would re-enabling a long-ignored Mockito test class plausibly require switching to MockitoJUnitRunner.Silent?

The file carries the evidence: two stubbings on a delegate window describe calls the parser makes only for the targeted window, so they are never realized. Strict mode reports UnnecessaryStubbingException for the class, not the method, which is why one dead stub can block the whole file. Silent changes nothing about verification.

4. The pre-existing catch logs with logger.warn(message, e). The new one logs with logger.warn(template, name, e.getMessage()). What does an operator lose?

SLF4J treats a trailing Throwable specially and prints its trace; a String argument fills a placeholder instead. The new line does add something the old one lacks, the offending parameter name, so this is a trade rather than a straight loss. Worth knowing which half you have when reading the log.

5. The catch sits inside a for loop over the declared entity ids, and it does not break or return. What behavior does that produce which return new Tuple<>(paramName, null) inside the catch would not?

Falling through keeps the loop's original intent, which is to try every declared id. Returning from the catch would abandon the search on the first failure. The cost is the repeated warning, and the returned name in the failure case, which the immediate return would have computed at line 903 instead. A catch that logs and continues is easy to skim past; what it continues into is the part worth reading.