Skip to content

WW-3576 Keep SessionMap in step with the HttpSession - #2042

Open
lukaszlenart wants to merge 27 commits into
mainfrom
feature/WW-3576-session-map-staleness
Open

lukaszlenart wants to merge 27 commits into
mainfrom
feature/WW-3576-session-map-staleness

Conversation

@lukaszlenart

@lukaszlenart lukaszlenart commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

Fixes WW-3576

What

SessionMap wraps HttpSession but cached state that belongs to the container, so it went stale whenever the real session changed under it. Two symptoms:

  • a NullPointerException when get() races invalidate() on the same map (check-then-lock on session.getId().intern(), then a re-read of the field inside the lock) — reproducible deterministically on main, most easily through execAndWait, whose background thread shares the request's map;
  • IllegalStateException once another request invalidates the session.

Supersedes #1577.

How

  • Pins the session it first sees; the field is volatile and read once per call into a local. No locking (the interned-id monitor is gone).
  • An IllegalStateException from the pinned session means it was invalidated: the map un-pins it and answers as "no session" (null / false / empty set / no-op).
  • entrySet() is built fresh on every call; entries is deprecated for removal (WW-5788).
  • Every SessionMap built with a request registers on that request, and request cleanup detaches all of them (deferred while request.isAsyncStarted(), until the async dispatch's cleanup). A detached map keeps working against a live pinned session; a put with none throws IllegalStateException instead of resolving a session through a request that has already ended.
  • StrutsBackgroundProcess.done is volatile: the polling request reads the flag the background thread writes.

An execAndWait session sandbox was built on this branch and then split out: it changes observable execAndWait behaviour (deferred background writes, frozen reads), so it moves to the 8.0.0 rewrite in WW-5787.

Changes visible to subclasses and integrations (for the 7.5.0 migration notes)

  • SessionMap: session is volatile; entries is @Deprecated(since = "7.5.0", forRemoval = true) and no longer read; no locking; put on a session invalidated elsewhere pins a new session while the request is live; IllegalStateException on put from a detached map with no live session.
  • The SessionMap(HttpServletRequest) constructor calls getAttribute/setAttribute on the request (registry key org.apache.struts2.dispatcher.SessionMap.maps; visible in #request and the devMode debug dump; not Serializable). Strict mocks in tests need stubs for both.
  • An app-owned startAsync + complete() without a dispatch leaves the request's maps attached.

Tests

  • SessionMapTest: deterministic race reproduction (latch-held invalidate()), per-operation invalidated-session tests, detach behaviour.
  • PrepareOperationsTest: detach of every request map, createSession replacement, async deferral.
  • ./mvnw test -DskipAssembly -pl core: 3543 tests, 0 failures, 0 errors.

Design and plan: docs/superpowers/specs/2026-10-18-WW-3576-session-map-staleness-design.md, docs/superpowers/plans/2026-10-18-WW-3576-session-map-staleness.md.

🤖 Generated with Claude Code

lukaszlenart and others added 22 commits October 18, 2026 08:50
…sion sandbox

Request-thread SessionMap pins its session, detects invalidation and drops the
entries cache and the interned-id lock. execAndWait background processes work on
a sandboxed session snapshot reconciled on the polling request, gated on the
session id, with per-action conflict resolution. Enforcing the sandbox is WW-5787.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ecAndWait sandbox

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he interned-id lock

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ut and harden the race test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ching SessionMap entries

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n invalidated session

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… once

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…writes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ict resolution

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ssion sandbox

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…session sandbox

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A detached map never calls its request again: with a live pinned session it
keeps working, and a put with no live pinned session throws
IllegalStateException instead of resolving a session through a request that
has already ended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er cycle

SessionMap no longer imports the sandbox: it owns a thread-local live-write
listener that the sandbox sets while the background action runs, so
current() and warnEscapeOnce() become private. The sandbox's operation log,
snapshot accessors and runInSandbox (now Callable-based) are package-private,
SessionOperation too; the remaining public members carry Javadoc. An entry's
setValue now notifies the listener like put/remove/clear/invalidate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The background process's context map is copied when the sandbox is handed
over, on the request thread, instead of on the background thread while the
request may still be rendering the wait page. An OgnlContext is copied as an
OgnlContext. A context bound by an overridden beforeInvocation() is honoured
and copied on the background thread. done is volatile, since it gates the
write-back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The removed paragraph contradicted the sandbox paragraph. The invisibility
test now fails when the background action failed or wrote nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tarted

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…started

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@lukaszlenart
lukaszlenart marked this pull request as ready for review October 18, 2026 10:59
lukaszlenart and others added 2 commits October 18, 2026 13:01
Suppress S3077 on the pinned session field, as for the localized-text caches:
volatile only publishes the reference. Drop a redundant import and poll with
Awaitility instead of Thread.sleep in the new tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BackgroundProcess.getSessionSandbox() returns Optional instead of null (the
method is new in this change, so nothing depends on the null contract).
Suppress S1948 on SessionMap.session (non-transient as before), S2160 on
SandboxedSessionMap (Map equality by contents) and S2925 on the test action's
simulated work; drop an exception the polling helper no longer throws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved session-rebinding and reconciliation races can hide completed writes, overwrite concurrent updates, or fail completion polling.

5 open issues
What changed in this PR

Addresses WW-3576 by updating Struts session-map handling and deferring execAndWait background session writes until completion polling.

Changes:

  • Refreshes session reads and detaches request maps during cleanup.
  • Adds background session snapshots, write-back, and per-action conflict resolution.
  • Adds regression tests and design documentation.
File Description
docs/​superpowers/​specs/​2026-10-18-WW-3576-session-map-staleness-design.md Defines session lifecycle and sandbox behavior.
docs/​superpowers/​plans/​2026-10-18-WW-3576-session-map-staleness.md Documents implementation and testing steps.
core/​src/​test/​java/​org/​apache/​struts2/​interceptor/​SessionWritingWaitAction.java Adds a background-writing test action.
core/​src/​test/​java/​org/​apache/​struts2/​interceptor/​ExecuteAndWaitInterceptorTest.java Tests isolation, write-back, and reinjection.
core/​src/​test/​java/​org/​apache/​struts2/​interceptor/​exec/​StrutsBackgroundProcessTest.java Tests context copying and hooks.
core/​src/​test/​java/​org/​apache/​struts2/​interceptor/​exec/​SessionSandboxReconcilerTest.java Tests reconciliation and conflict outcomes.
core/​src/​test/​java/​org/​apache/​struts2/​interceptor/​exec/​SandboxedSessionMapTest.java Tests snapshots, operation logging, and warnings.
core/​src/​test/​java/​org/​apache/​struts2/​interceptor/​debugging/​DebuggingInterceptorTest.java Updates expected request-registry output.
core/​src/​test/​java/​org/​apache/​struts2/​interceptor/​CreateSessionInterceptorTest.java Adapts mocks for map registration.
core/​src/​test/​java/​org/​apache/​struts2/​dispatcher/​SessionMapTest.java Tests invalidation, races, and detachment.
core/​src/​test/​java/​org/​apache/​struts2/​dispatcher/​PrepareOperationsTest.java Tests cleanup and async deferral.
core/​src/​main/​java/​org/​apache/​struts2/​interceptor/​ExecuteAndWaitInterceptor.java Integrates sandbox creation and write-back.
core/​src/​main/​java/​org/​apache/​struts2/​interceptor/​exec/​StrutsBackgroundProcess.java Installs background contexts and session sandboxes.
core/​src/​main/​java/​org/​apache/​struts2/​interceptor/​exec/​SessionSandboxReconciler.java Applies writes with session-ID and conflict checks.
core/​src/​main/​java/​org/​apache/​struts2/​interceptor/​exec/​SessionOperation.java Defines logged session operations.
core/​src/​main/​java/​org/​apache/​struts2/​interceptor/​exec/​SandboxedSessionMap.java Implements snapshot-backed session writes.
core/​src/​main/​java/​org/​apache/​struts2/​interceptor/​exec/​BackgroundProcess.java Adds optional sandbox integration methods.
core/​src/​main/​java/​org/​apache/​struts2/​dispatcher/​SessionMap.java Updates pinning, invalidation, and lifecycle handling.
core/​src/​main/​java/​org/​apache/​struts2/​dispatcher/​PrepareOperations.java Detaches maps during request cleanup.
core/​src/​main/​java/​org/​apache/​struts2/​action/​SessionConflictResolution.java Defines conflict-resolution choices.
core/​src/​main/​java/​org/​apache/​struts2/​action/​SessionConflict.java Carries conflicting session values.
core/​src/​main/​java/​org/​apache/​struts2/​action/​BackgroundSessionConflictAware.java Exposes per-action conflict handling.

Review effort: Balanced

Comment thread core/src/main/java/org/apache/struts2/interceptor/ExecuteAndWaitInterceptor.java Outdated
Comment thread core/src/main/java/org/apache/struts2/interceptor/exec/SandboxedSessionMap.java Outdated
lukaszlenart and others added 2 commits October 18, 2026 16:37
- A sandbox entry's setValue updates the entry as well as the sandbox.
- Write-back discards the remaining writes when the session is invalidated
  while it runs, instead of failing the polling request; an exception from
  the conflict hook still propagates.
- When write-back replaces the session, the polling context gets a new
  request SessionMap and session helper before re-injection.
- The default beforeInvocation() binds the handoff copy, so a
  super-calling override edits the context the action runs with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An IllegalStateException during write-back discards the remaining writes
only when the session is really invalidated; one thrown by a listener on a
live session propagates. A sandbox entry's setValue returns the entry's
previous value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

The sandbox changes observable execAndWait behaviour in a minor release:
background session writes are deferred until the job finishes, the job
no longer sees session changes from other requests, and a session-id
rotation discards its writes. It moves to the 8.0.0 rewrite (WW-5787).

WW-3576 keeps the SessionMap staleness fix, the request registry with
detach at request end, and the volatile done flag on
StrutsBackgroundProcess. ExecuteAndWaitInterceptor, BackgroundProcess and
StrutsBackgroundProcess otherwise return to main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@lukaszlenart lukaszlenart changed the title WW-3576 Keep SessionMap in step with the HttpSession and sandbox execAndWait session writes WW-3576 Keep SessionMap in step with the HttpSession Oct 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants