Skip to content

Commit 8dbc76a

Browse files
SuperCoolPencilMeet Mehta
andauthored
fix: minor issues with pause/resume, eta calculation, elapsed calculation, task_queue (#50)
* feat: Implement elapsed time tracking for downloads and introduce an intermediate "pausing" state for better UX. * fix: add idempotency check to prevent re-pausing if the download is already in a pausing or paused state. * feat: Add checks to prevent resuming a download that is still pausing or already running. * feat: Distinguish between total and session elapsed time in progress tracking for accurate speed calculation and reporting. * fix: while traversing task queue, we used range q.tasks, this tries to split tasks that have already been split * fix: Handle file rename race condition by checking if the destination file already exists with the correct size. * test: add tests to ensure all the bugs mentioned dont happen again * feat: Add temporary database setup and a worker pool to `TestStateSync` and adjust sleep durations for improved state synchronization testing. --------- Co-authored-by: Meet Mehta <supercoolpencil@duck.com>
1 parent ce1715c commit 8dbc76a

16 files changed

Lines changed: 365 additions & 41 deletions

File tree

‎internal/download/manager.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,10 +178,15 @@ func TUIDownload(ctx context.Context, cfg *types.DownloadConfig) error {
178178

179179
isPaused := cfg.State != nil && cfg.State.IsPaused()
180180
if downloadErr == nil && !isPaused && cfg.ProgressCh != nil {
181+
elapsed := time.Since(start)
182+
// For resumed downloads, add previously saved elapsed time
183+
if cfg.State != nil && cfg.State.SavedElapsed > 0 {
184+
elapsed += cfg.State.SavedElapsed
185+
}
181186
cfg.ProgressCh <- events.DownloadCompleteMsg{
182187
DownloadID: cfg.ID,
183188
Filename: finalFilename,
184-
Elapsed: time.Since(start),
189+
Elapsed: elapsed,
185190
Total: probe.FileSize,
186191
}
187192
}

‎internal/download/pool.go‎

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ package download
33
import (
44
"context"
55
"sync"
6+
"time"
67

78
"github.com/surge-downloader/surge/internal/engine/events"
89
"github.com/surge-downloader/surge/internal/engine/state"
910
"github.com/surge-downloader/surge/internal/engine/types"
11+
"github.com/surge-downloader/surge/internal/utils"
1012
)
1113

1214
// activeDownload tracks a download that's currently running
@@ -84,6 +86,11 @@ func (p *WorkerPool) Pause(downloadID string) {
8486

8587
// Set paused flag and cancel context
8688
if ad.config.State != nil {
89+
// Idempotency: If already pausing or paused, do nothing
90+
if ad.config.State.IsPausing() || ad.config.State.IsPaused() {
91+
return
92+
}
93+
ad.config.State.SetPausing(true) // Mark as transitioning to pause
8794
ad.config.State.Pause()
8895
}
8996

@@ -105,8 +112,8 @@ func (p *WorkerPool) PauseAll() {
105112
p.mu.RLock()
106113
ids := make([]string, 0, len(p.downloads)) //This stores the uuids of the downloads to be paused
107114
for id, ad := range p.downloads {
108-
// Only pause downloads that are actually active (not already paused or done)
109-
if ad != nil && ad.config.State != nil && !ad.config.State.IsPaused() && !ad.config.State.Done.Load() {
115+
// Only pause downloads that are actually active (not already paused or done or pausing)
116+
if ad != nil && ad.config.State != nil && !ad.config.State.IsPaused() && !ad.config.State.Done.Load() && !ad.config.State.IsPausing() {
110117
ids = append(ids, id)
111118
}
112119
}
@@ -151,6 +158,18 @@ func (p *WorkerPool) Resume(downloadID string) {
151158
return
152159
}
153160

161+
// Prevent race: Don't resume if still pausing
162+
if ad.config.State != nil && ad.config.State.IsPausing() {
163+
utils.Debug("Resume ignored: download %s is still pausing", downloadID)
164+
return
165+
}
166+
167+
// Idempotency: If already running (not paused), do nothing
168+
if ad.config.State != nil && !ad.config.State.IsPaused() {
169+
utils.Debug("Resume ignored: download %s is already running", downloadID)
170+
return
171+
}
172+
154173
// Clear paused flag and reset session start to avoid speed spikes/dips checks
155174
if ad.config.State != nil {
156175
ad.config.State.Resume()
@@ -187,10 +206,21 @@ func (p *WorkerPool) worker() {
187206

188207
err := TUIDownload(ctx, &ad.config)
189208

190-
// Check if this was a pause (not an error)
191-
isPaused := cfg.State != nil && cfg.State.IsPaused()
209+
// Logic:
210+
// 1. If Pause() was called: State.IsPaused() is true. We keep the task in p.downloads (so it can be resumed).
211+
// 2. If finished/error: We remove from p.downloads.
212+
213+
isPaused := ad.config.State != nil && ad.config.State.IsPaused()
214+
215+
// Clear "Pausing" transition state now that worker has exited
216+
if ad.config.State != nil {
217+
ad.config.State.SetPausing(false)
218+
}
192219

193-
if err != nil && !isPaused {
220+
if isPaused {
221+
utils.Debug("WorkerPool: Download %s paused cleanly", cfg.ID)
222+
// If paused, we keep it in downloads map for potential resume
223+
} else if err != nil {
194224
if cfg.State != nil {
195225
cfg.State.SetError(err)
196226
}
@@ -255,7 +285,9 @@ func (p *WorkerPool) GetStatus(id string) *types.DownloadStatus {
255285
Status: "downloading",
256286
}
257287

258-
if state.IsPaused() {
288+
if ad.config.State.IsPausing() {
289+
status.Status = "pausing"
290+
} else if ad.config.State.IsPaused() {
259291
status.Status = "paused"
260292
} else if state.Done.Load() {
261293
status.Status = "completed"
@@ -272,10 +304,10 @@ func (p *WorkerPool) GetStatus(id string) *types.DownloadStatus {
272304
}
273305

274306
// Calculate speed (MB/s)
275-
downloaded, _, elapsed, _, sessionStart := state.GetProgress()
307+
downloaded, _, _, sessionElapsed, _, sessionStart := state.GetProgress()
276308
sessionDownloaded := downloaded - sessionStart
277-
if elapsed.Seconds() > 0 && sessionDownloaded > 0 {
278-
bytesPerSec := float64(sessionDownloaded) / elapsed.Seconds()
309+
if sessionElapsed.Seconds() > 0 && sessionDownloaded > 0 {
310+
bytesPerSec := float64(sessionDownloaded) / sessionElapsed.Seconds()
279311
status.Speed = bytesPerSec / (1024 * 1024)
280312
}
281313

@@ -286,5 +318,38 @@ func (p *WorkerPool) GetStatus(id string) *types.DownloadStatus {
286318
func (p *WorkerPool) GracefulShutdown() {
287319
// ... existing implementation
288320
p.PauseAll()
321+
322+
// Wait for any downloads in "Pausing" state to finish transitioning
323+
// This ensures we don't exit while a database write is pending/active
324+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
325+
defer cancel()
326+
327+
ticker := time.NewTicker(100 * time.Millisecond)
328+
defer ticker.Stop()
329+
330+
for {
331+
p.mu.RLock()
332+
stillPausing := false
333+
for _, ad := range p.downloads {
334+
if ad.config.State != nil && ad.config.State.IsPausing() {
335+
stillPausing = true
336+
break
337+
}
338+
}
339+
p.mu.RUnlock()
340+
341+
if !stillPausing {
342+
break
343+
}
344+
345+
select {
346+
case <-ctx.Done():
347+
utils.Debug("GracefulShutdown: timed out waiting for downloads to pause")
348+
return // Return from function, loop will exit
349+
case <-ticker.C:
350+
continue
351+
}
352+
}
353+
289354
p.wg.Wait() // Blocks until all workers call Done()
290355
}

‎internal/download/pool_test.go‎

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,9 @@ func TestWorkerPool_Resume_RequeuesDownload(t *testing.T) {
500500
State: state,
501501
}
502502

503+
// Resume checks idempotency, so we MUST start in Paused state
504+
state.Paused.Store(true)
505+
503506
pool.mu.Lock()
504507
pool.downloads["test-id"] = &activeDownload{
505508
config: cfg,
@@ -547,7 +550,21 @@ func TestWorkerPool_GracefulShutdown_PausesAll(t *testing.T) {
547550
pool.mu.Unlock()
548551

549552
// GracefulShutdown should call PauseAll
553+
// PauseAll will set IsPausing() = true
554+
// GracefulShutdown waits for IsPausing() = false
555+
// We verify that PauseAll was called by checking state.IsPausing()
556+
// Then we clear it to unblock shutdown
557+
550558
done := make(chan bool)
559+
go func() {
560+
// Wait for PauseAll to be called (Pausing=true)
561+
for !state.IsPausing() {
562+
time.Sleep(10 * time.Millisecond)
563+
}
564+
// Simulate worker finishing pause transition
565+
state.SetPausing(false)
566+
}()
567+
551568
go func() {
552569
pool.GracefulShutdown()
553570
done <- true
@@ -628,7 +645,76 @@ func TestWorkerPool_HasDownload(t *testing.T) {
628645
t.Error("Expected HasDownload to return false for missing download")
629646
}
630647

631-
// 3. Persistent Check is harder to test without mocking 'state' package or setting up DB.
632-
// However, we can trust the integration test for that part or mock it if we refactored.
633648
// For now, this unit test covers the memory-check part of HasDownload which was the critical logic add.
634649
}
650+
651+
func TestWorkerPool_PauseResume_Idempotency(t *testing.T) {
652+
ch := make(chan any, 10)
653+
pool := NewWorkerPool(ch, 3)
654+
655+
state := types.NewProgressState("idempotent-test", 1000)
656+
657+
pool.mu.Lock()
658+
pool.downloads["idempotent-test"] = &activeDownload{
659+
config: types.DownloadConfig{
660+
ID: "idempotent-test",
661+
State: state,
662+
},
663+
}
664+
pool.mu.Unlock()
665+
666+
// 1. First Pause
667+
pool.Pause("idempotent-test")
668+
669+
// Should be Pausing
670+
if !state.IsPausing() {
671+
t.Error("Expected state to be Pausing after first Pause")
672+
}
673+
674+
// Consume message
675+
select {
676+
case <-ch:
677+
// OK
678+
case <-time.After(100 * time.Millisecond):
679+
t.Error("Expected pause message")
680+
}
681+
682+
// 2. Second Pause (Idempotent)
683+
// Should NOT send another message or change state significantly (still Pausing/Paused)
684+
pool.Pause("idempotent-test")
685+
686+
select {
687+
case <-ch:
688+
t.Error("Did not expect second pause message")
689+
default:
690+
// OK
691+
}
692+
693+
// Manually transition to Paused (simulating worker finish)
694+
state.SetPausing(false)
695+
state.Pause()
696+
697+
// 3. Resume
698+
pool.Resume("idempotent-test")
699+
if state.IsPaused() {
700+
t.Error("Expected state to be Resumed (not paused)")
701+
}
702+
703+
// Consume resume message
704+
select {
705+
case <-ch:
706+
// OK
707+
case <-time.After(100 * time.Millisecond):
708+
t.Error("Expected resume message")
709+
}
710+
711+
// 4. Second Resume (Idempotent)
712+
pool.Resume("idempotent-test")
713+
714+
select {
715+
case <-ch:
716+
t.Error("Did not expect second resume message")
717+
default:
718+
// OK
719+
}
720+
}

‎internal/engine/concurrent/downloader.go‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,8 @@ func (d *ConcurrentDownloader) Download(ctx context.Context, rawurl, destPath st
204204
tasks = savedState.Tasks
205205
if d.State != nil {
206206
d.State.Downloaded.Store(savedState.Downloaded)
207+
// Restore elapsed time from previous sessions
208+
d.State.SetSavedElapsed(time.Duration(savedState.Elapsed))
207209
// Fix speed spike: sync session start so we don't count previous bytes as new speed
208210
d.State.SyncSessionStart()
209211
}
@@ -348,6 +350,14 @@ func (d *ConcurrentDownloader) Download(ctx context.Context, rawurl, destPath st
348350
}
349351
computedDownloaded := fileSize - remainingBytes
350352

353+
// Calculate total elapsed time
354+
var totalElapsed time.Duration
355+
if d.State != nil {
356+
totalElapsed = d.State.SavedElapsed + time.Since(startTime)
357+
} else {
358+
totalElapsed = time.Since(startTime)
359+
}
360+
351361
// Save state for resume (use computed value for consistency)
352362
s := &types.DownloadState{
353363
URL: d.URL,
@@ -357,6 +367,7 @@ func (d *ConcurrentDownloader) Download(ctx context.Context, rawurl, destPath st
357367
Downloaded: computedDownloaded,
358368
Tasks: remainingTasks,
359369
Filename: filepath.Base(destPath),
370+
Elapsed: totalElapsed.Nanoseconds(),
360371
}
361372
if err := state.SaveState(d.URL, destPath, s); err != nil {
362373
utils.Debug("Failed to save pause state: %v", err)
@@ -387,6 +398,15 @@ func (d *ConcurrentDownloader) Download(ctx context.Context, rawurl, destPath st
387398

388399
// Rename from .surge to final destination
389400
if err := os.Rename(workingPath, destPath); err != nil {
401+
// Check for race condition: did someone else already rename it?
402+
if os.IsNotExist(err) {
403+
if info, statErr := os.Stat(destPath); statErr == nil && info.Size() == fileSize {
404+
utils.Debug("Race condition detected: File already exists and has correct size. Treating as success.")
405+
// Clean up state just in case, though usually done by caller
406+
_ = state.DeleteState(d.ID, d.URL, destPath)
407+
return nil
408+
}
409+
}
390410
return fmt.Errorf("failed to rename completed file: %w", err)
391411
}
392412

‎internal/engine/concurrent/task_queue.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@ func (q *TaskQueue) SplitLargestIfNeeded() bool {
106106
// Find largest queued task
107107
idx := -1
108108
var maxLen int64 = 0
109-
for i, t := range q.tasks {
109+
for i := q.head; i < len(q.tasks); i++ {
110+
t := q.tasks[i]
110111
if t.Length > maxLen && t.Length > 2*types.MinChunk {
111112
maxLen = t.Length
112113
idx = i

‎internal/engine/concurrent/task_queue_test.go‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,59 @@ func TestAlignedSplitSize(t *testing.T) {
132132
}
133133
}
134134
}
135+
func TestTaskQueue_SplitLargestIfNeeded_Regression(t *testing.T) {
136+
q := NewTaskQueue()
137+
138+
// Push 2 tasks
139+
q.Push(types.Task{Offset: 0, Length: types.MinChunk}) // Task 0
140+
q.Push(types.Task{Offset: 100, Length: 4 * types.MinChunk}) // Task 1 (should be split)
141+
142+
// Pop Task 0 (Head moves to 1)
143+
if _, ok := q.Pop(); !ok {
144+
t.Fatal("Failed to pop task 0")
145+
}
146+
147+
// Now Head is at 1 (Task 1). Task 0 is "dead" space in slice.
148+
// The bug was that SplitLargestIfNeeded started iteration at 0.
149+
// If it starts at 0, it might enable splitting of Task 0 again if it was large enough,
150+
// or mess up indexing.
151+
152+
// In this specific regression setup:
153+
// We want to ensure it splits Task 1 (index 1), NOT Task 0 (index 0).
154+
// Although Task 0 is small here, if the loop ignores head, it processes garbage/old data.
155+
156+
if !q.SplitLargestIfNeeded() {
157+
t.Error("Should have split Task 1")
158+
}
159+
160+
// Check queue state
161+
// Should have: Task 1 (split right half) at head, and Task 1 (split left half) appended.
162+
// Len should be 2.
163+
if q.Len() != 2 {
164+
t.Errorf("Len = %d, want 2", q.Len())
165+
}
166+
167+
// Check that we didn't resurrect Task 0 or duplicate it.
168+
t1, _ := q.Pop()
169+
t2, _ := q.Pop()
170+
171+
// Original Task 1 was Offset 100, Len 4*MinChunk
172+
// Split: Right half replaces it, Left half appended.
173+
// Left: Offset 100, Len 2*MinChunk
174+
// Right: Offset 100+2*MinChunk, Len 2*MinChunk
175+
176+
// Implementation detail: task_queue.go:132: q.tasks[idx] = right; q.tasks = append(q.tasks, left)
177+
// So next pop (t1) should be "right" half.
178+
// And t2 should be "left" half.
179+
180+
// Verify T1 (Right Half)
181+
expectedRightOffset := int64(100 + alignedSplitSize(4*types.MinChunk))
182+
if t1.Offset != expectedRightOffset {
183+
t.Errorf("First popped task offset %d, want %d", t1.Offset, expectedRightOffset)
184+
}
185+
186+
// Verify T2 (Left Half)
187+
if t2.Offset != 100 {
188+
t.Errorf("Second popped task offset %d, want 100", t2.Offset)
189+
}
190+
}

‎internal/engine/events/events.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type ProgressMsg struct {
1212
Downloaded int64
1313
Total int64
1414
Speed float64 // bytes per second
15+
Elapsed time.Duration
1516
ActiveConnections int
1617
}
1718

0 commit comments

Comments
 (0)