From d55e5540af6de070d3b1b9750e825c3054c6a30f Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Thu, 26 Feb 2026 15:35:04 +0800 Subject: [PATCH] fix(memory): bound lock memory and increase scanner buffer Address feedback from @yinwm for long-running daemon use: - Replace sync.Map with a fixed-size sharded lock array (64 mutexes). Keys are mapped via FNV hash, so memory is O(1) regardless of how many sessions are created over the process lifetime. - Increase scanner buffer cap from 1 MB to 10 MB. Tool results (read_file on large files, web search responses) can easily exceed 1 MB. The scanner still starts at 64 KB and only grows as needed. --- pkg/memory/jsonl.go | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/pkg/memory/jsonl.go b/pkg/memory/jsonl.go index eda9563fe..13f450835 100644 --- a/pkg/memory/jsonl.go +++ b/pkg/memory/jsonl.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "hash/fnv" "os" "path/filepath" "strings" @@ -14,6 +15,20 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" ) +const ( + // numLockShards is the fixed number of mutexes used to serialize + // per-session access. Using a sharded array instead of a map keeps + // memory bounded regardless of how many sessions are created over + // the lifetime of the process — important for a long-running daemon. + numLockShards = 64 + + // maxLineSize is the maximum size of a single JSON line in a .jsonl + // file. Tool results (read_file, web search, etc.) can be large, so + // we set a generous limit. The scanner starts at 64 KB and grows + // only as needed up to this cap. + maxLineSize = 10 * 1024 * 1024 // 10 MB +) + // sessionMeta holds per-session metadata stored in a .meta.json file. type sessionMeta struct { Key string `json:"key"` @@ -37,7 +52,7 @@ type sessionMeta struct { // append-only, which is both fast and crash-safe. type JSONLStore struct { dir string - locks sync.Map // map[string]*sync.Mutex, one per session + locks [numLockShards]sync.Mutex } // NewJSONLStore creates a new JSONL-backed store rooted at dir. @@ -49,10 +64,13 @@ func NewJSONLStore(dir string) (*JSONLStore, error) { return &JSONLStore{dir: dir}, nil } -// sessionLock returns (or creates) a per-session mutex. +// sessionLock returns a mutex for the given session key. +// Keys are mapped to a fixed pool of shards via FNV hash, so +// memory usage is O(1) regardless of total session count. func (s *JSONLStore) sessionLock(key string) *sync.Mutex { - v, _ := s.locks.LoadOrStore(key, &sync.Mutex{}) - return v.(*sync.Mutex) + h := fnv.New32a() + h.Write([]byte(key)) + return &s.locks[h.Sum32()%numLockShards] } func (s *JSONLStore) jsonlPath(key string) string { @@ -126,8 +144,8 @@ func readMessages(path string, skip int) ([]providers.Message, error) { var msgs []providers.Message scanner := bufio.NewScanner(f) - // Allow up to 1 MB per line for messages with large content. - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + // Allow large lines for tool results (read_file, web search, etc.). + scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize) lineNum := 0 for scanner.Scan() { @@ -172,7 +190,7 @@ func countLines(path string) (int, error) { n := 0 scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize) for scanner.Scan() { if len(scanner.Bytes()) > 0 { n++