M internal/llm/session.go => internal/llm/session.go +25 -0
@@ 7,6 7,7 @@ import (
"fmt"
"strings"
"sync"
+ "sync/atomic"
)
// SessionDefaults are copied to new sessions at creation time.
@@ 48,6 49,7 @@ type Session struct {
mu sync.RWMutex
closed bool
+ refs int32 // atomic reference count; session closed when it drops to 0
// Async generation + streaming support
streamCh chan string // raw text chunks during generation; nil when idle
@@ 377,6 379,29 @@ func (sm *SessionManager) Close(id int) error {
return nil
}
+// IncRef increments the reference count for the session with the given ID.
+// Called when a 9P client opens a file inside the session directory.
+func (sm *SessionManager) IncRef(id int) {
+ session := sm.Get(id)
+ if session != nil {
+ atomic.AddInt32(&session.refs, 1)
+ }
+}
+
+// DecRef decrements the reference count for the session with the given ID.
+// When the count reaches zero the session is closed and removed, freeing
+// all conversation history from memory. Called when a 9P client clunks
+// (closes) a file inside the session directory.
+func (sm *SessionManager) DecRef(id int) {
+ session := sm.Get(id)
+ if session == nil {
+ return
+ }
+ if atomic.AddInt32(&session.refs, -1) <= 0 {
+ sm.Close(id) //nolint:errcheck
+ }
+}
+
// Reset clears the conversation history for the given session.
func (sm *SessionManager) Reset(id int) error {
session := sm.Get(id)
M internal/llmfs/session_dir.go => internal/llmfs/session_dir.go +8 -1
@@ 160,7 160,14 @@ func (d *SessionsDir) Lookup(name string) (protocol.File, error) {
return nil, protocol.ErrNotFound
}
- return NewSessionDir(d.sm, id), nil
+ // Wrap in sessionRefDir so that any Topen of the directory (or its
+ // children via sessionRefDir.Lookup) increments the session ref count.
+ // When the last ref is clunked the session is freed automatically.
+ return &sessionRefDir{
+ Dir: NewSessionDir(d.sm, id),
+ sm: d.sm,
+ id: id,
+ }, nil
}
// Read returns directory listing as packed stat entries.
A internal/llmfs/session_ref.go => internal/llmfs/session_ref.go +77 -0
@@ 0,0 1,77 @@
+package llmfs
+
+import (
+ "sync"
+
+ "github.com/NERVsystems/llm9p/internal/llm"
+ "github.com/NERVsystems/llm9p/internal/protocol"
+)
+
+// sessionRefFile wraps a protocol.File and tracks a reference to its session.
+// It calls sm.IncRef(id) on Open and sm.DecRef(id) on the first Close, so
+// that sessions are automatically cleaned up when all their files are clunked.
+type sessionRefFile struct {
+ protocol.File
+ sm *llm.SessionManager
+ id int
+ once sync.Once
+ opened bool
+}
+
+func (r *sessionRefFile) Open(mode uint8) error {
+ err := r.File.Open(mode)
+ if err == nil {
+ r.opened = true
+ r.sm.IncRef(r.id)
+ }
+ return err
+}
+
+func (r *sessionRefFile) Close() error {
+ err := r.File.Close()
+ if r.opened {
+ r.once.Do(func() {
+ r.sm.DecRef(r.id)
+ })
+ }
+ return err
+}
+
+// sessionRefDir wraps a protocol.Dir (SessionDir) with the same ref counting.
+// It additionally overrides Lookup so that child files are also ref-counted.
+type sessionRefDir struct {
+ protocol.Dir
+ sm *llm.SessionManager
+ id int
+ once sync.Once
+ opened bool
+}
+
+func (r *sessionRefDir) Open(mode uint8) error {
+ err := r.Dir.Open(mode)
+ if err == nil {
+ r.opened = true
+ r.sm.IncRef(r.id)
+ }
+ return err
+}
+
+func (r *sessionRefDir) Close() error {
+ err := r.Dir.Close()
+ if r.opened {
+ r.once.Do(func() {
+ r.sm.DecRef(r.id)
+ })
+ }
+ return err
+}
+
+// Lookup wraps child files in sessionRefFile so each open fid contributes
+// its own reference to the session.
+func (r *sessionRefDir) Lookup(name string) (protocol.File, error) {
+ f, err := r.Dir.Lookup(name)
+ if err != nil {
+ return nil, err
+ }
+ return &sessionRefFile{File: f, sm: r.sm, id: r.id}, nil
+}
M internal/protocol/server.go => internal/protocol/server.go +7 -0
@@ 72,6 72,13 @@ func (s *Server) handleConn(conn net.Conn) {
s.mu.Unlock()
defer func() {
+ // Close all fids that were not explicitly clunked before disconnect.
+ // This triggers sessionRefFile/sessionRefDir.Close() which decrements
+ // session reference counts, allowing sessions to be freed when the
+ // last client using them disconnects.
+ for _, file := range state.fids {
+ file.Close() //nolint:errcheck
+ }
s.mu.Lock()
delete(s.clients, conn)
s.mu.Unlock()