Skip to content

Commit afd5e5e

Browse files
authored
fix: chunk large JCEF messages to prevent JetBrains sidebar freezes (#11899)
* fix: chunk large JCEF messages to prevent JetBrains sidebar freezes When session data exceeds ~1MB (e.g. long conversations with many context items), sendToWebview embeds the entire JSON payload in a single executeJavaScript call. JCEF's JS parser/compiler cannot handle source strings that large and freezes the IDE. This adds a chunked sending path: payloads over 1MB are base64-encoded, split into 512KB chunks sent as individual executeJavaScript calls that each append to a buffer variable, then a final call decodes and dispatches the reassembled message via window.postMessage. Each individual call is trivial for JCEF to parse, and V8 handles the string concatenation and final JSON.parse without issue. Messages under 1MB (the vast majority) take the existing fast path unchanged. * chore: trim verbose comments * fix: clean up partial chunk buffer on failure * fix: use array join instead of string concat for JCEF chunk reassembly Replaces O(n²) string concatenation (+=) with O(n) array push + join for JS-side chunk buffering. Extracts script generation into testable buildChunkScripts() and adds unit tests for chunking logic. * perf: increase chunk size from 512KB to 2MB Reduces IPC round-trips by 4x (e.g. 7 calls instead of 26 for 10MB). Safe because chunks are trivial JS with pure Base64 string literals. * fix: decode chunked messages as UTF-8 to prevent mojibake atob() returns Latin-1, so multi-byte UTF-8 characters (em dashes, accented letters, CJK, emoji) were mangled. Use TextDecoder to properly decode UTF-8 bytes after base64 decoding.
1 parent afb6b21 commit afd5e5e

File tree

2 files changed

+155
-3
lines changed

2 files changed

+155
-3
lines changed

extensions/intellij/src/main/kotlin/com/github/continuedev/continueintellijextension/browser/ContinueBrowser.kt

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import com.intellij.ui.jcef.*
1313
import org.cef.CefApp
1414
import org.cef.browser.CefBrowser
1515
import org.cef.handler.CefLoadHandlerAdapter
16+
import java.util.Base64
1617
import javax.swing.JComponent
1718

1819
class ContinueBrowser(
@@ -90,14 +91,39 @@ class ContinueBrowser(
9091
return
9192
}
9293
val json = gsonService.gson.toJson(BrowserMessage(messageType, messageId, data))
93-
val jsCode = """window.postMessage($json, "*");"""
9494
try {
95-
browser.cefBrowser.executeJavaScript(jsCode, getGuiUrl(), 0)
95+
if (json.length <= CHUNKED_MESSAGE_THRESHOLD) {
96+
browser.cefBrowser.executeJavaScript(
97+
"""window.postMessage($json, "*");""", getGuiUrl(), 0
98+
)
99+
} else {
100+
sendChunked(json, messageId)
101+
}
96102
} catch (error: Exception) {
97103
log.warn(error)
98104
}
99105
}
100106

107+
// Base64-encode and send in 512KB chunks to avoid JCEF freezing on large JS source strings
108+
private fun sendChunked(json: String, bufferId: String) {
109+
val scripts = buildChunkScripts(json, bufferId)
110+
val url = getGuiUrl()
111+
112+
browser.cefBrowser.executeJavaScript(scripts.init, url, 0)
113+
114+
try {
115+
for (chunkScript in scripts.chunks) {
116+
browser.cefBrowser.executeJavaScript(chunkScript, url, 0)
117+
}
118+
browser.cefBrowser.executeJavaScript(scripts.finalize, url, 0)
119+
} catch (e: Exception) {
120+
try {
121+
browser.cefBrowser.executeJavaScript(scripts.cleanup, url, 0)
122+
} catch (_: Exception) {}
123+
throw e
124+
}
125+
}
126+
101127
private fun executeJavaScript(myJSQueryOpenInBrowser: JBCefJSQuery) {
102128
val script = """
103129
window.postIntellijMessage = function(messageType, data, messageId) {
@@ -134,11 +160,40 @@ class ContinueBrowser(
134160
}
135161
}
136162

137-
private companion object {
163+
internal data class ChunkScripts(
164+
val init: String,
165+
val chunks: List<String>,
166+
val finalize: String,
167+
val cleanup: String,
168+
)
169+
170+
internal companion object {
171+
internal const val CHUNKED_MESSAGE_THRESHOLD = 1 * 1024 * 1024 // 1MB
172+
internal const val CHUNK_SIZE = 2 * 1024 * 1024 // 2MB
138173

139174
private fun getGuiUrl() =
140175
System.getenv("GUI_URL") ?: "http://continue/index.html"
141176

177+
internal fun buildChunkScripts(json: String, bufferId: String, chunkSize: Int = CHUNK_SIZE): ChunkScripts {
178+
val encoded = Base64.getEncoder().encodeToString(json.toByteArray(Charsets.UTF_8))
179+
val chunks = mutableListOf<String>()
180+
181+
var offset = 0
182+
while (offset < encoded.length) {
183+
val end = minOf(offset + chunkSize, encoded.length)
184+
val chunk = encoded.substring(offset, end)
185+
chunks.add("""window.__cc["$bufferId"].push("$chunk");""")
186+
offset = end
187+
}
188+
189+
return ChunkScripts(
190+
init = """window.__cc=window.__cc||{};window.__cc["$bufferId"]=[];""",
191+
chunks = chunks,
192+
finalize = """try{var b=atob(window.__cc["$bufferId"].join(""));window.postMessage(JSON.parse(new TextDecoder().decode(Uint8Array.from(b,function(c){return c.charCodeAt(0)}))),"*")}
193+
finally{delete window.__cc["$bufferId"]}""",
194+
cleanup = """delete window.__cc["$bufferId"];""",
195+
)
196+
}
142197
}
143198

144199
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package com.github.continuedev.continueintellijextension.unit
2+
3+
import com.github.continuedev.continueintellijextension.browser.ContinueBrowser
4+
import com.github.continuedev.continueintellijextension.browser.ContinueBrowser.Companion.buildChunkScripts
5+
import junit.framework.TestCase
6+
import java.util.Base64
7+
8+
class ContinueBrowserChunkTest : TestCase() {
9+
10+
fun `test small message produces single chunk`() {
11+
val json = """{"messageType":"test","data":"hello"}"""
12+
val scripts = buildChunkScripts(json, "buf1")
13+
14+
assertEquals(1, scripts.chunks.size)
15+
assertReassemblesTo(json, scripts.chunks, "buf1")
16+
}
17+
18+
fun `test message splits into expected number of chunks`() {
19+
val json = """{"data":"${"x".repeat(2_000_000)}"}"""
20+
val chunkSize = ContinueBrowser.CHUNK_SIZE
21+
val encoded = Base64.getEncoder().encodeToString(json.toByteArray(Charsets.UTF_8))
22+
val expectedChunks = (encoded.length + chunkSize - 1) / chunkSize
23+
24+
val scripts = buildChunkScripts(json, "buf2")
25+
26+
assertEquals(expectedChunks, scripts.chunks.size)
27+
assertReassemblesTo(json, scripts.chunks, "buf2")
28+
}
29+
30+
fun `test custom chunk size`() {
31+
val json = """{"data":"${"a".repeat(100)}"}"""
32+
val scripts = buildChunkScripts(json, "buf3", chunkSize = 32)
33+
34+
assertTrue(scripts.chunks.size > 1)
35+
assertReassemblesTo(json, scripts.chunks, "buf3")
36+
}
37+
38+
fun `test init script creates array buffer`() {
39+
val scripts = buildChunkScripts("{}", "myId")
40+
assertEquals("""window.__cc=window.__cc||{};window.__cc["myId"]=[];""", scripts.init)
41+
}
42+
43+
fun `test finalize script joins and decodes`() {
44+
val scripts = buildChunkScripts("{}", "myId")
45+
assertTrue(scripts.finalize.contains("""window.__cc["myId"].join("")"""))
46+
assertTrue(scripts.finalize.contains("atob"))
47+
assertTrue(scripts.finalize.contains("TextDecoder"))
48+
assertTrue(scripts.finalize.contains("JSON.parse"))
49+
assertTrue(scripts.finalize.contains("""delete window.__cc["myId"]"""))
50+
}
51+
52+
fun `test cleanup script deletes buffer`() {
53+
val scripts = buildChunkScripts("{}", "myId")
54+
assertEquals("""delete window.__cc["myId"];""", scripts.cleanup)
55+
}
56+
57+
fun `test special characters survive base64 round-trip`() {
58+
val json = """{"data":"héllo wörld 日本語 emoji: 🎉 quotes: \"nested\""}"""
59+
val scripts = buildChunkScripts(json, "buf4", chunkSize = 32)
60+
61+
assertReassemblesTo(json, scripts.chunks, "buf4")
62+
}
63+
64+
fun `test exact chunk boundary`() {
65+
// Create a JSON string whose Base64 encoding is exactly 2x chunkSize
66+
val chunkSize = 64
67+
val target = chunkSize * 2
68+
// Base64 output is ceil(input/3)*4, so we need input = target * 3 / 4 bytes
69+
val payloadSize = target * 3 / 4
70+
val json = "x".repeat(payloadSize)
71+
val encoded = Base64.getEncoder().encodeToString(json.toByteArray(Charsets.UTF_8))
72+
73+
assertEquals(target, encoded.length)
74+
75+
val scripts = buildChunkScripts(json, "buf5", chunkSize = chunkSize)
76+
assertEquals(2, scripts.chunks.size)
77+
assertReassemblesTo(json, scripts.chunks, "buf5")
78+
}
79+
80+
/**
81+
* Simulates the JS-side reassembly: extract push payloads, join, base64-decode,
82+
* and verify the result matches the original JSON.
83+
*/
84+
private fun assertReassemblesTo(expectedJson: String, chunkScripts: List<String>, bufferId: String) {
85+
val pushPrefix = """window.__cc["$bufferId"].push(""""
86+
val pushSuffix = """");"""
87+
88+
val reassembled = chunkScripts.joinToString("") { script ->
89+
assertTrue("Chunk should use array push", script.startsWith(pushPrefix))
90+
assertTrue(script.endsWith(pushSuffix))
91+
script.removePrefix(pushPrefix).removeSuffix(pushSuffix)
92+
}
93+
94+
val decoded = String(Base64.getDecoder().decode(reassembled), Charsets.UTF_8)
95+
assertEquals(expectedJson, decoded)
96+
}
97+
}

0 commit comments

Comments
 (0)