Skip to content

Commit f7edd7e

Browse files
committed
fix(radls): refcount DocumentVersion to free tree-sitter C memory
Every parser.Parse allocates a *ts.Tree wrapping C-heap memory. go-tree-sitter requires explicit Close() to release it (the upstream README explicitly warns against finalizers due to cgo hazards: Nodes hold references into the C tree but the Go GC can't see them, so a finalizer-driven Close can fire while another goroutine is walking the tree). The Phase 8b snapshot model dropped the only call site that closed trees, so every didChange now leaks one tree until the process exits. Implement reference counting on DocumentVersion. The Document holds one reference; each State.Snapshot / SnapshotByID call bumps the count and returns to a caller who MUST call Release. When the count reaches zero the tree is Close()d. The acquire path uses CAS rather than plain Add so we can't resurrect a snapshot whose tree has already been freed - the 'weak-to-strong reference upgrade' pattern. A small race window exists between loading Document.snapshot and acquire(): the writer can Release the old version we observed. acquire returns false in that case and we retry; Document.snapshot has by then been updated to point at a newer version. Also fix the secondary leak in RadTree.Update (the standalone checker path): the old tree was being abandoned without Close. Now Update Closes the previous root. The RadTree.Close becomes idempotent via sync.Once because the underlying ts.Tree.Close is NOT idempotent (always calls ts_tree_delete; second call double-frees). closeOnce lets callers defer-Close confidently and also protects the Document.Update -> Release path when Update's child tree replaces a snapshot. Tests: explicit refs-go-to-zero -> tree-nil verification, late-acquire-on-released-snapshot must fail, all existing snapshot/concurrency tests updated to Release. Race-clean under -race across the full project.
1 parent 535a564 commit f7edd7e

8 files changed

Lines changed: 293 additions & 26 deletions

File tree

radls/analysis/differential_test.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,12 @@ func TestDifferentialFinalStateMatchesFromScratch(t *testing.T) {
7070
})
7171
}
7272
incSnap := incremental.Snapshot(uri)
73+
defer incSnap.Release()
7374

7475
scratch := freshState()
7576
scratch.AddDoc(uri, final)
7677
scrSnap := scratch.Snapshot(uri)
78+
defer scrSnap.Release()
7779

7880
assertEquivalent(t, incSnap, scrSnap)
7981
})
@@ -106,8 +108,16 @@ func TestDifferentialEditCommutativityForIndependentDocs(t *testing.T) {
106108
s2.UpdateDoc(uriB, []lsp.TextDocumentContentChangeEvent{{Text: textB}})
107109
s2.UpdateDoc(uriA, []lsp.TextDocumentContentChangeEvent{{Text: textA}})
108110

109-
assertEquivalent(t, s1.Snapshot(uriA), s2.Snapshot(uriA))
110-
assertEquivalent(t, s1.Snapshot(uriB), s2.Snapshot(uriB))
111+
s1A := s1.Snapshot(uriA)
112+
s2A := s2.Snapshot(uriA)
113+
s1B := s1.Snapshot(uriB)
114+
s2B := s2.Snapshot(uriB)
115+
defer s1A.Release()
116+
defer s2A.Release()
117+
defer s1B.Release()
118+
defer s2B.Release()
119+
assertEquivalent(t, s1A, s2A)
120+
assertEquivalent(t, s1B, s2B)
111121
}
112122

