Skip to content

Commit 347afd1

Browse files
committed
keyviz: deep-copy snapshot rows + slot bounds
Codex round-2 P2: Snapshot consumers were aliasing live sampler state. snapshotMeta returned Start/End by reference (so flushed rows tracked later RegisterRoute mutations) and ringBuffer.Range only copied MatrixColumn structs (so concurrent Flush could rotate the underlying Rows slice, and caller mutation could corrupt stored history). Clone Start/End in snapshotMeta and add a cloneColumn helper that ringBuffer.Range uses to materialise a fully-owned column per call. Regression test TestSnapshotReturnsDeepCopy mutates a returned rows bounds and verifies a follow-up snapshot is unaffected.
1 parent e492ad5 commit 347afd1

3 files changed

Lines changed: 58 additions & 7 deletions

File tree

keyviz/ring_buffer.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,10 @@ func (r *ringBuffer) Push(col MatrixColumn) {
4242

4343
// Range returns the columns whose At falls in [from, to), oldest
4444
// first. Either bound may be the zero Time, meaning unbounded on that
45-
// side. The returned slice is freshly allocated; callers may mutate
46-
// it freely.
45+
// side. The returned slice is a deep copy: each column has its own
46+
// Rows slice and each row owns its Start/End byte slices, so callers
47+
// may mutate any field without corrupting stored history or racing
48+
// with concurrent flushes.
4749
func (r *ringBuffer) Range(from, to time.Time) []MatrixColumn {
4850
all := r.snapshotOrdered()
4951
// snapshotOrdered is already chronologically ordered.
@@ -63,10 +65,27 @@ func (r *ringBuffer) Range(from, to time.Time) []MatrixColumn {
6365
return nil
6466
}
6567
out := make([]MatrixColumn, hi-lo)
66-
copy(out, all[lo:hi])
68+
for i, src := range all[lo:hi] {
69+
out[i] = cloneColumn(src)
70+
}
6771
return out
6872
}
6973

74+
// cloneColumn returns a deep copy of col: a fresh Rows slice with
75+
// each row's Start/End and MemberRoutes independently allocated.
76+
func cloneColumn(col MatrixColumn) MatrixColumn {
77+
rows := make([]MatrixRow, len(col.Rows))
78+
for i, row := range col.Rows {
79+
rows[i] = row
80+
rows[i].Start = cloneBytes(row.Start)
81+
rows[i].End = cloneBytes(row.End)
82+
if len(row.MemberRoutes) > 0 {
83+
rows[i].MemberRoutes = append([]uint64(nil), row.MemberRoutes...)
84+
}
85+
}
86+
return MatrixColumn{At: col.At, Rows: rows}
87+
}
88+
7089
// snapshotOrdered returns a chronologically ordered (oldest first)
7190
// view of the buffer contents in a fresh slice.
7291
func (r *ringBuffer) snapshotOrdered() []MatrixColumn {

keyviz/sampler.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,13 +161,14 @@ type routeSlot struct {
161161

162162
// snapshotMeta returns a defensive copy of the slot's metadata under
163163
// the read lock. Used by Flush so the row it emits doesn't share
164-
// MemberRoutes with the live slot (which a later RegisterRoute may
165-
// extend).
164+
// Start/End/MemberRoutes with the live slot (which a later
165+
// RegisterRoute may extend, and which the snapshot API exports to
166+
// external consumers that may mutate the bounds).
166167
func (s *routeSlot) snapshotMeta() (start, end []byte, aggregate bool, members []uint64) {
167168
s.metaMu.RLock()
168169
defer s.metaMu.RUnlock()
169-
start = s.Start
170-
end = s.End
170+
start = cloneBytes(s.Start)
171+
end = cloneBytes(s.End)
171172
aggregate = s.Aggregate
172173
if len(s.MemberRoutes) > 0 {
173174
members = append([]uint64(nil), s.MemberRoutes...)

keyviz/sampler_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,3 +454,34 @@ func columnTimes(cols []MatrixColumn) []time.Time {
454454
}
455455
return out
456456
}
457+
458+
// TestSnapshotReturnsDeepCopy guards the public-API contract that the
459+
// Snapshot result is fully owned by the caller: mutating row bounds or
460+
// member-route slices must not corrupt later snapshots, and must not
461+
// race with concurrent Flush/RegisterRoute.
462+
func TestSnapshotReturnsDeepCopy(t *testing.T) {
463+
t.Parallel()
464+
s, _ := newTestSampler(t, MemSamplerOptions{Step: time.Second, HistoryColumns: 4})
465+
if !s.RegisterRoute(1, []byte("aaaa"), []byte("bbbb")) {
466+
t.Fatal("RegisterRoute(1) returned false")
467+
}
468+
s.Observe(1, OpRead, 1, 2)
469+
s.Flush()
470+
471+
first := s.Snapshot(time.Time{}, time.Time{})
472+
if len(first) == 0 || len(first[0].Rows) == 0 {
473+
t.Fatalf("expected at least one row in first snapshot, got %+v", first)
474+
}
475+
first[0].Rows[0].Start[0] = 'X'
476+
first[0].Rows[0].End[0] = 'Y'
477+
first[0].Rows = nil
478+
479+
second := s.Snapshot(time.Time{}, time.Time{})
480+
if len(second) == 0 || len(second[0].Rows) == 0 {
481+
t.Fatalf("second snapshot lost rows after caller mutation: %+v", second)
482+
}
483+
r := second[0].Rows[0]
484+
if string(r.Start) != "aaaa" || string(r.End) != "bbbb" {
485+
t.Fatalf("snapshot bounds aliased live state: start=%q end=%q", r.Start, r.End)
486+
}
487+
}

0 commit comments

Comments
 (0)