Selection handles now track the finger 1:1 (anchor grab point + displacement) instead of snapping by whole lines, and crossing the opposite handle flips the selection (native behaviour) instead of clearing it. Caret and tap/handle line resolution use VisualLineStarts instead of the min-Y baseline: the window's first visual line may be an empty line with no recorded glyphs, which used to draw boundary carets one line too low per leading empty line and land taps/dragged handles one line below the finger. New exported ui.CaretPoint centralises byte->insertion-point mapping. The off-screen caret no longer clamps to the window edge: EditorLayout ships the true (possibly negative / past-end) window-relative cursor and the renderer skips the caret when the cursor is outside the shaped window, so scrolling past the caret no longer makes it jump onto the top/bottom line. IME/router replay fixes: key.FocusCmd is issued only on a focus transition (a per-frame no-op still takes the immediate-command path and re-queues all pointer events), and the key.SelectionCmd IME sync is deferred while a handle drag is in progress (each push re-injected the drag into every gesture). Handle drags forward only Grabbed events; a tap inside a handle grab box is a no-op. Also: key.FocusEvent no longer logs as unexpected in main; dead code removed (worker taskWrapper, browser applyXxxResult stubs, scrollIndex, mock_setup sortModeKey/lineSpan helpers); mock FileSystem.ListPaths prefix match uses strings.HasPrefix; build scripts run the new scripts/check.sh static gate (go vet + staticcheck). Tests: caret_point_test, touch_selection updates (flip/empty-line cases), off-window caret e2e, selection drag e2e grab step.
325 lines
8.0 KiB
Go
325 lines
8.0 KiB
Go
package pool
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// WorkerPool manages a pool of worker goroutines that execute IO tasks.
|
|
// It follows the architecture's single-owner pattern:
|
|
// - Logic goroutine dispatches tasks on highWorkChan/lowWorkChan
|
|
// - Workers execute tasks and post results on resultChan
|
|
// - Workers never access state directly
|
|
type WorkerPool struct {
|
|
highWorkChan chan Task
|
|
lowWorkChan chan Task
|
|
resultChan chan Result
|
|
workerCount int
|
|
stopOnce sync.Once
|
|
stopChan chan struct{}
|
|
wg sync.WaitGroup
|
|
taskCounter atomic.Int64
|
|
running atomic.Bool
|
|
|
|
// pendingTasks tracks in-flight tasks for cancellation by directory.
|
|
pendingTasks map[string][]Task
|
|
pendingMu sync.Mutex
|
|
}
|
|
|
|
// NewWorkerPool creates a new worker pool with the given number of workers.
|
|
// Buffered channels prevent blocking when all workers are busy.
|
|
func NewWorkerPool(workerCount int) *WorkerPool {
|
|
if workerCount <= 0 {
|
|
workerCount = 4
|
|
}
|
|
|
|
return &WorkerPool{
|
|
highWorkChan: make(chan Task, workerCount*2),
|
|
lowWorkChan: make(chan Task, workerCount*2),
|
|
resultChan: make(chan Result, workerCount*4),
|
|
workerCount: workerCount,
|
|
stopChan: make(chan struct{}),
|
|
pendingTasks: make(map[string][]Task),
|
|
}
|
|
}
|
|
|
|
// Start begins processing tasks. Workers are started immediately.
|
|
func (wp *WorkerPool) Start() {
|
|
if wp.running.Swap(true) {
|
|
return // Already started
|
|
}
|
|
|
|
for i := 0; i < wp.workerCount; i++ {
|
|
wp.wg.Add(1)
|
|
go wp.worker(i)
|
|
}
|
|
}
|
|
|
|
// Stop gracefully shuts down the worker pool.
|
|
// All in-flight tasks are completed before shutdown.
|
|
func (wp *WorkerPool) Stop() {
|
|
wp.stopOnce.Do(func() {
|
|
wp.running.Store(false)
|
|
close(wp.stopChan)
|
|
// Close work channels so drain() can complete
|
|
close(wp.highWorkChan)
|
|
close(wp.lowWorkChan)
|
|
// Wait for workers to finish draining
|
|
wp.wg.Wait()
|
|
close(wp.resultChan)
|
|
})
|
|
}
|
|
|
|
// Dispatch submits a task to the worker pool.
|
|
// It blocks if the work channel is full (buffered to pool size).
|
|
//
|
|
// Use this for UI-critical tasks where the result is required for correctness.
|
|
// The logic goroutine must expect exactly one result for every dispatched task.
|
|
// Results are never dropped: the worker blocks until the logic goroutine
|
|
// consumes the result from resultChan.
|
|
func (wp *WorkerPool) Dispatch(task Task) {
|
|
wp.taskCounter.Add(1)
|
|
|
|
// Track pending task for cancellation
|
|
wp.trackPending(task)
|
|
|
|
if task.Priority() == HighPriority {
|
|
wp.highWorkChan <- task
|
|
} else {
|
|
wp.lowWorkChan <- task
|
|
}
|
|
}
|
|
|
|
// DispatchNonBlocking attempts to submit a task without blocking.
|
|
// Returns false if the channel is full, in which case the task is silently dropped.
|
|
//
|
|
// Use this ONLY for fire-and-forget background tasks that can be retried later
|
|
// (e.g., auto-save, undo persistence, cache writes). The task is idempotent and
|
|
// the application does not depend on receiving a result.
|
|
//
|
|
// CRITICAL: Do not use this for tasks where the logic goroutine waits for a
|
|
// specific result. If the task is dropped, the logic goroutine will wait forever
|
|
// for a result that will never arrive, causing a livelock.
|
|
func (wp *WorkerPool) DispatchNonBlocking(task Task) bool {
|
|
wp.taskCounter.Add(1)
|
|
|
|
if task.Priority() == HighPriority {
|
|
select {
|
|
case wp.highWorkChan <- task:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
} else {
|
|
select {
|
|
case wp.lowWorkChan <- task:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// WorkerID returns the number of workers in the pool.
|
|
func (wp *WorkerPool) WorkerCount() int {
|
|
return wp.workerCount
|
|
}
|
|
|
|
// IsRunning returns true if the pool is currently running.
|
|
func (wp *WorkerPool) IsRunning() bool {
|
|
return wp.running.Load()
|
|
}
|
|
|
|
// TaskCount returns the total number of tasks dispatched.
|
|
func (wp *WorkerPool) TaskCount() int64 {
|
|
return wp.taskCounter.Load()
|
|
}
|
|
|
|
// ResultChan returns the result channel for the logic goroutine to consume
|
|
// results from completed tasks.
|
|
func (wp *WorkerPool) ResultChan() <-chan Result {
|
|
return wp.resultChan
|
|
}
|
|
|
|
// worker is the main loop for each worker goroutine.
|
|
// It prioritizes high-priority tasks over low-priority tasks.
|
|
func (wp *WorkerPool) worker(id int) {
|
|
defer wp.wg.Done()
|
|
|
|
for {
|
|
select {
|
|
case <-wp.stopChan:
|
|
// Drain remaining work before exiting
|
|
wp.drain(wp.highWorkChan)
|
|
wp.drain(wp.lowWorkChan)
|
|
return
|
|
|
|
case task, ok := <-wp.highWorkChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
if task == nil {
|
|
continue
|
|
}
|
|
result := wp.execute(task)
|
|
wp.post(result)
|
|
|
|
default:
|
|
// No high-priority work; check low-priority
|
|
select {
|
|
case <-wp.stopChan:
|
|
// Drain remaining work before exiting
|
|
wp.drain(wp.highWorkChan)
|
|
wp.drain(wp.lowWorkChan)
|
|
return
|
|
|
|
case task, ok := <-wp.highWorkChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
if task == nil {
|
|
continue
|
|
}
|
|
result := wp.execute(task)
|
|
wp.post(result)
|
|
|
|
case task, ok := <-wp.lowWorkChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
if task == nil {
|
|
continue
|
|
}
|
|
result := wp.execute(task)
|
|
wp.post(result)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// drain processes all remaining tasks in a channel before shutdown.
|
|
func (wp *WorkerPool) drain(ch chan Task) {
|
|
for task := range ch {
|
|
result := wp.execute(task)
|
|
wp.post(result)
|
|
}
|
|
}
|
|
|
|
// execute runs a task with timeout enforcement and captures timing information.
|
|
func (wp *WorkerPool) execute(task Task) Result {
|
|
start := time.Now()
|
|
|
|
// Apply timeout if specified
|
|
ctx := task.Context()
|
|
var cancel context.CancelFunc
|
|
if timeout := task.Timeout(); timeout > 0 {
|
|
ctx, cancel = context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
// Set the timeout context on the task if it supports it
|
|
if setter, ok := task.(interface{ SetContext(context.Context) }); ok {
|
|
setter.SetContext(ctx)
|
|
}
|
|
}
|
|
|
|
// Execute the task with timeout enforcement
|
|
result := wp.executeWithTimeout(task, ctx)
|
|
result.Timestamp = start.Add(time.Since(start))
|
|
|
|
// Set DirPath from the task
|
|
result.DirPath = task.DirPath()
|
|
|
|
// Remove from pending tasks
|
|
wp.untrackPending(task)
|
|
|
|
return result
|
|
}
|
|
|
|
// executeWithTimeout runs a task with timeout enforcement.
|
|
// If the task exceeds its timeout, it returns a timeout error.
|
|
func (wp *WorkerPool) executeWithTimeout(task Task, ctx context.Context) Result {
|
|
// Channel to receive the result
|
|
resultChan := make(chan Result, 1)
|
|
|
|
// Run the task in a goroutine
|
|
go func() {
|
|
resultChan <- task.Execute()
|
|
}()
|
|
|
|
// Wait for either the result or the context to be done
|
|
select {
|
|
case result := <-resultChan:
|
|
return result
|
|
case <-ctx.Done():
|
|
// Task exceeded its timeout or was cancelled
|
|
return Result{
|
|
TaskID: task.TaskID(),
|
|
TaskType: task.TaskType(),
|
|
Success: false,
|
|
Error: ctx.Err(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// post sends a result to the result channel.
|
|
// Blocking: waits for the logic goroutine to drain the channel.
|
|
// Results are never dropped to ensure every dispatched task produces exactly one result.
|
|
func (wp *WorkerPool) post(result Result) {
|
|
wp.resultChan <- result
|
|
}
|
|
|
|
// CancelPendingTasks cancels all pending tasks for the given directory path.
|
|
// This is useful when the user navigates away from a directory and pending
|
|
// load operations should be cancelled.
|
|
func (wp *WorkerPool) CancelPendingTasks(dirPath string) {
|
|
wp.pendingMu.Lock()
|
|
defer wp.pendingMu.Unlock()
|
|
|
|
tasks, ok := wp.pendingTasks[dirPath]
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Cancel all pending tasks for this directory
|
|
for _, task := range tasks {
|
|
task.Cancel()
|
|
}
|
|
|
|
// Remove the entry
|
|
delete(wp.pendingTasks, dirPath)
|
|
}
|
|
|
|
// trackPending adds a task to the pending tasks map for cancellation tracking.
|
|
func (wp *WorkerPool) trackPending(task Task) {
|
|
wp.pendingMu.Lock()
|
|
defer wp.pendingMu.Unlock()
|
|
|
|
dirPath := task.DirPath()
|
|
if dirPath == "" {
|
|
return
|
|
}
|
|
|
|
wp.pendingTasks[dirPath] = append(wp.pendingTasks[dirPath], task)
|
|
}
|
|
|
|
// untrackPending removes a task from the pending tasks map.
|
|
func (wp *WorkerPool) untrackPending(task Task) {
|
|
wp.pendingMu.Lock()
|
|
defer wp.pendingMu.Unlock()
|
|
|
|
dirPath := task.DirPath()
|
|
tasks, ok := wp.pendingTasks[dirPath]
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Remove the task from the list
|
|
for i, t := range tasks {
|
|
if t.TaskID() == task.TaskID() {
|
|
wp.pendingTasks[dirPath] = append(tasks[:i], tasks[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
}
|