113123
func freshState() *State {

radls/analysis/document.go

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,25 @@ import (
1616
// concurrently without coordination: the data inside is guaranteed
1717
// not to mutate. The next didChange produces a NEW DocumentVersion
1818
// (built off the old) and atomically swaps the owning Document's
19-
// current pointer; old versions remain valid for any reader still
20-
// holding them, and are GC'd once unreferenced.
19+
// current pointer.
2120
//
2221
// This is the load-bearing piece of Phase 8: it lets LSP request
2322
// handlers (hover, goto-def, completion, etc.) grab a snapshot once
2423
// and reason about a frozen world for the duration of the request,
2524
// rather than racing against the next keystroke.
25+
//
26+
// Lifetime: the underlying tree-sitter tree owns C-heap memory that
27+
// the Go GC can't reclaim. We refcount accordingly: the Document
28+
// holds one reference; each call to State.Snapshot bumps the count
29+
// and returns the snapshot to the caller, who MUST call Release()
30+
// when done. When the count reaches zero, the tree is Close()d.
31+
//
32+
// Why refcounting and not a finalizer: tree-sitter Nodes carry
33+
// references into the C tree that the Go GC doesn't see. A
34+
// finalizer can fire on a tree while another goroutine is walking
35+
// nodes that point into it - the documented cgo hazard. Explicit
36+
// release gives us deterministic free at the moment we know no
37+
// reader is touching the tree.
2638
type DocumentVersion struct {
2739
uri string
2840
fileID FileID
@@ -32,17 +44,59 @@ type DocumentVersion struct {
3244
ast *rl.SourceFile
3345
lineIndex *LineIndex
3446
diagnostics []lsp.Diagnostic
47+
48+
// refs starts at 1 - the Document that owns this version holds
49+
// the initial reference. Each State.Snapshot caller bumps to one
50+
// more; their matching Release drops it. When this hits zero the
51+
// underlying tree is freed and any later acquire() returns false.
52+
refs atomic.Int32
3553
}
3654

37-
func (v *DocumentVersion) URI() string { return v.uri }
38-
func (v *DocumentVersion) FileID() FileID { return v.fileID }
39-
func (v *DocumentVersion) Version() int64 { return v.version }
40-
func (v *DocumentVersion) Text() string { return v.text }
41-
func (v *DocumentVersion) Tree() *rts.RadTree { return v.tree }
42-
func (v *DocumentVersion) AST() *rl.SourceFile { return v.ast }
43-
func (v *DocumentVersion) LineIndex() *LineIndex { return v.lineIndex }
55+
func (v *DocumentVersion) URI() string { return v.uri }
56+
func (v *DocumentVersion) FileID() FileID { return v.fileID }
57+
func (v *DocumentVersion) Version() int64 { return v.version }
58+
func (v *DocumentVersion) Text() string { return v.text }
59+
func (v *DocumentVersion) Tree() *rts.RadTree { return v.tree }
60+
func (v *DocumentVersion) AST() *rl.SourceFile { return v.ast }
61+
func (v *DocumentVersion) LineIndex() *LineIndex { return v.lineIndex }
4462
func (v *DocumentVersion) Diagnostics() []lsp.Diagnostic { return v.diagnostics }
4563

64+
// acquire bumps the refcount if the snapshot is still live. Returns
65+
// false if the snapshot has already been released (refs == 0), in
66+
// which case the caller should retry the Document.Snapshot load -
67+
// the State has a newer version.
68+
//
69+
// Uses CAS rather than a plain Add so we never resurrect a snapshot
70+
// whose tree has already been Close()d. This is the standard
71+
// "weak-to-strong reference upgrade" pattern.
72+
func (v *DocumentVersion) acquire() bool {
73+
for {
74+
n := v.refs.Load()
75+
if n == 0 {
76+
return false
77+
}
78+
if v.refs.CompareAndSwap(n, n+1) {
79+
return true
80+
}
81+
}
82+
}
83+
84+
// Release drops one reference. When the count reaches zero the
85+
// underlying tree-sitter tree is freed. Each call to State.Snapshot
86+
// pairs with exactly one Release - callers typically `defer
87+
// snap.Release()` right after the nil-check.
88+
func (v *DocumentVersion) Release() {
89+
if v == nil {
90+
return
91+
}
92+
if v.refs.Add(-1) == 0 {
93+
if v.tree != nil {
94+
v.tree.Close()
95+
v.tree = nil
96+
}
97+
}
98+
}
99+
46100
// GetLine returns the source of the line at the given index, or "" if
47101
// out of range. Kept on DocumentVersion (not LineIndex) because callers
48102
// usually want both the text and the index together.
@@ -78,13 +132,19 @@ func (d *Document) Snapshot() *DocumentVersion {
78132

79133
// Update runs `produce` under the writer lock to compute the next
80134
// version from the previous (nil on first open), then atomically swaps
81-
// it into place. Returns the new version.
135+
// it into place. The new version arrives with refs=1 (held by us);
136+
// after the store we Release the previous version, dropping
137+
// Document's reference to it. Any reader that had already Acquired
138+
// the old version keeps it alive via the refcount.
82139
func (d *Document) Update(produce func(prev *DocumentVersion) *DocumentVersion) *DocumentVersion {
83140
d.mu.Lock()
84141
defer d.mu.Unlock()
85142
prev := d.snapshot.Load()
86143
next := produce(prev)
87144
d.snapshot.Store(next)
145+
if prev != nil {
146+
prev.Release()
147+
}
88148
return next
89149
}
90150

@@ -107,7 +167,7 @@ func buildVersion(
107167
checker := check.NewCheckerWithTree(tree, parser, text, ast)
108168
diags := runChecker(checker, lineIndex, encoding)
109169

110-
return &DocumentVersion{
170+
v := &DocumentVersion{
111171
uri: uri,
112172
fileID: fileID,
113173
version: version,
@@ -117,6 +177,10 @@ func buildVersion(
117177
lineIndex: lineIndex,
118178
diagnostics: diags,
119179
}
180+
// Owner's reference. Released by Document.Update when this
181+
// version is replaced by a successor.
182+
v.refs.Store(1)
183+
return v
120184
}
121185

122186
// runChecker is the boundary between check.Diagnostic (utf-8 byte

radls/analysis/document_test.go

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import (
1010
// TestSnapshotStability exercises the central guarantee of Phase 8:
1111
// once a reader has a *DocumentVersion, subsequent writes don't change
1212
// what that pointer observes. The reader sees the world as it was
13-
// when it grabbed its snapshot.
13+
// when it grabbed its snapshot. The reader's Acquire keeps the
14+
// underlying tree alive across writer-driven version swaps.
1415
func TestSnapshotStability(t *testing.T) {
1516
s := NewState()
1617
s.SetEncoding(EncodingUTF16)
@@ -22,6 +23,7 @@ func TestSnapshotStability(t *testing.T) {
2223
if first == nil {
2324
t.Fatal("expected snapshot after AddDoc")
2425
}
26+
defer first.Release()
2527
if got := first.Text(); got != "x = 1" {
2628
t.Errorf("v1 text: got %q, want %q", got, "x = 1")
2729
}
@@ -43,6 +45,7 @@ func TestSnapshotStability(t *testing.T) {
4345
}
4446

4547
latest := s.Snapshot(uri)
48+
defer latest.Release()
4649
if latest == first {
4750
t.Errorf("Snapshot() returned the same pointer after updates")
4851
}
@@ -54,6 +57,52 @@ func TestSnapshotStability(t *testing.T) {
5457
}
5558
}
5659

60+
// TestSnapshotReleaseFreesTreeWhenLastRefDropped verifies the tree
61+
// is closed once the refcount reaches zero. Direct test of the
62+
// memory-leak fix: each Snapshot bumps refs, each Release drops one,
63+
// and when no more references exist the tree's C memory is freed.
64+
func TestSnapshotReleaseFreesTreeWhenLastRefDropped(t *testing.T) {
65+
s := NewState()
66+
s.SetEncoding(EncodingUTF16)
67+
const uri = "file:///release.rad"
68+
s.AddDoc(uri, "x = 1")
69+
70+
first := s.Snapshot(uri)
71+
if first == nil {
72+
t.Fatal("expected snapshot")
73+
}
74+
// At this point: refs = 2 (Document + caller).
75+
if got := first.refs.Load(); got != 2 {
76+
t.Errorf("after Snapshot: refs=%d, want 2", got)
77+
}
78+
79+
// Update once. Document drops its reference to `first`, leaving
80+
// just the caller's reference.
81+
s.UpdateDoc(uri, []lsp.TextDocumentContentChangeEvent{{Text: "x = 2"}})
82+
if got := first.refs.Load(); got != 1 {
83+
t.Errorf("after Update: refs=%d, want 1", got)
84+
}
85+
if first.tree == nil {
86+
t.Error("tree should still be alive while caller holds a ref")
87+
}
88+
89+
// Caller releases. Refcount hits zero, tree is closed and set
90+
// to nil.
91+
first.Release()
92+
if got := first.refs.Load(); got != 0 {
93+
t.Errorf("after Release: refs=%d, want 0", got)
94+
}
95+
if first.tree != nil {
96+
t.Error("tree should be nil after last Release")
97+
}
98+
99+
// A late Acquire on a freed snapshot must fail. Without this the
100+
// refcount could go negative and we'd never detect the bug.
101+
if first.acquire() {
102+
t.Error("acquire on released snapshot should fail")
103+
}
104+
}
105+
57106
// TestSnapshotConcurrentReaders runs many goroutines that all read
58107
// snapshots while writers churn. Doesn't assert on race conditions
59108
// directly (that's `go test -race`'s job), but does verify the data
@@ -102,6 +151,7 @@ func TestSnapshotConcurrentReaders(t *testing.T) {
102151
txt := snap.Text()
103152
_ = snap.Version()
104153
_ = snap.LineIndex().LineCount()
154+
snap.Release()
105155
if len(txt) == 0 {
106156
t.Errorf("empty text in snapshot")
107157
return
@@ -112,7 +162,10 @@ func TestSnapshotConcurrentReaders(t *testing.T) {
112162

113163
// Let it run a bit, then stop.
114164
for i := 0; i < 200; i++ {
115-
_ = s.Snapshot(uri)
165+
snap := s.Snapshot(uri)
166+
if snap != nil {
167+
snap.Release()
168+
}
116169
}
117170
close(stop)
118171
wg.Wait()

radls/analysis/fileid_test.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ func TestFileIDAssignment(t *testing.T) {
4242
if snapA == nil {
4343
t.Fatal("SnapshotByID(idA) returned nil")
4444
}
45+
defer snapA.Release()
4546
if snapA.URI() != uriA {
4647
t.Errorf("SnapshotByID resolved to wrong doc: got URI %q, want %q",
4748
snapA.URI(), uriA)
@@ -76,8 +77,13 @@ func TestFileIDStableAcrossUpdates(t *testing.T) {
7677
}
7778

7879
snap := s.SnapshotByID(id1)
79-
if snap == nil || snap.Text() != "v2" {
80-
t.Errorf("SnapshotByID after update: expected text v2, got %+v", snap)
80+
if snap == nil {
81+
t.Errorf("SnapshotByID after update: nil")
82+
} else {
83+
defer snap.Release()
84+
if snap.Text() != "v2" {
85+
t.Errorf("SnapshotByID after update: expected text v2, got %q", snap.Text())
86+
}
8187
}
8288
}
8389

radls/analysis/state.go

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,24 @@ func (s *State) FileIDFor(uri string) FileID {
6565
}
6666

6767
// SnapshotByID returns the current snapshot for a FileID, or nil if
68-
// the id isn't known. Lets internal code that holds a FileID grab the
69-
// latest version without converting back to URI first.
68+
// the id isn't known. Same Acquire/Release contract as Snapshot:
69+
// caller MUST Release() what they get back.
7070
func (s *State) SnapshotByID(id FileID) *DocumentVersion {
7171
s.mu.RLock()
7272
doc, ok := s.idToDoc[id]
7373
s.mu.RUnlock()
7474
if !ok {
7575
return nil
7676
}
77-
return doc.Snapshot()
77+
for {
78+
snap := doc.Snapshot()
79+
if snap == nil {
80+
return nil
81+
}
82+
if snap.acquire() {
83+
return snap
84+
}
85+
}
7886
}
7987

8088
// Encoding returns the LSP position encoding currently in use.
@@ -90,17 +98,34 @@ func (s *State) SetEncoding(enc PositionEncoding) {
9098
s.encoding = enc
9199
}
92100

93-
// Snapshot returns the current version of the named document, or nil
94-
// if the document isn't open. Lock-free on the version side; the docs
95-
// map lookup takes the RWMutex read lock briefly.
101+
// Snapshot returns the current version of the named document with
102+
// its refcount incremented. The caller MUST call Release() when
103+
// done (typically `defer snap.Release()` right after the nil-check).
104+
// Returns nil if the document isn't open.
105+
//
106+
// The acquire-after-load loop handles a small race window: between
107+
// loading the atomic pointer and bumping the refcount, the writer
108+
// could have Released the version we observed. In that case acquire
109+
// returns false and we retry; the Document.snapshot pointer has
110+
// already been updated to a newer version by the time we get here.
96111
func (s *State) Snapshot(uri string) *DocumentVersion {
97112
s.mu.RLock()
98113
doc, ok := s.docs[uri]
99114
s.mu.RUnlock()
100115
if !ok {
101116
return nil
102117
}
103-
return doc.Snapshot()
118+
for {
119+
snap := doc.Snapshot()
120+
if snap == nil {
121+
return nil
122+
}
123+
if snap.acquire() {
124+
return snap
125+
}
126+
// snap was released between Load and acquire; the writer
127+
// must have stored a newer version. Retry.
128+
}
104129
}
105130

106131
// document returns the *Document handle, creating any missing entry is

radls/server/server.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ func (s *Server) handleDidOpen(_ context.Context, params json.RawMessage) (err e
9797
// somehow the snapshot is gone we skip - notifyDiagnostics with an
9898
// empty slice would clear any prior diagnostics, which is wrong.
9999
if snap := s.s.Snapshot(uri); snap != nil {
100+
defer snap.Release()
100101
s.notifyDiagnostics(uri, snap.Diagnostics())
101102
}
102103
return
@@ -117,6 +118,7 @@ func (s *Server) handleDidChange(_ context.Context, params json.RawMessage) (err
117118
// further keystrokes between trigger and fire will have
118119
// produced newer versions, and we want the latest.
119120
if snap := s.s.Snapshot(uri); snap != nil {
121+
defer snap.Release()
120122
s.notifyDiagnostics(uri, snap.Diagnostics())
121123
}
122124
})
@@ -132,6 +134,9 @@ func (s *Server) handleCompletion(_ context.Context, params json.RawMessage) (re
132134
// Any subsequent didChange produces a new snapshot but this
133135
// handler operates on the one it grabbed - frozen, race-free.
134136
snap := s.s.Snapshot(completionParams.TextDocument.Uri)
137+
if snap != nil {
138+
defer snap.Release()
139+
}
135140
result, err = s.s.Complete(snap, completionParams.Position)
136141
return
137142
}
@@ -142,6 +147,9 @@ func (s *Server) handleCodeAction(_ context.Context, params json.RawMessage) (re
142147
return
143148
}
144149
snap := s.s.Snapshot(codeActionParams.TextDocument.Uri)
150+
if snap != nil {
151+
defer snap.Release()
152+
}
145153
result, err = s.s.CodeAction(snap, codeActionParams.Range)
146154
return
147155
}

0 commit comments

Comments
 (0)