Skip to content

Commit ba1aade

Browse files
authored
use a per-call DecimalFormat in OggAudioParser duration output (#2920)
1 parent 203da8c commit ba1aade

2 files changed

Lines changed: 135 additions & 8 deletions

File tree

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/OggAudioParser.java

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,6 @@
4646
public abstract class OggAudioParser extends AbstractParser {
4747
private static final long serialVersionUID = 5168743829615945633L;
4848

49-
private static final DecimalFormat DURATION_FORMAT =
50-
(DecimalFormat) NumberFormat.getNumberInstance(Locale.ROOT);
51-
static {
52-
DURATION_FORMAT.applyPattern("0.0#");
53-
}
54-
5549
protected static void extractChannelInfo(Metadata metadata, OggAudioInfoHeader info) {
5650
extractChannelInfo(metadata, info.getNumChannels());
5751
}
@@ -133,8 +127,13 @@ protected static void extractDuration(Metadata metadata, XHTMLContentHandler xht
133127
double duration) throws SAXException {
134128
// Record the duration, if available
135129
if (duration > 0) {
136-
// Save as metadata to the nearest .01 seconds
137-
metadata.add(XMPDM.DURATION, DURATION_FORMAT.format(duration));
130+
// Save as metadata to the nearest .01 seconds.
131+
// DecimalFormat is not thread-safe and these parsers are shared across
132+
// threads, so create a new one per call (see MP4Parser).
133+
DecimalFormat durationFormat =
134+
(DecimalFormat) NumberFormat.getNumberInstance(Locale.ROOT);
135+
durationFormat.applyPattern("0.0#");
136+
metadata.add(XMPDM.DURATION, durationFormat.format(duration));
138137

139138
// Output as Hours / Minutes / Seconds / Parts
140139
String durationStr = formatDuration(duration);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.tika.parser.ogg;
18+
19+
import static org.junit.jupiter.api.Assertions.assertEquals;
20+
import static org.junit.jupiter.api.Assertions.assertTrue;
21+
22+
import java.text.DecimalFormat;
23+
import java.text.NumberFormat;
24+
import java.util.ArrayList;
25+
import java.util.List;
26+
import java.util.Locale;
27+
import java.util.Map;
28+
import java.util.concurrent.ConcurrentHashMap;
29+
import java.util.concurrent.CountDownLatch;
30+
import java.util.concurrent.ExecutorService;
31+
import java.util.concurrent.Executors;
32+
import java.util.concurrent.TimeUnit;
33+
import java.util.concurrent.atomic.AtomicReference;
34+
35+
import org.junit.jupiter.api.Test;
36+
import org.junit.jupiter.api.Timeout;
37+
38+
import org.apache.tika.metadata.Metadata;
39+
import org.apache.tika.metadata.XMPDM;
40+
import org.apache.tika.sax.BodyContentHandler;
41+
import org.apache.tika.sax.XHTMLContentHandler;
42+
43+
/**
44+
* Regression test for the thread safety of duration formatting in
45+
* {@link OggAudioParser}. The ogg audio parsers are singletons shared across
46+
* threads, so the {@link DecimalFormat} used to emit {@link XMPDM#DURATION}
47+
* must not be shared static state. This drives {@code extractDuration}
48+
* concurrently with a range of duration shapes and asserts the emitted
49+
* metadata always matches the single-threaded value.
50+
*/
51+
public class OggAudioParserDurationTest {
52+
53+
// Durations chosen to exercise rounding / off-fast-path behaviour of the
54+
// "0.0#" pattern, where a shared formatter's mutable state would show up.
55+
private static final double[] DURATIONS = {
56+
0.1, 1.005, 12.34, 59.995, 123.456, 3599.999, 7200.05, 86399.9
57+
};
58+
59+
@Test
60+
@Timeout(60)
61+
public void durationFormattingIsThreadSafe() throws Exception {
62+
// Expected values computed single-threaded with the same pattern.
63+
String[] expected = new String[DURATIONS.length];
64+
for (int i = 0; i < DURATIONS.length; i++) {
65+
expected[i] = format(DURATIONS[i]);
66+
}
67+
68+
int threads = 32;
69+
int iterationsPerThread = 5000;
70+
ExecutorService executor = Executors.newFixedThreadPool(threads);
71+
CountDownLatch start = new CountDownLatch(1);
72+
Map<String, String> mismatches = new ConcurrentHashMap<>();
73+
AtomicReference<Throwable> failure = new AtomicReference<>();
74+
List<java.util.concurrent.Future<?>> futures = new ArrayList<>();
75+
76+
try {
77+
for (int t = 0; t < threads; t++) {
78+
final int offset = t;
79+
futures.add(executor.submit(() -> {
80+
try {
81+
start.await();
82+
for (int i = 0; i < iterationsPerThread; i++) {
83+
int idx = (offset + i) % DURATIONS.length;
84+
Metadata metadata = new Metadata();
85+
XHTMLContentHandler xhtml =
86+
new XHTMLContentHandler(new BodyContentHandler(), metadata);
87+
xhtml.startDocument();
88+
OggAudioParser.extractDuration(metadata, xhtml, DURATIONS[idx]);
89+
String actual = metadata.get(XMPDM.DURATION);
90+
if (!expected[idx].equals(actual)) {
91+
mismatches.putIfAbsent(expected[idx], String.valueOf(actual));
92+
}
93+
}
94+
} catch (Throwable ex) {
95+
failure.compareAndSet(null, ex);
96+
}
97+
}));
98+
}
99+
start.countDown();
100+
executor.shutdown();
101+
assertTrue(executor.awaitTermination(50, TimeUnit.SECONDS),
102+
"duration formatting did not finish in time");
103+
} finally {
104+
executor.shutdownNow();
105+
}
106+
107+
if (failure.get() != null) {
108+
throw new AssertionError("concurrent duration formatting threw", failure.get());
109+
}
110+
assertTrue(mismatches.isEmpty(),
111+
"concurrent duration formatting produced corrupted output: " + mismatches);
112+
}
113+
114+
@Test
115+
public void durationOutputMatchesPattern() throws Exception {
116+
Metadata metadata = new Metadata();
117+
XHTMLContentHandler xhtml = new XHTMLContentHandler(new BodyContentHandler(), metadata);
118+
xhtml.startDocument();
119+
OggAudioParser.extractDuration(metadata, xhtml, 123.456);
120+
assertEquals(format(123.456), metadata.get(XMPDM.DURATION));
121+
}
122+
123+
private static String format(double duration) {
124+
DecimalFormat df = (DecimalFormat) NumberFormat.getNumberInstance(Locale.ROOT);
125+
df.applyPattern("0.0#");
126+
return df.format(duration);
127+
}
128+
}

0 commit comments

Comments
 (0)