Skip to content

Make StringQuoter date parsing thread-safe#10322

Open
metsw24-max wants to merge 3 commits into
gwtproject:mainfrom
metsw24-max:stringquoter-date-threadsafe
Open

Make StringQuoter date parsing thread-safe#10322
metsw24-max wants to merge 3 commits into
gwtproject:mainfrom
metsw24-max:stringquoter-date-threadsafe

Conversation

@metsw24-max

Copy link
Copy Markdown

RequestFactory and AutoBean decode Date values on the server through StringQuoter.tryParseDate, which shared two static SimpleDateFormat instances across all request threads. SimpleDateFormat parsing mutates internal Calendar state, so concurrent requests race and return wrong dates or throw. Give each thread its own formatter via ThreadLocal.

RequestFactory and AutoBean decode Date values on the server through StringQuoter.tryParseDate, which shared two static SimpleDateFormat instances across all request threads. SimpleDateFormat parsing mutates internal Calendar state, so concurrent requests race and return wrong dates or throw. Give each thread its own formatter via ThreadLocal.
@niloc132

Copy link
Copy Markdown
Member

Interesting - the code is definitely wrong, but does this actually fail in any real use case? It looks like you could manufacture such a scenario by manually writing JSON, but clearly AutoBeans (and thus, RequestFactory) won't serialize dates using these formats. I imagine these were speculatively added, but never actually used.

public Splittable encode(Object value) {
return StringQuoter.create(String.valueOf(((Date) value).getTime()));
}

That seems contradicted by discussion at #6330 (esp #6330 (comment)) and 3df95e6. Nevertheless, there are still no tests using date strings that I can find.

I'm apprehensive about merging this despite its correctness, given the abandoned #10307 and the behavior of the account (many PRs to many unrelated repos with weak followup and tendency towards LLM-like content).

@vjay82

vjay82 commented May 27, 2026

Copy link
Copy Markdown

If the code was never used, then it could be removed, no? However, if it is in use, it looks more correct than before, proposed by a LLM or not, no?

@niloc132

Copy link
Copy Markdown
Member

Right - the code looks right, but the use case needs tests. The linked issue makes me suspect it is (or at least "was") used in some capacity, so removing a feature of a public api is at least somewhat dangerous, and there's probably no downside... but especially if it is tool-generated code, it should come with a test.

As a maintainer, I think it is appropriate for me to have a degree of skepticism for "more correct than before" code coming from low-effort sources, as a way to gain trust and attack the project. I'm definitely not accusing @metsw24-max here - the fixes to all the various repos they've sent code to seem well-intentioned, but very few have tests or actual use cases.

@metsw24-max

Copy link
Copy Markdown
Author

Fair point on the missing tests. Pushed a JRE test (StringQuoterJreTest) that covers the millis, ISO-8601, Z-suffix, RFC 2822, and unparseable paths, plus a concurrent hammer on tryParseDate that fires 16 threads x 2000 parses at the same date string. On the unpatched code it reliably produces wrong Dates (got ~11 on my box); with the ThreadLocal it sits at zero. Also wired into AutoBeanSuite.

@niloc132

Copy link
Copy Markdown
Member

Thanks - locally I haven't had the test pass without the fix, though I did see only one wrong date value (out of 16*2000 checks) - usually single digit, but rather less than 5, so the test does what it is there to do.

@zbynek

zbynek commented May 28, 2026

Copy link
Copy Markdown
Collaborator

Why wasn't this caught by https://errorprone.info/bugpattern/DateFormatConstant ? The rule exists in 2.33.


