From ca028607759bfeee3af1334285c567e4f395c8dd Mon Sep 17 00:00:00 2001 From: GaltRanch Date: Wed, 15 Apr 2026 16:54:05 -0300 Subject: [PATCH] fix: cap FileChangeSuggester.suggestions buffer + v2.10.83 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2.10.82 forensic audit flagged FileChangeSuggester as a memory pressure risk under extreme filesystem events (node_modules churn, cross-branch git checkout). The pending buffer was already capped at 500 (line 278), but the suggestions buffer was not — if nothing ever drained via getSuggestions (callback broken, UI stopped consuming), the array grew without bound. Fix: slice to the most recent 200 entries after each push. The UI can't display more than a handful of suggestions at a time anyway, so losing older ones under churn is the correct trade-off. Audit findings dismissed (verified): - extractRequestedPort regex permissiveness — downstream validation rejects ports outside 1024-65535, so the supposedly "permissive" match of "port 22" never reaches the spawner. Polish, not a bug. - PipelineStep / PipelineResult types — audit itself said "no obvious issues", no change needed. Tests: 3 new in file-watcher.test.ts covering the cap, drain-then- empty behavior, and clear() reset. The tests are pure in-memory (no fs) because the underlying FileWatcher is fs-backed and out of scope for a unit test. Co-Authored-By: Kulvex Code --- package.json | 2 +- src/core/file-watcher.test.ts | 64 +++++++++++++++++++++++++++++++++++ src/core/file-watcher.ts | 10 ++++++ 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 src/core/file-watcher.test.ts diff --git a/package.json b/package.json index 0ce9daa..a61bbc6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kcode", - "version": "2.10.82", + "version": "2.10.83", "description": "AI-powered coding assistant for the terminal - by Astrolexis", "author": "Astrolexis", "module": "src/index.ts", diff --git a/src/core/file-watcher.test.ts b/src/core/file-watcher.test.ts new file mode 100644 index 0000000..1f25358 --- /dev/null +++ b/src/core/file-watcher.test.ts @@ -0,0 +1,64 @@ +// Regression test for the v2.10.82 forensic audit finding about +// unbounded memory growth in FileChangeSuggester.suggestions. The +// pending buffer was already capped at 500; suggestions was not. +// Under high-churn scenarios (node_modules reinstall, git checkout +// between large branches) with a slow consumer, the array could +// grow without bound. +// +// This test does NOT cover FileWatcher's fs-backed behavior because +// that requires a real filesystem and is out of scope for a unit +// test. The suggester is pure in-memory logic. + +import { describe, expect, test } from "bun:test"; +import { FileChangeSuggester } from "./file-watcher"; +import type { FileChangeEvent } from "./file-watcher"; + +function makeChanges(n: number, baseName: string): FileChangeEvent[] { + const events: FileChangeEvent[] = []; + for (let i = 0; i < n; i++) { + events.push({ + type: "modify", + path: `/project/src/${baseName}-${i}.test.ts`, + timestamp: Date.now(), + }); + } + return events; +} + +describe("FileChangeSuggester memory caps", () => { + test("suggestions buffer is capped to 200 after repeated pushes", async () => { + const suggester = new FileChangeSuggester(); + + // 300 distinct test-file changes → ~300 unique suggestions + // (each uses the file path in the message so they dedupe per + // batch but are distinct across batches). + for (let batch = 0; batch < 6; batch++) { + suggester.addChanges(makeChanges(50, `b${batch}`)); + // Wait out the debounce so the internal process() runs and + // flushes the batch into suggestions. + await new Promise((r) => setTimeout(r, 600)); + } + + // suggestions should be capped even though we never drained it + const drained = suggester.getSuggestions(); + expect(drained.length).toBeLessThanOrEqual(200); + }); + + test("drained suggestions are cleared so subsequent batches start fresh", async () => { + const suggester = new FileChangeSuggester(); + suggester.addChanges(makeChanges(10, "a")); + await new Promise((r) => setTimeout(r, 600)); + const first = suggester.getSuggestions(); + expect(first.length).toBeGreaterThan(0); + // After draining, hasSuggestions should be false + expect(suggester.hasSuggestions).toBe(false); + }); + + test("clear() resets both pending and suggestions", async () => { + const suggester = new FileChangeSuggester(); + suggester.addChanges(makeChanges(10, "a")); + suggester.clear(); + await new Promise((r) => setTimeout(r, 600)); + expect(suggester.hasSuggestions).toBe(false); + }); +}); diff --git a/src/core/file-watcher.ts b/src/core/file-watcher.ts index a6e5338..80f4074 100644 --- a/src/core/file-watcher.ts +++ b/src/core/file-watcher.ts @@ -375,6 +375,16 @@ export class FileChangeSuggester { if (newSuggestions.length > 0) { this.suggestions.push(...newSuggestions); + // Cap the suggestions buffer. Without this, if nothing ever + // drains via getSuggestions (e.g. the callback is broken or + // the UI stops consuming them), the array grows unbounded + // under high-churn scenarios like node_modules reinstalls. + // v2.10.82 audit flagged this as a memory-pressure risk — + // keeping the most recent 200 is more than the UI can show + // anyway. + if (this.suggestions.length > 200) { + this.suggestions = this.suggestions.slice(-200); + } if (this.onSuggestion) { try { this.onSuggestion(newSuggestions);