diff --git a/java/fory-json/src/main/java/org/apache/fory/json/reader/Latin1JsonReader.java b/java/fory-json/src/main/java/org/apache/fory/json/reader/Latin1JsonReader.java index 3bebb2c895..b26d420e62 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/reader/Latin1JsonReader.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/reader/Latin1JsonReader.java @@ -53,7 +53,13 @@ public final class Latin1JsonReader extends JsonReader { private static final byte[] EMPTY_BYTES = new byte[0]; private static final int INITIAL_STRING_DECODE_BUFFER_SIZE = 1024; + + /** The floor the decode buffer is shrunk back to; it is kept until it is twice what is needed. */ private static final int RETAINED_STRING_DECODE_BUFFER_SIZE = 8192; + + /** The ceiling on what a single document can keep alive in a pooled reader between parses. */ + private static final int MAX_RETAINED_STRING_DECODE_BUFFER_SIZE = 1 << 17; + private static final boolean LITTLE_ENDIAN = NativeByteOrder.IS_LITTLE_ENDIAN; private static final long BYTE_ONES = 0x0101010101010101L; private static final int INT_BYTE_ONES = 0x01010101; @@ -80,6 +86,13 @@ public final class Latin1JsonReader extends JsonReader { // Latin1 string content and field-name hashing must keep unsigned byte conversion. private byte[] input; private byte[] stringDecodeBuffer = new byte[INITIAL_STRING_DECODE_BUFFER_SIZE]; + + /** + * Bytes of the longest string decoded since the last {@link #clear()}, so a steady workload keeps + * them. + */ + private int stringDecodeHighWater; + // Keep the cache after hot representation fields; an inherited reference shifts their offsets. private final FieldNameCache fieldNameCache; private ZoneIdCache zoneIdCache; @@ -275,6 +288,7 @@ protected CharSequence decodeQuotedText(int start, int end) { outBytes = ensureStringDecodeCapacity(outBytes, out + 2); out = putUtf16Char(outBytes, out, ch); } + recordStringDecodeUse(out); return decodedQuotedText(outBytes, out, true); } @@ -396,8 +410,26 @@ public void clear() { reset(); input = EMPTY_BYTES; position = 0; - if (stringDecodeBuffer.length > RETAINED_STRING_DECODE_BUFFER_SIZE) { - stringDecodeBuffer = new byte[RETAINED_STRING_DECODE_BUFFER_SIZE]; + // Keep what the last document needed, so consecutive large documents stop re-growing the + // buffer, but never pin more than the ceiling in a pooled reader. + int keep = + Math.max( + RETAINED_STRING_DECODE_BUFFER_SIZE, + Math.min(stringDecodeHighWater, MAX_RETAINED_STRING_DECODE_BUFFER_SIZE)); + if (stringDecodeBuffer.length > MAX_RETAINED_STRING_DECODE_BUFFER_SIZE + || stringDecodeBuffer.length - keep >= keep) { + stringDecodeBuffer = new byte[keep]; + } + stringDecodeHighWater = 0; + } + + /** + * Both decode paths end here, so a buffer grown for quoted text is kept the same way a String's + * is. + */ + private void recordStringDecodeUse(int length) { + if (length > stringDecodeHighWater) { + stringDecodeHighWater = length; } } @@ -2759,6 +2791,24 @@ private String readStringLatin1Tail(byte[] bytes, int out, int ch) { bytes = ensureStringDecodeCapacity(bytes, out + 1); bytes[out++] = (byte) ch; } + // Copy the plain characters up to the next stop character in one pass. Without this the whole + // remainder of a string is decoded one character at a time once the first escape is seen. + int runStart = position; + int wordEnd = input.length - Long.BYTES; + while (position <= wordEnd) { + long stopMask = stringStopMask(LittleEndian.getInt64(input, position)); + if (stopMask != 0) { + position += Long.numberOfTrailingZeros(stopMask) >>> 3; + break; + } + position += Long.BYTES; + } + int run = position - runStart; + if (run > 0) { + bytes = ensureStringDecodeCapacity(bytes, out + run); + System.arraycopy(input, runStart, bytes, out, run); + out += run; + } if (position >= input.length) { throw error("Unterminated string"); } @@ -2835,6 +2885,7 @@ private char readLowSurrogateEscape() { } private String finishDecodedString(byte[] bytes, int length, boolean utf16) { + recordStringDecodeUse(length); // The decode buffer is reader-owned reusable storage; returned Strings must own exact bytes. byte[] result = new byte[length]; System.arraycopy(bytes, 0, result, 0, length); diff --git a/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf16JsonReader.java b/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf16JsonReader.java index 1827b3ef9a..d882826291 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf16JsonReader.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf16JsonReader.java @@ -50,7 +50,13 @@ */ public final class Utf16JsonReader extends JsonReader { private static final int INITIAL_STRING_DECODE_BUFFER_SIZE = 1024; + + /** The floor the decode buffer is shrunk back to; it is kept until it is twice what is needed. */ private static final int RETAINED_STRING_DECODE_BUFFER_SIZE = 8192; + + /** The ceiling on what a single document can keep alive in a pooled reader between parses. */ + private static final int MAX_RETAINED_STRING_DECODE_BUFFER_SIZE = 1 << 17; + private static final boolean LITTLE_ENDIAN = NativeByteOrder.IS_LITTLE_ENDIAN; private static final long BYTE_ONES = 0x0101010101010101L; private static final long BYTE_HIGH_BITS = 0x8080808080808080L; @@ -76,6 +82,13 @@ public final class Utf16JsonReader extends JsonReader { private byte[] bytes; private int length; private byte[] stringDecodeBuffer = new byte[INITIAL_STRING_DECODE_BUFFER_SIZE]; + + /** + * Bytes of the longest string decoded since the last {@link #clear()}, so a steady workload keeps + * them. + */ + private int stringDecodeHighWater; + // Keep the cache after hot representation fields; an inherited reference shifts their offsets. private final FieldNameCache fieldNameCache; private ZoneIdCache zoneIdCache; @@ -271,6 +284,7 @@ protected CharSequence decodeQuotedText(int start, int end) { outBytes = ensureStringDecodeCapacity(outBytes, out + 2); out = putUtf16Char(outBytes, out, ch); } + recordStringDecodeUse(out); return decodedQuotedText(outBytes, out, true); } @@ -417,8 +431,26 @@ public void clear() { bytes = null; length = 0; position = 0; - if (stringDecodeBuffer.length > RETAINED_STRING_DECODE_BUFFER_SIZE) { - stringDecodeBuffer = new byte[RETAINED_STRING_DECODE_BUFFER_SIZE]; + // Keep what the last document needed, so consecutive large documents stop re-growing the + // buffer, but never pin more than the ceiling in a pooled reader. + int keep = + Math.max( + RETAINED_STRING_DECODE_BUFFER_SIZE, + Math.min(stringDecodeHighWater, MAX_RETAINED_STRING_DECODE_BUFFER_SIZE)); + if (stringDecodeBuffer.length > MAX_RETAINED_STRING_DECODE_BUFFER_SIZE + || stringDecodeBuffer.length - keep >= keep) { + stringDecodeBuffer = new byte[keep]; + } + stringDecodeHighWater = 0; + } + + /** + * Both decode paths end here, so a buffer grown for quoted text is kept the same way a String's + * is. + */ + private void recordStringDecodeUse(int length) { + if (length > stringDecodeHighWater) { + stringDecodeHighWater = length; } } @@ -2111,6 +2143,31 @@ private String readStringLatin1Tail(byte[] outBytes, int out, int ch) { out = putUtf16Char(outBytes, out, (char) ch); return readStringUtf16Tail(outBytes, out, nextStringChar()); } + // Copy the plain characters up to the next stop character in one pass. Without this the whole + // remainder of a string is decoded one character at a time once the first escape is seen. + if (LITTLE_ENDIAN && bytes != null) { + int runStart = position; + int wordEnd = length - 4; + while (position <= wordEnd) { + long word = LittleEndian.getInt64(bytes, position << 1); + long nonLatin = word & UTF16_NON_LATIN_BYTES; + long stopMask = utf16StringStopMask(word, nonLatin) | nonLatin; + if (stopMask != 0) { + position += Long.numberOfTrailingZeros(stopMask) >>> 4; + break; + } + position += 4; + } + int run = position - runStart; + if (run > 0) { + outBytes = ensureStringDecodeCapacity(outBytes, out + run); + byte[] localBytes = bytes; + for (int i = 0, offset = runStart << 1; i < run; i++, offset += 2) { + outBytes[out + i] = localBytes[offset]; + } + out += run; + } + } ch = nextStringChar(); } } @@ -2208,6 +2265,7 @@ private char readLowSurrogateEscape() { } private String finishDecodedString(byte[] outBytes, int length, boolean utf16) { + recordStringDecodeUse(length); // The decode buffer is reader-owned reusable storage; returned Strings must own exact bytes. byte[] result = new byte[length]; System.arraycopy(outBytes, 0, result, 0, length); diff --git a/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf8JsonReader.java b/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf8JsonReader.java index f9672cd72e..2582c96b3c 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf8JsonReader.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf8JsonReader.java @@ -80,7 +80,13 @@ public final class Utf8JsonReader extends JsonReader { 1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000, 1000000000 }; private static final int INITIAL_STRING_DECODE_BUFFER_SIZE = 1024; + + /** The floor the decode buffer is shrunk back to; it is kept until it is twice what is needed. */ private static final int RETAINED_STRING_DECODE_BUFFER_SIZE = 8192; + + /** The ceiling on what a single document can keep alive in a pooled reader between parses. */ + private static final int MAX_RETAINED_STRING_DECODE_BUFFER_SIZE = 1 << 17; + private static final boolean LITTLE_ENDIAN = NativeByteOrder.IS_LITTLE_ENDIAN; private static final long BYTE_ONES = 0x0101010101010101L; private static final int INT_BYTE_ONES = 0x01010101; @@ -126,6 +132,13 @@ public final class Utf8JsonReader extends JsonReader { private byte[] input; private int inputLimit; private byte[] stringDecodeBuffer = new byte[INITIAL_STRING_DECODE_BUFFER_SIZE]; + + /** + * Bytes of the longest string decoded since the last {@link #clear()}, so a steady workload keeps + * them. + */ + private int stringDecodeHighWater; + // Keep the cache after hot representation fields; an inherited reference shifts their offsets. private final FieldNameCache fieldNameCache; private ZoneIdCache zoneIdCache; @@ -379,6 +392,7 @@ protected CharSequence decodeQuotedText(int start, int end) { out = putUtf16Char(outBytes, out, Character.lowSurrogate(codePoint)); } } + recordStringDecodeUse(out); return decodedQuotedText(outBytes, out, true); } @@ -540,8 +554,26 @@ public void clear() { input = EMPTY_BYTES; inputLimit = 0; position = 0; - if (stringDecodeBuffer.length > RETAINED_STRING_DECODE_BUFFER_SIZE) { - stringDecodeBuffer = new byte[RETAINED_STRING_DECODE_BUFFER_SIZE]; + // Keep what the last document needed, so consecutive large documents stop re-growing the + // buffer, but never pin more than the ceiling in a pooled reader. + int keep = + Math.max( + RETAINED_STRING_DECODE_BUFFER_SIZE, + Math.min(stringDecodeHighWater, MAX_RETAINED_STRING_DECODE_BUFFER_SIZE)); + if (stringDecodeBuffer.length > MAX_RETAINED_STRING_DECODE_BUFFER_SIZE + || stringDecodeBuffer.length - keep >= keep) { + stringDecodeBuffer = new byte[keep]; + } + stringDecodeHighWater = 0; + } + + /** + * Both decode paths end here, so a buffer grown for quoted text is kept the same way a String's + * is. + */ + private void recordStringDecodeUse(int length) { + if (length > stringDecodeHighWater) { + stringDecodeHighWater = length; } } @@ -4417,6 +4449,25 @@ private String readStringLatin1Tail(byte[] bytes, int out, int b) { return readStringUtf16Tail(bytes, out); } } + // Copy the plain characters up to the next stop character in one pass. Without this the whole + // remainder of a string is decoded one character at a time once the first escape is seen. + int runStart = position; + int wordEnd = inputLimit - Long.BYTES; + while (position <= wordEnd) { + long word = LittleEndian.getInt64(input, position); + long stopMask = stringStopMask(word); + if (stopMask != 0) { + position += Long.numberOfTrailingZeros(stopMask) >>> 3; + break; + } + position += Long.BYTES; + } + int run = position - runStart; + if (run > 0) { + bytes = ensureStringDecodeCapacity(bytes, out + run); + System.arraycopy(input, runStart, bytes, out, run); + out += run; + } if (position >= inputLimit) { throw error("Unterminated string"); } @@ -4687,6 +4738,7 @@ private char readLowSurrogateEscape() { } private String finishDecodedString(byte[] bytes, int length, boolean utf16) { + recordStringDecodeUse(length); // Strings must not share the reader-owned decode buffer; the buffer is reused by later reads. byte[] result = new byte[length]; System.arraycopy(bytes, 0, result, 0, length); diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonStringTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonStringTest.java index b848a9d4ed..aab34999cd 100644 --- a/java/fory-json/src/test/java/org/apache/fory/json/JsonStringTest.java +++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonStringTest.java @@ -573,26 +573,131 @@ public void readStringInputEscapes(boolean codegen) { assertThrows(ForyJsonException.class, () -> json.fromJson("\"\\uD83D\"", String.class)); } + /** + * A buffer the recent documents needed is kept, so a stream of large strings stops re-growing it, + * and it is released once a later document no longer needs it. + */ @Test - public void readerDecodeBufferShrinks() throws Exception { + public void readerDecodeBufferIsKeptWhileNeeded() throws Exception { String latin1Input = "\"" + repeat('a', 9000) + "\\n\""; + String smallInput = "\"" + repeat('a', 8) + "\\n\""; if (StringSerializer.isBytesBackedString() && StringSerializer.isLatin1Coder(StringSerializer.getStringCoder(latin1Input))) { Latin1JsonReader latin1Reader = newLatin1Reader(latin1Input); assertEquals(latin1Reader.readString(), repeat('a', 9000) + "\n"); - assertTrue(readerBufferLength(latin1Reader) > 8192); + int grown = readerBufferLength(latin1Reader); + assertTrue(grown > 8192); + latin1Reader.clear(); + assertEquals(readerBufferLength(latin1Reader), grown); + latin1Reader.reset(latin1Input); + assertEquals(latin1Reader.readString(), repeat('a', 9000) + "\n"); + assertEquals(readerBufferLength(latin1Reader), grown); + latin1Reader.clear(); + latin1Reader.reset(smallInput); + assertEquals(latin1Reader.readString(), repeat('a', 8) + "\n"); latin1Reader.clear(); assertEquals(readerBufferLength(latin1Reader), 8192); } - String utf16Input = "\"中文" + repeat('b', 9000) + "\\n\""; + // A buffer no document should pin: it is released whatever the shrink threshold says. + String hugeInput = "\"\\n" + repeat('a', 200000) + "\""; + Latin1JsonReader hugeReader = newLatin1Reader(hugeInput.getBytes(StandardCharsets.ISO_8859_1)); + assertEquals(hugeReader.readString(), "\n" + repeat('a', 200000)); + int hugeGrown = readerBufferLength(hugeReader); + assertTrue(hugeGrown > 131072 && hugeGrown < 262144); + hugeReader.clear(); + assertTrue(readerBufferLength(hugeReader) <= 131072); + + // The quoted text view shares the same buffer, so growing it there has to count the same way. + Utf8JsonReader quotedReader = newUtf8Reader(latin1Input.getBytes(StandardCharsets.UTF_8)); + assertEquals(quotedReader.readQuotedText().toString(), repeat('a', 9000) + "\n"); + int quotedGrown = readerBufferLength(quotedReader); + assertTrue(quotedGrown > 8192); + quotedReader.clear(); + assertEquals(readerBufferLength(quotedReader), quotedGrown); + + Utf8JsonReader utf8Reader = newUtf8Reader(latin1Input.getBytes(StandardCharsets.UTF_8)); + assertEquals(utf8Reader.readString(), repeat('a', 9000) + "\n"); + int utf8Grown = readerBufferLength(utf8Reader); + assertTrue(utf8Grown > 8192); + utf8Reader.clear(); + assertEquals(readerBufferLength(utf8Reader), utf8Grown); + utf8Reader.reset(smallInput.getBytes(StandardCharsets.UTF_8)); + assertEquals(utf8Reader.readString(), repeat('a', 8) + "\n"); + utf8Reader.clear(); + assertEquals(readerBufferLength(utf8Reader), 8192); + + String utf16Input = "\"\u4e2d\u6587" + repeat('b', 9000) + "\\n\""; Utf16JsonReader utf16Reader = newUtf16Reader(utf16Input); - assertEquals(utf16Reader.readString(), "中文" + repeat('b', 9000) + "\n"); - assertTrue(readerBufferLength(utf16Reader) > 8192); + assertEquals(utf16Reader.readString(), "\u4e2d\u6587" + repeat('b', 9000) + "\n"); + int grown = readerBufferLength(utf16Reader); + assertTrue(grown > 8192); + utf16Reader.clear(); + assertEquals(readerBufferLength(utf16Reader), grown); + String smallUtf16 = "\"\u4e2d" + repeat('b', 8) + "\\n\""; + byte[] smallUtf16Bytes = new byte[smallUtf16.length() << 1]; + StringSerializer.copyStringCharsToBytes(smallUtf16, smallUtf16Bytes); + utf16Reader.reset(smallUtf16, smallUtf16Bytes); + assertEquals(utf16Reader.readString(), "\u4e2d" + repeat('b', 8) + "\n"); utf16Reader.clear(); assertEquals(readerBufferLength(utf16Reader), 8192); } + /** + * The run scan sits between the escape handling and these checks, so both must still fire after + * one. + */ + @Test + public void rejectMalformedInputAfterAnEscape() { + ForyJson json = newJson(true); + String plain = repeat('a', 24); + // The bad character is followed by more plain text so it falls inside a scanned word rather + // than + // the scalar remainder the scan leaves at the end of the buffer. + for (String document : + new String[] { + "\"x\\n" + plain, + "\"x\\n" + plain + "\u0001" + plain + "\"", + "\"x\\n" + plain + "\\x" + plain + "\"", + "\"x\\n" + plain + "\\uD83D" + plain + "\"", + "\"x\\n" + plain + "\u0001\"", + "\"x\\n" + plain + "\\x\"" + }) { + assertThrows(ForyJsonException.class, () -> json.fromJson(document, String.class)); + assertThrows(ForyJsonException.class, () -> readUtf8String(json, document)); + } + } + + /** + * Plain text after an escape is copied in bulk, and that copy has to stop wherever the + * character-at-a-time path would have handled the character itself - a latin1 byte above 0x7F, + * text outside latin1, and a surrogate pair. + */ + @Test + public void readEscapeFollowedByNonAsciiText() { + ForyJson json = newJson(true); + String[] tails = { + "a", + "\u00e9", + "\u00ff", + "\u4e2d\u6587", + "\uD83D\uDE00", + "a\u00e9\u4e2d\u6587b", + "\u00e9" + repeat('c', 40) + }; + for (String tail : tails) { + for (int lead = 0; lead <= 20; lead++) { + for (int gap = 0; gap <= 8; gap++) { + String value = repeat('a', lead) + "\n" + repeat('c', gap) + tail + repeat('b', 7); + String document = + "\"" + repeat('a', lead) + "\\n" + repeat('c', gap) + tail + repeat('b', 7) + "\""; + assertEquals(json.fromJson(document, String.class), value); + assertEquals(readUtf8String(json, document), value); + } + } + } + } + @Test(dataProvider = "enableCodegen") public void readUnicodeFieldNames(boolean codegen) { ForyJson json = newJson(codegen);