Skip to content

fix(rag): guarantee forward progress in chunkText - #83

Open
Agnik47 wants to merge 1 commit into
supermemoryai:mainfrom
Agnik47:fix/rag-chunktext-infinite-loop
Open

fix(rag): guarantee forward progress in chunkText#83
Agnik47 wants to merge 1 commit into
supermemoryai:mainfrom
Agnik47:fix/rag-chunktext-infinite-loop

Conversation

@Agnik47

@Agnik47 Agnik47 commented Aug 14, 2026

Copy link
Copy Markdown

Fixes #69

The bug

chunkText (src/providers/rag/index.ts) advances its window with start = breakPoint + 1 - overlap, clamped only by if (start < 0) start = 0. Nothing keeps that value ahead of the previous start.

The half-chunk floor (start + chunkSize * 0.5) guards the ". " and "\n" candidates, but it is not re-checked after the lastIndexOf(" ", end) fallback — which is precisely the branch that produces a break point close to start. The only remaining guard there is breakPoint <= start, so with the shipped CHUNK_SIZE = 1600 / CHUNK_OVERLAP = 320 any break point in (start, start + 319] pushes the next start backwards, and the clamp parks it at 0 forever.

Reproduced against main — one early space followed by an unbroken run:

const text = "x".repeat(10) + " " + "a".repeat(5000)
chunkText(text)   // never returns
iter 0 start 0 breakPoint 10 nextStart 0 chunkLen 10
iter 1 start 0 breakPoint 10 nextStart 0 chunkLen 10
iter 2 start 0 breakPoint 10 nextStart 0 chunkLen 10
...

start never leaves 0, and each iteration pushes a fresh ~10-character slice, so the process spins and grows until the heap is exhausted.

Any ingested session containing a long unbroken token hits this: a URL, a base64 image, a stack trace, a minified payload, or CJK text (lastIndexOf(" ") is whitespace-based). chunkText runs inside RAGProvider.ingest under ConcurrentExecutor with no timeout, and ingest is checkpointed per session — so the hang reproduces at the same session on every resume and the run can never make progress.

The fix

Two changes, both in the loop:

  1. Apply the half-chunk floor to the word fallback too. A degenerate break point now falls through to breakPoint = end, producing a genuine full-size chunk instead of a sliver. (The dropped breakPoint <= start clauses were redundant — breakPoint <= start already implies breakPoint < start + chunkSize * 0.5.)
  2. Clamp the step forward: start = Math.max(breakPoint + 1 - overlap, start + 1). The floor above already keeps the step positive at the default sizes; this keeps the loop terminating for caller-supplied sizes where overlap exceeds the step, and makes the start < 0 clamp unnecessary.

Normal prose is unaffected. Its break points already clear the floor (>= start + 800), so the step is >= start + 481 and Math.max picks the unchanged value — chunks still end on sentence boundaries with the same overlap.

Verification

  • bun test — 8 new tests pass in src/providers/rag/chunking.test.ts: the reported input, full-size chunks rather than slivers, source coverage, an overlap larger than the chunk step, plus the existing sentence-boundary and overlap behaviour as regression guards.
  • Fuzz: 3000 randomised inputs (lengths 0–6000, chunkSize 1–2000, overlap 0–2000, alphabets mixing spaces, periods, newlines, unbroken runs and CJK) all terminate with content preserved. Every one of these can hang on main.
  • tsc --noEmit clean for src/providers/rag.

One note on the tests: against the unfixed code they hang rather than fail. The loop is synchronous, so bun's per-test timeout cannot interrupt it — that is the bug, not a flaw in the tests.

chunkText is now exported so it can be tested directly; it is otherwise unchanged in signature and behaviour. The diff is scoped to the loop — I left the file's pre-existing prettier --check warning (an over-long logger.info line, present on main) alone.

`chunkText` advanced its window with `start = breakPoint + 1 - overlap`,
clamped only by `if (start < 0) start = 0`. Nothing kept that ahead of the
previous `start`. The half-chunk floor (`start + chunkSize * 0.5`) was
applied to the `". "` and `"\n"` candidates but not re-checked after the
`lastIndexOf(" ", end)` fallback — which is exactly the branch that yields
a break point close to `start`.

With the shipped CHUNK_SIZE 1600 / CHUNK_OVERLAP 320, any break point in
`(start, start + 319]` sends the next `start` backwards, and the clamp
parks it at 0 forever. Text carrying a long unbroken token — a URL, a
base64 blob, minified JSON, or CJK with no ASCII spaces — hangs the loop
while pushing a fresh sliver chunk every iteration, so the process spins
*and* grows until the heap is exhausted. `chunkText` runs inside
`RAGProvider.ingest` under `ConcurrentExecutor` with no timeout, and
ingest is checkpointed per session, so the hang reproduces at the same
session on every resume and the run can never make progress.

Apply the half-chunk floor to the word fallback too, so a degenerate break
point falls through to `breakPoint = end` and yields a genuine full-size
chunk instead of a sliver. Then clamp the step forward with
`Math.max(breakPoint + 1 - overlap, start + 1)`, which keeps the loop
terminating even for caller-supplied sizes where the overlap exceeds the
step. Normal prose is unaffected: its break points already clear the floor,
so the step is unchanged and chunks still end on sentence boundaries.

Export `chunkText` and cover it: the pathological input from the report,
full-size chunks instead of slivers, source coverage, an overlap larger
than the chunk step, and the existing sentence-boundary and overlap
behaviour. Note that on the unfixed code these hang rather than fail — the
loop is synchronous, so a per-test timeout cannot interrupt it.

Fixes supermemoryai#69

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFXnxCW2dLbbC2Hi7Pi92A
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant