Skip to content

chunkText in the RAG provider can loop forever and exhaust memory on text without whitespace #69

Description

@rajarshidattapy

Description

src/providers/rag/index.ts:33-68 advances its cursor by breakPoint + 1 - overlap. Nothing guarantees that this is greater than the current start. The only guard is breakPoint <= start, but with CHUNK_OVERLAP = 320 the loop fails to advance for any breakPoint in (start, start + 319]:

if (breakPoint <= start) breakPoint = end   // only catches breakPoint <= start
chunks.push(text.slice(start, breakPoint + 1).trim())
start = breakPoint + 1 - overlap            // can be <= the previous start
if (start < 0) start = 0

The < start + chunkSize * 0.5 sanity check is applied to the ". " and "\n" candidates but not re-checked after the final lastIndexOf(" ", end) fallback on line 55, which is exactly the branch that produces a small breakPoint.

Reproduction

// 10 chars, one space, then 5000 unbroken chars — a URL, base64 blob, or minified JSON
const text = "x".repeat(10) + " " + "a".repeat(5000)
chunkText(text)   // never returns

Verified: 50,000 iterations with start still at 0 and the chunks array at 50,000 entries and growing. Each iteration pushes a fresh ~11-character slice, so the process hangs and leaks until the heap is exhausted.

The trigger is: the last space/newline/period before start + 1600 lies within 320 characters of start. Any ingested session containing a long unbroken token — a URL, a base64 image, a stack trace, a minified payload, a CJK passage with no ASCII spaces (tokenize/lastIndexOf(" ") are whitespace-based) — hits it.

Impact

chunkText runs inside RAGProvider.ingest, which runs inside ConcurrentExecutor with no timeout. A single bad session hangs the ingest phase indefinitely with no error and no progress output, and takes the whole run's memory down with it. Because ingest is checkpointed per session, the hang reproduces on every resume attempt at the same session — the run can never make progress.

Suggested fix

Guarantee forward progress regardless of where the break point lands:

-    start = breakPoint + 1 - overlap
-    if (start < 0) start = 0
+    const next = breakPoint + 1 - overlap
+    start = Math.max(next, start + 1)

Better still, apply the start + chunkSize * 0.5 floor to the space fallback too, so a degenerate break point falls through to breakPoint = end and produces a genuine full-size chunk.

Worth a unit test with the repro string above — this is precisely the kind of loop that needs one runnable check behind it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions