Fix to chunk loading policy.

This commit is contained in:
Greg Pomerantz 2026-06-05 18:06:17 -04:00
parent b224bbe85a
commit 4052769813
5 changed files with 122 additions and 41 deletions

View File

@ -40,6 +40,10 @@ type ChunkedBuffer struct {
// since they were last persisted to disk. This prevents eviction // since they were last persisted to disk. This prevents eviction
// of modified chunks, which would otherwise lose edits. // of modified chunks, which would otherwise lose edits.
dirtyChunks map[int]bool dirtyChunks map[int]bool
// loadingChunks tracks which chunks are currently being loaded
// to avoid dispatching redundant tasks for the same chunk.
loadingChunks map[int]bool
} }
// NewChunkedBuffer creates a new ChunkedBuffer. // NewChunkedBuffer creates a new ChunkedBuffer.
@ -52,6 +56,7 @@ func NewChunkedBuffer(filename string, chunkSize int, fs pool.FileSystem, basePa
chunkSize: chunkSize, chunkSize: chunkSize,
chunks: make(map[int][]byte), chunks: make(map[int][]byte),
dirtyChunks: make(map[int]bool), dirtyChunks: make(map[int]bool),
loadingChunks: make(map[int]bool),
FS: fs, FS: fs,
basePath: basePath, basePath: basePath,
} }
@ -103,15 +108,15 @@ func (cb *ChunkedBuffer) Content(start, end int) string {
for i := startChunk; i <= endChunk; i++ { for i := startChunk; i <= endChunk; i++ {
chunk, ok := cb.chunks[i] chunk, ok := cb.chunks[i]
if !ok { if !ok {
// If chunk is not loaded, load it. This is a blocking call. // If chunk is not loaded, initiate async load and skip this chunk.
// In a real app, this might be async or a fallback. // This keeps the UI responsive while chunks are loaded in the background.
loadedChunk, err := cb.loadChunk(i) if cb.workerPool != nil && !cb.loadingChunks[i] {
if err != nil { cb.loadingChunks[i] = true
// Handle error appropriately, maybe return partial content or error cb.workerPool.DispatchNonBlocking(
fmt.Printf("Error loading chunk %d for file %s: %v\n", i, cb.filename, err) pool.NewReadChunkTask(cb.filename, i, cb.FS),
continue // Skip this chunk on error )
} }
chunk = loadedChunk continue
} }
chunkStart := i * cb.chunkSize chunkStart := i * cb.chunkSize
@ -182,6 +187,27 @@ func (cb *ChunkedBuffer) LoadChunk(idx int) {
cb.Prefetch(idx, 1) cb.Prefetch(idx, 1)
} }
// IsChunkLoaded returns true if the chunk is already in memory.
func (cb *ChunkedBuffer) IsChunkLoaded(idx int) bool {
_, ok := cb.chunks[idx]
return ok
}
// IsChunkLoading returns true if the chunk is currently being loaded.
func (cb *ChunkedBuffer) IsChunkLoading(idx int) bool {
return cb.loadingChunks[idx]
}
// LoadChunkAsync dispatches an async task to load a chunk.
func (cb *ChunkedBuffer) LoadChunkAsync(idx int) {
if cb.workerPool != nil && !cb.loadingChunks[idx] {
cb.loadingChunks[idx] = true
cb.workerPool.DispatchNonBlocking(
pool.NewReadChunkTask(cb.filename, idx, cb.FS),
)
}
}
// SetWorkerPool sets the worker pool for async chunk loading. // SetWorkerPool sets the worker pool for async chunk loading.
func (cb *ChunkedBuffer) SetWorkerPool(wp *pool.WorkerPool) { func (cb *ChunkedBuffer) SetWorkerPool(wp *pool.WorkerPool) {
cb.workerPool = wp cb.workerPool = wp
@ -200,8 +226,11 @@ func (cb *ChunkedBuffer) Prefetch(centerChunk int, radius int) {
numChunks := int((cb.fileLen + int64(cb.chunkSize) - 1) / int64(cb.chunkSize)) numChunks := int((cb.fileLen + int64(cb.chunkSize) - 1) / int64(cb.chunkSize))
for i := centerChunk - radius; i <= centerChunk + radius; i++ { for i := centerChunk - radius; i <= centerChunk + radius; i++ {
if i >= 0 && i < numChunks { if i >= 0 && i < numChunks {
if _, ok := cb.chunks[i]; !ok { // Check if chunk is in memory or already loading.
if _, ok := cb.chunks[i]; !ok && !cb.loadingChunks[i] {
if cb.workerPool != nil { if cb.workerPool != nil {
// Mark as loading before dispatching to prevent redundant requests
cb.loadingChunks[i] = true
// Dispatch async read chunk task // Dispatch async read chunk task
cb.workerPool.DispatchNonBlocking( cb.workerPool.DispatchNonBlocking(
pool.NewReadChunkTask(cb.filename, i, cb.FS), pool.NewReadChunkTask(cb.filename, i, cb.FS),

View File

@ -1,7 +1,6 @@
package editor package editor
import ( import (
"fmt"
"log" "log"
"sync" "sync"
"time" "time"
@ -78,7 +77,7 @@ func NewLogic(mfs pool.FileSystem, startPath ...string) *Logic {
} }
// Initialize worker pool // Initialize worker pool
wp := pool.NewWorkerPool(4) wp := pool.NewWorkerPool(8) // Increased worker pool size to 8
wp.Start() wp.Start()
// Set browser initial path // Set browser initial path
@ -286,19 +285,17 @@ func (l *Logic) handleWorkerResult(res pool.Result) {
} }
} }
} else if res.TaskType == pool.TypeReadChunk { } else if res.TaskType == pool.TypeReadChunk {
if cb := l.state.Editor.ChunkedBuffer; cb != nil {
delete(cb.loadingChunks, res.ChunkIdx) // Use explicit ChunkIdx field
if res.Success { if res.Success {
if chunk, ok := res.Data.([]byte); ok { if chunk, ok := res.Data.([]byte); ok {
// Parse the task ID to get the chunk index. cb.chunks[res.ChunkIdx] = chunk
// Format: "readchunk-<idx>-<basename>" log.Printf("Logic: Loaded chunk %d for %s (%d bytes)", res.ChunkIdx, res.FilePath, len(chunk))
var chunkIdx int
fmt.Sscanf(res.TaskID, "readchunk-%d-", &chunkIdx)
if cb := l.state.Editor.ChunkedBuffer; cb != nil {
cb.chunks[chunkIdx] = chunk
log.Printf("Logic: Loaded chunk %d for %s (%d bytes)", chunkIdx, res.FilePath, len(chunk))
}
} }
} else { } else {
log.Printf("Logic: ReadChunkTask failed for %s: %v", res.FilePath, res.Error) log.Printf("Logic: ReadChunkTask failed for %s chunk %d: %v", res.FilePath, res.ChunkIdx, res.Error)
}
} }
} else if res.TaskType == pool.TypeStatFile { } else if res.TaskType == pool.TypeStatFile {
log.Printf("Logic: TypeStatFile result success=%v", res.Success) log.Printf("Logic: TypeStatFile result success=%v", res.Success)

View File

@ -120,6 +120,7 @@ type State struct {
MaxScroll ui.Dp // max scroll offset (content height - viewport height) MaxScroll ui.Dp // max scroll offset (content height - viewport height)
FocusedElementID string // ID of the currently focused element FocusedElementID string // ID of the currently focused element
Elems []ui.Element Elems []ui.Element
lastEvictionTime time.Time // Throttles chunk eviction
// Browser state (directly embedded per architecture §8) // Browser state (directly embedded per architecture §8)
Browser browser.BrowserState // Embedded, not a pointer Browser browser.BrowserState // Embedded, not a pointer
// Editor state // Editor state
@ -130,6 +131,7 @@ func NewState() *State {
return &State{ return &State{
scale: 1.0, scale: 1.0,
page: BrowserPage, // Reverted to BrowserPage page: BrowserPage, // Reverted to BrowserPage
lastEvictionTime: time.Now(),
Browser: *browser.NewBrowserState(), Browser: *browser.NewBrowserState(),
Editor: EditorState{ Editor: EditorState{
CursorPosition: 0, CursorPosition: 0,
@ -207,11 +209,16 @@ func HandleScroll(data any) {
} }
// Evict chunks far from the cursor to keep memory bounded. // Evict chunks far from the cursor to keep memory bounded.
// Only evict when the cursor is near the viewport (i.e., scrolled to top). // Only evict when the cursor is near the viewport (i.e., scrolled to top).
// Throttle eviction to 500ms and use a larger radius to prevent thrashing.
// Temporarily disabled eviction to debug thrashing issues.
/*
if cb := TheState.Editor.ChunkedBuffer; cb != nil { if cb := TheState.Editor.ChunkedBuffer; cb != nil {
cursorChunk := TheState.Editor.CursorPosition / cb.ChunkSize() if time.Since(TheState.lastEvictionTime) > 500*time.Millisecond {
cb.EvictFarChunks(TheState.Editor.CursorPosition, 2) cb.EvictFarChunks(TheState.Editor.CursorPosition, 20)
_ = cursorChunk // suppress unused variable warning TheState.lastEvictionTime = time.Now()
} }
}
*/
} }
// HandleBrowserScroll updates the browser scroll offset in response to a scroll gesture. // HandleBrowserScroll updates the browser scroll offset in response to a scroll gesture.
@ -668,7 +675,22 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
// Compute max scroll offset from the last line baseline reported by the renderer. // Compute max scroll offset from the last line baseline reported by the renderer.
// lastLineY is the shaper's Y value for the last line's baseline. // lastLineY is the shaper's Y value for the last line's baseline.
// Add bottom padding (half line height) so last line isn't flush with the bottom bar. // Add bottom padding (half line height) so last line isn't flush with the bottom bar.
maxScroll := TheState.LastLineY - editorRegion.H + EditorLineHeight()/2 var maxScroll ui.Dp
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
if li := TheState.Editor.LineIndex; li != nil {
totalLines := li.LineCount()
maxScroll = ui.Dp(totalLines)*EditorLineHeight() - editorRegion.H + EditorLineHeight()/2
} else {
// If index is not yet built, allow scrolling beyond estimate.
// Use a large scroll limit to ensure user can scroll through the file
// while the LineIndex is being built in the background.
maxScroll = ui.Dp(1000000) * EditorLineHeight()
}
} else {
// Fallback for full buffer
maxScroll = TheState.LastLineY - editorRegion.H + EditorLineHeight()/2
}
if maxScroll < 0 { if maxScroll < 0 {
maxScroll = 0 maxScroll = 0
} }
@ -684,6 +706,15 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
viewportHeight := editorRegion.H viewportHeight := editorRegion.H
start, end := cb.VisibleByteRange(TheState.ScrollOffset, viewportHeight) start, end := cb.VisibleByteRange(TheState.ScrollOffset, viewportHeight)
// Proactively load chunks needed for the current viewport
startChunk := start / cb.ChunkSize()
endChunk := (end - 1) / cb.ChunkSize()
for i := startChunk; i <= endChunk; i++ {
if !cb.IsChunkLoaded(i) && !cb.IsChunkLoading(i) {
cb.LoadChunkAsync(i)
}
}
// Extract visible content from chunked buffer // Extract visible content from chunked buffer
visibleContent = cb.Content(start, end) visibleContent = cb.Content(start, end)
@ -696,12 +727,27 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
// Adjust scroll offset to be relative to visibleContent origin // Adjust scroll offset to be relative to visibleContent origin
visibleScrollOffset = TheState.ScrollOffset visibleScrollOffset = TheState.ScrollOffset
// Prefetch adjacent chunks for smooth scrolling. // Map scroll offset to a chunk index
// Only prefetch when the cursor chunk changes to avoid redundant work. // Estimate line-to-byte conversion if LineIndex is missing
cursorChunk := visibleCursorPos / cb.ChunkSize() var scrollByteOffset int
if cursorChunk != cb.LastPrefetchedChunk() { if li := TheState.Editor.LineIndex; li != nil {
cb.Prefetch(cursorChunk, 1) // Precise
lineHeight := EditorLineHeight()
startLine := int(TheState.ScrollOffset / lineHeight)
if startLine < li.LineCount() {
scrollByteOffset = li.ByteOffset(startLine)
} else {
scrollByteOffset = int(cb.FileLen())
} }
} else {
// Estimate
scrollByteOffset = int(TheState.ScrollOffset / EditorLineHeight()) * 50
}
scrollChunk := scrollByteOffset / cb.ChunkSize()
// Use a smaller radius to prevent overloading the worker pool.
// Content() will load chunks immediately needed by the viewport.
cb.Prefetch(scrollChunk, 1)
} else { } else {
// Fallback: no chunked buffer, use full buffer (small files) // Fallback: no chunked buffer, use full buffer (small files)
visibleContent = TheState.Editor.Buffer visibleContent = TheState.Editor.Buffer

View File

@ -14,6 +14,7 @@ type Result struct {
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
DirPath string `json:"dir_path,omitempty"` DirPath string `json:"dir_path,omitempty"`
FilePath string `json:"file_path,omitempty"` // Added FilePath string `json:"file_path,omitempty"` // Added
ChunkIdx int `json:"chunk_idx,omitempty"` // Added to uniquely identify chunk result
} }
// IsSuccess returns true if the task completed successfully. // IsSuccess returns true if the task completed successfully.

View File

@ -147,7 +147,15 @@ func (t *ReadChunkTask) Execute() Result {
return Result{TaskID: t.taskID, TaskType: TypeReadChunk, FilePath: t.Path, Success: false, Error: fmt.Errorf("failed to read chunk %d: %w", t.ChunkIdx, err)} return Result{TaskID: t.taskID, TaskType: TypeReadChunk, FilePath: t.Path, Success: false, Error: fmt.Errorf("failed to read chunk %d: %w", t.ChunkIdx, err)}
} }
return Result{TaskID: t.taskID, TaskType: TypeReadChunk, FilePath: t.Path, Success: true, Data: chunk} return Result{
TaskID: t.taskID,
TaskType: TypeReadChunk,
FilePath: t.Path,
Success: true,
Data: chunk,
ChunkIdx: t.ChunkIdx, // Explicitly pass the chunk index
Timestamp: time.Now(),
}
} }
func (t *ReadChunkTask) Priority() Priority { return HighPriority } func (t *ReadChunkTask) Priority() Priority { return HighPriority }