public void testTryParseDateMillis() {
Date d = new Date(1234567890123L);
assertEquals(d, StringQuoter.tryParseDate(Long.toString(d.getTime())));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think it would be more transparent if all arguments for tryParseDate in this file were string literals to make it obvious what kinds of inputs tryParseDate accepts.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good call. Switched every tryParseDate argument to a string literal so the accepted formats are visible at the call site. They all point at the same instant (2009-02-13T23:31:30.123 UTC) with explicit +0000 offsets, so the expected Dates are deterministic regardless of the box's timezone.

@niloc132

Copy link
Copy Markdown
Member

@zbynek nice find - it looks like that is configured as a warning rather than an error, and common.ant.xml sets nowarn so we don't get warnings in the logs. There are a lot of warnings though... some might be valid, but a large number of them are javadoc or deprecation related.

google/error-prone#424 exists to let projects pick out warnings to disable or let them fail the build, but hasn't been fixed.

When I turn warnings on, I don't see the DateFormatConstant in the output - we might have too many warnings to see all of them...

Here's a count of each warning type, via ant clean dist-dev | grep bugpattern | sort -nr | uniq -c | sort -nr

   4679     [javac]     (see https://errorprone.info/bugpattern/MissingOverride)
    341     [javac]     (see https://errorprone.info/bugpattern/JavaUtilDate)
    284     [javac]     (see https://errorprone.info/bugpattern/ReferenceEquality)
    248     [javac]     (see https://errorprone.info/bugpattern/InvalidInlineTag)
    241     [javac]     (see https://errorprone.info/bugpattern/NonApiType)
    213     [javac]     (see https://errorprone.info/bugpattern/UnnecessaryParentheses)
    198     [javac]     (see https://errorprone.info/bugpattern/InlineMeSuggester)
    146     [javac]     (see https://errorprone.info/bugpattern/SameNameButDifferent)
    143     [javac]     (see https://errorprone.info/bugpattern/JdkObsolete)
    137     [javac]     (see https://errorprone.info/bugpattern/UnusedVariable)
    116     [javac]     (see https://errorprone.info/bugpattern/BadImport)
     89     [javac]     (see https://errorprone.info/bugpattern/StringSplitter)
     75     [javac]     (see https://errorprone.info/bugpattern/DefaultCharset)
     72     [javac]     (see https://errorprone.info/bugpattern/IdentityHashMapUsage)
     71     [javac]     (see https://errorprone.info/bugpattern/OperatorPrecedence)
     67     [javac]     (see https://errorprone.info/bugpattern/NotJavadoc)
     65     [javac]     (see https://errorprone.info/bugpattern/InvalidLink)
     64     [javac]     (see https://errorprone.info/bugpattern/TypeParameterUnusedInFormals)
     63     [javac]     (see https://errorprone.info/bugpattern/ProtectedMembersInFinalClass)
     59     [javac]     (see https://errorprone.info/bugpattern/AnnotateFormatMethod)
     55     [javac]     (see https://errorprone.info/bugpattern/InvalidBlockTag)
     43     [javac]     (see https://errorprone.info/bugpattern/UnrecognisedJavadocTag)
     35     [javac]     (see https://errorprone.info/bugpattern/ClassCanBeStatic)
     34     [javac]     (see https://errorprone.info/bugpattern/ClassNewInstance)
     33     [javac]     (see https://errorprone.info/bugpattern/InvalidParam)
     32     [javac]     (see https://errorprone.info/bugpattern/StaticAssignmentInConstructor)
     24     [javac]     (see https://errorprone.info/bugpattern/StringCharset)
     22     [javac]     (see https://errorprone.info/bugpattern/FallThrough)
     20     [javac]     (see https://errorprone.info/bugpattern/MissingCasesInEnumSwitch)
     18     [javac]     (see https://errorprone.info/bugpattern/ImmutableEnumChecker)
     17     [javac]     (see https://errorprone.info/bugpattern/EqualsGetClass)
     16     [javac]     (see https://errorprone.info/bugpattern/BoxedPrimitiveConstructor)
     14     [javac]     (see https://errorprone.info/bugpattern/OutlineNone)
     12     [javac]     (see https://errorprone.info/bugpattern/InlineFormatString)
     11     [javac]     (see https://errorprone.info/bugpattern/ReturnAtTheEndOfVoidFunction)
     11     [javac]     (see https://errorprone.info/bugpattern/MixedMutabilityReturnType)
     11     [javac]     (see https://errorprone.info/bugpattern/LongDoubleConversion)
     11     [javac]     (see https://errorprone.info/bugpattern/DoNotCallSuggester)
     11     [javac]     (see https://errorprone.info/bugpattern/CatchAndPrintStackTrace)
     10     [javac]     (see https://errorprone.info/bugpattern/FutureReturnValueIgnored)
      9     [javac]     (see https://errorprone.info/bugpattern/UnusedMethod)
      9     [javac]     (see https://errorprone.info/bugpattern/SynchronizeOnNonFinalField)
      9     [javac]     (see https://errorprone.info/bugpattern/InlineMeInliner)
      8     [javac]     (see https://errorprone.info/bugpattern/InconsistentCapitalization)
      8     [javac]     (see https://errorprone.info/bugpattern/HidingField)
      6     [javac]     (see https://errorprone.info/bugpattern/URLEqualsHashCode)
      6     [javac]     (see https://errorprone.info/bugpattern/SuperEqualsIsObjectEquals)
      6     [javac]     (see https://errorprone.info/bugpattern/ReturnFromVoid)
      6     [javac]     (see https://errorprone.info/bugpattern/EscapedEntity)
      6     [javac]     (see https://errorprone.info/bugpattern/EqualsUnsafeCast)
      5     [javac]     (see https://errorprone.info/bugpattern/UnsynchronizedOverridesSynchronized)
      5     [javac]     (see https://errorprone.info/bugpattern/OrphanedFormatString)
      4     [javac]     (see https://errorprone.info/bugpattern/UnusedTypeParameter)
      4     [javac]     (see https://errorprone.info/bugpattern/UnusedNestedClass)
      4     [javac]     (see https://errorprone.info/bugpattern/TypeParameterShadowing)
      4     [javac]     (see https://errorprone.info/bugpattern/TypeEquals)
      4     [javac]     (see https://errorprone.info/bugpattern/ShortCircuitBoolean)
      4     [javac]     (see https://errorprone.info/bugpattern/OverrideThrowableToString)
      4     [javac]     (see https://errorprone.info/bugpattern/NarrowCalculation)
      4     [javac]     (see https://errorprone.info/bugpattern/LoopOverCharArray)
      4     [javac]     (see https://errorprone.info/bugpattern/IntLongMath)
      4     [javac]     (see https://errorprone.info/bugpattern/DateFormatConstant)
      4     [javac]     (see https://errorprone.info/bugpattern/DateChecker)
      3     [javac]     (see https://errorprone.info/bugpattern/UnicodeEscape)
      3     [javac]     (see https://errorprone.info/bugpattern/UndefinedEquals)
      3     [javac]     (see https://errorprone.info/bugpattern/ToStringReturnsNull)
      2     [javac]     (see https://errorprone.info/bugpattern/StaticAssignmentOfThrowable)
      2     [javac]     (see https://errorprone.info/bugpattern/ModifyCollectionInEnhancedForLoop)
      2     [javac]     (see https://errorprone.info/bugpattern/LabelledBreakTarget)
      2     [javac]     (see https://errorprone.info/bugpattern/JavaLangClash)
      2     [javac]     (see https://errorprone.info/bugpattern/IncrementInForLoopAndHeader)
      2     [javac]     (see https://errorprone.info/bugpattern/EqualsIncompatibleType)
      2     [javac]     (see https://errorprone.info/bugpattern/EmptyTopLevelDeclaration)
      2     [javac]     (see https://errorprone.info/bugpattern/ArgumentSelectionDefectChecker)
      1     [javac]     (see https://errorprone.info/bugpattern/WaitNotInLoop)
      1     [javac]     (see https://errorprone.info/bugpattern/ThreadPriorityCheck)
      1     [javac]     (see https://errorprone.info/bugpattern/InvalidThrows)
      1     [javac]     (see https://errorprone.info/bugpattern/InlineTrivialConstant)
      1     [javac]     (see https://errorprone.info/bugpattern/Finally)
      1     [javac]     (see https://errorprone.info/bugpattern/AlmostJavadoc)

The four DateFormatConstant issues are this one class, repeated twice.

To repro this list:

diff --git a/common.ant.xml b/common.ant.xml
index d5e0312342..c10d87c16c 100755
--- a/common.ant.xml
+++ b/common.ant.xml
@@ -55,7 +55,7 @@
   <property name="javac.debuglevel" value="lines,vars,source"/>
   <property name="javac.encoding" value="utf-8"/>
   <property name="javac.release" value="11"/>
-  <property name="javac.nowarn" value="true"/>
+  <property name="javac.nowarn" value="false"/>
 
   <!-- javac and errorprone instructions from https://errorprone.info/docs/installation#ant -->
   <path id="errorprone.processorpath.ref">
@@ -184,6 +184,8 @@
         <compilerarg value="-J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED" />
         <compilerarg value="-J--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED" />
         <compilerarg value="-J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED" />
+        <compilerarg value="-Xmaxwarns" />
+        <compilerarg value="99999" />
         <compilerarg value="-XDcompilePolicy=simple"/>
         <compilerarg value="-processorpath"/>
         <compilerarg pathref="mergedprocessorpath.ref"/>

* null) or silently wrong Dates. Hammer tryParseDate from many threads and
* make sure every call returns the expected value.
*/
public void testTryParseDateConcurrent() throws Exception {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

There is quite a bit going on here:

  1. pool.submit() does return a Future and that Future is not recorded and checked for exceptions. Without the ThreadLocal code in StringQuoter the SimpleDateFormat.parse() method can actually throw exceptions other than ParseException (for example NumberFormatException) during concurrent execution. Thus StringQuoter.tryParseDate() might throw exceptions which are only recorded in the Future. In that case the test might be green because neither null or wrong have been increased.
  2. The CountDownLatch.countDown() method is called directly after the submit for-loop. This does not guarantee that all 16 threads are actually already waiting. Sometimes they do, sometimes they don't. Submitted work can be put into a queue. What you really want is calling start.countDown() followed by start.await() inside the run() method and use a CountDownLatch(threads). Otherwise a second CountDownLatch is needed if we really want a dedicated signal to start the work in the test method itself.
  3. InterruptedException should be rethrown as a runtime exception. If 1. is implemented the test can check for 16 InterruptedException in the Future to figure out if the test has actually been executed or not. The test is generally brittle and we might not want to trust a green test if too many (or all threads) have been interrupted before actually doing anything.
  4. start.await() should probably use a timeout as well and some AssertionError with a message should be thrown if await returns via timeout. This is not strictly needed but gives a hint that thread synchronization did not happen in a timely manner. Also it kind of protects against a stupid future issue if for whatever reason CountDownLatch and ExecutorService have not been initialized with the same number (e.g. latch > pool threads).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

1 for certain should be fixed - 2 could/should be combined with it, countdown as each thread is ready to run, then get() all threads before shutdown (then we shouldnt even need shutdown vs shutdownNow, since by definition all work is finished). 4 could spuriously succeed too if somehow we were slow enough to just not run through all the iterations (in GHA... i've seen stranger things, so that might actually be important).

3 seems important too if interrupts played a role in test code - though I wouldn't bet on us handling them appropriately across production compiler code, which may be acceptable for batch
code.

The good news is that the test fails without the fix, consistently. The latch bothered me at first read, but "it fails when wrong, and succeeds when obviously correct" is already a lot better than we had.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Reworked the harness along these lines:

  1. The Futures are now collected and get() with a timeout after the start signal, so anything a worker throws (the NumberFormatException path included) is rethrown and fails the test instead of being lost.
  2. ready is now a CountDownLatch(threads): each worker counts down and the main thread awaits it before releasing start, so all 16 are parked before the race begins.
  3. InterruptedException in the worker is rethrown as a RuntimeException, which surfaces through Future.get().
  4. Every await (ready, start, get) has a 60s timeout with a message, so a synchronization stall fails loudly rather than passing by accident.

Since we now get() every worker before shutting the pool down, all the work is guaranteed finished by then.

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.

5 participants