WW-5650 Obtain a fresh JSON reader/writer per request in JSONInterceptor#1782
WW-5650 Obtain a fresh JSON reader/writer per request in JSONInterceptor#1782lukaszlenart wants to merge 4 commits into
Conversation
lukaszlenart
left a comment
There was a problem hiding this comment.
Code Review
Overview
Follow-up to the earlier thread-safety fixes (#1775/#1776). Those confined per-operation parse/serialize state to a ThreadLocal because a single StrutsJSONReader/StrutsJSONWriter was shared across concurrent requests via the singleton JSONInterceptor. This PR takes a cleaner structural approach: the interceptor now resolves a fresh JSONUtil (and thus fresh reader + writer) per request from the container, so no state is ever shared across threads. The ThreadLocal machinery reverts to plain instance fields, and the classes are documented as not thread-safe.
Correctness — solid
- Prototype scopes confirmed.
struts-plugin.xmlregistersJSONWriter,JSONReader, andJSONUtilall asscope="prototype", socontainer.getInstance(JSONUtil.class)genuinely returns a fresh object graph each call.JSONUtil.setReader/setWriterare@Inject, so the fresh util carries a fresh reader/writer. The design premise holds. getJSONUtil()called once perintercept()and threaded throughapplyLimitsToReader(jsonUtil)and the SMDserializepath — no leftover reference to the removed cached field.- JSONResult path untouched and already safe. Results are instantiated per invocation, each with its own
@InjectJSONUtil, so it never shared state. Correctly left alone. - Defensive reset preserved.
write(...)resetsbuf/stack/exprStack/root/buildExprat entry andread(String)resetsit/c/depth, so even sequential reuse of one instance stays clean. - No stragglers. No remaining
setJsonUtilcallers on the interceptor; all tests migrated tosetContainer.
Style / conventions
- Mirrors the in-tree
AbstractFileUploadInterceptorpattern (injectContainer, resolve collaborators per request). - Javadoc updates accurately describe the new contract and reference WW-5650.
- The
protected getJSONUtil()seam is a clean, testable extension point; public/protected reader/writer signatures are unchanged, so subclasses andstruts.json.reader/writeroverrides keep working.
Performance
Each request now constructs a fresh JSONUtil + reader + writer via container.getInstance. A modest per-request cost versus the previous single injected instance — acceptable for eliminating shared mutable state. The expensive part, StrutsJSONWriter.BEAN_INFO_CACHE, remains static, so introspection results are still cached across instances.
Test coverage — minor gap
- Removing the concurrency tests is correct: they asserted an invariant the design intentionally drops (a single shared instance being safe).
testObtainsFreshJSONUtilAndReaderPerInvocationis a good structural guard against re-caching/singleton-scoping (distinctJSONUtil+ distinctgetReader()per call).- Gap: nothing exercises writer freshness per request the same way. The util exposes no writer getter (acknowledged in the PR), so it's understandable, but the writer was the more dangerous of the two (cross-response data leakage). Consider a small integration-level assertion if a getter can reasonably be exposed. Not blocking.
Security
Safe resolution of the original vulnerability class (one request's serialized data surfacing in another's response). Since instances are no longer shared, the race is structurally impossible rather than mitigated by cleanup discipline. Net improvement.
Nit
StrutsJSONWriteradds a trailing blank line before the closing brace — trivial.
Verdict
Clean, well-reasoned refactor that removes complexity while strengthening the safety guarantee. Correctness verified against the plugin config and call sites. LGTM after considering the optional writer-freshness test.
🤖 Generated with Claude Code
There was a problem hiding this comment.
Pull request overview
This PR refactors the Struts JSON plugin to avoid sharing JSON parse/serialize state across concurrent requests by having JSONInterceptor resolve a fresh JSONUtil (and thus fresh reader/writer instances) per interception, instead of relying on per-call ThreadLocal state in StrutsJSONReader/StrutsJSONWriter.
Changes:
JSONInterceptornow injectsContainerand obtains a freshJSONUtilper invocation (with agetJSONUtil()seam), and applies limits to that per-invocation reader.StrutsJSONReader/StrutsJSONWriterrevert fromThreadLocalstate back to instance fields and are documented as not thread-safe (prototype-per-operation expectation).- Concurrency-focused reader/writer tests are removed and replaced with an interceptor-level “fresh instance” test.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| plugins/json/src/main/java/org/apache/struts2/json/JSONInterceptor.java | Switches from cached JSONUtil injection to per-invocation resolution via Container, and threads that instance through reader-limit application and JSON-RPC handling. |
| plugins/json/src/main/java/org/apache/struts2/json/StrutsJSONReader.java | Removes ThreadLocal parse state and returns to per-instance parse fields; updates class documentation to non-thread-safe/prototype usage. |
| plugins/json/src/main/java/org/apache/struts2/json/StrutsJSONWriter.java | Removes ThreadLocal write state and returns to per-instance write fields; updates class documentation to non-thread-safe/prototype usage. |
| plugins/json/src/test/java/org/apache/struts2/json/JSONInterceptorTest.java | Updates interceptor construction to use Container and adds a freshness test for per-acquisition JSONUtil/reader instances. |
| plugins/json/src/test/java/org/apache/struts2/json/StrutsJSONReaderTest.java | Removes concurrency reuse regression test that’s no longer aligned with the new “fresh instance per operation” design. |
| plugins/json/src/test/java/org/apache/struts2/json/StrutsJSONWriterTest.java | Removes concurrency reuse regression test that’s no longer aligned with the new “fresh instance per operation” design. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Move getJSONUtil() into the JSON and JSON-RPC branches of intercept() so requests with a non-JSON content type no longer construct and discard an unused JSONUtil/reader/writer graph. Also trim a stray trailing blank line in StrutsJSONWriter. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
| public void testObtainsFreshJSONUtilAndReaderPerInvocation() { | ||
| JSONInterceptor interceptor = new JSONInterceptor(); | ||
| interceptor.setContainer(container); // StrutsTestCase-provided container | ||
|
|
||
| JSONUtil first = interceptor.getJSONUtil(); | ||
| JSONUtil second = interceptor.getJSONUtil(); | ||
|
|
||
| assertNotSame("interceptor must obtain a fresh JSONUtil per request", first, second); | ||
| assertNotSame("each fresh JSONUtil must carry its own reader (no shared parse state)", | ||
| first.getReader(), second.getReader()); | ||
| } |
|
The per-request prototype approach looks sound and preserves the fix from #1775/#1776, so this is a non-blocking suggestion, not a gate. One coverage point worth considering: testObtainsFreshJSONUtilAndReaderPerInvocation asserts a fresh reader per invocation, but not a fresh writer, and nothing asserts that JSONResult obtains a fresh writer per request. Since StrutsJSONWriter now reverts to plain instance fields and is explicitly documented not thread-safe (including the lazily-initialized SimpleDateFormat in date()), the response-side protection from WW-5644 now depends entirely on the prototype lifecycle. There is no live bug today (JSONResult is instantiated per request), but if the bean were ever changed to singleton, or a writer cached, the cross-request response leak would return with no failing test to catch it. Suggestion: (1) assert that two fresh JSONUtil instances carry distinct writers (would need a getWriter() accessor, which JSONUtil does not currently expose), and (2) a JSONResult-level test confirming a fresh writer per result. Happy to send a patch for these if useful. |



What
Follow-up refactor of the JSON plugin's reader/writer thread-safety handling.
The interim fixes in #1775 / #1776 confined
StrutsJSONReader/StrutsJSONWriterper-operationstate to a
ThreadLocal, created fresh per call and cleared in afinally. That works, but itrelies on a per-request cleanup contract and keeps a single reader/writer instance shared across
threads.
This change removes that machinery in favour of a simpler, structural approach: the singleton
JSONInterceptornow obtains a freshJSONUtil(and thus a fresh reader + writer) per requestvia the container, so no parse/serialize state is ever shared across threads in the first place.
With instances no longer shared,
StrutsJSONReader/StrutsJSONWriterrevert to plain,single-use instance fields and are documented as not thread-safe.
Why this is safe / minimal
JSONResultwas already built per request (it injects aprototypeJSONUtil) and isuntouched — it never shared state.
JSONReader,JSONWriterandJSONUtilare already registeredscope="prototype", socontainer.getInstance(JSONUtil.class)hands back a fresh graph each call and continues to honourthe
struts.json.reader/struts.json.writeroverrides.AbstractFileUploadInterceptorinjectsContainerandresolves collaborators per request).
Changes
JSONInterceptor: drop the cached@Inject JSONUtilfield; injectContainerand resolve afresh
JSONUtilperintercept()(newprotected getJSONUtil()seam).StrutsJSONReader/StrutsJSONWriter: revertThreadLocalstate back to plain instance fields;add a "not thread-safe — obtain a fresh instance per operation" class note.
intentionally drops (a single shared instance being safe), so they are replaced by an
interceptor-level test asserting a distinct
JSONUtil/reader is obtained per acquisition.Testing
mvn test -DskipAssembly -pl plugins/json→ all green (141 tests).JSONWriterOverrideTestpasses,confirming
struts.json.reader/struts.json.writeroverrides still resolve through the container.Notes
subclasses and reader/writer overrides continue to work.
getJSONUtil()(JSONUtil exposes nowriter getter); this guards against re-caching/singleton-scoping the util.
Fixes WW-5650
🤖 Generated with Claude Code