1package vt
2
3import (
4 "bytes"
5 "compress/zlib"
6 "errors"
7 "fmt"
8 "io"
9 "sync"
10
11 renderer "github.com/bnema/vev-vt/core"
12)
13
14// ErrHistoryCorrupt reports corruption of an internally compressed sealed page.
15// Public history decoders reject malformed external data before installing it.
16var ErrHistoryCorrupt = errors.New("corrupt compressed history page")
17
18const maxHistoryPageEncodedBytes = 256 << 20
19
20// sealedPage owns immutable semantic contents and mutable physical backing.
21// Evicted-prefix wrappers and borrowed views share this owner. A returned Frame
22// is an internal read-only borrow: dropping the cached pointer cannot invalidate
23// an in-flight reader because Go keeps that reader's page reference alive.
24type sealedPage struct {
25 mu sync.Mutex
26 frame renderer.Frame
27 compressed []byte
28 encodedSize int
29 width, height int
30 reads, observed uint64
31 observedOnce bool
32 incompressible bool
33 restores uint64
34}
35
36func newSealedPage(frame renderer.Frame) *sealedPage {
37 return &sealedPage{frame: frame, width: frame.Width, height: frame.Height}
38}
39
40func (p *sealedPage) readFrame(cache bool) (renderer.Frame, error) {
41 p.mu.Lock()
42 defer p.mu.Unlock()
43 p.reads++
44 if p.frame.Height != 0 {
45 return p.frame, nil
46 }
47 frame, err := p.decode()
48 if err != nil {
49 return renderer.Frame{}, err
50 }
51 p.restores++
52 if cache {
53 p.frame = frame
54 }
55 return frame, nil
56}
57
58// decode is called with the page lock held. The exact uncompressed length and
59// zlib checksum are validated before VTC1's full semantic preflight runs.
60func (p *sealedPage) decode() (renderer.Frame, error) {
61 invalid := func(err error) (renderer.Frame, error) {
62 return renderer.Frame{}, fmt.Errorf("%w: %v", ErrHistoryCorrupt, err)
63 }
64 if p.encodedSize < historyHeaderBytes || p.encodedSize > maxHistoryPageEncodedBytes || len(p.compressed) == 0 {
65 return invalid(errors.New("missing backing"))
66 }
67 source := bytes.NewReader(p.compressed)
68 r, err := zlib.NewReader(source)
69 if err != nil {
70 return invalid(err)
71 }
72 data, readErr := io.ReadAll(io.LimitReader(r, int64(p.encodedSize)+1))
73 closeErr := r.Close()
74 if readErr != nil {
75 return invalid(readErr)
76 }
77 if closeErr != nil {
78 return invalid(closeErr)
79 }
80 if len(data) != p.encodedSize || source.Len() != 0 {
81 return invalid(errors.New("backing length mismatch"))
82 }
83 view, err := UnmarshalHistory(data)
84 if err != nil {
85 return invalid(err)
86 }
87 if len(view.chunks) != 1 {
88 return invalid(errors.New("unexpected page count"))
89 }
90 frame := view.chunks[0].page.frame
91 if frame.Width != p.width || frame.Height != p.height {
92 return invalid(errors.New("page geometry mismatch"))
93 }
94 return frame, nil
95}
96
97func (c *HistoryChunk) frameView() renderer.Frame {
98 frame, err := c.page.readFrame(true)
99 if err != nil {
100 panic(err)
101 } // Never substitute blank data for corrupted history.
102 return frame
103}
104
105// Restore validates and caches the chunk's backing before a caller starts a
106// read transaction. Ordinary reads restore transparently; if private backing is
107// corrupted they panic with ErrHistoryCorrupt rather than silently losing text.
108func (c *HistoryChunk) Restore() error {
109 if c == nil {
110 return nil
111 }
112 _, err := c.page.readFrame(true)
113 return err
114}
115
116func (p *sealedPage) compressIfIdle() (bool, error) {
117 p.mu.Lock()
118 defer p.mu.Unlock()
119 if !p.observedOnce || p.observed != p.reads {
120 p.observed, p.observedOnce = p.reads, true
121 return false, nil
122 }
123 if p.frame.Height == 0 || p.incompressible {
124 return false, nil
125 }
126 if len(p.compressed) != 0 {
127 p.frame = renderer.Frame{}
128 return true, nil
129 }
130 // Encode the full backing, not an evicted-prefix wrapper. Synthetic IDs are
131 // local to this private envelope; public stable IDs remain on the wrappers.
132 chunk := &HistoryChunk{page: newSealedPage(p.frame), count: p.height, width: p.width,
133 bounds: make([]LineBound, p.height), rowIDs: make([]RowID, p.height)}
134 for y := range p.height {
135 chunk.bounds[y] = LineBound{End: p.width}
136 chunk.rowIDs[y] = RowID(y + 1)
137 }
138 raw, err := MarshalHistory(HistoryView{chunks: []*HistoryChunk{chunk}, rows: p.height, nextRowID: RowID(p.height + 1)})
139 if err != nil {
140 return false, err
141 }
142 if len(raw) > maxHistoryPageEncodedBytes {
143 return false, errors.New("history page exceeds encoded backing limit")
144 }
145 var out bytes.Buffer
146 w, err := zlib.NewWriterLevel(&out, zlib.BestSpeed)
147 if err != nil {
148 return false, err
149 }
150 if _, err := w.Write(raw); err != nil {
151 _ = w.Close()
152 return false, err
153 }
154 if err := w.Close(); err != nil {
155 return false, err
156 }
157 if out.Len() >= len(raw) {
158 p.incompressible = true
159 return false, nil
160 }
161 p.compressed = make([]byte, out.Len())
162 copy(p.compressed, out.Bytes())
163 p.encodedSize = len(raw)
164 p.frame = renderer.Frame{}
165 return true, nil
166}
167
168// CompressIdle visits at most maxPages sealed pages. Call it from the history
169// owner's idle scheduler, never concurrently with append/eviction. A page must
170// remain unread across two visits; the newest sealed chunk and mutable tail are
171// kept hot. There are no internal goroutines, clocks, pooling or mmap mappings.
172func (h *History) CompressIdle(maxPages int) (int, error) {
173 if h == nil || maxPages <= 0 || len(h.chunks) < 2 {
174 return 0, nil
175 }
176 eligible := len(h.chunks) - 1
177 compressed := 0
178 for range min(maxPages, eligible) {
179 h.compressionCursor %= eligible
180 chunk := h.chunks[h.compressionCursor]
181 h.compressionCursor++
182 didCompress, err := chunk.page.compressIfIdle()
183 if err != nil {
184 return compressed, err
185 }
186 if didCompress {
187 compressed++
188 }
189 }
190 return compressed, nil
191}
192
193// HistoryCompressionStats describes retained physical backing, not process RSS.
194// ResidentLogicalBytes excludes Go allocator/map overhead; CompressedBytes is
195// the encoded backing size. Retention remains governed by LogicalBytes alone.
196type HistoryCompressionStats struct {
197 ColdPages int
198 CompressedBytes uint64
199 ResidentLogicalBytes uint64
200 Restores uint64
201}
202
203func (h *History) CompressionStats() HistoryCompressionStats {
204 var stats HistoryCompressionStats
205 if h == nil {
206 return stats
207 }
208 stats.ResidentLogicalBytes = h.tailBytes
209 for _, chunk := range h.chunks {
210 p := chunk.page
211 p.mu.Lock()
212 if p.frame.Height == 0 {
213 stats.ColdPages++
214 } else {
215 stats.ResidentLogicalBytes += p.frame.LogicalBytes()
216 }
217 stats.CompressedBytes += uint64(len(p.compressed))
218 stats.Restores += p.restores
219 p.mu.Unlock()
220 }
221 return stats
222}