Skip to content

Commit 506aa8b

Browse files
authored
Merge pull request #40 from Deep-CodeAI/fix/1028-lenient-parser-oom
fix(#1028): LenientJsonParser infinite-loop / OOM on non-JSON content
2 parents c95ee6d + f785181 commit 506aa8b

7 files changed

Lines changed: 111 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
All notable changes to Agents.KT are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Pre-1.0, minor bumps may add new public API; existing API surface is preserved.
44

5+
## [0.2.3] — 2026-05-04
6+
7+
Hotfix patch — single bug.
8+
9+
### Fixed
10+
- `LenientJsonParser` no longer infinite-loops / OOMs on input where a JSON array or object contains a non-numeric, non-string, non-keyword character (e.g. `[abc]`, `{"k": foo}`, `[<html>]`). The previous `parseValue()` fell through to `parseNumber()` for any unrecognized character; `parseNumber()` returned 0 without advancing `pos`, so `parseArray()` / `parseObject()` spun forever, accumulating zeros until the heap was exhausted. The 0.2.2 `MAX_NESTING_DEPTH` guard (#854) only caught deep nesting, not zero-progress in a single loop body. Two-layer fix: `parseValue()` is now strict on the `else` branch (throws on unknown chars; the throw is caught by the top-level `parse(input)` try/catch and returns `null`, preserving the lenient contract); `parseArray()` and `parseObject()` carry zero-progress guards as defense-in-depth (#1028).
11+
12+
### Trigger path in the wild
13+
Any LLM response or HTTP body containing `[…non-JSON content…]` would hit this — including non-Ollama responses on `localhost:11434` (HTML error pages, JSON error blobs with embedded brackets), small-model output that emitted markdown tables or pseudo-JSON, or test fixtures pointing the agent at unrelated services. Surfaced as `OutOfMemoryError` during agent invocation, several seconds after the request started.
14+
515
## [0.2.2] — 2026-05-03
616

717
A feature-heavy patch release — REPL deployment, multi-agent JAR composition (Swarm), four new observability hooks, two new budget controls, classpath-resource prompt loading, and a slimmer README. Pre-1.0 patch bump — no breaking changes; all existing API surface preserved.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ Topical guides:
171171
```kotlin
172172
// build.gradle.kts
173173
dependencies {
174-
implementation("ai.deep-code:agents-kt:0.2.2")
174+
implementation("ai.deep-code:agents-kt:0.2.3")
175175
}
176176
```
177177

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ plugins {
66
}
77

88
group = "ai.deep-code"
9-
version = "0.2.2"
9+
version = "0.2.3"
1010

1111
repositories {
1212
mavenCentral()

src/main/kotlin/agents_engine/generation/LenientJsonParser.kt

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,14 @@ internal object LenientJsonParser {
7676
'"' -> parseString()
7777
't', 'f' -> parseBoolean()
7878
'n' -> parseNull()
79-
else -> parseNumber()
79+
'-', in '0'..'9' -> parseNumber()
80+
// #1028 — refuse to fall through to parseNumber on non-numeric chars.
81+
// The old `else -> parseNumber()` returned 0 without advancing `pos`
82+
// (empty digit run), causing parseArray/parseObject to spin forever
83+
// on input like `[abc]`. Throw → caught by parse(input) → returns null.
84+
else -> throw IllegalStateException(
85+
"LenientJsonParser: unexpected character '${s[pos]}' at pos $pos"
86+
)
8087
}
8188
}
8289

@@ -97,6 +104,7 @@ internal object LenientJsonParser {
97104
val map = linkedMapOf<String, Any?>()
98105
skipWs()
99106
while (pos < s.length && s[pos] != '}') {
107+
val before = pos
100108
skipWs()
101109
val key = parseString()
102110
skipWs()
@@ -106,6 +114,12 @@ internal object LenientJsonParser {
106114
skipWs()
107115
if (pos < s.length && s[pos] == ',') pos++
108116
skipWs()
117+
// #1028 — defense-in-depth: refuse to spin if no progress was made.
118+
if (pos == before) {
119+
throw IllegalStateException(
120+
"LenientJsonParser: zero-progress at pos $pos in object"
121+
)
122+
}
109123
}
110124
if (pos < s.length) pos++ // consume '}'
111125
return map
@@ -116,10 +130,17 @@ internal object LenientJsonParser {
116130
val list = mutableListOf<Any?>()
117131
skipWs()
118132
while (pos < s.length && s[pos] != ']') {
133+
val before = pos
119134
list.add(parseValue())
120135
skipWs()
121136
if (pos < s.length && s[pos] == ',') pos++
122137
skipWs()
138+
// #1028 — defense-in-depth: refuse to spin if no progress was made.
139+
if (pos == before) {
140+
throw IllegalStateException(
141+
"LenientJsonParser: zero-progress at pos $pos in array"
142+
)
143+
}
123144
}
124145
if (pos < s.length) pos++ // consume ']'
125146
return list

src/main/kotlin/agents_engine/mcp/McpRunner.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import java.util.concurrent.CountDownLatch
2525
*/
2626
object McpRunner {
2727

28-
private const val VERSION = "0.2.2"
28+
private const val VERSION = "0.2.3"
2929

3030
fun serve(
3131
agent: Agent<*, *>,

src/main/kotlin/agents_engine/runtime/LiveRunner.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import kotlinx.coroutines.runBlocking
3030
*/
3131
object LiveRunner {
3232

33-
private const val VERSION = "0.2.2"
33+
private const val VERSION = "0.2.3"
3434

3535
fun serve(
3636
agent: Agent<String, *>,
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package agents_engine.generation
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertEquals
5+
import kotlin.test.assertNull
6+
7+
/**
8+
* #1028 — `LenientJsonParser` infinite-loop / OOM regression guard.
9+
*
10+
* In 0.2.2, `parseValue()` fell through to `parseNumber()` on any unrecognized
11+
* character. `parseNumber()` returned 0 without advancing `pos` when the input
12+
* had no digits. `parseArray()` / `parseObject()` spun forever, growing the
13+
* accumulator until the heap was exhausted.
14+
*
15+
* The fix has two layers:
16+
* 1. `parseValue()` throws on a non-JSON-prefix char instead of falling
17+
* through to `parseNumber()`. Caught by `parse(input)` → returns null.
18+
* 2. `parseArray()` / `parseObject()` throw on zero-progress in their loop
19+
* body — defense-in-depth against any future regression.
20+
*
21+
* These tests are bounded by `Thread`-side time guards because a regression
22+
* would manifest as OOM (slow) rather than as an assertion failure.
23+
*/
24+
class LenientJsonParserOomGuardTest {
25+
26+
private fun assertCompletesIn(maxMs: Long, block: () -> Any?): Any? {
27+
var result: Any? = null
28+
val thread = Thread { result = block() }
29+
thread.isDaemon = true
30+
thread.start()
31+
thread.join(maxMs)
32+
if (thread.isAlive) {
33+
// Don't try to interrupt — the bug spins on heap allocation, not on a
34+
// checkpoint that responds to interrupts. Just fail the test.
35+
throw AssertionError("LenientJsonParser did not complete within ${maxMs}ms — likely an infinite loop")
36+
}
37+
return result
38+
}
39+
40+
@Test
41+
fun `array with non-JSON content does not OOM`() {
42+
assertNull(assertCompletesIn(2_000) { LenientJsonParser.parse("[abc]") })
43+
assertNull(assertCompletesIn(2_000) { LenientJsonParser.parse("[<html>]") })
44+
assertNull(assertCompletesIn(2_000) { LenientJsonParser.parse("[1, abc, 3]") })
45+
}
46+
47+
@Test
48+
fun `object with unquoted bare-word value does not OOM`() {
49+
assertNull(assertCompletesIn(2_000) { LenientJsonParser.parse("{\"k\": abc}") })
50+
assertNull(assertCompletesIn(2_000) { LenientJsonParser.parse("{\"x\": <html>, \"y\": 1}") })
51+
}
52+
53+
@Test
54+
fun `nested unquoted bare-word values do not OOM`() {
55+
assertNull(assertCompletesIn(2_000) { LenientJsonParser.parse("{\"outer\": [abc, def]}") })
56+
assertNull(assertCompletesIn(2_000) { LenientJsonParser.parse("[[abc], [def]]") })
57+
}
58+
59+
@Test
60+
fun `legitimate parses still work`() {
61+
assertEquals(listOf(1, 2, 3), LenientJsonParser.parse("[1, 2, 3]"))
62+
assertEquals(mapOf("k" to "v"), LenientJsonParser.parse("{\"k\":\"v\"}"))
63+
assertEquals(listOf("a", "b"), LenientJsonParser.parse("[\"a\", \"b\"]"))
64+
assertEquals(listOf(1, "two", true, null), LenientJsonParser.parse("[1, \"two\", true, null]"))
65+
assertEquals(emptyList<Any?>(), LenientJsonParser.parse("[]"))
66+
assertEquals(emptyMap<String, Any?>(), LenientJsonParser.parse("{}"))
67+
assertEquals(mapOf("nested" to listOf(1, 2)), LenientJsonParser.parse("{\"nested\":[1, 2]}"))
68+
}
69+
70+
@Test
71+
fun `negative numbers and decimals still parse`() {
72+
assertEquals(listOf(-1, -2.5), LenientJsonParser.parse("[-1, -2.5]"))
73+
assertEquals(mapOf("temp" to -40), LenientJsonParser.parse("{\"temp\":-40}"))
74+
}
75+
}

0 commit comments

Comments
 (0)